Skip to content

Harbor — Architectural decisions log

Append-only record of decisions that have been settled. One entry per decision. Reading this file is the fastest way to answer "wait, why did we pick X?" without re-litigating.

If a decision is later reversed or superseded, do NOT delete the original entry — append a new entry with Supersedes: D-NN and update the Status of the superseded entry to Superseded by D-MM.

The decisions here are mirrored in the RFC (which is the design source of truth). When they conflict, the RFC wins; file an entry here noting the discrepancy and resolve in the same PR.


D-001 — Identity is the triple (tenant, user, session)

Date: 2026-05-08 Status: Settled Where it lives: RFC §4, AGENTS.md §6 Why: The runtime must support concurrent sessions for the same user without context leakage. Tenant-only isolation is insufficient for multi-user agents. The triple is mandatory; there is no opt-out knob.


D-002 — Console is a Protocol client; Runtime is headless

Date: 2026-05-08 Status: Settled Where it lives: RFC §5, AGENTS.md §1, §4.5, §13 Why: The predecessor's Playground re-implemented runtime concepts (2,478 lines, 30+ HTTP routes, parallel state-store protocol). Decoupling unlocks remote attach, fleet view, third-party consoles, IDE/TUI clients, and prevents the "framework with a playground" trap. The Runtime never imports Console code.


D-003 — Planner is swappable behind one interface

Date: 2026-05-08 Status: Settled Where it lives: RFC §3.2, §6.2, AGENTS.md §1 Why: The biggest architectural lift over the predecessor. The runtime owns mechanism; planners own reasoning policy. ReAct is the V1 reference; Plan-Execute, Workflow, Graph, Deterministic, Supervisor, MultiAgent, HumanApproval can plug in over time without runtime changes.


D-004 — Persistence triad shipped at V1: in-mem + SQLite + Postgres

Date: 2026-05-08 Status: Settled Where it lives: RFC §9, AGENTS.md §9 Why: Three drivers from t=0 forces a clean abstraction. Designing against one tends to leak that backend's assumptions into the contract. The predecessor shipped contracts with no production backends; operators DIY-ed queueing. Harbor closes that gap.


D-005 — Skills are a Harbor subsystem (not pushed entirely to Portico)

Date: 2026-05-08 Status: Settled Where it lives: RFC §6.7, harbor_skills_subsystem memory Why: The token-savvy DB-backed search/context/virtual-directory pattern is the predecessor's strongest subsystem; Harbor inherits it cleanly. Portico still owns distribution across tenants; Harbor consumes via a SkillProvider driver. Plus: Skills.md importer (closes the per-skill manual-adaptation gap) and an in-runtime skill generator with persistence (the predecessor's draft generator can't save).


D-006 — Background-task persistence: in-process at V1, durable post-V1

Date: 2026-05-08 Status: Settled Where it lives: RFC §6.8 (and §6.12 contracts), master plan post-V1 list Why: V1 ships the contract. A durable backend (Postgres-as-queue or similar) lands post-V1 once the operational shape is clear.


D-007 — A2A: full spec compliance from V1

Date: 2026-05-08 Status: Settled Where it lives: RFC §6.4, master plan phase 29 Why: The predecessor ships full A2A spec compliance in code (the public docs lagged — that's the lesson Harbor's doc hygiene closes). Harbor inherits the surface verbatim from t=0; A2A peers appear as just-another-tool-source under the unified abstraction.


D-008 — Sessions = longer-lived multi-turn conversations containing many Runs

Date: 2026-05-08 Status: Settled Where it lives: RFC §6.9, glossary Why: Resolves the predecessor's ambiguity between StreamingSession and SessionManager. Identity is (tenant, user, session); RunID is per-execution; TraceID (OTel) may span Runs.


D-009 — CLI dev-loop subcommand: harbor dev

Date: 2026-05-08 Status: Settled Where it lives: RFC §8, master plan phase 64 Why: Boots local Runtime + Console + observability + hot reload + dynamic agent scaffolding with draft saving. Console is still a protocol client even on localhost; same code path as remote attach.


D-010 — Code-level tool calling (LLM = decision-maker, not runner)

Date: 2026-05-08 Status: Settled Where it lives: RFC §6.4 + §6.5, brief 07, harbor_design_principles memory Why: The LLM emits text/JSON describing intent; the runtime parses, dispatches, and merges. Provider-native tool calling APIs are NOT used. Provider differences disappear; the runtime owns the protocol. The LLM client surface collapses to one method. The runtime trio (ActionParser / Dispatcher / ObservationRenderer) plus siblings (RepairLoop, SchemaSanitizer) are the design pieces. Reversibility: if community standard hardens around native tool calling later, a second LLMClient driver can be added — the runtime doesn't change.


D-011 — Unified pause/resume primitive (HITL + OAuth + A2A AUTH_REQUIRED + steering PAUSE)

Date: 2026-05-08 Status: Settled Where it lives: RFC §3.3 + §6.3, harbor_protocol memory Why: Four seemingly-distinct features all converge on one runtime-level pause. The predecessor implements pause inside the planner loop, forcing every pause-shaped feature to reinvent coordination. Harbor's primitive lives at the runtime; planners and tools both signal "I need a pause" and the runtime drives the protocol-level event + resume token.


D-012 — LLM client: bifrost (resolves Q-3); rejects CGo-required candidate

Date: 2026-05-08 Status: Settled Where it lives: RFC §6.5, RFC §11 Q-3 RESOLVED, brief 08 Why: Original candidate (liter-llm) requires CGo bindings to a Rust core, conflicting with AGENTS.md §5/§13. Bifrost is pure Go (verified by direct source inspection: zero import "C", zero #cgo, zero binary blobs), 23 first-class providers, empirically validated against six OpenRouter-routed models — 23/24 gating items pass. Bifrost's Tools/ToolChoice parameters are NOT used (see D-010).


D-013 — Go 1.26+ minimum

Date: 2026-05-08 Status: Settled Where it lives: AGENTS.md §5, RFC §10, .golangci.yml, .github/workflows/ci.yml, go.mod Why: Bumped from 1.22 to match bifrost's go.mod floor. Go 1.26 is current; no downside to the bump.


D-014 — License: Apache-2.0 (MIT was the considered alternate)

Date: 2026-05-08 Status: Settled Where it lives: RFC §10, /LICENSE, README.md Why: Patent grant matters for an SDK companies will build on; NOTICE-file mechanism makes attribution explicit; consistency with the infrastructure neighborhood (Go, Kubernetes, OTel, gRPC, bifrost). MIT remains a real alternate; flip is mechanical (no code changes).


D-015 — Code-level tool calling justification recorded in RFC §6.4

Date: 2026-05-08 Status: Settled (acknowledged as a minority position) Where it lives: RFC §6.4, glossary, this entry Why: Maintainer explicitly questioned whether to switch to provider-native tool calling. Trade-off analysis confirmed code-level is the right call for Harbor's architecture: consistent with runtime/planner separation, swappable planner, cross-provider uniformity, single-method LLM client, custom opcodes (task.subagent, parallel with join spec), simpler streaming, and future-reversibility. Accuracy gap is closing as instruction-tuned models improve. Recorded so future re-reads understand it was a deliberate, examined choice.


D-016 — Governance is a Harbor subsystem; middleware between Runtime and LLMClient driver

Date: 2026-05-08 Status: Settled Where it lives: RFC §6.15, master plan phases 36a + 36b + 91–96, glossary Why: Bifrost (the LLM-call substrate) doesn't know Harbor's identity triple. Identity-scoped policies (cost ceilings, rate limits, per-call MaxTokens, key rotation, model swap, failover, circuit breakers) live in a Harbor middleware layer that wraps the LLMClient interface. The LLMClient interface stays one method.


D-017 — V1 Governance scope: cost ceilings + rate limits + MaxTokens; operator-driven runtime control is post-V1

Date: 2026-05-08 Status: Settled Where it lives: RFC §6.15, master plan phases 36a + 36b (V1) and 91–96 (post-V1) Why: A solo dev running production agents needs bankruptcy prevention from t=0 (cost accumulators + ceilings + rate limits). Live operator-driven controls (key rotation via Protocol, mid-session model swap, failover chains, circuit breakers, caching, PII redaction) require Console to land first; their phases sit explicitly in the post-V1 cluster (91–96) so they are tracked, not forgotten.


D-018 — Failover is a Harbor policy, not bifrost per-request Fallbacks

Date: 2026-05-08 Status: Settled (post-V1 implementation, phase 93) Where it lives: RFC §6.15, master plan phase 93 Why: Bifrost has a Fallbacks []Fallback field on each request — that's a per-call escape hatch with no audit awareness of Harbor's identity scopes. Harbor's failover is a policy with cost + rate-limit + audit implications; centralizing it in the Governance subsystem keeps every fallback hop a Harbor event with the identity triple attached.


D-019 — Key rotation via Account.GetKeysForProvider per-request lookup, not bifrost ReloadConfig

Date: 2026-05-08 Status: Settled (post-V1 implementation, phase 91) Where it lives: RFC §6.15, master plan phase 91 Why: ReloadConfig is whole-config replacement and races with in-flight requests. Account.GetKeysForProvider(ctx, provider) is invoked by bifrost on each request; Harbor's Account impl reads the live key set from a runtime-controlled atomic source. Console-pushed key rotations take effect on the next call with no config-swap race; old keys are invalidated immediately.


D-020 — PII redaction at the LLM boundary lives in Audit; Governance owns thresholds

Date: 2026-05-08 Status: Settled Where it lives: RFC §6.15, master plan phase 96 Why: Redaction is one canonical concern with multiple emit paths (logs, audit events, persisted state). Owning it in Audit gives one redactor; Governance owning it would split responsibility and risk inconsistent output. Governance owns thresholds (cost, rate, tokens) where the canonical concern is policy enforcement.


D-021 — Multimodality scope: inputs in V1, outputs as post-V1 tool wrappers

Date: 2026-05-08 Status: Settled Where it lives: RFC §6.5 (Multimodal inputs subsection), master plan phases 32 + 33 (V1 inputs) + 97 + 98 (post-V1 outputs), glossary (ContentPart, ImagePart, AudioPart, FilePart) Why: The predecessor accumulated an ambient "text-only" assumption that became expensive to retrofit. Harbor settles multimodal inputs at V1 (image/audio/file via ChatMessage.Content's Parts slice; bifrost handles per-provider translation) so the LLM call surface is correct from t=0 — sending images to LLMs as part of analysis is the common case, not a feature. Outputs (image generation, TTS, transcription, video) are delivered as tools that return ArtifactRefs; the planner dispatches them via the existing tool catalog (RFC §6.4 code-level dispatch). This keeps the LLMClient interface one method and aligns multimodal output with the runtime's existing tool-dispatch story.


D-022 — ArtifactRef is the canonical binary representation for multimodal content

Date: 2026-05-08 Status: Settled Where it lives: RFC §6.5 (canonical binary representation paragraph), §6.10 (Artifacts), glossary (Artifact, ArtifactRef, multimodal part types) Why: Three supply forms exist for image/audio/file content (URL, DataURL, ArtifactRef). Above the heavy-output threshold (32 KB default — RFC §6.10), the runtime automatically materializes inline DataURL content into ArtifactRefs and rewrites the message before event emission, audit, and persistence. This keeps event payloads, audit logs, and memory turns from carrying raw bytes; it also gives audit redaction a stable canonical form to handle (ArtifactRef passes through unredacted; DataURL is rewritten to placeholder + ref). URLs pass through unchanged when the provider can fetch them directly.


D-023 — Flow-as-Tool: Go-coded flow.Definition ships V1; declarative recipe (YAML) format ships V1.1

Date: 2026-05-09 Status: Settled Where it lives: RFC §6.1 (Flow-as-Tool subsection) + §6.4 (Flow transport variant), master plan phase 26a (V1) + phase 100 (post-V1 recipe loader), glossary (Flow, Definition, Budget for flows, Recipe) Why: A Flow is a typed DAG of Nodes assembled into a runnable unit and registered as a Tool the planner can call. This composes (a) the existing subflow + reliability shell (NodePolicy retry / exponential backoff / timeout) from §6.1, (b) the unified tool dispatch path from §6.4, and (c) the identity-tier Governance ceilings from §6.15, without adding a parallel orchestration concept. V1 ships the Go-coded Definition shape so the contract is settled and operators can ship flows in code; recipes (declarative YAML loaders into the same Definition struct) ship V1.1 to keep V1 scope tight without losing the surface. Per-flow Budget composes with run-level + identity-level budgets via min(): any layer can abort the flow, whichever cap fires first.


D-024 — ToolPolicy reliability shell wraps every tool invocation, regardless of transport

Date: 2026-05-09 Status: Settled Where it lives: RFC §6.4 (Tool.Policy field + reliability-shell paragraph), master plan phase 26 (acceptance criteria), glossary (ToolPolicy) Why: A predecessor pattern worth preserving: even the minimum-expression tool — a plain Go function decorated as a tool — got per-call timeout / retry-with-backoff / validation for free. Harbor settles this at the catalog level: Tool.Policy is a ToolPolicy mirroring NodePolicy (§6.1). The Dispatcher trio (§6.4) wraps every tool invocation in the shell once; Transport (InProcess / HTTP / MCP / A2A / Flow) does not change the resilience guarantees. Defaults fire when ToolPolicy is zero-valued, so tools.RegisterFunc(name, fn) is production-resilient with no ceremony. Operators who want non-default policy pass tools.WithPolicy(...). Same backoff math + retry classes as NodePolicy so the surface is one mental model.


D-025 — Concurrent reuse contract: compiled artifacts immutable; per-run state lives in ctx + RunContext

Date: 2026-05-09 Status: Settled Where it lives: RFC §3.5 ("The concurrent reuse contract"), AGENTS.md §5 ("Concurrent reuse contract — non-negotiable"), §11 (mandatory concurrent-reuse tests), §13 (forbidden: mutable state on compiled artifacts), docs/plans/_template.md (pre-merge checklist), every Wave 1+ phase plan. Why: The predecessor's most expensive retrofit was thread-safety on its first-version flow runtime — the singleton "build a flow once, reuse across runs" pattern had mutable state that bled across concurrent invocations once parallelism was enabled. Harbor closes this from t=0 by settling four guarantees for every compiled artifact (flow.Engine, Tool, Planner, MemoryStore, Redactor, LLMClient, ToolCatalog): no data races, no context bleed, no cancellation cross-talk, no goroutine leaks. Every phase that builds a reusable artifact ships a concurrent-reuse test (N≥100 invocations under -race); the test is part of the pre-merge checklist and the drift-audit-adjacent phase plan template. Mutable state on artifacts that crosses run boundaries is a forbidden practice (AGENTS.md §13). Per-run state lives in ctx + RunContext; this constraint shapes every interface signature in the runtime.


D-026 — Context-window safety net: no raw heavy content reaches the LLM; standard ArtifactStub everywhere

Date: 2026-05-09 Status: Settled Where it lives: RFC §6.5 ("Context-window safety net" subsection + standard ArtifactStub schema), RFC §6.10 (heavy-output threshold), AGENTS.md §13 (forbidden: raw heavy content in LLM messages), master plan phase 32 (LLM client core enforces the catch-all pass), glossary (Context-window safety net, ArtifactStub, ErrContextLeak, ErrContextWindowExceeded). Why: The predecessor learned the hard way that LLM context windows balloon when artifacts (images, PDFs, large tool outputs, memory turns) are not consistently offloaded — the safety net was retrofitted later. Harbor settles the pattern as a runtime-wide invariant from t=0: no message reaching the LLMClient carries raw heavy content. Multi-stage enforcement: (1) producers (tool dispatcher, memory subsystem, multimodal input materialization, ObservationRenderer) substitute heavy content with ArtifactRefs as part of their normal output; (2) a single catch-all pass at the LLM-client edge walks the assembled CompleteRequest and fails loudly with ErrContextLeak if any ≥-threshold raw payload survived, plus fails with ErrContextWindowExceeded if the estimated token count is within the configured ContextWindowReserve (default 5%) of the model's context limit. V1 does not auto-truncate when the budget guard fires — the planner receives a typed error and is responsible for recovery (drop older turns, summarize, etc.); auto-cascade is post-V1 work. The standard ArtifactStub schema ({artifact_ref, mime, size_bytes, hash, summary, fetch}) is the only thing the LLM sees in place of heavy content; format is uniform across all producers and providers — no per-model swapping.


D-028 — Event bus surface reconciliation: identity.Quadruple field, EventBus name, replay deferred to Phase 06, sealed-via-embedded-Sealed payload pattern, SafePayload bypass

Date: 2026-05-09 Status: Settled (supersedes the earlier RFC §6.13 sketch) Where it lives: RFC §6.13 (revised; earlier sketch retained as "kept for history"), docs/plans/phase-05-events.md ("Findings I'm departing from"), internal/events/events.go (the shipped surface), internal/events/payloads.go (bus-internal SafePayload types). Why: The earlier RFC §6.13 sketch carried flat identity strings (TenantID, UserID, SessionID, RunID), an EmittedAt time, optional metric-shaped fields (LatencyMs *float64, TokensIn/Out *uint32, CostUSD *float64, QueueDepth), called the bus interface Bus, and ranged it over a Replay(ctx, Cursor, Filter) method. The shipped Phase 05 surface diverged in five load-bearing ways: (1) identity reuseIdentity identity.Quadruple re-uses Phase 01's type so a single concept lives in one place rather than four scattered string fields; (2) renamed to EventBus so the symbol doesn't collide with generic Go vocabulary at call sites; (3) Replay deferred to Phase 06 (the in-memory ring-buffer driver) and exposed through a future capability interface, keeping the core EventBus surface to three methods; (4) no inline metric fields — Phase 56 will derive metric labels from Event.Extra (a bounded map[string]string) so the cardinality boundary is explicit; (5) sealed-via-embedded-Sealed payload pattern plus the SafePayload marker (composing SafeSealed) — bus-internal payloads bypass the audit redactor (preserving typed access on the subscriber side), external payloads default to redactor-walked RedactedMap. The OccurredAt rename (from EmittedAt) keeps the field's verb consistent with the new "emit"/"publish" terminology in the bus implementation. Phase 05 plan acknowledged none of these in its "Findings I'm departing from" section; this entry closes that drift retrospectively. The earlier sketch is preserved verbatim in §6.13's "kept for history" paragraph.


D-027 — StateStore is a generic (Quadruple, Kind, Bytes) surface; typed wrappers land at consumer phases

Date: 2026-05-09 Status: Settled (supersedes RFC §6.11's typed-multi-method sketch) Where it lives: RFC §6.11 (revised to the generic surface), docs/plans/phase-07-state.md ("Findings I'm departing from"), internal/state/ (will land in Phase 07 implementation), every consuming phase that wraps the surface (08 sessions, 20 tasks, 22 distributed, 23 memory, 42 planner, 50 steering — each ships its own typed adapter atop the generic interface). Why: The earlier RFC §6.11 sketch listed 21 typed methods (SaveTask, SaveTrajectory, SaveBinding, SaveSteering, SaveMemoryState, …) keyed on Go types (Task, Trajectory, RemoteAgentBinding, SteeringEvent, MemoryKey, …) that do not exist yet — they belong to phases not in Wave 2 (sessions Phase 08, tasks Phase 20, distributed Phase 22, memory Phase 23, planner Phase 42, steering Phase 50). A leaf persistence interface cannot import types from its consumers without inverting the dependency graph. Harbor settles the call: StateStore is a five-method surface keyed on (identity.Quadruple, Kind string, Bytes []byte) with idempotency on a caller-provided EventID (ULID). Consuming phases land their typed wrapper at their own layer (SessionRegistry.Save(s Session) reduces to StateStore.Save(StateRecord{Identity: s.Identity, Kind: "session.lifecycle", Bytes: marshal(s)})). Strictly more general than the typed surface, fully covered by the conformance suite, and avoids the leaf-imports-consumer cycle. Forward-only migrations, three-driver parity (in-mem / SQLite / Postgres), and the no-Supports*-ceremony rule from §9 still apply unchanged. The earlier sketch is not deleted from history (it captured intent); this entry supersedes it.


D-029 — Replay returns []Event, not a fresh Subscription

Date: 2026-05-09 Status: Settled (supersedes brief 06 §2 sketch for Phase 06) Where it lives: docs/plans/phase-06-events-replay.md ("Findings I'm departing from"), brief 06 §2 (the original sketch is preserved unchanged in the brief), internal/events/events.go (the Replayer interface lands in Phase 06's implementation PR), glossary (Replayer capability interface, Cursor). Why: Brief 06 §2 sketched Replay(ctx, Cursor, Filter) (Subscription, error) returning a fresh Subscription whose stream interleaves historical-then-live events. That coupling makes the historical/live boundary fuzzy and forces the bus to dedupe at the seam between snapshot and live tail — exactly the kind of "subtle invariant maintained by clever code" pattern the predecessor learned to regret. Harbor settles the surface as Replay(ctx, Cursor, Filter) ([]Event, error): a snapshot of historical events strictly between the cursor and the bus's current sequence, with the caller responsible for combining the snapshot with a fresh Subscribe if it wants to continue live. The split gives the no-duplicate / no-gap guarantee a clean home — Publish stamps every event with Sequence, and a subscriber's cursor is "the last sequence I have." If a future phase needs a one-shot ReplayAndSubscribe, it composes on top of these two primitives without changing driver implementations. The brief sketch is preserved unchanged in docs/research/06-events-observability-devx.md §2; this entry records the implementation departure.


D-030 — TaskRegistry surface split: per-task in Phase 20, groups + retain-turn + WatchGroup in Phase 21

Date: 2026-05-10 Status: Settled Where it lives: docs/plans/phase-20-tasks.md ("Findings I'm departing from"), docs/plans/phase-21-tasks-groups.md (the follow-up surface), internal/tasks/tasks.go (the shipped Phase 20 surface), brief 05 §7 (the original sketch recommending one phase for the full surface). Why: Brief 05 §7 phase decomposition recommended one phase for the full TaskRegistry (per-task surface + groups + retain-turn + patches + ack-background). Harbor splits this across Phase 20 (per-task surface) and Phase 21 (groups + retain-turn + WatchGroup + patches). Per-task lifecycle is independently shippable and has zero dependencies on group governance; bundling the whole TaskService into one phase would slow the wave-end E2E and delay the per-task surface that downstream phases (steering Phase 53, planner Phase 42) want as a stable foundation. The split keeps Phase 20's TaskRegistry interface narrow (Spawn / SpawnTool / Get / List / Cancel / Prioritize / Mark*) while Phase 21's PR extends the same interface with group + retain-turn methods against a stable per-task subset. Brief 05's recommendation is preserved verbatim in docs/research/05-state-tasks-artifacts-sessions.md §7; this entry records the implementation departure.


D-031 — Distributed contracts: full A2A v1 surface mapping + loopback V1 driver; vendored proto pinned by commit SHA

Date: 2026-05-10 Status: Settled Where it lives: RFC §6.4 + §6.12, docs/plans/phase-22-distributed.md, docs/specifications/a2a.proto (vendored at commit ae6a562d5d972f2c4b184f748bb32e1fa9aa7bf2, 2026-04-23), docs/specifications/README.md, internal/distributed/ (the shipped Phase 22 surface), this entry. Why: D-007 settled "A2A full spec compliance from V1." Phase 22 realises that commitment by hand-transcribing the entire A2A v1 surface into Go: every A2AService RPC maps 1:1 to a RemoteTransport method, every proto message has a Go counterpart in internal/distributed/a2a/types.go, every oneof variant (Part, SecurityScheme, OAuthFlows, StreamResponse, SendMessageResponse) is represented as a Go interface + concrete-type-per-variant discriminated union with a Kind() string discriminator. The TaskState 8-value enum, the Role 3-value enum, and every nested message (AgentCard, AgentInterface, AgentSkill, AgentCardSignature, TaskPushNotificationConfig, AuthenticationInfo, the five SecurityScheme concretes, the five OAuth flow concretes including the two deprecated ones for spec parity, every request/response envelope) ship as named Go types. Phase 29's southbound A2A driver inherits the surface without churn. The proto is vendored at a pinned commit SHA so the source-of-truth is searchable from inside the repo; bumps land as deps(specs): PRs. The Go shapes are hand-written (not protoc-generated) because: (a) Phase 22 must not pull google.golang.org/grpc / google.golang.org/protobuf into a contracts-only package — Phase 29 owns that decision; (b) the hand-written shapes integrate cleanly with identity.Quadruple, slog logging, and Harbor's error idioms; (c) the types_test.go coverage gate (a hand-maintained list of 50 expected type names with a count assertion) makes the transcription auditable. The V1 driver is loopback — in-process dispatch routed through an in-memory Agent interface (in internal/distributed/drivers/loopback/agent.go) so the conformance suite can simulate every A2A RPC without leaving the process. The conformance suite IS the gate: future drivers (durable bus at phase 86, A2A wire at phase 29) inherit it verbatim.


D-032 — Wake-on-resolution is a planner-concrete responsibility; TaskRegistry stays neutral

Date: 2026-05-10 Status: Settled Where it lives: docs/plans/phase-21-task-groups.md (the WatchGroup surface + the three wake-mode names documented at internal/tasks/groups.go package godoc), docs/plans/README.md Phase 42 / 45 / 48 / 49 detail blocks (the consumption contract), internal/tasks/tasks.go (the neutral WatchGroup(sessionID identity.Identity, groupID TaskGroupID) (<-chan GroupCompletion, func(), error) surface — no Mode enum baked in), planner phase plans when authored. Why: Phase 21 closed the predecessor's silent gap where non-retain-turn SpawnTask groups left the planner with no signal that all members had resolved. The fix is WatchGroup + GroupCompletion — a non-blocking notification channel the planner subscribes to. But the policy of how a planner reacts to that channel (wake the LLM eagerly, poll on its next deterministic iteration, or hybrid push + sidecar status emitter) is a planner-shape concern, not a TaskRegistry concern. Burning a WakeMode enum into the registry would either force every planner concrete onto the same policy or introduce a Supports* capability protocol — both anti-patterns under AGENTS.md §4.4. So the registry stays neutral and the three wake modes (push / poll / hybrid) are documented at the internal/tasks package godoc with the same vocabulary the planner phases consume. Each concrete planner (Phase 42+) MUST implement at least one of the three modes for non-retain-turn group continuation; the planner conformance pack (Phase 49) MUST exercise the round-trip (SpawnTask → group completes → planner re-enters → reads MemberOutcome). The retain-turn flow (turn-bound parallel) keeps its existing RegisterRetainTurnWaiter path — WatchGroup is strictly the non-retain-turn dual. Naming the wake modes in one canonical place keeps third-party planner authors aligned and makes the conformance assertion testable.


D-033 — Memory subsystem: identity-rejection emits memory.identity_rejected on the bus with "<missing>" substitution for the partial-triple identity field

Date: 2026-05-11 Status: Settled Where it lives: RFC §6.6, docs/plans/phase-23-memory.md ("Brief findings incorporated" + "Risks / open questions"), internal/memory/events.go (the event-type constant + registration + MemoryIdentityRejectedPayload), internal/memory/reject.go (EmitIdentityRejected + identityRejectionReason), brief 04 §4.2 + §6. Why: Brief 04 §4.2 settles that a MemoryStore operation with a missing identity component MUST (a) fail closed with ErrIdentityRequired and (b) emit an audit event so the rejection is observable on the event bus. The brief does not name the event type. Harbor settles it as memory.identity_rejected, registered in the canonical events registry via this phase's init(). The payload (MemoryIdentityRejectedPayload) is SafePayload by construction — Operation is a bounded enumerable method name, Reason is a static string naming the missing component(s); no caller-controlled bytes survive on the payload. The naming and the SafePayload classification are mine, recorded so a later phase auditor doesn't flag either as drift. The event's Identity field is also load-bearing: Phase 05's ValidateEvent rejects empty-triple events with ErrIdentityRequired, so the rejection event itself cannot be Identity = identity.Quadruple{} even though the rejected input was. The settled solution: substitute any empty component with a "<missing>" sentinel on the published event so ValidateEvent passes; the payload's Reason field names the truly missing component(s), and subscribers MAY Admin: true-filter to fan-in cross-tenant rejections. The memory record persistence key is also settled at this phase: Kind = "memory.state" for the typed-wrapper-over-StateStore write (D-027 pattern), per-Quadruple slot, with the persisted bytes shaped as {strategy, turns} JSON. Phase 23 only writes empty records (Strategy=none has no mutations); Phase 24 will append turn data; Phase 25's persistent drivers will inherit the shape unchanged.


D-034 — Persistent memory drivers own their memory_state tables; Deps.State accepted-but-unused; wire envelope memory.Record exported for cross-driver byte-stable Snapshot/Restore

Date: 2026-05-11 Status: Settled Where it lives: RFC §6.6, RFC §9, docs/plans/phase-25-memory-drivers.md ("Findings I'm departing from" + "Risks / open questions"), internal/memory/wire.go (Record + KindMemoryState), internal/memory/drivers/sqlite/{sqlite.go,migrations/0001_init.sql}, internal/memory/drivers/postgres/{postgres.go,migrations/0001_init.sql}. Why: Phase 23's InMem MemoryStore persists records through the injected state.StateStore per D-027 (typed-wrapper-over-generic, Kind="memory.state"). The Phase 25 persistent drivers (SQLite + Postgres) instead maintain their own memory_state table — this is a deliberate departure from D-027's "one StateStore, many typed wrappers" model and is mandated by the master plan ("Your SQLite/PG drivers persist memory state to their OWN tables ... but the byte serialisation contract is the same shape so cross-driver Snapshot/Restore round-trips byte-stable"). Two consequences are now settled: (1) the memory.Deps.State field is accepted by the persistent drivers but unused — the existing validateDeps contract still requires non-nil to preserve backward compatibility with the InMem driver (which DOES use State), so the persistent drivers hold the reference without writing to it; (2) the wire envelope previously named memoryStateRecord inside the InMem driver is promoted to an exported memory.Record type at internal/memory/wire.go (with the canonical KindMemoryState routing constant alongside it) so all three drivers marshal byte-identical JSON, enabling the Phase 25 acceptance criterion that a Snapshot taken by one driver Restore-round-trips byte-stably through another. Each persistent subsystem's Postgres migration runner uses a distinct pg_advisory_lock key (fnv64aSigned("harbor-memory-migrations")) so the state + memory migration runners cannot collide.


D-035 — Memory strategies: OverflowDropOldest is the only OverflowPolicy; recovery loop is bounded by RecoveryBacklogMax with drop-oldest + memory.recovery_dropped emit; retry/backoff/cadence are constants, not config

Date: 2026-05-11 Status: Settled Where it lives: RFC §6.6, docs/plans/phase-24-memory-strategies.md ("Findings I'm departing from" + "Risks / open questions"), internal/memory/memory.go (OverflowPolicy enum + OverflowDropOldest constant + ValidateHealthTransition + ErrInvalidHealthTransition + transition table), internal/memory/events.go (EventTypeMemoryHealthChanged + EventTypeMemoryRecoveryDropped + HealthChangedPayload + RecoveryDroppedPayload), internal/memory/health.go (EmitHealthChanged + EmitRecoveryDropped), internal/memory/strategy/rolling_summary.go (the constants defaultRetryAttempts = 3, defaultRetryBackoffBase = 100*time.Millisecond, defaultDegradedRetryEvery = 10*time.Second; the bounded recovery loop), brief 04 §2 + §4.1. Why: Three narrow scope calls at this phase, all driven by AGENTS.md §13's "no silent degradation" rule + the "fail loudly" principle:

  1. OverflowPolicy narrows from brief 04 §2's three-option enum to a single OverflowDropOldest. Brief 04 §2 names truncate_oldest, truncate_summary, and error. Harbor ships only OverflowDropOldest. Rationale: (a) truncate_summary requires the summariser inside the truncation path which conflates two strategies; (b) error is a silent-degradation footgun — an over-budget AddTurn returning ErrBudgetExceeded would force every caller to handle the error or silently lose turns, which is exactly the pattern AGENTS.md §13 closes. The narrow enum lets the surface grow if a real LLM-client integration (Phase 32+) surfaces a use case for truncate_summary ("always keep a summary line, drop oldest verbatim turns first"); today the simpler shape avoids the footgun.
  2. Recovery loop is bounded by RecoveryBacklogMax with drop-oldest + memory.recovery_dropped emit on overflow. Brief 04 §4.1 names the bound; the drop-oldest action + the recovery-dropped event are mine, recorded here so a later auditor doesn't flag the naming or the SafePayload classification as drift. The payload is SafePayload by construction — only a bounded Reason string survives ("backlog_overflow" today). Default RecoveryBacklogMax = 16 sized to absorb a short summariser outage (≈4 minutes at defaultDegradedRetryEvery = 10s × 16 retries) without unbounded memory growth.
  3. Retry / backoff / cadence knobs from brief 04 §2 (RetryAttempts, RetryBackoffBase, DegradedRetryEvery) do NOT land in config.MemoryConfig. Only RecoveryBacklogMax is operator-tunable. The three constants live in internal/memory/strategy/rolling_summary.go as package constants (defaultRetryAttempts = 3, defaultRetryBackoffBase = 100*time.Millisecond, defaultDegradedRetryEvery = 10*time.Second). Rationale: nobody has needed to tune them yet, exposing knobs no one has a calibrated answer for is fighting yaml; if the LLM-client integration (Phase 32+) surfaces real-world miscalibration we re-litigate via an RFC PR + a new MemoryConfig field. Keeping the surface narrow today avoids version-skew between an operator's harbor.yaml and a future Harbor that retunes the defaults internally.

The Health FSM transition table is also settled at this phase: healthy ↔ retry ↔ degraded ↔ recovering with the explicit edges listed in internal/memory/memory.go's healthTransitions map. Self-loops are valid; any other pair is rejected by ValidateHealthTransition with ErrInvalidHealthTransition (fail-loud — an invalid transition is a programming error in the calling executor, not a recoverable state). The full matrix is property-tested in internal/memory/strategy/strategy_test.go::TestValidateHealthTransition_Matrix. The Health FSM's observable degradation path (memory.health_changed emit on transition) is the explicit, documented exception to AGENTS.md §13's "no silent degradation" rule — degraded mode IS the observable failure surface, and emitting the event makes it observable (and therefore not silent).


D-036 — HTTP tool driver: URL/body/header templates use text/template + explicit urlquery; secrets live in Auth only

Date: 2026-05-11 Status: Settled Where it lives: docs/plans/phase-27-tools-http.md ("Findings I'm departing from"), internal/tools/drivers/http/http.go (the checkNoSecretLeak guard + compileTemplate with missingkey=error + urlquery funcmap), internal/tools/drivers/http/manifest.go (the loader's pre-compile leak check + the ${ENV_VAR}-only secret form), docs/glossary.md (AuthSpec + UTCP manifest + RegisterHTTPTool), AGENTS.md §7 (credential boundary rule this implements). Why: Brief 03 §3 sketched HTTP tool registration with "url-template substitution from args" but did not specify the credential boundary. Without a constraint, the simplest implementation lets operators interpolate ${API_KEY} or {{ .Auth.token }} directly into the URL — which means the secret crosses the audit redactor, lives in observability logs, and rides through any caching layer. Harbor's tools-HTTP driver tightens this from t=0: URL / body / header templates are text/template strings whose only namespace is .Args.*; the loader runs a regex check ({{[\s-]*\.Auth\b) against every template at register / load time and rejects matches with ErrTemplateSecretLeak. Secrets enter the driver only via the Auth map (operator-supplied), and the manifest loader requires the ${ENV_VAR} reference form — literal secret strings are also rejected at load time. Combined: a leaked secret in an HTTP tool config is a register-time error, never a runtime data leak. Templates use missingkey=error so {{ .Args.unknown }} fails loudly rather than silently rendering as empty (consistent with the runtime's "fail loudly" rule, AGENTS.md §5). The urlquery funcmap alias is documented in package godoc so operators write {{ .Args.city | urlquery }} explicitly when the substituted value must be URL-escaped; the default rendering does NOT auto-escape (Go's text/template is byte-faithful), so this is the operator's responsibility for now — a future enhancement could auto-escape every substitution if the asymmetry proves error-prone.


D-037 — MCP southbound driver wraps github.com/modelcontextprotocol/go-sdk@v1.6.0; transport-reconnect lives in ToolPolicy, not in a parallel state machine

Date: 2026-05-11 Status: Settled Where it lives: RFC §6.4, docs/plans/phase-28-tools-mcp.md ("Findings I'm departing from" + "Risks / open questions"), internal/tools/drivers/mcp/ (the shipped driver), internal/tools/drivers/mcp/auto.go (the MCPTransportMode selector + auto-fallback at Provider.Connect, not at Transport.Connect), brief 03 §4 (the "reconnect-on-failure" brief recommendation), this entry. Why: Brief 03 §4 named "reconnect-on-failure" as a Phase 28 requirement. The Go MCP SDK's StreamableClientTransport already ships an internal exponential-backoff reconnect loop for the standalone SSE stream; stdio + SSE transports leave session-level failures to the caller. Harbor handles those failures at the ToolPolicy retry shell (D-024) — the descriptor's Invoke closure re-runs callTool which re-reads sessionForRead, so a ToolPolicy retry transparently uses a reconnected session when the operator runs Provider.Connect again. Implementing a parallel reconnect state machine inside the driver would have shipped "two parallel implementations of the same conceptual feature" (AGENTS.md §13 forbidden practice) — one in ToolPolicy, one in the driver — and required new sentinels + new audit events to make the per-driver reconnect observable. The settled design keeps reliability at the catalog edge and the driver thin. SDK version v1.6.0 is pinned (its Go floor 1.25 ≤ Harbor floor 1.26); bumps are routine deps PRs with conformance suite re-run. Auto-mode fallback (streamable-HTTP → SSE) was lifted from Transport.Connect to Provider.Connect so the SDK's client.Connect initialize-handshake failures are also covered — a Transport.Connect-only fallback would miss "endpoint answers HTTP but isn't really streamable".


D-038 — A2A southbound driver: JSON-RPC binding, route-scoring weights settled, push-config storage forwarded to peer (no local mirror)

Date: 2026-05-11 Status: Settled Where it lives: RFC §6.4, master plan phase 29, docs/plans/phase-29-tools-a2a.md, internal/distributed/drivers/a2a/registry.go, internal/distributed/drivers/a2a/a2a.go package godoc, internal/tools/drivers/a2a/a2a.go, glossary (A2A peer, Agent Card cache, Route scoring). Why: Phase 29 lands the first wire-level A2A driver. Three design calls warrant a settled entry so a later auditor doesn't churn them. (1) Wire binding. The vendored proto carries both service A2AService { rpc … } (gRPC stubs) AND google.api.http annotations (HTTP+JSON binding). Phase 29 implements the JSON-RPC 2.0 over HTTPS binding per the master-plan Phase 29 detail block and brief 03 §5; gRPC + HTTP+JSON bindings on the same peer's AgentCard are accepted as read-only metadata until those drivers ship. The driver matches AgentInterface.ProtocolBinding == "JSONRPC" (the Phase 22 constant a2a.ProtocolBindingJSONRPC); peers declaring no JSONRPC interface fail loudly with ErrNoJSONRPCInterface. (2) Route-scoring weights. The Registry's CompositeScore = (5 × TrustTier) + (1000 / max(1, LatencyTierMS)) + (10 × CapabilityScore). Trust outranks latency 5:1 (safety first); latency is the tie-breaker among similarly-trusted peers (the 1000/lat_ms term saturates at the LatencyWeight when latency is 1ms, drops to 1.0 at 1000ms); capability match adds an additive boost so a peer that declares the exact AgentSkill.ID outranks a tag-match. Lower latency + lexicographic URL break composite ties so the result is deterministic. Weights are tunable post-V1 but not exposed at V1 (a single deployment uses one canonical scoring policy). (3) Push-notification config storage. The master-plan detail block specifies "store push-notification configs in-memory at V1." Phase 29's southbound driver IS the client (issuing Create/Get/List/Delete against the peer); the peer is responsible for durability. The wire driver forwards CRUD verbatim and stores nothing locally. A multi-replica Harbor consequently sees per-peer push-config state — acceptable for V1; durable mirroring is a Phase 23 (memory) / Phase 15 (SQLite state) / Phase 16 (Postgres state) compose post-V1. HTTPS-only is enforced for non-loopback peers (AGENTS.md §7); HTTP is allowed for 127.0.0.1, ::1, localhost, and operator-allowlisted loopback shapes only. The conformance suite (internal/distributed/conformancetest.RunRemoteTransport) is the gate — passes verbatim against the wire driver bound to an httptest.Server-shaped mock A2A peer.


D-039 — LLM-edge safety pass: mandatory-by-construction, ordering = materialize → leak-detect → token-budget; safety wrapper is the registry's only handout

Date: 2026-05-11 Status: Settled Where it lives: RFC §6.5 ("Context-window safety net" subsection), AGENTS.md §13 (forbidden: raw heavy content reaches LLMClient), master plan phase 32 (acceptance criteria), docs/plans/phase-32-llm-client.md, internal/llm/safety.go (safetyClient), internal/llm/registry.go (Open returns the wrapper, not the raw Driver), glossary (Context-window safety net). Why: D-026 settled the what ("no message reaching the LLMClient carries raw heavy content; fail loudly via ErrContextLeak / ErrContextWindowExceeded"); Phase 32 settles the how. Three design calls warrant a settled entry so a later auditor doesn't churn them. (1) Mandatory-by-construction. internal/llm.Open(...) returns an LLMClient interface whose only concrete implementation is the package-private *safetyClient. The factory builds a Driver (the unexported-by-naming surface) and wraps it. A caller cannot bypass the safety pass through the registry; a caller who genuinely needs a bare Driver (an evaluation harness that has already run the pass) constructs the wrapper directly in its own package — but the production code path is the registry. This is the AGENTS.md §13 "fail-loudly + capability mandatory" pattern applied to the safety net: the runtime fails closed, not "fails open with a feature-flag." (2) Pass ordering. Inside safetyClient.Complete, the steps are: identity → structural-validate → materialize → leak-detect → token-budget → driver. Materialize runs BEFORE leak-detect so a producer that ships a oversize DataURL gets one more chance to be rewritten; a producer that ships raw bytes in a Text field (not a DataURL) is caught by leak-detect. The token-budget guard runs LAST so it sees the post-materialize byte count (an ArtifactStub-rewritten message is small; estimation reflects what the driver will actually send). Cancellation is honoured between every step via ctx.Err(). (3) No auto-cascade at V1. The token-budget guard fails loudly with ErrContextWindowExceeded; V1 does NOT truncate or summarize automatically. The planner is responsible for recovery (drop older turns, summarize, etc.). Auto-cascade is post-V1 work — an extension of memory's rolling_summary plus a PromptAssembler orchestrator; tracked but not on V1's floor. The acceptance bar of "fails loudly = observable" is settled at the bus emit (llm.context_window_exceeded); operators quantify how often the guard fires and tune ContextWindowReserve accordingly.


D-040 — bifrost driver design: single-provider per Harbor instance; env.NAME API-key resolution at New time (fail-closed on missing); stream cancellation abandons the chunk reader; cost emit lives in the driver

Date: 2026-05-11 Status: Settled Where it lives: RFC §6.5, RFC §11 Q-3 (RESOLVED), docs/plans/phase-33-bifrost.md, internal/llm/drivers/bifrost/bifrost.go (Driver + Complete + streamComplete), internal/llm/drivers/bifrost/account.go (Account + resolveAPIKey), internal/llm/drivers/bifrost/cost.go (emit helper), glossary (BifrostDriver, BifrostContext, ProviderRouting), brief 08. Why: Brief 08 settled the adoption ("bifrost is the V1 LLM driver"); Phase 33 settles the adapter shape. Four design calls warrant a settled entry so a later auditor doesn't churn them. (1) Single-provider per Harbor instance. LLMConfig (Phase 32) ships Provider / Model / APIKey / BaseURL / Timeout as singular fields; Phase 33's Account advertises exactly one configured provider. The operator's harbor.yaml carries the bifrost-side Provider (e.g. openrouter); the per-model ModelProfiles keys carry the upstream identifier (openai/gpt-5.3-chat). Multi-provider routing per Harbor instance is post-V1; deployments needing multiple endpoints run multiple Harbor instances. (2) API-key resolution at New, not at Complete. Account.resolveAPIKey reads cfg.APIKey once at construction. The literal "sk-..." form is the value; the "env.NAME" form looks up os.Getenv(NAME) and fails closed with ErrMissingAPIKey (naming the env var) if unset. Fail-at-boot is the runtime principle (AGENTS.md §5); a runtime that boots clean and fails the first user request because of a missing key is the silent-degradation footgun §13 closes. The key value is NEVER logged, surfaced in errors, or emitted on the bus. (3) Stream cancellation abandons the chunk reader. Brief 08 §"Cancellation caveat" observed that bifrost's chunk channel can take a few seconds to close on some providers after ctx cancel. The driver's streamComplete does a select on <-ctx.Done() and the chunk channel; on ctx-cancel the driver returns ctx.Err() IMMEDIATELY and never waits for the channel close. Bifrost's worker goroutine continues draining upstream on its own; the goroutine-leak test asserts baseline restoration. (4) Cost emit lives in the driver, not the safety client. The Phase 32 safety client is provider-blind; the driver knows the request's model and observes bifrost's BifrostCost shape. cost.go::emitCostRecorded publishes llm.cost.recorded after a successful Complete with the full identity quadruple + model + cost + usage; Phase 36a's governance accumulator subscribes against this emit site. If a future phase ships a second non-mock LLM driver and wants cost emission to fold into the safety client (so all drivers emit for free), the wave-end audit can re-litigate — V1 has one production LLM driver, so the redundancy doesn't matter.


D-041 — Provider corrections: outside the safety pass; single baked-in mode; CorrectionsProfile lives on ModelProfile; hook-registered wrapper

Date: 2026-05-11 Status: Settled Where it lives: RFC §6.5, docs/plans/phase-34-provider-corrections.md, internal/llm/llm.go (CorrectionsProfile + four enum types), internal/llm/registry.go (RegisterCorrectionsWrapper hook + Open compose order), internal/llm/corrections/corrections.go (Wrap + init() self-registration), internal/config/config.go (LLMCorrectionsConfig, LLMCorrectionsProfileConfig), internal/config/validate.go (enum allowlists), brief 03 §4–§5, brief 08 §"Phase 34 scope shrinks slightly".

Why: Phase 34 ships the per-provider correction layer between Harbor's runtime and the Phase 32 safetyClient(driver). Four design calls warrant a settled entry so a later auditor doesn't churn them.

  1. Compose order is corrections(safetyClient(driver)) — corrections OUTSIDE safety. The safety pass (D-026 / D-039) materializes oversize DataURLs, asserts no raw heavy content survived, and runs the token-budget guard. If corrections wrapped INSIDE safety, the safety pass would evaluate the PRE-correction request and any future correction that grows token count would slip past. With corrections outside, the safety pass sees the POST-correction request (the final outgoing payload reaching the driver) and its invariants apply to what actually leaves the runtime. Phase 34's quirks today are content-preserving (reordering, schema mutation, envelope translation, usage backfill); future quirks may not be. The outside-safety arrangement is the safe default.

  2. Single baked-in mode — no use_native toggle. Brief 03 §5 documented the predecessor's use_native_llm=True/False toggle that shipped TWO LiteLLM/native implementations in parallel and is exactly the "two parallel implementations of the same conceptual feature" §13 rejects. Harbor picks one architecture (corrections.Wrap over a bifrost-backed driver) and compiles the per-provider quirks into a single layer. The operator's only choice is enable: true (production default) or enable: false (test-only escape hatch). The yaml field is a *bool so the loader distinguishes "operator omitted" (nil → default true) from "operator explicitly disabled."

  3. CorrectionsProfile lives on llm.ModelProfile, not in internal/llm/corrections. Two reasons: (a) Import-cycle avoidance — corrections imports llm; placing the profile TYPE on ModelProfile in the llm package lets the corrections sub-package consume it without a back-edge. (b) Single source of truth — ModelProfile already carries JSONSchemaMode (Phase 35), DefaultMaxTokens (Phase 36b), ReasoningEffort (Phase 33), CostOverrides (Phase 36a). The corrections fields belong in the same bundle so an operator's harbor.yaml model_profiles[<model>]: block is the one canonical place per-model quirks land. The corrections LOGIC stays in internal/llm/corrections/.

  4. Hook-registered wrapper, blank-imported in cmd/harbor/main.go. llm.RegisterCorrectionsWrapper(fn) is the seam: the corrections package's init() calls it with Wrap. Production binaries blank-import _ "github.com/hurtener/Harbor/internal/llm/corrections" so the registration fires at boot. Tests that exercise the safety pass in isolation set cfg.DisableCorrections = true; tests that exercise the corrections layer directly call corrections.Wrap without going through llm.Open. This pattern mirrors §4.4's driver-registry seam — write-once-at-init, blank-import for production wiring, opt-out for tests.

Inverse-naming the snapshot field DisableCorrections (instead of CorrectionsEnabled) means the zero-value matches the production default: programmatic snapshot construction in tests does not have to flip an extra knob to get correct behaviour. The config loader's *bool Enabled field resolves to DisableCorrections = !*Enabled at the boundary (Phase 64+ implements the mapping; today the snapshot is constructed directly by tests).


D-042 — Custom OpenAI-compatible providers: operator-declared via yaml, OpenAI base-type only (Phase 33a), per-provider network knobs override global NetworkDefaults, env var resolves at New time

Date: 2026-05-12 Status: Settled Where it lives: RFC §6.5, docs/plans/phase-33a-custom-providers.md, internal/config/config.go (LLMCustomProviderConfig + LLMNetworkDefaults), internal/config/validate.go (cross-check against native ∪ custom names; nativeBifrostProviders mirror; allowedCustomBaseProviderTypes), internal/llm/registry.go (CustomProviderSpec + NetworkDefaults on ConfigSnapshot), internal/llm/drivers/bifrost/account.go (Account widened to support custom primary; buildCustomProviderConfig; customByName table), brief 03 §"Provider catalog", brief 08 §"Architecture".

Why: Phase 33 shipped a thin bifrost adapter for the native provider list (OpenAI / OpenRouter / Anthropic / Cohere / Mistral / NIM / etc.). Operators want to wire OpenAI-compatible endpoints (NIM as the canonical first case, plus vLLM, ollama, lm-studio, in-house gateways) without per-provider Go code. Bifrost ships schemas.CustomProviderConfig for exactly this use case — Phase 33a exposes the operator-tunable subset. Four design calls warrant a settled entry.

  1. OpenAI-compatible base type only at Phase 33a. LLMCustomProviderConfig.BaseProviderType defaults to "openai" and only that value is accepted at this phase. Bifrost itself supports Anthropic / Mistral / etc. as base types for custom providers; widening Harbor's surface is a Phase 33b/c task once we have evidence operators need it. The narrow surface today avoids fighting yaml when no one's calibrated the per-base-type quirks yet. The validator's allowedCustomBaseProviderTypes map gates this; widening is a one-line table edit + a phase plan.

  2. Per-provider network knobs override global NetworkDefaults. Phase 33a unifies Timeout / MaxRetries / RetryBackoff* / Concurrency / BufferSize under one operator-facing surface (llm.network_defaults) with per-provider overrides on each custom entry. Zero-valued per-provider fields fall through to the global; zero-valued globals fall through to bifrost's package-level defaults. The fallthrough order (per-provider > global > bifrost-default) is identical for native primary and custom primary — operators tune them with one mental model. The motivating case is NIM cold-start latency (often > 60s); a 180-second per-provider Timeout on the NIM entry survives the cold-start without pulling every other provider's timeout up.

  3. API key resolution: env.NAME for native primary, raw env var NAME for custom providers. The native primary path (Phase 33) inherited LLMConfig.APIKey with the env.NAME form because the field overloads literal-or-env. Custom providers have a dedicated APIKeyEnvVar field; operators write the env var NAME directly (e.g. "NVIDIA_API_KEY", NOT "env.NVIDIA_API_KEY"). This is one indirection shorter and avoids the literal-vs-env ambiguity for the multi-provider case. The validator rejects env. prefixes on APIKeyEnvVar with a clear error so the operator notices the asymmetry. Both forms resolve os.Getenv(NAME) at New time; missing env vars fail closed with ErrMissingAPIKey naming the unset variable.

  4. GetConfiguredProviders returns the single PRIMARY provider only — D-040 preserved. Phase 33a's Account holds a customByName map of every declared custom provider but GetConfiguredProviders returns only the one named by LLMConfig.Provider. Multi-provider routing within a single Harbor instance is a future extension; the seam (the table, the per-provider config resolution) is ready but Phase 33a does not commit to multi-routing semantics. This keeps D-040's "single-provider per Harbor instance" intact while making the future widening additive (no API change to GetConfiguredProviders — just return the full table when the time comes).

The operator-facing BaseURL gotcha lands in this entry too: bifrost's OpenAI provider appends /v1/chat/completions to whatever BaseURL the operator sets. Operators write the HOST root (https://integrate.api.nvidia.com) — NOT the full /v1/ path — for the canonical case. Endpoints whose URL already includes /v1 use RequestPathOverrides to override the suffix. The example yaml documents this; the wire-level integration test asserts the path is /v1/chat/completions (not /v1/v1/...).

Sub-second Timeout values get rounded down to zero by bifrost's int(seconds) conversion at the NetworkConfig.DefaultRequestTimeoutInSeconds boundary. Operators who need sub-second timeouts wait for Phase 33b's NetworkConfig widening; today the practical minimum is 1 second. The custom-provider wire timeout test uses 1s vs 3s server sleep to clear this boundary.


D-043 — LLM-edge compose order: retry(downgrade(corrections(safety(driver)))); OutputMode.Tools is Harbor-side prompted output, not provider tool-calling; Validator is a CompleteRequest field

Date: 2026-05-12 Status: Settled Where it lives: RFC §6.5, docs/plans/phase-35-structured-output.md, docs/plans/phase-36-retry-feedback.md, internal/llm/llm.go (OutputMode enum + CompleteRequest.Validator field + ModelProfile.OutputMode/MaxRetries), internal/llm/registry.go (RegisterDowngradeWrapper + RegisterRetryWrapper + the compose chain in Open), internal/llm/output/ (downgrade wrapper), internal/llm/retry/ (retry wrapper), internal/llm/errors.go (IsInvalidJSONSchemaError + new sentinels), internal/llm/events.go (ModeDowngradedPayload + RetryWithFeedbackPayload), brief 03 §6, brief 07.

Why: Phases 35 + 36 ship two new wrappers on top of Phase 32's safetyClient and Phase 34's corrections.Wrap. Three design calls warrant a settled entry.

  1. Compose order — retry(downgrade(corrections(safety(driver)))). Three principles drive the order, outermost first:

    • Retry is outermost. A validator-driven retry appends a corrective user turn to the conversation; the new turn must flow through corrections + downgrade + safety on each attempt. Corrections normalize message ordering (NIM rejects mid-thread system) — if retry sat INSIDE corrections, the corrected message slice would be augmented with the corrective turn AFTER the reorder, breaking the invariant on the second attempt.
    • Downgrade sits between retry and corrections. A downgrade rewrites ResponseFormat (e.g. json_schemajson_object + system-prompt instruction); corrections then re-shape the per-provider envelope for the rewritten format (Anthropic envelope translation; JSONOnly stash-the-schema hint). If downgrade sat INSIDE corrections, the corrections layer would only see the ORIGINAL format; the downgraded format would skip the per-provider shaping.
    • Corrections sit between downgrade and safety. Settled by D-041. The safety net (D-039 / mandatory-by-construction) sees the post-corrections request — leak-detection and the token-budget guard apply to the final outgoing payload regardless of whether downgrade or retry rewrote it.

    The chain is composed in llm.Open via three write-once hooks: RegisterCorrectionsWrapper (Phase 34), RegisterDowngradeWrapper (Phase 35), RegisterRetryWrapper (Phase 36). The wrappers self-register via init() in their respective sub-packages; cmd/harbor/main.go blank-imports them. The ConfigSnapshot.DisableDowngrade / DisableRetry inverse-named knobs (zero-value = enabled) let tests exercise lower layers in isolation.

  2. OutputMode.Tools is a Harbor-side prompted-output strategy, NOT provider tool-calling. RFC §6.4 + brief 07 keep tool dispatch runtime-side. OutputMode.Tools asks the model to emit {"name":"respond_with","arguments":{...}} as plain JSON output (parsed locally by the runtime); the bifrost driver never sees provider-native tools= / tool_choice= / function_call / tool_use parameters. The static guard in scripts/smoke/phase-35.sh greps internal/llm/output/ for the canonical provider-tool-call symbol names; a leak fails the smoke. The package godoc names the boundary explicitly so future readers don't reintroduce the violation by reaching for bifrost's native tool-call API.

  3. Validator is a field on CompleteRequest, not a separate method. Two alternatives were considered: (a) Validator func(CompleteResponse) error field on CompleteRequest — the retry wrapper runs the loop internally; (b) a client.Validate(resp) error method on LLMClient — callers run the loop themselves. Option (a) wins because Phase 36a / governance wraps the OUTER client with PreCall / PostCall hooks — the retry loop must stay INSIDE the governance wrapper so each retry's call counts against the identity budget. Surfacing the loop as a caller-driven Validate method would leak retry semantics to governance and require every caller to re-implement the bounded loop. The field-on-request shape also lets the validator be nil (the common case) — the wrapper becomes a pure pass-through with one branch.

The wrapper's corrective-sub-prompt template ships fixed at Phase 36: assistant turn echoes the rejected content; a user turn says "Your previous response failed validation: <truncated reason>. Please respond again, addressing this issue exactly." Tuning is post-V1 — operators who need a different template can shadow-wrap the retry layer in their own code.

IsInvalidJSONSchemaError is the boundary the downgrade wrapper uses to classify driver errors. The classifier checks (1) errors.Is(err, ErrInvalidJSONSchema) for drivers that wrap with the sentinel, and (2) a small case-insensitive substring allowlist (json_schema, json schema, invalid schema, schema validation, response_format, response format, structured output, json mode, json_object). The allowlist is deliberately narrow to avoid false-positive downgrades on transient / auth / 5xx failures. Drivers can tighten the classification by wrapping their provider-specific schema errors with ErrInvalidJSONSchema — Phase 33's bifrost driver is a §17.6 follow-up candidate.

ResponseFormatProfile.ResponseFormatJSONOnly (Phase 34) and OutputMode.Prompted (Phase 35) are deliberately distinct concepts. JSONOnly is a corrections-layer per-provider quirk: "this provider rejects json_schema at the wire level, surface schema as Extra["schema_hint"]." Prompted is a Harbor-side output-mode strategy: "skip native schema enforcement entirely, instruct the model to emit JSON matching the schema via system prompt." They compose: a Prompted request flowing through a JSONOnly profile would have the schema both in the system prompt (Prompted's job) and in Extra["schema_hint"] (JSONOnly's job, when a FormatJSONSchema survives). Operators who want one or the other (not both) set OutputMode and leave the corrections profile default, or vice versa.


<!-- Append new entries below this line in the form:

D-NNN — <one-line summary>

Date: YYYY-MM-DD Status: Settled | Tentative | Superseded by D-MMM | Reverted Where it lives: <files> Why: <2-3 sentences> -->

D-044 — Governance ships latent at V1: interface + math wired, every enforcement path is opt-in; PostCall is the in-band cost accumulator; compose order governance(retry(downgrade(corrections(safety(driver)))))

Date: 2026-05-12 Status: Settled Where it lives: RFC §6.15, docs/plans/phase-36a-cost-accumulator.md, docs/plans/phase-36b-rate-limit-maxtokens.md, internal/governance/ (Subsystem + Wrap + CostAccumulator + RateLimiter + MaxTokensEnforcer + Compound + registry + events + errors), internal/llm/registry.go (RegisterGovernanceWrapper + the new outermost compose step in Open), internal/config/config.go (GovernanceConfig.IdentityTiers + DefaultTier + GovernanceTierConfig + GovernanceRateLimitConfig), internal/config/validate.go (the tier validator block), examples/harbor.yaml (the latent-default + commented opt-in block), brief 03 §6, brief 06 §3.

Why: Phases 36a + 36b establish Harbor's governance subsystem (cost ceilings, rate limits, per-call MaxTokens) wrapping the LLM-edge chain. Four design calls warrant a settled entry.

  1. Latent V1 default. The interface, accumulator math, token-bucket math, persistence (three-driver state-store conformance), event taxonomy (governance.budget_exceeded / governance.rate_limited / governance.maxtokens_exceeded), and the compose seam all ship in Wave 7b. Every enforcement path defaults to permit — an operator must populate Governance.IdentityTiers with at least one tier (and set DefaultTier or supply a custom TierResolver) for any policy to fire. Each tier's fields (BudgetCeilingUSD, RateLimit, MaxTokens) are independently opt-in. This is the Wave 7b scoping decision: V1 ships plumbing visible to operators but enforcement waits on operator policy. Future Protocol-driven setters (post-V1 phase 91) let Console flip tiers without restart.

  2. PostCall is the in-band cost accumulator path — NOT a subscription to llm.cost.recorded. The cost-recorded event fires from the bifrost driver (Phase 33's emitCostRecorded) and remains the operator-facing observability stream. The governance accumulator updates synchronously in PostCall per RFC §6.15 line 1128 ("PostCall... Accumulates cost / tokens / latency"). A subscriber-based accumulator opens a race window where the next PreCall checks the ceiling before the previous call's cost lands; ceiling enforcement correctness requires synchronous update. The atomic CAS (math.Float64bits + CompareAndSwap) lets concurrent PostCalls accumulate lock-free.

  3. Compose order governance(retry(downgrade(corrections(safety(driver))))). Governance is the OUTERMOST wrapper, sitting outside Phase 36's retry per D-043 + master plan line 420. A PreCall that fires ErrBudgetExceeded MUST short-circuit before retry / downgrade burn attempts; rejecting once is the correct semantics. PostCall runs after the entire downstream chain returns, so it sees the final outcome (post-downgrade, post-retry). governance.SetFactory is the per-process hook; cmd/harbor blank-imports internal/governance so the wrapper hook seats at boot. With no factory set, the hook is a pass-through (latent default — even a registered package import does not implicitly enforce).

  4. Concurrent-call ceiling overshoot is bounded, not zero. The PreCall→inner→PostCall sequence creates a race where N concurrent in-flight calls can each see "below ceiling" before any PostCall lands. The accumulator overshoots by at most in_flight × per_call_max_cost. The conformance test asserts total ≤ ceiling + N × per_call_max_cost rather than strict equality. Operators who need first-cross-blocks-everyone semantics get them post-V1 via the unified pause/resume primitive (RFC §6.15 line 1181) — V1 ships eventually-consistent ceilings. governance.budget_exceeded events emit only from PreCall on the NEXT call after a breach; a PostCall that pushes the accumulator over the ceiling is accepted (the call already happened) and the breach surfaces via the cost-recorded observability stream.

The MaxTokens semantic is fail-loud not clamp (master plan line 420 + RFC §6.15 line 1122 both say ErrMaxTokensExceeded). Refunds on call failure are out of scope (RFC §6.15 simplicity — drain-on-PreCall is final). State persistence is one record per identity (Kind=governance.cost for accumulator, Kind=governance.bucket for buckets), JSON-encoded for cross-driver byte-stability; the wire shape carries a schema version field for forward-compat.

governance.NewCompound(subs...) bundles MaxTokensEnforcer (cheapest reject — no state I/O), RateLimiter (per-key mutex + per-identity state write), and CostAccumulator (state I/O on every PostCall) into one Subsystem. Fan-out order is operator-driven; the default ordering puts cheapest-first so a likely rejection short-circuits before reaching the state-heavy accumulator.

D-045 — Skills LocalDB driver owns its own tables (no piggyback on StateStore); FTS5 detected at open with deterministic regex/exact fallback

Date: 2026-05-12 Status: Settled Where it lives: RFC §6.7, docs/plans/phase-37-skills-store.md, internal/skills/skills.go (Deps has no State field), internal/skills/drivers/localdb/localdb.go (driver opens its own DB), internal/skills/drivers/localdb/migrations/0001_init.sql (own skills + skills_fts schema), internal/skills/drivers/localdb/search.go (FTS5 → regex → exact ladder), brief 04 §4.3 + §4.4.

Why: Phase 37 lands the SkillStore subsystem. Two design calls warrant a settled entry.

  1. D-034 analog: skills drivers own their tables; the Deps struct does NOT carry a StateStore. Memory's Phase 25 settled the precedent — persistent memory drivers own a dedicated memory_state table rather than piggybacking on the StateStore's state_records shape (D-034). The skills LocalDB driver follows the same pattern with a dedicated skills + skills_fts schema. Three reasons compound:

    • Schema fit. Skill has 20+ load-bearing columns (Origin, OriginRef, Scope, ScopeTenantID, ScopeProjectID, ContentHash, JSON-encoded slices, lifecycle timestamps). The StateStore's (Quadruple, Kind, Bytes) envelope means every column lookup is a JSON probe — fine for opaque memory blobs, not for an indexed FTS5 corpus.
    • FTS5 needs a real table. The FTS5 virtual table uses content='skills' content_rowid='rowid' external-content mode + INSERT/DELETE/UPDATE triggers to mirror the skills table. Building this against state_records would require a phantom-content table and a custom rowid mapping; the per-driver skills schema is cleaner.
    • Cross-driver portability. The Portico SkillStore driver (post-V1) talks to a remote MCP server and has no StateStore need either — keeping the seam free of StateStore obligations widens the door for future drivers (Git, OCI, HTTP).
  2. FTS5 availability detected at open via a probe query; the ranking ladder gracefully falls through to regex/exact when FTS5 is unavailable. brief 04 §4.4 mandates the fallback test. modernc.org/sqlite compiles FTS5 in by default, so the production path always uses FTS5; the fallback is a correctness gate for builds (and for tests that force ftsAvailable = false via the internal test surface). No operator-facing knob to force the regex/exact path — that would be a "two parallel implementations of the same feature" (AGENTS.md §13 forbidden practice). Detection is mechanical: SELECT count(*) FROM skills_fts WHERE skills_fts MATCH '__fts_probe__' either succeeds (FTS5 alive) or errors (the migration's CREATE VIRTUAL TABLE rolled back on a build without FTS5).

D-046 — Skill ContentHash is sha256 over canonicalised content fields, excluding Origin / OriginRef / Scope / lifecycle timestamps

Date: 2026-05-12 Status: Settled Where it lives: RFC §6.7, docs/plans/phase-37-skills-store.md, internal/skills/wire.go (CanonicalContentHash), internal/skills/drivers/localdb/localdb.go (LWW + idempotency check uses the hash), brief 04 §4.8.

Why: Conflict policy needs a deterministic gate. brief 04 §4.8 says "Generated → Generated: last-write-wins gated by content_hash change" — the hash must be stable across re-imports (the same Skills.md pack imported twice produces the same hash) and resilient to caller-side normalisation noise.

The canonical hash envelope:

  • Included: Name, Title, Description, Trigger, TaskType, sorted Tags, ordered Steps, ordered Preconditions, ordered FailureModes, sorted RequiredTools, sorted RequiredNS, sorted RequiredTags, Extra (key-sorted text rendering).
  • Excluded: Origin, OriginRef, Scope, ScopeTenantID, ScopeProjectID — provenance metadata that legitimately differs across import paths without representing content drift.
  • Excluded: CreatedAt, UpdatedAt, LastUsed, UseCount — lifecycle state that evolves over a row's life.

Slice fields are sorted before hashing when ordering is non-semantic (Tags, RequiredTools, RequiredNS, RequiredTags); preserved when ordering is semantic (Steps, Preconditions, FailureModes — these are procedural prose rendered to the planner in declared order). Field separator is \x1f (ASCII unit-separator) so caller-supplied newlines / whitespace / pipes can't collide with the envelope framing.

Extra participates because the generator may stamp model-specific metadata there that legitimately differs between drafts even when the body text is identical (e.g. the model fingerprint that produced the skill). The renderer accepts string / int / int64 / float64 / bool / nil and substitutes <unhashable> for anything else so a caller-side type bug yields a stable hash rather than a panic or non-deterministic ordering.

The hash version is implicit at V1 — changes to the envelope format (adding / removing / reordering fields) require a 0002_*.sql migration that rehashes existing rows AND a new decisions entry naming the old/new envelope. Operators with frozen content_hash values in external systems are explicitly out of scope at V1; we cross that bridge when a downstream system surfaces the hash externally.


D-047 — Planner package owns PauseReason, FinishReason, WakeMode, and the SpawnSpec shape; the TaskRegistry stays neutral on wake-mode

Date: 2026-05-12 Status: Settled Where it lives: RFC §6.2, RFC §6.3, RFC §3.2, docs/plans/phase-42-planner-iface.md, internal/planner/planner.go (PauseReason, FinishReason), internal/planner/wake.go (WakeMode, WakeAware, ResolveWakeMode), internal/planner/decision.go (SpawnSpec wrapping the planner-side subset of tasks.SpawnRequest), brief 02 §2.

Why: Phase 42 lands the planner's swappable seam (CLAUDE.md §1 / RFC §3.2). Four design calls warrant a settled entry.

  1. PauseReason lives in the planner package, not in a pauseresume package. The unified pause/resume primitive (later phase) is not yet shipped; brief 02 §2 sketches PauseReason as a planner-local type. Phase 42 follows the sketch — the four canonical values (approval_required, await_input, external_event, constraints_conflict) live in internal/planner/planner.go. When the unified pauseresume phase lands, it MAY canonicalise via a typedef bridge (pauseresume.Reason = planner.PauseReason) without changing call sites. The enum values match the RFC §6.3 canonical strings exactly, so the bridge is byte-stable.

  2. SpawnSpec is a planner-side projection of tasks.SpawnRequest, not a duplicate type. Brief 02 §2 sketches SpawnTask{ Kind TaskKind; Spec TaskSpec } as planner-local types. Phase 42 departs: SpawnTask.Kind is the production tasks.TaskKind; SpawnTask.Spec is planner.SpawnSpec (the planner-visible subset — Description, Query, Priority, RetainTurn, FailFast). The Runtime fills the rest of tasks.SpawnRequest (Identity from the run quadruple; IdempotencyKey from the planner step counter; PropagateOnCancel from the default; NotifyOnComplete from the spawn intent) at dispatch time. Duplicating tasks.TaskKind in the planner would be a §13 "two parallel implementations of the same conceptual feature" smell — internal/tasks is NOT a internal/runtime/... package, so the import is fine.

  3. WakeMode enum + optional WakeAware interface live in the planner package — the TaskRegistry stays neutral (D-032). D-032 settled that the wake-on-resolution strategy (push / poll / hybrid) is a planner-concrete concern, not a registry concern. Phase 42's internal/planner/wake.go ships the canonical enum + the optional WakeAware interface a concrete may implement to declare its mode. The conformance pack (Phase 49) uses planner.ResolveWakeMode(planner.Planner) WakeMode (which falls back to WakePush for concretes that skip WakeAware) to assert the round-trip. The WakeAware interface is NOT a Supports* capability protocol (§4.4 forbids those when all V1 drivers implement everything) — it's identity / metadata for a single mode each concrete picks at construction time. The conformance assertion exercises BOTH branches (concretes with WakeAware AND concretes without).

  4. FinishReason is canonical at the planner edge, NOT at the Protocol edge. The Protocol's task.completed / task.failed event payloads (later phase) project FinishReason into a Protocol-stable representation; the planner-internal enum is the truth source. Phase 42's enum (goal, no_path, cancelled, deadline_exceeded, constraints_conflict) covers the V1 terminals; future phases (phase-44-schema-repair, phase-50-pauseresume) add no new reasons — every terminal collapses to one of these five. IsValidFinishReason is the validator the Runtime executor will use to reject malformed Decisions before dispatch.

Additionally, Phase 42 declares the planner-emitted event taxonomy (planner.decision, planner.finish, planner.error) in internal/planner/events.go and registers the types via events.RegisterEventType from the package init(). The payload structs land at Phase 45 (the first concrete that emits); registering the type names at Phase 42 lets future concretes emit without re-registering. The stub finish.Planner does not emit (Emit may be nil); concrete planners (Phase 45+) MUST nil-check before calling.

The §13 import-graph lint test (internal/planner/conformance/importgraph_test.go) is the gate that keeps internal/planner/... decoupled from internal/runtime/.... The test walks the planner subtree with go/parser and fails the build on any internal/runtime/... import. Concretes added at Phase 45 / 48 inherit the gate without re-authoring.

D-049 — Trajectory fail-loudly Serialize contract lives in internal/planner/trajectory/; process-local handle registry at V1; canonical JSON ordering; Phase 42 stub retired

Date: 2026-05-12 Status: Settled Where it lives: RFC §6.2, RFC §3.4, RFC §6.3, docs/plans/phase-43-trajectory.md, internal/planner/trajectory/trajectory.go (Trajectory + Step + nested types), internal/planner/trajectory/toolcontext.go (ToolContext split + HandleID), internal/planner/trajectory/registry.go (HandleRegistry interface + process-local driver), internal/planner/trajectory/errors.go (ErrUnserializable + ErrToolContextLost struct sentinels), internal/planner/trajectory/serialize.go (Serialize + Deserialize + reflective walker), internal/planner/trajectory.go (alias re-exports from the subpackage), brief 02 §4.

Why: Phase 43 closes the load-bearing predecessor-bug: the silent-context-loss path where a non-serialisable handle in pause state was dropped silently. Four design calls warrant a settled entry.

  1. The trajectory subsystem lives at internal/planner/trajectory/, not directly in internal/planner/. The master plan's Subsystem column reads planner/trajectory; Phase 42 shipped the type skeleton inline in internal/planner/trajectory.go (file, not subpackage) as a placeholder. Phase 43 moves the load-bearing types (Trajectory, Step, ToolContext, HandleID, HandleRegistry, ErrUnserializable, ErrToolContextLost) into the canonical subpackage so the §4.4 extensibility-seam pattern applies — future drivers (a distributed handle registry, alternate serialisers) land alongside the existing process-local driver. The legacy planner-package types become type aliases (type Trajectory = trajectory.Trajectory) so existing call sites compile unchanged. Phase 42's stub ErrTrajectoryNotImplemented is retired: the only consumer was Phase 42's own test, which Phase 43 updates to exercise the real fail-loudly contract.

  2. Trajectory.Serialize uses a reflective pre-flight walker; the walker drives the canonical fail-loudly contract. The stdlib json.Marshal reports non-encodable types via *UnsupportedTypeError / *UnsupportedValueError — adequate for binary outcome but inadequate for the actionable field path the contract requires. Phase 43's walker recursively traverses the trajectory by reflect.Value, tracking the dotted field path ("Trajectory.Steps[3].Observation.callback"); on the first non-encodable leaf it returns (nil, ErrUnserializable{Field: <path>}). The walker mirrors encoding/json's encoding rules verbatim (chan / func / unsafe.Pointer / complex are rejected; nil interfaces / nil pointers / nil slices encode as JSON null; []byte encodes as base64; json.Marshaler implementers are probed; struct fields with json:"-" are skipped; cyclic graphs surface as ErrUnserializable{Field: ... <cycle>} via a visited-pointer-address map). On the happy path the walker passes; json.Marshal then produces the canonical bytes.

  3. HandleRegistry is process-local at V1; distributed-handle directory is a post-V1 RFC concern. RFC §6.3 already documents this constraint: "V1: process-local. Resume must run in the same Runtime process. The seam for a distributed handle directory exists (the registry is an interface) but no production driver ships at V1." Phase 43 ships the HandleRegistry interface (Set / Get / Delete) with one V1 driver — processLocalRegistry backed by sync.Map. The choice of sync.Map over map + RWMutex matches the read-heavy access pattern (one Set on tool dispatch, many Gets across pause/resume / planner steps); D-025 concurrent-reuse stress under -race is green with N=128. The fail-loud contract is Get returns (nil, ErrToolContextLost{Handle: id}) on miss — never (nil, nil). This is the load-bearing closure: the predecessor's try { ... } catch { return None } shape is rejected here, in Trajectory.Serialize, and in the planner-package alias re-exports — three places enforcing the same invariant.

  4. Canonical JSON ordering: declaration-order struct fields + alphabetised map keys. The stdlib encoding/json emits struct fields in declaration order (per JSON tag) and alphabetises map[string]X keys. Combined with explicit JSON tags on every Trajectory field, the canonical form is stable across re-encoding when any-valued fields hold JSON-tree shapes (map[string]any / []any / primitives). The runtime planner-step builder (later phase) follows this discipline; Phase 43's golden-bytes test uses the same shape and pins the canonical encoding. When any values hold Go structs, the first encoding (declaration-order) and the second encoding (alphabetised map after Deserialize) MAY diverge — the godoc on LLMContext / HintState / Step.Observation documents the discipline.

Round-trip byte stability is the load-bearing acceptance criterion from RFC §3.4 + brief 02 §4: Serialize → Deserialize → Serialize produces byte-identical output. The invariant is asserted in trajectory_test.go::TestRoundTrip_ByteStable against a populated trajectory using JSON-tree shapes throughout.

The §11 mandatory pause/resume serialisation test (toolcontext_test.go::TestPauseStateSerialisation_FailsLoudlyOnUnserializableContext) constructs a pause-state-shaped trajectory whose ToolContext.Serializable carries a live channel masquerading as a "config" value; asserts Serialize returns ErrUnserializable with the channel's key in the field path. The companion test TestResumeWithStaleHandle_ReturnsErrToolContextLost verifies the second half of the contract: a serialised trajectory carrying a HandleID whose registry mapping has died (simulated by a fresh HandleRegistry on the resume side) surfaces ErrToolContextLost on Get — never (nil, nil).

The D-025 concurrent-reuse contract is pinned in concurrent_test.go across four tests: N=128 goroutines serialising distinct trajectories, N=128 goroutines exercising HandleRegistry.Set/Get/Delete on disjoint IDs, N=128 goroutines reading a shared handle, and N=128 goroutines serialising a shared read-only trajectory. All four exit under -race with no leaks (baseline runtime.NumGoroutine restored), no context bleed, no byte-stability violations across concurrent invocations.

The §13 forbidden practice of "silent degradation" is closed by construction at three layers: Trajectory.Serialize (no try/catch → nil path), HandleRegistry.Get (no (nil, nil) return), and the planner-package alias re-exports (ErrUnserializable / ErrToolContextLost are public sentinels callers reach for via errors.As). Phase 51's pause-record contract (later phase) consumes this phase's Serialize bytes; Phase 51 inherits the fail-loud invariants without re-authoring.


D-050 — Repair ladder ordering (salvage → schema repair → graceful failure → multi-action salvage); graceful failure is Finish{NoPath} not error; Followup carried via Metadata; parser+loop both live under internal/planner/repair/

Date: 2026-05-12 Status: Settled Where it lives: RFC §6.2 (Settled — "salvage → schema repair → graceful failure → multi-action salvage" + arg_fill_enabled / repair_attempts / max_consecutive_arg_failures knobs), docs/plans/phase-44-schema-repair.md, internal/planner/repair/repair.go (Config, RepairLoop, Run, gracefulFailure), internal/planner/repair/parser.go (ActionParser), internal/planner/events.go (EventTypePlannerRepairExhausted, RepairExhaustedPayload), brief 02 §6, brief 07 §3 + §8 + §10.

Why: Phase 44 lands the salvage / schema-repair / graceful-failure / multi-action-salvage ladder for planner steps. Four design calls warrant a settled entry.

  1. Ladder ordering is load-bearing: salvage → schema repair → graceful failure → multi-action salvage. RFC §6.2 states the ladder explicitly. The order is binding because each step's invariant depends on the prior step:

    • Salvage is FIRST because a malformed parse leaves the loop without typed []planner.CallTool to validate. The parser is the only tolerant pass in the ladder — it accepts fenced JSON (```json), prose-wrapped JSON, multi-object scans, and bare arrays. Brief 07 §3 catalogued the predecessor's parser modes; Phase 44's ActionParser.Parse ships them as the salvage step.
    • Schema repair is SECOND because validating args is meaningful only on a parsed action. The corrective sub-prompt names the tool + the validator's complaint verbatim ("argument X failed: <validator error>; please re-emit with the corrected field"), which is a focused signal the LLM can act on. Bounded by Config.RepairAttempts.
    • Graceful failure is THIRD (a terminal short-circuit, not a step) because brief 07 §10 catalogued the failure-mode-blind footgun in the predecessor: "if the model's response is consistently malformed, _repair_attempts (default 3) of identical-shape feedback may never converge." Phase 44's Config.MaxConsecutiveArgFailures is a separate counter from Config.RepairAttempts — identical-shape failures terminate via the consecutive-failure path even when the attempts budget is high. Default MaxConsecutiveArgFailures = 2 < RepairAttempts = 3 so the storm guard typically fires first.
    • Multi-action salvage is FOURTH (a packaging step, not a step) because it operates on the OUTPUT of salvage + repair. When the parser returned >1 well-formed CallTool and every one validates, the loop packages them as CallParallel{Branches: [...], Join: JoinAll} rather than re-asking the LLM. Concretes that want sequential salvage opt out by setting Config.ArgFillEnabled = false.
  2. Graceful failure is Finish{Reason: NoPath, Metadata["followup"]=true}, NOT an error. The repair loop's Run returns (planner.Decision, error). On graceful failure the loop returns (Finish{}, nil) — Finish IS the success path the planner contract describes. An error return would conflate two distinct conditions: (a) the LLM client surfaced a transient error (caller must retry / abort the run), vs. (b) the repair ladder exhausted (the planner step itself produced a terminal Finish that the runtime executor maps to task.completed with reason=no_path). Conflating them would break the planner contract (§13 forbids two-parallel-implementations of the same feature; here the feature is "what shape the planner returns at step end"). The planner.repair_exhausted event emit is the load-bearing observability surface — graceful failure is NOT silent (§13 silent-degradation ban). The event payload carries the attempt count, consecutive-failure counter, and truncated chain of validator reasons; operators see the failure loudly via the bus + audit pipeline.

  3. Followup carried via Metadata["followup"] = true, NOT a new field on planner.Finish. Brief 02 §6 spec'd Finish{Reason: NoPath, Followup: true} but Phase 42 froze the Finish struct (Reason, Payload, Metadata — D-047). Adding a Followup bool field would require touching every Phase 45 / 48 / 49 concrete and the conformance pack, and would re-litigate D-047. Metadata is already the documented surface for terminal-decision annotations (the stub finish.Planner uses it for run_id round-trip; Phase 45 will use it for the planner's free-form Reasoning hash). Phase 49's conformance pack reads Metadata["followup"] to detect the followup signal; glossary entries spell out the convention. Same applies to the auxiliary fields the loop stamps for observability: Metadata["repair_attempts"], Metadata["repair_consecutive_arg_failures"], Metadata["repair_chain"], Metadata["repair_error"].

  4. Parser + loop both live under internal/planner/repair/, NOT internal/runtime/planner/parser/. Brief 07 §8 sketched ActionParser at internal/runtime/planner/parser/. Phase 44 co-locates the parser with the loop under internal/planner/repair/. Three reasons:

    • Import-graph contract (Phase 42 settled). The planner subtree MUST NOT import internal/runtime/...internal/planner/conformance/importgraph_test.go is the §13 gate. The parser is a planner-side utility (it produces planner.CallTool shapes the loop returns to the runtime executor); it cannot live at internal/runtime/planner/parser/ without breaking the gate.
    • Master-plan glossary lines 927 + 930. "ActionParser (internal/runtime/planner/parser/) | 44 (Schema repair pipeline) + 45 (Reference ReAct planner)" + "RepairLoop | 44 (Schema repair pipeline)". The path "internal/runtime/planner/parser/" in the glossary is pre-RFC nomenclature; the RFC's settled home is internal/planner/.... Co-locating with the loop matches the "owned in one phase, consumed in another" pattern that the master plan glossary describes.
    • Single-package cohesion. Parser + loop + feedback builder + events live in one Go package. The package's godoc describes the ladder; the implementation is one file per concern (repair.go, parser.go, feedback.go); the test files mirror that split (repair_test.go, parser_test.go, integration_test.go, d025_test.go). Splitting parser into a sibling package would force a public API on what is structurally an implementation detail of the repair loop.

Additionally, Phase 44 ships:

  • Config.ArgFillEnabled — opt-in. When false the loop returns the parser's first valid action(s) verbatim and lets the dispatcher's tool.invalid_args reject path handle schema misfits. Phase 45 (ReAct) defaults this to true; Phase 48 (Deterministic) defaults it to false (the deterministic planner does not consume LLM output, so the knob is structurally irrelevant).
  • Config.RepairAttempts default = 3 matching brief 07 §3 step 5's predecessor default. The storm guard is Config.MaxConsecutiveArgFailures = 2 < 3 so the typical malformed-shape session terminates after 2 LLM calls rather than burning the full 3.
  • planner.repair_exhausted event taxonomy. The event type registers in internal/planner/events.go::init() alongside planner.decision / planner.finish / planner.error (Phase 42 entries). The typed RepairExhaustedPayload (SafePayload) carries Identity, Attempts, ConsecutiveArgFailures, Reasons []string (each entry truncated to 256 bytes), OccurredAt. The payload struct ships in the same PR as the emit site — distinct from the Phase 42 pattern where payload structs deferred to Phase 45 — because Phase 44 IS the first emitter, so deferral would be a fail-loudly violation.
  • No two-parallel-retry-implementations (§13). The repair loop calls llm.LLMClient.Complete; the LLM client (composed at internal/llm/registry.go::Open) already has the Phase 36 retry-with-feedback wrapper inside. Repair is OUTSIDE the LLM call (it consumes the response); retry-with-feedback is INSIDE the LLM call (it wraps a single attempt). The smoke script guards against internal/planner/repair/ importing internal/llm/retry — composition stays at the registry edge.

The internal/planner/repair/d025_test.go ships the N=128 concurrent-reuse stress: one shared RepairLoop instance, per-goroutine identity quadruples, four response patterns (clean salvage / parser-correction / multi-action / graceful-failure), per-call identity round-trip assertion at three boundaries (the stub client's seen-ctx, the success-path Decision's Reasoning field, the graceful-failure-path RepairExhaustedPayload.Identity). Pre-cancelled ctxes on i%5==0 verify cancellation cross-talk is absent.


D-048 — Phase 38 planner-skill tools: split into three Tools (not a SkillProvider struct); default-deny capability filter; chars/4 budgeter aligned with §6.5 LLM safety net

Date: 2026-05-12 Status: Settled Where it lives: RFC §6.7, docs/plans/phase-38-skill-planner-tools.md, internal/skills/tools/tools.go (Register, searchHandler, getHandler, listHandler), internal/skills/tools/filter.go (capability subset gate), internal/skills/tools/redactor.go (tool-name + PII redaction), internal/skills/tools/budgeter.go (Fit ladder + ErrSkillTooLarge), brief 04 §4.5.

Why: Phase 38 lands the planner-facing surface for the skills subsystem. Three design calls warrant a settled entry.

  1. The planner-facing surface is three discrete Tools, not a SkillProvider struct. RFC §6.7's sketch shows a SkillProvider interface with Search / GetByName / List / Directory / FormatForInjection — modelled after the predecessor's monolithic provider. Phase 38 splits the surface across three Tools registered through the Phase 26 catalog (skill_search, skill_get, skill_list) plus Phase 39's Directory(cfg) API rather than a single struct. Two reasons:

    • Catalog dispatch uniformity. Every other Harbor tool (HTTP, MCP, A2A, in-process, flow) goes through the catalog; carving a separate dispatch path for skills would split the reliability shell (ToolPolicy — D-024) and the audit emit taxonomy (tool.invoked / tool.completed / tool.failed). Three Tools-on-the-catalog gives the planner the same observability surface as any other tool.
    • Per-tool ergonomics. skill_get carries the tiered budgeter; skill_search carries ranking; skill_list carries paging. A single SkillProvider.FormatForInjection would have to multiplex these concerns — three Tools keep each handler narrow. The capability filter + redactor are shared utilities (Filter, Redact), not a methods-on-a-struct API, so future tools (Phase 39 Directory, Phase 41 skill_propose) reuse them by calling.

    The departure from the RFC sketch is recorded here, not silent — future readers chasing the RFC's SkillProvider shape land here and see the rationale.

  2. Capability filter is default-deny. When CapabilityContext.AllowedTools / AllowedNamespaces / AllowedTags is empty, a skill with non-empty Required* lists is rejected. The predecessor's _skill_is_applicable documents the same stance ("required must be a subset of allowed"; empty allowed is a strict subset only of empty required) — Phase 38 ports it verbatim. The alternative ("empty allowed = everything passes") would silently leak high-capability skills into low-capability runs the first time an operator forgot to populate the allowed-set; default-deny fails closed, matches CLAUDE.md §6 rule 9 ("identity is mandatory"), and is the only stance that survives an operator-config-bug audit. Skills with empty Required* lists for every axis are unconstrained — they neither carry nor demand a capability.

  3. The tiered budgeter uses the chars/4 token estimator, aligned with the §6.5 LLM safety net (D-026). Two alternatives existed: (a) a tokenizer-backed estimator (tiktoken / Anthropic counter) for precision; (b) chars/4 for simplicity. Phase 38 picks (b) because:

    • Consistency with the safety net. RFC §6.5's context-window safety net uses chars/4 as its budget envelope at V1 (D-026); the planner-side budgeter MUST agree on the cost model or the safety net would surface ErrContextWindowExceeded on payloads the budgeter accepted. Same estimator → coherent gate.
    • CGo-free constraint. Most production tokenizers (tiktoken-go, anthropic-tokenizer) either pull a C library or a sizeable pre-trained vocabulary table; both inflate the binary and either break the CGo-free constraint or burden the cold-start footprint. The chars/4 envelope is byte-counting — zero binary cost, deterministic, well-understood industry low-precision heuristic.
    • Swappable point. The estimator lives in one function (tokensForcharsEstimate); a post-V1 swap-in via a tokenizer interface is a one-package change. We cross that bridge when an operator surfaces a real over-budget bug; until then, the safety net's chars/4 is the cost authority.

    The budgeter's ladder (full → drop optional → cap steps to 3 → ErrSkillTooLarge) ports brief 04 §4.5 verbatim. Step 4 fails loud per CLAUDE.md §5 — no silent degradation; the planner sees ErrSkillTooLarge wrapped and can either reformulate via LLM retry feedback or shrink its MaxTokens and retry.

The CapabilityContext value is a value-type carried on the args of all three Tools; it is never mutated in-flight and is safe to share across N goroutines (D-025). The Phase 38 helpers (Filter, Redact, Fit) are pure functions over value inputs — no shared state, no closures over per-run data. Phase 39's Directory(cfg) will reuse Filter + Redact directly; Phase 41's skill_propose(persist=true) will reuse the validator path on the input draft.

D-051 — Phase 45 ReAct planner: JSON-only action format with _finish reserved tool name; single-tool-call-per-step (multi-action salvage reduced to first); MaxSteps circuit breaker + planner.max_steps_exceeded fail-loudly emit; WakePush declaration ships ahead of the SpawnTask emission path; SpawnTask / AwaitTask / RequestPause emission deferred to later phases

Date: 2026-05-12 Status: Settled Where it lives: RFC §6.2, RFC §3.2, docs/plans/phase-45-react-planner.md, internal/planner/react/react.go (ReActPlanner, FinishToolName, DefaultMaxSteps, DefaultSystemPrompt, the six functional options, Next, WakeMode, mapDecision, translateFinishCall, reduceToSingleAction, maxStepsExceeded, emitMaxStepsExceeded), internal/planner/react/prompt.go (PromptBuilder interface + defaultBuilder), internal/planner/events.go (EventTypePlannerMaxStepsExceeded, MaxStepsExceededPayload), internal/planner/conformance/conformance.go (Harness.RunContextFactory extension), brief 02 §2 + §4 + §5 + §6 + §7, brief 07 §2 + §3 + §5 + §10.

Why: Phase 45 lands Harbor's first concrete Planner implementation — the LLM-driven ReAct step loop that bridges Phase 32's LLMClient, Phase 43's Trajectory, Phase 44's RepairLoop, and Phase 42's Planner seam. Five design calls warrant a settled entry.

  1. JSON-only action format with _finish as a reserved prompt-time tool name — NOT a magic-string opcode in the Decision sum. Brief 02 §2 sketches the LLM emitting one of six Decision shapes (CallTool / CallParallel / SpawnTask / AwaitTask / RequestPause / Finish); brief 07 §3 catalogues the predecessor's parser stack and §5 documents the assistant/user-rendered observation shape. Phase 45 narrows the V1 prompt-emission surface to exactly two envelopes:

    • {"tool": "<name>", "args": {...}, "reasoning": "..."} — a tool call.
    • {"tool": "_finish", "args": {"answer": "..."}, "reasoning": "..."} — completion.

    The reserved _finish name is intercepted by the planner BEFORE it returns the Decision. react.translateFinishCall translates the parsed CallTool{Tool: "_finish"} into planner.Finish{Reason: planner.FinishGoal, Payload: <args.answer>} — the Decision sum stays sealed; the planner contract surfaces only the typed Finish shape. The predecessor's "magic strings as next_node" anti-pattern (D-047) is explicitly rejected: _finish lives in the LLM-prompt convention, NOT in the planner-internal Decision opcodes. The leading underscore is a documented hygiene convention; future runtime catalog registration MAY reject _-prefixed tool names to make the collision impossible. The integration with Phase 44's repair.RepairLoop flows naturally — the loop returns a CallTool with the reserved name; the planner's mapDecision switch detects and translates BEFORE the runtime executor would dispatch the reserved name as a real tool.

  2. Single-tool-call-per-step semantics with multi-action salvage reduced to the first action. RFC §6.2 + the Phase 45 master-plan detail block: "LLM call loop, JSON-only action format, tool selection, completion detection, single tool call per step. No parallel, no schema repair beyond a single retry." Phase 44's RepairLoop ships multi-action salvage as a CallParallel (D-050 — when the parser returns >1 well-formed CallTool and every one validates, the loop promotes to CallParallel{JoinAll}). Phase 45 overrides this at the planner concrete level: react.reduceToSingleAction collapses a CallParallel from the loop to its first CallTool. The rest are dropped — V1 minimum viable per the master-plan detail block. Three rationales:

    • No parallel executor exists yet. Phase 47 ships CallParallel execution (Deps: 45, 14); returning CallParallel from Phase 45 would prematurely commit to a runtime dispatch path with no executor. The error would surface as planner.ErrInvalidDecision at runtime dispatch time, but the planner's contract is to return a Decision the runtime CAN execute.
    • Unwind point is one method. reduceToSingleAction is the entire override surface. Phase 47 deletes the override; the rest of the planner is unchanged. The brief 02 §6 "queue the additional read-only tool calls for sequential execution without another LLM hop" promise revisits at Phase 47 — until then, the dropped actions ARE NOT surfaced as fallback context to the next prompt (a forwarding-the-rejected-actions path would have no test coverage until Phase 47 lands).
    • Special case for the _finish first branch. When the first branch of a multi-action salvage is the reserved _finish tool, the reduction must still translate to a Finish Decision (the completion semantics MUST NOT change with the reduction). The unit test TestNext_ParallelWithFinishFirstStillFinishes pins this.

    Brief 02 §6 lists multi-action salvage as the Phase 44 default; Phase 45 departs at the planner concrete level (not at the loop level — the loop is reused as-is per §13 two-parallel-implementations ban).

  3. MaxSteps circuit breaker as planner-side defence in depth — accompanied by the planner.max_steps_exceeded fail-loudly emit. Brief 02 §2 puts MaxSteps / HopBudget at runtime level only; RFC §6.2's RunContext.Budget.HopBudget is the authoritative runtime gate. Phase 45 ALSO ships a planner-side WithMaxSteps functional option (default 12) as a circuit breaker against a buggy LLM mock that never returns _finish AND a runtime that hasn't yet wired the hop-budget enforcement (Phase 47+). When len(rc.Trajectory.Steps) >= MaxSteps at the start of Next, the planner:

    • Emits planner.max_steps_exceeded (registered in internal/planner/events.go alongside planner.repair_exhausted; typed MaxStepsExceededPayload SafePayload carries Identity, MaxSteps, StepsObserved, LastTool, OccurredAt).
    • Returns Finish{Reason: planner.FinishNoPath, Metadata: {"max_steps_exceeded": true, "max_steps": <cap>, "steps_observed": <count>, "last_tool": <name>, "run_id": <runID>, "via": "react.maxStepsExceeded"}}.
    • Does NOT call the LLM (the breaker fires BEFORE any LLM call — a runaway must not burn additional completions).

    The emit is the load-bearing observability surface that makes the breaker NOT silent (§13 silent-degradation ban). The same fail-loudly shape as Phase 44's planner.repair_exhausted — different graceful-failure source (repair-loop exhaustion vs. planner-side step cap), same observability shape. When Phase 47's runtime hop-budget enforcement lands, MaxSteps becomes a redundant defence in depth (preferred over a load-bearing single gate). The runtime's hop budget remains the authoritative gate; the planner's MaxSteps is the secondary one.

  4. WakePush declaration (D-032) ships at Phase 45 ahead of the SpawnTask emission path. Phase 45's master-plan detail block: "ReAct ships the push wake mode (D-032): a non-retain-turn SpawnTask returns control to the runtime; the runtime registers the planner against tasks.WatchGroup; on GroupCompletion the runtime re-invokes Planner.Next with the resolved MemberOutcome slice surfaced through RunContext." Phase 45's ReActPlanner implements planner.WakeAware returning planner.WakePush; the conformance pack's WakeMode_Declared subtest asserts planner.ResolveWakeMode(reactPlanner) == planner.WakePush. The SpawnTask emission PATH itself is deferred to a later concrete-planner upgrade — the V1 prompt schema is intentionally narrow (only CallTool / _finish); SpawnTask emission would need additional prompt-engineering surface to describe background tasks to the LLM, which is out of scope for "minimum viable." The WakePush declaration is still load-bearing: it binds ReAct to the conformance pack's wake-mode-round-trip subtest (Phase 49) so that when SpawnTask emission lands, the binding is already in place.

  5. Phase 45 V1 deferrals: SpawnTask / AwaitTask / RequestPause emission, multi-action fallback-context forwarding, runtime loop, trajectory compression. The master-plan detail block reads "minimum viable"; Phase 45 ships exactly the surface the spec names. Deferrals:

    • SpawnTask / AwaitTask emission: the prompt schema doesn't describe background tasks. A later planner upgrade (or a separate concrete) extends the schema; Phase 45 surfaces only CallTool / _finish to the LLM.
    • RequestPause emission: Phase 50 ships the unified pause/resume primitive; until then, there's no pauseresume.Coordinator for RequestPause to dispatch into. Phase 45 observes rc.Control.PauseRequested from incoming steering but does NOT emit RequestPause itself.
    • Multi-action fallback-context forwarding: the rejected actions in reduceToSingleAction are dropped at V1 (no forwarding to the next prompt). Phase 47 will revisit when the parallel executor exists.
    • Runtime loop / multi-step orchestration: Phase 45 ships Next(ctx, rc) (Decision, error) — ONE step. The runtime executor that calls Next in a loop, executes Decisions, and threads observations back into the next prompt lands in the planner-runtime wiring phases (Phase 47+).
    • Trajectory compression / summariser: the prompt builder consumes Trajectory.Summary when set (the read path is shipped); the summariser that populates Trajectory.Summary lands in Phase 46.

    The deferrals are recorded here, not silent — future readers chasing the planner concrete's full surface land in this entry first.

Additionally, Phase 45 extends internal/planner/conformance/conformance.Harness with an optional RunContextFactory field so the Sanity scenario receives a populated identity quadruple. The Phase 42 harness skeleton's Sanity subtest passed a zero RunContext; the stub finish.Planner accepted it because that stub does NOT enforce identity. Phase 45's planner enforces identity (§6 rule 9 + D-001) and would otherwise fail the Sanity scenario. The harness extension is backward-compatible (nil RunContextFactory falls back to the zero RunContext for the stub).

The internal/planner/react/d025_test.go ships the N=128 concurrent-reuse stress: one shared *ReActPlanner instance, per-goroutine identity quadruples + ctxes, per-goroutine LLM stubs returning _finish envelopes whose args.answer carries the run's RunID. The terminal Finish.Payload is asserted to match the goroutine's RunID (no identity bleed); pre-cancelled ctxes on i%5==0 return ctx.Err() (no cancellation cross-talk); the goroutine baseline is restored within 500ms of WaitGroup join (no leak). The shared planner's StepsTaken() atomic counter is asserted to match the expected non-cancelled count, proving the per-call mutation is correctly atomic.

The §13 import-graph contract is preserved by construction — internal/planner/react/ imports only internal/llm, internal/planner, internal/planner/repair, internal/events, internal/tools, and stdlib packages. No internal/runtime/... imports; the Phase 42 lint test (internal/planner/conformance/importgraph_test.go) covers the new package by construction (it walks the entire planner subtree). The Phase 45 smoke script asserts the same via grep at every preflight gate.


D-053 — Phase 40 Skills.md importer: byte-stable round-trip via raw-frontmatter passthrough and line-based body parsing; attachments as ArtifactRef option (b); fail-closed at every parse failure mode

Date: 2026-05-12 Status: Settled Where it lives: RFC §6.7, docs/plans/phase-40-skills-importer.md, internal/skills/importer/importer.go (Importer interface + Import / Export / Close, Deps{Store}, sentinels ErrMissingFrontmatter / ErrMalformedYAML / ErrMissingTrigger / ErrEmptySteps / ErrUnknownSection / ErrAttachmentOutsideRoot / ErrInvalidAttachmentRef / ErrRoundTripDrift / ErrImporterClosed), internal/skills/importer/parser.go (scanFrontmatter, parseFrontmatter, bodyParse, resolveAttachments, uploadAttachment, classifySection, slugify, nameFallbackFromHint, doImport), internal/skills/importer/exporter.go (doExport, synthesiseFrontmatter, desubstituteArtifacts), internal/skills/importer/path_safety.go (resolveSafePath, pathHasPrefix), internal/skills/importer/testdata/golden/*.md + *.want.json (5 fixtures), brief 04 §4.7 + §5 + §6.

Why: Phase 40 closes the predecessor's per-skill-manual-adaptation gap — the load-bearing Harbor-defining feature (RFC §6.7, brief 04 §1). The byte-stable round-trip Export(Import(b)) == b is the tested invariant that distinguishes a working importer from a working-by-coincidence importer. Four design calls warrant a settled entry.

  1. Byte-stable round-trip via raw-frontmatter passthrough — NOT YAML re-emission. Brief 04 §4.7 step 1 says "CommonMark-only parser"; step 5 says "round-trip byte-stable." A naive implementation parses YAML into a struct, parses Markdown into an AST, and re-emits both — which never survives the round-trip because (a) every YAML emitter has its own key-ordering / quoting / spacing convention, (b) every CommonMark AST loses some source-side fidelity (heading underline style, list bullet character, blank-line-between-paragraphs count). Phase 40 picks a different shape:

    • Frontmatter: the importer captures the raw bytes between the --- fences VERBATIM via scanFrontmatter. The parsed frontmatterFields struct is used for value extraction (validation, slugified-name fallback, Skill struct population); the raw bytes are stashed in Skill.Extra["_importer.frontmatter_raw"] for Export. doExport reads the raw bytes back and emits them between fresh --- fences. Authors hand-ordering keys (name first, description second — a common Skills.md convention) round-trip byte-stable.
    • Body: the importer ships a line-based deterministic parser (bodyParse) — strictly stricter than CommonMark, but deterministic by construction. Section headings (## Steps, ## Preconditions, ## Failure modes) are accepted with case + plural + trailing-colon variations on the parse side; Export emits the canonical heading (canonicalHeading). A source with ## steps parses correctly but does NOT round-trip byte-stable — the invariant gates canonical sources only. The golden corpus uses canonical headings throughout.
    • List items: one line per item, prefix -␣ (dash-space) required. The parser rejects lazy-continuation list items (CommonMark allows them; Skills.md is stricter). Blank lines inside a section are tolerated as separators; non-list-item prose inside a section is rejected via ErrUnknownSection.

    The departure from "CommonMark-only parser" is recorded in the plan's "Findings I'm departing from" section. Two reasons: (a) full CommonMark parsers (e.g. goldmark) ship AST-rendering only, not AST-to-source emission, so a round-trip through one would still need to carry the original source text and re-emit from it — which is what the line-based parser does directly; (b) adding a new dependency for a single use-case violates the CLAUDE.md §13 forbidden-practices section on heavy frameworks. The line-based parser uses only stdlib + the existing goccy/go-yaml (already used by internal/config/loader.go).

  2. Attachments resolve to artifacts.ArtifactRef (option (b) per RFC §6.7). Brief 04 §5 surfaced three options for inline ![alt](path) references: (a) inline at import (simple, blows up the Skill row); (b) store as artifact references (clean but couples to artifact subsystem); (c) keep filesystem-backed and re-resolve at injection (fast but breaks once skills move between machines). RFC §6.7 settled on (b) — Phase 40 implements it:

    • On Import, resolveAttachments walks the description + every section list item via imageRefRegexp. For each ![alt](path) reference: read the file under ImportSource.AllowedRoot (path-safety guarded — see point 3), upload via Deps.Store.PutBytes(ctx, src.Scope, data, {Namespace: "skills-importer"}), replace the path in the body with artifact://<ArtifactRef.ID>. The mapping (Path → Ref) is captured in ImportArtifacts.PathToRef.
    • On Export, desubstituteArtifacts walks the body for artifact://<ID> markers and substitutes each ID back to its source-side path verbatim via the reverse lookup. A dangling ID (not in PathToRef) returns wrapped ErrInvalidAttachmentRef — Export never silently emits a broken reference.
    • URL / data:URI refs (http://, https://, data:, artifact://) are kept verbatim and NOT uploaded — they don't resolve to filesystem paths; the importer stays offline at V1 (no network calls).
    • Duplicate paths in one source return ErrInvalidAttachmentRef at Import time. Duplicates would break Export's injectivity (one path → many refs → the reverse mapping is ambiguous) and are an authoring smell — fail-closed at parse time.
    • ArtifactScope is caller-supplied via ImportSource.Scope. The importer does NOT synthesise the scope itself — callers (Phase 60+ upload handlers) thread the identity quadruple plus the import-task ID through. The convention (documented in the plan, not enforced by the importer) is TaskID = "import:" + sha256(src)[:12] so all attachments of one Skills.md file cluster under a stable task-shaped key.
  3. Path-traversal protection at path_safety.go (CLAUDE.md §7 #5). Every relative attachment path is resolved via:

    • filepath.IsAbs rejection — Skills.md is path-relative-to-source; absolute paths are rejected with wrapped ErrAttachmentOutsideRoot.
    • Empty path / empty AllowedRoot rejection — the operator must declare a safe root; empty fields are rejected to fail closed.
    • filepath.Clean + pathHasPrefix(joined, canonicalRoot) lexical check — the standard traversal guard. The pathHasPrefix helper avoids the /a matching /abc false-positive by appending the OS separator to the root before the prefix check.
    • filepath.EvalSymlinks symlink check — when the path exists, both the joined path and the canonical root are evaluated for symlinks; the prefix check is repeated on the evaluated paths. This blocks the attachments/link -> ../../outside.txt escape. When the path does NOT exist (the caller is probing — currently not a path the importer takes, but defended for future read-before-write callers), the symlink-eval step is skipped and the lexical check carries.

    The helper is the canonical path-safety guard for the skills subsystem; future skills-side callers (Phase 41 generator if it persists attachments, etc.) reuse it.

  4. Fail-closed at every parse failure mode — no lenient flag at V1 (CLAUDE.md §13 silent-degradation ban). The exhaustive failure-mode set:

    • ErrMissingFrontmatter: source does not begin with ---\n. Empty file lands here too.
    • ErrMalformedYAML: opening fence found but closing fence missing, or YAML parser failed.
    • ErrMissingTrigger (wraps skills.ErrInvalidSkill): frontmatter parsed but trigger: empty after trim. The Phase 37 validator pinned trigger as the planner-visible match cue (brief 04 §4.7 step 4); empty trigger is a hard reject.
    • ErrEmptySteps (wraps skills.ErrInvalidSkill): body parsed but ## Steps absent or had zero list items. Same Phase 37 validator rule.
    • ErrUnknownSection: body contained a ## Heading outside the canonical set, or a duplicate section, or non-list-item prose inside a section. A lenient flag that accepted unknown sections would silently drop content (the planner would see only the canonical fields); fail-closed avoids surprise.
    • ErrMalformedYAML (also covers): YAML keys that fail to decode into the typed frontmatterFields struct.
    • ErrAttachmentOutsideRoot: path-safety rejection (see point 3).
    • ErrInvalidAttachmentRef: duplicate attachment path at Import, OR dangling artifact:// reference at Export.
    • ErrRoundTripDrift: reserved for tests that explicitly assert byte-stable round-trip; the importer does not emit it from production code.
    • ErrImporterClosed: any method called after Close.

    The set is exhaustive — every failure mode has a typed sentinel that callers compare via errors.Is. There is no silent-degradation path; every parse failure surfaces with a wrapped error and a %v context string naming the offending input.

Additionally, Phase 40 ships:

  • N=128 D-025 concurrent-reuse test. One shared *importerImpl instance; per-goroutine distinct in-memory Skills.md payloads (the Name field encodes idx so cross-goroutine bleed surfaces as a name-mismatch); pre-cancelled ctxes on i%5==0 return ctx.Err() without affecting siblings; goroutine baseline restored within 500ms of WaitGroup.Wait. Under -race. The Importer holds no per-call mutable state on itself — closed is an atomic.Bool; the injected ArtifactStore is D-025 safe per Phase 17's conformance suite.

  • 5-fixture golden corpus under internal/skills/importer/testdata/golden/: minimal.md (trigger + steps only), full.md (every section + every frontmatter field), preconditions-only.md, failure-modes-only.md, with-attachments.md. Each fixture ships a .want.json mirror that the importer's Skill output must match deep-equal (lifecycle fields excluded; ContentHash is recomputed at Import via skills.CanonicalContentHash). The with-attachments.want.json carries a <REF:attachments/example.txt> placeholder that the test substitutes with the actual ArtifactRef.ID before comparing. Every fixture is asserted byte-stable via bytes.Equal(src, Export(Import(src))).

  • 93.8% statement coverage on internal/skills/importer (target 90%). The uncovered branches are defensive (filepath.Abs error path on the canonical root, EvalSymlinks root-eval error path, the Export method's closed-state branch when ctx.Err() also fires — race-window edge case). Not material to the load-bearing surface.

  • Phase 37 hand-off via Skill.Extra: the raw frontmatter bytes and the source-hash are stashed in Skill.Extra["_importer.frontmatter_raw"] and Skill.Extra["_importer.source_sha256"]. The Phase 37 CanonicalContentHash includes Extra via its key-sorted text rendering, so changes to the raw frontmatter (even when the parsed fields are identical) produce a different ContentHash — exactly the LWW gate the Phase 37 conflict policy needs. The hash exclusion of Origin / OriginRef / Scope (D-046) is preserved — a Skills.md re-imported via a different OriginRef (different pack version) still hashes identically when the content is the same.

The internal/skills/importer/concurrent_test.go ships the N=128 stress; internal/skills/importer/path_safety_test.go ships the 6-entry path-safety rejection table + the symlink-escape test; internal/skills/importer/negative_test.go ships the 10 negative cases; internal/skills/importer/importer_test.go ships the golden corpus assertions; internal/skills/importer/attachments_test.go wires the real inmem.ArtifactStore through the seam and asserts round-trip + duplicate-rejection + URL-passthrough + close-survival. The Phase 40 smoke script (scripts/smoke/phase-40.sh) asserts the test surface passes under -race AND the golden corpus directory is non-empty (the round-trip invariant has nothing to assert against without fixtures).


D-054 — Phase 41 skill generator: skill_propose(persist=true) with conflict-policy precedence (PackImport-protected; Generated→Generated content-hash-gated LWW); audit-mandatory with persist rollback on emit failure; default Scope=project; Promote is a Go-level API not a planner tool

Date: 2026-05-12 Status: Settled Where it lives: RFC §6.7, docs/plans/phase-41-skill-generator.md, internal/skills/generator/generator.go (Register, Propose, Promote, SkillDraft, SkillReceipt, ProposeResult, ErrSkillConflict, ErrSkillConflictSentinel, ToolNameSkillPropose, buildSkillFromDraft), internal/skills/generator/events.go (SkillProposedPayload), internal/skills/generator/audit.go (redactExcerpt, emitProposed, auditExcerptCap), internal/skills/events.go (EventTypeSkillProposed), internal/skills/skills.go (ScopeSession), brief 04 §4.8 + §5 + §6.

Why: Phase 41 closes the predecessor's "draft generator can't save" gap — Harbor's runtime persists generated skills, and every persist emits a mandatory audit event. Four design calls warrant a settled entry.

  1. Conflict policy precedence: PackImport-protected first; Generated→Generated content-hash-gated LWW second; insert otherwise. The policy is the load-bearing rule from RFC §6.7 + brief 04 §4.8 ("refuse to overwrite a Origin=PackImport skill with the same name. For Origin=Generated → Origin=Generated, last-write-wins gated by content_hash change"). Phase 41 centralizes the precedence in generator.Propose:

    • Probe via SkillStore.Get BEFORE the upsert. If the existing row is Origin=PackImport, refuse with *ErrSkillConflict{Reason:"pack_import_protected"} AND emit skill.proposed with Result="rejected". The audit emit on rejection is load-bearing — the rejection IS observable on the audit pipeline (matches RFC §6.7's "audit is mandatory" framing extended to refusals).
    • If the existing row is Origin=Generated AND ContentHash matches the incoming draft's canonical hash, return Result="idempotent". No DB write needed; the audit event still lands so subscribers can correlate the call.
    • Otherwise fall through to SkillStore.Upsert — which is either an insert (no existing row) or a LWW overwrite (existing Generated with different hash). The Phase 37 storage layer's ErrPackOverwriteRefused is still wrapped defensively at the generator boundary in case a probe-then-upsert race lets a fresh pack row slip in between probe and upsert.

    The order of probes is binding: pack-protection wins over hash-idempotency because a same-content-hash drafted skill against an existing pack row should still be refused (the operator's invariant is that pack rows are inviolable to the generator, regardless of whether the generated draft happens to match the pack's content).

  2. Audit-mandatory with persist rollback on emit failure. Every persist=true call emits a skill.proposed event BEFORE returning success. Caller-controlled excerpts (SkillDraft.Title / Trigger) flow through audit.Redactor.Redact BEFORE the payload is built; the bounded post-redactor excerpts land on the typed SkillProposedPayload (SafePayload, so the bus does not re-run them through the redactor). The payload also carries Name, Origin, OriginRef, ContentHash, Scope, Result, Reason, and Promotion — all bounded enumerable strings or hex hashes; no untyped tool arguments in audit payloads (CLAUDE.md §7 rule 7).

    Audit-emit failure aborts the persist. Three branches:

    • Insert / LWW emit failure: the DB row was committed by store.Upsert; on skill.proposed emit failure the generator calls store.Delete(ctx, q, name) to roll back. The caller's subsequent Get returns ErrSkillNotFound. The wrapped error names the audit-emit failure as the cause; if the rollback Delete ALSO fails, the wrapped error names both failures. This is the spec's "audit-emit failure elevated to a first-class concern" requirement.
    • Idempotent emit failure: no DB write happened; the wrapped error simply surfaces the emit failure. The row stays intact (matches existing Generated content).
    • Rejection emit failure: no DB write happened; the wrapped error surfaces the audit-emit failure rather than the *ErrSkillConflict so the audit pipeline's drift is the dominant fault.

    The same fail-loud shape applies to Promote: per-target audit emit failure rolls back the target's row via store.Delete(ctx, target, name). The strict-fail model — the first failing target aborts the whole call, subsequent targets are NOT attempted — is the simplest semantics matching the storage layer's transactional shape.

  3. Default Scope=project. RFC §6.7's "Generator scope default — Settled" decision + brief 04 Q-4 are honored verbatim: when SkillDraft.Scope is empty, Propose stamps ScopeProject before validation. Three rationales:

    • Scope=session (the narrower default) would mean every generated skill stays trapped in the originating session — the predecessor's "draft generator" pattern in even more degraded form. The user-facing promise of skill_propose is "the LLM authored a reusable skill"; the default must be reusable.
    • Scope=tenant (the broader default) overshares — a skill authored by user A's planner should not auto-leak to user B's projects.
    • project is the operator-declared aggregation point at which "this team's planners should see this generated skill" — the right default-with-room-for-explicit-broaden.

    The Promote API explicitly handles broadening (session → project, project → tenant): operators or composition code can elevate a skill's visibility after the fact without touching Propose.

  4. Promote is a Go-level API, not a planner-callable Tool. Cross-session promotion is an operator concern (who decides which sessions get a generated skill is a policy question, not a planner reasoning question). Surfacing skill_promote as a planner tool would expose every running session to the cross-session-write capability — a privilege escalation that would let one user's planner write into another user's session. Phase 41 ships Promote(ctx, store, deps, src, name, []targets, scope) as a Go-level function only; the planner-callable catalog tool is skill_propose alone. Phase 39's Directory subsystem will layer a more ergonomic promotion surface (e.g. an operator-facing endpoint that takes a project ID and fans out to discovered session siblings) on top of Promote's primitive; Phase 41's API is the minimum-viable seam.

    The cross-session no-leak invariant is testable end-to-end: identity A persists Scope=session → identity B sees nothing via skill_search AND direct store.Get. Identity A calls Promote(idA, name, []{idB}, ScopeProject) → identity B sees the skill via both surfaces. The integration test TestIntegration_CrossSessionPromotion_AgainstLocalDB exercises this exactly. CLAUDE.md §6 rule 10: cross-session isolation tests are mandatory.

Additionally, Phase 41 adds ScopeSession to the skills.Scope enumeration (Phase 37 declared only Project | Tenant | Global; the session-scope marker was missing). The validator at skills.Skill.Validate accepts the new value; the localdb driver's existing identity filter already enforces session-only visibility for Scope=session rows (the storage layer's WHERE tenant = ? AND user = ? AND session = ? is unconditional). The Promote API rejects scope=session as contradictory (a promotion target other than the source session at session scope is meaningless).

The internal/skills/generator/concurrent_test.go ships the D-025 N=128 concurrent-reuse stress: per-goroutine identity quadruples + distinct skill names against ONE shared catalog. The identity-bleed detector asserts each receipt's Name + OriginRef reflect the calling goroutine's identity (no cross-goroutine state leaks via shared map or closure capture). Pre-cancelled ctxes on i%5==0 surface either context.Canceled or graceful exit (no cancellation cross-talk); the goroutine baseline is restored within 500ms of WaitGroup join (no leak). The companion TestConcurrent_SameNameResolvesDeterministically proves that 16 concurrent writers proposing the SAME (identity, name) resolve to exactly one persisted state with the remaining 15 reporting idempotent (their hash matches the first writer's). Coverage: 92.2% on internal/skills/generator (target 90%).


D-052 — Phase 39 virtual directory: dual-source pinning (DirectoryConfig.Pinned + Skill.Extra["pinned"]); pinned partition exempted only from MaxEntries cap when fits, never from capability filter; IncludeFields deferred; deterministic Name ASC tie-break

Date: 2026-05-12 Status: Settled Where it lives: RFC §6.7, docs/plans/phase-39-virtual-directory.md, internal/skills/directory.go (Directory, DirectoryConfig, SkillView, SelectionPinnedThenRecent, SelectionPinnedThenTop, NewDirectory, View, partitionByPinning, sortBySelection, filterByCapability, projectToSkillView), internal/skills/capfilter/capfilter.go (BuildSet, Subset, DisallowedNames, Replacement, Scrub — the shared capability-filter / scrub primitives, see the correction note below), brief 04 §3 + §4.5 + §4.6 + §6.

Why: Phase 39 lands the planner-facing virtual-directory snapshot of the SkillStore. Four design calls warrant a settled entry.

  1. Dual-source pinning: DirectoryConfig.Pinned (config-declared name list) PLUS Skill.Extra["pinned"] == true (runtime-stamped boolean). Brief 04 §3 sketches VirtualDir.Pinned []string as a static config field; Phase 39 keeps the config-declared list (operator-authored, survives restart) AND honours a runtime-stamped boolean on the skill itself (Extra["pinned"]) that a future operator tool / Console action will set. The two channels are OR'd at partitionByPinning time — a skill marked pinned by EITHER channel is in the pinned partition. The LocalDB driver's marshalExtra / unmarshalExtra round-trips Extra through JSON unchanged, so no schema change is required at this phase; a future skill_pin planner tool can stamp the boolean without touching the storage shape. The dual source means operators can pin via config (declarative, version-controlled) and the runtime can pin via skill update (dynamic, identity-scoped) without two parallel implementations of the same concept (§13).
  2. Pinned skills are exempted ONLY from the MaxEntries cap, NEVER from the capability filter. Brief 04 §4.5 documents the injection-time concerns (capability filter + redaction + budgeter). The V1 stance: a pinned skill that fails the capability filter under the run's identity is NOT in the View. Reason: if a misconfigured allowed-set could leak a high-capability skill via the pin channel, the pin channel becomes a security bypass. The pin is a prominence signal, not a visibility signal. The pinned partition is filled in declaration order (the config-declared Pinned list first, then Extra["pinned"] skills sorted by the selection rule), then the unpinned remainder is filled until MaxEntries. When count(pinned-after-filter) > MaxEntries, pinned skills truncate to the first MaxEntries (in declaration order, then per-selection sort on the Extra tail) and no unpinned skill appears. This is the load-bearing invariant Property_PinnedAlwaysIncluded_WhenFitsBudget asserts.
  3. IncludeFields is deferred. Brief 04 §3 lists IncludeFields []string on VirtualDir; Phase 39 always emits the four SkillView projection fields (Name, Title, Trigger, TaskType). Rationale: the projection is consumer-side; the cost of carrying the four strings per entry is negligible (≤ 200 rows × four strings); a per-call field knob would introduce a hidden-state branch (some callers see Title, some don't) that breaks the SkillView's wire-stability for downstream consumers. If a future caller surfaces a real need to drop a field (e.g. to keep a Console projection under a render budget), the knob lands then with one test per included combination. The deferred knob matches D-048's stance that operator-facing surfaces narrow at V1 to avoid hidden-state branches.
  4. Deterministic ordering: Name ASC is the tie-break on both selection rules. pinned_then_recent sorts the unpinned remainder by UpdatedAt DESC, Name ASC; pinned_then_top by UseCount DESC, Name ASC. The tie-break is load-bearing because two skills with the same UpdatedAt (or UseCount) would otherwise produce a non-deterministic View across calls, breaking the byte-stability promise downstream Console projections rely on. The pinned partition follows the same per-selection sort on its Extra["pinned"] tail (after the declaration-order config pins). MaxEntries default = 30, range [1, 200] per brief 04 §3 verbatim; pinned by the smoke script so a silent change surfaces here.

Correction (Wave 8 §17.5 checkpoint audit, 2026-05-14). This entry originally claimed Phase 39 "reuses Phase 38's tools.Filter and tools.Redact by direct import — no parallel filter / redactor implementation." That was factually wrong about the import mechanics: internal/skills/tools imports internal/skills, so internal/skills (where directory.go lives) cannot import internal/skills/tools — an import cycle. As shipped, Phase 39 duplicated the subset/scrub logic inline in directory.go ("the two implementations MUST stay in lockstep" comments and all) — the exact CLAUDE.md §13 "two parallel implementations of one feature" anti-pattern. The audit closed it for real (per §17.6 — fix the bug where it lives): the subset gate, disallowed-name computation, replacement selection, and word-boundary scrub were extracted into a new stdlib-only leaf package internal/skills/capfilter. Both internal/skills and internal/skills/tools import capfilter (no cycle — it depends on neither). The capability-filter logic now lives in exactly one place; tools.Filter / tools.Redact keep their skills.Skill-typed signatures and the directory does its own per-Skill field plumbing over the shared primitives. The decision (capability filter is integrity-critical, default-deny, pinned skills not exempt) is unchanged — only the false claim about how the code is shared is corrected.

The directory is the consumer of the catalog primitives the planner already trusts. Identity-mandatory: every View call reads the identity quadruple from ctx (matching internal/skills/tools/'s shape), returns wrapped skills.ErrIdentityRequired on a missing component, AND emits skill.identity_rejected via skills.EmitIdentityRejected so the rejection is observable on the bus, not silent (§13).

The internal/skills/directory_concurrent_test.go ships the D-025 stress: N=128 goroutines invoking View against ONE shared *Directory, per-goroutine identity quadruples, per-goroutine expected pin sets. The shared *Directory is immutable after NewDirectory; per-call state lives in ctx + the CapabilityContext value-type input. Property tests (testing/quick) on three invariants: pinned-always-included when count ≤ MaxEntries, View length ≤ MaxEntries, identity scoping (a skill scoped to identity A is NEVER in the View of identity B).


D-055 — Phase 46 trajectory summariser: Summariser interface + CompressionRunner live in internal/planner/; TrajectorySummary alias on Phase 43's Summary; chars/4 estimator mirrors LLM-edge surface; compression replaces step history in prompt builds; ReAct is the in-PR consumer satisfying the §13 primitive-with-consumer rule

Date: 2026-05-13 Status: Settled Where it lives: RFC §6.2, brief 02 §4, docs/plans/phase-46-trajectory-summariser.md, internal/planner/compression.go (Summariser, TrajectorySummary, TokenEstimator, DefaultTokenEstimator, CompressionRunner, NewCompressionRunner, WithTokenEstimator, MaybeCompress, ErrNilTrajectory, ErrEmptySummary), internal/planner/events.go (EventTypeTrajectoryCompressed, EventTypeTrajectoryCompressionFailed, TrajectoryCompressedPayload, TrajectoryCompressionFailedPayload), internal/planner/planner.go (Budget.TokenBudget), internal/planner/react/prompt.go (defaultBuilder.Build summary-replaces-step-history swap), internal/planner/react/compression_integration_test.go (the §13 consumer gate).

Why: Phase 46 closes the runtime-side trajectory summariser primitive. Six design calls warrant a settled entry.

  1. Summariser interface + CompressionRunner live in internal/planner/ (NOT in internal/planner/trajectory/). The master plan's Subsystem column for Phase 46 reads planner. The Summariser's signature requires planner.RunContext; the trajectory subpackage CANNOT import the planner package without an import cycle (Phase 43's D-049 settled that the planner package imports trajectory via aliases, not the reverse). The compression primitive sits alongside Decision, Planner, and RunContext in the planner package — the same level the rest of Phase 42's load-bearing types sit at. The TrajectorySummary type is a type TrajectorySummary = trajectory.Summary alias declared at the planner-package level so callers outside the trajectory subpackage use the RFC's canonical name without ambiguity. Underlying struct stays in internal/planner/trajectory/trajectory.go (Phase 43's D-049 location); the JSON tag ("summary") is unchanged so wire compatibility is preserved across the pause-record contract (Phase 51's future consumer).

  2. defaultBuilder.Build swaps the per-step assistant/user pair loop for the summary block when rc.Trajectory.Summary != nil. Phase 45 shipped the builder reading Summary ADDITIVELY (the summary appeared as an extra block alongside step history). Phase 46 departs from that shape: when Summary is non-nil, the per-step loop is SKIPPED entirely; the summary IS the trajectory representation. Brief 02 §4 explicitly says: "The compressed digest replaces the raw step history in subsequent prompt builds." Rendering both would double-count tokens and defeat the compression. The Phase 45 additive shape was a forward-compatibility seam against Phase 46 (the master plan called Phase 46 "compression / summariser" and reserved the field); Phase 46 closes the seam by tightening the rendering rule. The existing Phase 45 TestDefaultBuilder_RendersSummary test passes (it doesn't pass Summary AND non-empty Steps simultaneously); the new TestDefaultBuilder_WithSummary_SkipsStepHistory test pins the Phase 46 contract. Background-task outcomes (the D-032 push-wake seam) still surface as a trailing user turn regardless of compaction — they are the LATEST signal the planner has and must reach it on the next step. A future phase MAY route background outcomes through the summariser; Phase 46 keeps them as a separate trailing turn.

  3. DefaultTokenEstimator uses chars/4 over Trajectory.Serialize bytes — mirroring internal/llm/tokens.go::chars4Estimator. §13 bans two parallel implementations of the same conceptual feature; the LLM-edge estimator is the canonical chars/4 surface, and the trajectory-compression estimator deliberately mirrors its len/4 + 1 per-fragment formula. The trajectory is treated as one fragment by the runner since Serialize produces the planner-facing JSON projection. The chars/4 algorithm under-counts multimodal content compared to the LLM-edge estimator (which adds 256 tokens per non-text part); trajectories don't typically carry multimodal parts directly in LLMContext — heavy content is upstream of the trajectory per the D-026 safety pass — so the simpler walker is sufficient at Phase 46. A future estimator that structurally walks the trajectory (re-using the LLM-edge tokeniser to count multimodal parts at 256 tokens each) is a Phase 47+ refinement; the TokenEstimator functional-option seam is the unwind point. Estimator errors propagate verbatim through MaybeCompress — a Phase 43 ErrUnserializable from Serialize is the typical failure mode and is surfaced loudly with the trajectory.compression_failed emit carrying ErrorCode="estimator_error".

  4. Fail-loudly contract at the summariser boundary (§13). A non-nil error from Summariser.Summarise propagates verbatim through CompressionRunner.MaybeCompress; the runner does NOT fall through to "skip compression and use raw history" — silent degradation is the bug §13 explicitly bans. A (nil, nil) return from the summariser is also a contract violation (the implementation MUST return a non-nil summary on success OR a non-nil error); the runner surfaces this as ErrEmptySummary so the bug is loud, not silent. Both failure paths emit trajectory.compression_failed BEFORE returning, classified by an error-code bucket (summariser_error / empty_summary / estimator_error). The success path emits trajectory.compressed. Together the two emits make compression observable in both directions — companion to Phase 44's planner.repair_exhausted and Phase 45's planner.max_steps_exceeded. Identity is mandatory at the runner boundary (§6 rule 9 + D-001): a partial quadruple returns wrapped llm.ErrIdentityMissing — the same sentinel the rest of the runtime uses.

  5. Idempotency short-circuit when tr.Summary != nil. A second MaybeCompress call against an already-compressed trajectory returns nil without invoking the summariser. The engine that owns the cadence policy (Phase 47+ planner-runtime stitch) is the layer responsible for clearing tr.Summary when re-compaction is needed. Phase 46 ships the V1 idempotency contract; cadence + re-compaction triggers land at the engine wire-up phase. Concretely: the unit test TestMaybeCompress_AlreadyCompressed_Idempotent pins the current behaviour; a future engine that decides "after the trajectory grows 2× past the last compression, re-summarise" will clear the field via tr.Summary = nil before re-calling MaybeCompress. This keeps the runner stateless across calls (per-call inspection of Trajectory.Summary is enough) while leaving the cadence seam open for the engine to fill.

  6. ReAct is the in-PR consumer that satisfies CLAUDE.md §13's primitive-with-consumer rule. A primitive that lands without a concrete that exercises it bit-rots. Phase 46's primitive is the Summariser interface + CompressionRunner; the in-PR consumer is the Phase 45 ReAct planner via the prompt.go::defaultBuilder swap (call sites: internal/planner/react/prompt.go → reads rc.Trajectory.Summary; internal/planner/react/compression_integration_test.go → drives the end-to-end test). The integration test wires real events.EventBus + real CompressionRunner + real ReActPlanner: an over-budget trajectory triggers compression, the planner's next prompt is built from the summary only (zero raw-step assistant turns; the LLM is called exactly once). The failure-mode scenario (errSummariser) surfaces trajectory.compression_failed on the real bus with the run's identity. Without this consumer the primitive would have no test-time witness that the prompt builder actually reads Trajectory.Summary correctly; the integration test IS the gate.

The internal/planner/compression_concurrent_test.go ships the D-025 N=128 concurrent-reuse stress: shared *CompressionRunner, per-goroutine identity quadruples + per-goroutine trajectories, the countingSummariser stamps the goroutine's RunID into the summary's Note field for context-bleed detection (no other goroutine's RunID surfaces). Pre-cancelled ctxes on i%5==0 verify cancellation honoring without cross-talk; baseline runtime.NumGoroutine restored within 500ms of WaitGroup join. The supplementary TestCompressionRunner_SharedAcrossGoroutines_NoRaceOnEstimator exercises the idempotent read path under -race (single shared trajectory with pre-stamped summary; 64 goroutines short-circuit cleanly). The TestCompressionRunner_EmitClosure_ConcurrentSafe asserts the emit closure receives every event without drops when N=64 goroutines emit through a shared runner. Coverage targets met: internal/planner ≥ 80% (Phase 46 incremental); internal/planner/react ≥ 85% (Phase 45 surface preserved; the Phase 46 prompt-builder swap is covered by TestDefaultBuilder_WithSummary_SkipsStepHistory + TestDefaultBuilder_NoSummary_RendersStepHistory regression guard).


D-057 — Phase 48 deterministic planner: DecisionTreeStep abstraction over typed Decision returns; WakePoll non-blocking WatchGroup semantics; iface-validation lens proving Planner swappability; §13 primitive-with-consumer closed by in-scenario SpawnTask + AwaitTask emission

Date: 2026-05-13 Status: Settled Where it lives: RFC §6.2 + RFC §11 Q-6, docs/plans/phase-48-deterministic-planner.md, internal/planner/deterministic/deterministic.go (DeterministicPlanner, Option, NewDeterministicPlanner, Next, WakeMode, WithSteps, WithRegistry, WithName), internal/planner/deterministic/steps.go (DecisionTreeStep, CallToolStep, FinishStep, PauseStep, SpawnAndAwaitStep, WatchGroupStep), internal/planner/errors.go (ErrIdentityRequired, ErrInvalidConfig, ErrDeterministicStep), brief 02 §1 + §2 + §5 + §7, brief 05 §1.

Why: Phase 48 lands Harbor's second concrete Planner implementation. Four design calls warrant a settled entry.

  1. DecisionTreeStep interface — typed step abstraction over a sealed-sum Decision return; no magic-string opcodes. Each step exposes Decide(ctx, rc) (planner.Decision, bool, error). The boolean reports whether the step claimed the call: true → walker returns the decision verbatim; false → walker advances; non-nil error → walker propagates wrapped planner.ErrDeterministicStep (fail-loudly per §13 — no silent skip on error). The predecessor's "magic strings as next_node" anti-pattern (brief 02 §2) is rejected: every step returns one of the six sealed-sum Decision shapes directly. The interface is exported so operators can implement custom steps; five in-package types ship (CallToolStep, FinishStep, PauseStep, SpawnAndAwaitStep, WatchGroupStep). A tree that exhausts every step without a claim returns Finish{NoPath, Metadata["deterministic"]="no_step_matched"} — fail-loudly per §13 (a silently looping planner is the worst kind of misconfiguration shape).
  2. WakePoll semantics — non-blocking receive on tasks.WatchGroup from the planner side; emit AwaitTask on not-ready. The deterministic planner declares planner.WakeAware returning planner.WakePoll. The on-disk realisation lives in SpawnAndAwaitStep and WatchGroupStep: each Decide call performs a select { case completion := <-ch: ...; default: AwaitTask } against the channel returned by tasks.TaskRegistry.WatchGroup. When the channel hasn't fired, the planner emits AwaitTask{TaskID: <owner>} and the runtime sleeps the step until the next deterministic boundary; when it has fired, the operator-supplied OnResolved([]MemberOutcome) callback returns the next decision. No LLM, no eager wake — a clean deterministic shape that proves the TaskRegistry's WatchGroup surface is mode-neutral (D-032). The registry has no knowledge that a poller is reading its channel non-blockingly; no WakeMode field on registry types, no Supports* capability protocol.
  3. The deterministic planner is the iface-validation lens — proves CLAUDE.md §1 property 3 ("the Planner is swappable") on disk. RFC §11 Q-6 settled the second V1 planner concrete as deterministic precisely because it exercises a non-LLM Decision shape end-to-end: same Runtime, same Planner interface, same RunContext view, same Decision sum — but no LLM, no prompt builder, no retry / downgrade / corrections / safety / governance composition. If the interface were structurally biased toward an LLM-driven concrete, the deterministic planner would surface the bias loudly at construction time. It does not. Phase 48 is the on-disk proof, not a doc claim.
  4. §13 primitive-with-consumer policy — SpawnTask + AwaitTask emission closes the policy for the deterministic-planner side of the wave. Phase 42 shipped the Decision sum's SpawnTask and AwaitTask shapes; Phase 20/21 shipped the TaskRegistry + TaskGroup + WatchGroup mechanism. The Phase 48 SpawnAndAwaitStep's scenario test (spawn_await_scenario_test.go) wires a real tasks.TaskRegistry (in-process driver) + real events.EventBus (inmem driver) and asserts the planner emits SpawnTaskAwaitTaskCallToolFinish across four Next calls, with the registry's task lifecycle driven through SpawnSealGroupMarkRunningMarkComplete between calls. The §13 rule (added to CLAUDE.md via PR #67) is binary: a primitive lands with its first consumer in the same wave. Phase 48 supplies the deterministic-planner side; future planner upgrades (or Phase 47's ReAct emission upgrade) close the ReAct side. Phase 49's conformance pack uses Phase 48's concrete as the second leg of cross-planner round-trip scenarios — the deterministic planner exercises each of CallTool, SpawnTask, AwaitTask, Finish in its scenario test so Phase 49 has the cross-planner coverage.

Identity is mandatory (§6 rule 9 + D-001). The deterministic planner returns wrapped planner.ErrIdentityRequired on a partial quadruple at Next entry — defensive in depth alongside the runtime engine's identity propagation. Fail-loud construction (§13). NewDeterministicPlanner returns wrapped planner.ErrInvalidConfig when the configured step set is empty, when any configured step is nil, or when a group-aware step is configured without WithRegistry. Configuration errors NEVER surface at Next time; the constructor is the boundary.

Concurrent reuse pinned (D-025). DeterministicPlanner is a reusable artifact: the receiver is read-only after construction; per-run state lives on the stack and in RunContext. SpawnAndAwaitStep holds an internal sync.Map keyed by (SessionID, StepID) so per-run spawn-tracking state is safe across N concurrent runs against the shared planner. internal/planner/deterministic/d025_test.go pins N=128 concurrent Next invocations against one shared instance under -race — asserts no races, no identity bleed (each call's Finish.Metadata["run_id"] matches the goroutine's RunID), no cancellation cross-talk (pre-cancelled ctx on i%5==0 returns ctx.Err() without affecting siblings), no goroutine leak (baseline runtime.NumGoroutine restored within 500ms of WaitGroup join). Coverage: 90.1% on internal/planner/deterministic (target 85%).


D-056 — Phase 47 parallel executor + ReAct CallParallel / SpawnTask / AwaitTask emission: three reserved tool names as V1 emission surface; reduceToSingleAction deletion timing; AbsoluteMaxParallel = 50; JoinSpec enum semantics (JoinAll / JoinFirstSuccess / JoinN); atomic-setup vs in-flight failure handling; §13 primitive-with-consumer compliance

Date: 2026-05-13 Status: Settled Where it lives: RFC §6.2, docs/plans/phase-47-parallel-emission.md, internal/runtime/parallel/parallel.go (Executor, Resolver, Result, New, Execute, normaliseJoin, validateJoin, dispatchAll, dispatchFirstSuccess, dispatchN, invokeBranch), internal/planner/decision.go (JoinN, JoinSpec.N), internal/planner/errors.go (AbsoluteMaxParallel, ErrParallelCapExceeded, ErrParallelInvalidJoin, ErrParallelBranchInvalidArgs, ErrParallelPauseUnsupported), internal/planner/react/react.go (SpawnTaskToolName, AwaitTaskToolName, translateSpawnCall, translateAwaitCall, mapDecision, DefaultSystemPrompt), test/integration/phase47_spawn_await_test.go, scripts/smoke/phase-47.sh.

Why: Phase 47 closes three primitive-with-consumer gaps in one PR per CLAUDE.md §13's "shipping a primitive without its first consumer in the same wave" forbidden practice. Six design calls warrant a settled entry.

  1. Three reserved tool names as the V1 emission surface — _finish (Phase 45 / D-051), _spawn_task and _await_task (Phase 47 / D-056). The reserved-name convention follows D-051: prompt-time strings translated by mapDecision to typed Decisions BEFORE return; the Decision sum stays sealed (no "magic string as next_node" anti-pattern). Two design rationales:

    • Why a reserved-tool convention vs. a top-level JSON envelope {"decision":"spawn","args":{...}}. The reserved-tool shape lets the LLM stay in one prompt-schema mode — it ALWAYS emits a {"tool":..., "args":..., "reasoning":...} shape, never switching between "tool envelope" and "decision envelope" mid-conversation. Single-mode prompts compress better in the LLM's representation (fewer competing patterns to navigate) and reduce repair-loop pressure: the parser already handles tool envelopes; spawn/await go through the same path. The downside (the LLM can in principle emit a _spawn_task shape that looks identical to a real tool with that name) is mitigated by the leading-underscore convention — future runtime catalog registration MAY reject underscore-prefixed tool names; today the dispatcher would reject any _-prefixed tool that wasn't intercepted by mapDecision first.
    • Why fail-loudly on malformed args (vs. silent emit of the literal _spawn_task CallTool the dispatcher would reject anyway). The §13 silent-degradation ban means errors must be explicit. mapDecision returning (Decision, error) surfaces the translation failure at the planner boundary; the runtime sees a clean error rather than a CallTool-shaped pseudo-decision the catalog cannot dispatch.
  2. reduceToSingleAction deletion timing — Phase 47, NOT later. The Phase 45 plan named the deletion timing explicitly ("the reduceToSingleAction method is the unwind point — Phase 47 deletes the override"). The Phase 47 PR honours the hand-off because the §13 "two parallel implementations of the same conceptual feature" rule is active: Phase 44's RepairLoop already produces CallParallel{Join: JoinAll} when multi-action salvage triggers; the Phase 45 collapse override was a V1 stop-gap. Carrying both shapes (the Phase 44 emission + the Phase 45 collapse) past Phase 47 would mean two parallel implementations of "what happens when the LLM emits multiple actions" — Phase 47 picks the deepening (let the executor dispatch) and deletes the override. The smoke script asserts the absence of the symbol via grep-v as the drift gate.

  3. AbsoluteMaxParallel = 50 system cap rationale. RFC §6.2 settled the value at 50. Three rationales:

    • Defence in depth against a runaway emission. A buggy LLM emitting 1000 branches must not consume 1000 goroutines + 1000 tool-dispatch budgets. 50 is comfortably above the "I want to parallelise this small fan-out" use case (3-10 branches typical) while staying below "the LLM ran away."
    • The soft cap is the planner's PlanningHints.MaxParallel. Operators tune the soft cap per session / per tenant; the hard cap is system-wide. Operator-tunable hard cap would re-introduce the "two parallel implementations" smell (a config-driven cap + a code-driven cap); the system cap stays settled.
    • Defence against a malicious / adversarial LLM emission. A jailbreak prompt that coerces the LLM into emitting "1000 branches of delete_everything" gets rejected at the executor boundary; even if every branch's validator was permissive, the cap fires first. The cap is the last line of defence before goroutine + descriptor multiplication.
  4. JoinSpec enum semantics — JoinAll / JoinFirstSuccess / JoinN. Three explicit shapes ship; JoinKeyed remains a documented future surface but is rejected at dispatch with ErrParallelInvalidJoin (the "not implemented at Phase 47" message names the deferral). Per-shape rationales:

    • JoinAll (the default). The most common shape: fan out, collect every observation, surface them all back to the planner for the next step. The Phase 44 repair loop's multi-action salvage uses this as its default join.
    • JoinFirstSuccess. The "race to first success" shape: the planner emits N alternate tool calls and wants whichever responds first (e.g. three different search providers; whichever finishes first). Cancellation: the executor derives a child ctx; on first success, the child ctx cancels; slow branches that honour ctx exit promptly. Failures do NOT cancel until every branch terminates — a slow success can still arrive after a fast failure.
    • JoinN. The "fault-tolerant fan-out" shape: emit N+M branches, wait for N successes, cancel the rest. Setup validates 0 < N ≤ len(Branches). JoinN returns successes in COMPLETION order (each Result still carries its original branch Index for the deterministic merge key downstream — the merge ordering is the branch's input position, NOT completion order).
  5. Atomic-setup vs in-flight failure handling. RFC §6.2's "atomicity contract": atomic setup validation (any branch's invalid args fails the whole call BEFORE execution); in-flight failures land per-branch on Result.Err. Two failure modes, two different shapes:

    • Setup-time failures (atomic): branch count cap exceeded, JoinSpec malformed, descriptor not registered, args validator rejects — ALL surface as the executor's return error. The slice is nil. NO branch executes. This is the load-bearing "atomicity contract" surface.
    • In-flight failures (per-branch): a branch's desc.Invoke returns an error; the executor catches it, populates Result.Err, surfaces the result alongside the successful peers. The call-level error stays nil for JoinAll (mixed-success-and-failure is a normal observation shape); JoinFirstSuccess and JoinN exhaustion return a joined error wrapping every failure when no branch met the threshold. The distinction prevents the planner from seeing a "whole call failed" when in fact one tool returned a soft error the LLM can incorporate into its next step's reasoning.
  6. §13 primitive-with-consumer policy compliance — three primitives, three consumers in one PR. CLAUDE.md §13 forbids shipping a primitive without its first consumer in the same wave. Phase 47 closes three gaps in one wave:

    • Parallel-call executor (the master-plan Phase 47 row's original scope). Consumer: ReAct emits CallParallel (pass-through); Phase 44's repair loop already produces the shape from multi-action salvage. Both ends ship in this PR.
    • SpawnTask Decision shape (shipped Phase 42 without emitter). Consumer: ReAct's _spawn_task reserved tool translation + the integration test's spawn → group → wake → re-entry round-trip.
    • AwaitTask Decision shape (shipped Phase 42 without emitter). Consumer: ReAct's _await_task reserved tool translation.

    The §13 rule explicitly names SpawnTask and AwaitTask as the pair that MUST land together: "a planner that can spawn a background task but cannot join it produces orphan work the runtime cannot recover." Phase 47's PR bundles them per the binding rule. The unified pause/resume primitive (Phase 50) is the next §13 application of the same rule — it will land with a RequestPause-emitting consumer in the same wave.

The internal/runtime/parallel/concurrent_test.go ships the D-025 N=128 reuse stress: one shared *parallel.Executor, per-goroutine identity quadruples (no bleed), pre-cancelled ctxes on i%17==0 (no cross-talk), goroutine baseline restored within 2s of WaitGroup join (no leak). The Phase 45 internal/planner/react/d025_test.go test already covers the upgraded ReAct emission paths transitively (any Next call exercises mapDecision's new cases). Coverage: internal/runtime/parallel ≥ 85% (master-plan target).


D-058 — Phase 49 planner conformance pack: shared scenario suite both Wave 8 concretes pass; capability-gated subtests; wake-mode round-trip wired against real tasks.TaskRegistry + real events.EventBus (D-032 binding); Wave 8 wave-end E2E bundled in same PR per §17.5

Date: 2026-05-13 Status: Settled Where it lives: RFC §6.2, docs/plans/phase-49-conformance-pack.md, internal/planner/conformance/conformance.go (Phase 42 skeleton scenarios + Phase 49 scenario bodies: Capability flags + ScenarioName constants + Harness extensions: ScenarioFactory, Capabilities, TaskRegistryFactory, PrebuiltPlannerFactory; WakeRoundTripDeps + DefaultTaskRegistryFactory + DefaultRunContext + DefaultReactContentMap + SecondStepContent + scenarioContentTrim; per-scenario implementations runTopPromptsScenario, runMalformedLLMScenario, runParallelAtomicityScenario, runWakeRoundTripScenario (with push/poll dispatch), runBudgetAwareScenario, runPauseBoundsScenario, runSteeringDrainScenario, runConcurrentReuseScenario), internal/planner/react/conformance_test.go + internal/planner/react/conformance_helpers_test.go (ReAct's full-suite invocation; scripted multi-response LLM for the push wake-round-trip), internal/planner/deterministic/conformance_test.go (Deterministic's full-suite invocation; parallelEmitStep + SpawnAndAwaitStep-based PrebuiltPlannerFactory), test/integration/wave8_test.go (Wave 8 wave-end E2E — three focused tests covering the push round-trip across the assembled surface, the missing-identity fail-closed scenario, and the N=10 concurrency stress).

Why: Phase 49 closes the planner-track wave by filling the Phase 42 conformance harness skeleton AND landing the Wave 8 wave-end E2E in one PR. Three design calls warrant a settled entry.

  1. Capability-gated scenarios: Capability flags + Harness.Capabilities bitmask let one Run entrypoint drive both LLM-driven and non-LLM concretes without dual-suite drift. A non-LLM concrete (Deterministic) calling the LLM-only scenario (MalformedLLM_Salvage) would either (a) skip silently if we picked permissive defaults — §13's silent-degradation ban catches this — or (b) fail with a nil LLM client shape that's a configuration bug, not a planner bug. The capability flags (CapabilityLLMDriven, CapabilityCanPause, CapabilityWakeRoundTrip, CapabilityHonoursCancelControl) gate scenarios at the entry point; a scenario whose required capability is absent calls t.Skip WITH A REASON — never silently. Phase 49 ships two pre-built capability sets (CapabilitySetReAct, CapabilitySetDeterministic); future concretes (Plan-Execute, Workflow, Graph, Supervisor) pick the capability set that matches their shape, and the conformance pack scales without modification.

  2. The WakeMode_RoundTrip scenario is the LOAD-BEARING D-032 binding — real TaskRegistry + real EventBus, no mocks at the seam. RFC §6.2 + master plan Phase 49 detail block ship the wake-mode round-trip as the unmissable scenario: "Failure to wire tasks.WatchGroup is the test's failure mode, not silent deadlock." Phase 49 wires the scenario against the production inprocess tasks.TaskRegistry driver, the production inmem events.EventBus driver, and the production inmem state.StateStore. Mocks at this seam would defeat the test's purpose (a mock that always delivers GroupCompletion instantly would mask a real wiring bug that delays the delivery; a mock that always blocks would mask a planner that fails to honour the non-blocking receive contract). The harness's TaskRegistryFactory field exposes the production-driver factory (DefaultTaskRegistryFactory); the harness's WakeMode field dispatches the round-trip to push or poll. For push (ReAct): the scenario simulates the runtime engine's role at Phase 60+, spawning the real task and surfacing the MemberOutcome through RunContext.Trajectory.Background. For poll (Deterministic): the scenario uses PrebuiltPlannerFactory to construct the planner WITH the registry bound (via deterministic.WithRegistry), then calls Next repeatedly — observing the non-blocking receive pattern: SpawnTask → AwaitTask (group open) → resolved Decision (group complete). The §17.4 "no time.Sleep for synchronisation" rule holds: bounded eventually-style waits with 2s deadlines and runtime.Gosched yields between retries.

  3. Wave 8 wave-end E2E bundled in same PR per §17.5 — three focused tests covering happy path, failure mode, and concurrency stress. §17.5 makes the wave-end checkpoint audit + wave-end E2E binding at every wave boundary. The wave-end E2E exercises the same primitives the conformance pack tests, but ACROSS the full assembled surface: Skills (localdb) + Planner (ReAct) + Tools (in-process) + Tasks (inprocess) + Memory (inmem) + LLM (mock) + Events (inmem) + State (inmem). Three tests cover §17.3's mandatory dimensions:

    • TestE2E_Wave8_ReactSpawnWakeRoundTrip_AssembledSurface: real ReAct planner emits _spawn_task against scripted mock LLM → real registry spawns and resolves → planner re-enters with MemberOutcome surfaced → emits Finish. Memory captures the turn. Skill store presence on the surface is asserted via an Upsert + Get round-trip.
    • TestE2E_Wave8_MissingIdentity_FailsClosed: ReAct's identity-mandatory pre-check rejects a Next call with no identity in the RunContext quadruple, returning wrapped llm.ErrIdentityMissing BEFORE the LLM completion fires. Memory + skill stores also reject missing identity — same fail-loudly contract. The scenario is the §17.3 #3 "at least one failure mode" requirement.
    • TestE2E_Wave8_Concurrency_NoCrossTalk: N=10 concurrent ReAct runs against ONE shared planner + ONE shared catalog + ONE shared registry + ONE shared memory store. Every 3rd goroutine derives a pre-cancelled ctx (cancellation cross-talk gate); the race detector is the gate for data races. Baseline goroutine count restored on teardown (within a +16 tolerance for driver-retained background workers).

    The §17.6 fix-in-same-PR rule was honoured but no cross-phase bug surfaced — the per-package tests Phase 42-48 land had already covered the seam interactions Phase 49's E2E exercises. The conformance pack DID surface a usability gap (the Phase 42 Harness shape needed extension to drive scenario-specific planner configurations); the extension is additive (existing fields preserved verbatim) so no per-concrete test regresses.

Identity is mandatory across every scenario (§6 rule 9 + D-001). The conformance pack's DefaultRunContext factory stamps a populated quadruple; per-concrete tests build their own factories on the same shape. §13 import-graph contract preserved: the conformance package imports internal/audit, internal/config, internal/events, internal/state, internal/tasks, internal/identity, internal/planner — NONE of which are internal/runtime/.... The Phase 42 importgraph_test.go walks the planner subtree and gates the contract; Phase 49 adds no internal/runtime/... imports.

Concurrent reuse pinned (D-025). The ConcurrentReuse_D025 scenario in the pack runs N=64 parallel Next calls against ONE shared planner from the harness factory; the race detector is the gate; per-goroutine RunID round-trip checks for context bleed. The Wave 8 E2E's concurrency stress (N=10) provides the cross-package complement. Coverage: internal/planner/conformance ≥ 80% (Phase 49 target). The pack's coverage is asserted by Run-against-both-concretes: each concrete's test exercises every non-skipped scenario, and the skip paths are exercised by the capability-gating fallthrough.


D-059 — Agent identity model: agent_id is a runtime registration identity, NOT an isolation principal; the isolation tuple stays (tenant, user, session, run); agents carry a three-ID model (agent_id / incarnation / version_hash)

Date: 2026-05-14 Status: Settled Where it lives: RFC §6.16 (Agent Registry), CLAUDE.md / AGENTS.md §6 (clarifying note), docs/plans/phase-53a-agent-registry.md, docs/glossary.md (agent_id, incarnation, version_hash, registration identity).

Why: During Console information-architecture planning the question "is agent_id a fourth element of the identity tuple?" surfaced repeatedly and threatened to leak implicit assumptions into every identity-touching phase. It is settled here so it does not get re-litigated.

  1. agent_id is a registration identity, not an isolation principal. Harbor's isolation boundary is and stays the tuple (tenant, user, session, run) (RFC §4, §6 rules, D-001). An agent is a runtime entity — it has a planner, tool bindings, memory bindings, policies, health — but it runs within (tenant, user, session); it does not widen the isolation boundary. Memory drivers, state drivers, event subscribers continue to scope by the tuple, never by agent_id. This dissolves the recurring confusion: there are two orthogonal concepts — "agent as a registered, runtime-tracked entity with a stable ID and lifecycle" (this decision) and "agent as an isolation boundary" (explicitly rejected for V1).
  2. Agents carry a three-ID model. agent_id — stable, "which logical agent," minted once at first registration, persisted, rehydrated on restart. incarnation — ephemeral, "which boot of it," bumps on every process start. version_hash — content-derived, "which configuration," a deterministic hash over (prompt set, tool set + schemas, planner config, model policy); bumps only when configuration content changes. The three answer different questions and must not be collapsed: a restart with no config change yields the same agent_id + same version_hash + a new incarnation; a restart after a prompt edit bumps both incarnation and version_hash.
  3. version_hash is load-bearing for the post-V1 Evaluations / version-control program (D-064). If every agent carries a config hash from V1, Evaluations can later attribute success-rate changes to a specific configuration version with zero retrofit. It is cheap to compute at registration and is the free precursor to prompt/tool evolution work.
  4. Consumers. Phase 30 (tool-side OAuth) keys agent-bound tokens by the registration agent_id — never by an isolation-tuple element. The Console Agents page (RFC §7, D-062) renders the three-ID model. See [[D-060]] for the subsystem that owns minting and persistence, and [[D-061]] for the Console-DB boundary.

D-060 — Agent Registry is an in-process, per-runtime-instance, StateStore-backed subsystem; it covers both creation cases (locally-hosted + connect-to-remote); restart rehydrates (restart ≠ recreate)

Date: 2026-05-14 Status: Settled Where it lives: RFC §6.16, CLAUDE.md / AGENTS.md §3 (internal/runtime/registry/ layout entry), docs/plans/phase-53a-agent-registry.md, master plan phase 53a.

Why: "How can the registry mint an agent_id if Harbor can be used by anybody?" — Harbor is a Go-native SDK + single static binary; there is no central Harbor service, and there must not be one. The minting and ownership model is settled here.

  1. The registry is not a central authority — it is an in-process subsystem inside each harbor instance. Every harbor process (or every embedding of the library) maintains its own registry.AgentRegistry, persisted via that instance's configured StateStore driver (in-mem / SQLite / Postgres, §4.4 seam). This is the same shape Harbor already uses for (tenant, user, session): Harbor never mints identity globally — it receives identity from the operator's auth boundary and scopes state locally. agent_id never needs to be globally unique; it only needs to be unique within the runtime instance that issued it, which is collision-free by construction (ULID/UUID).
  2. Two creation cases, both landing in the registry. Locally-hosted agent — the runtime instance is running the agent; it mints a local agent_id. Connect-to-remote agent — the agent runs in someone else's Harbor (or is any A2A-speaking peer); the local runtime assigns a handle (agent_id local to this instance), and the canonical identity of the remote agent is its A2A AgentCard, owned by the remote operator. See [[D-061]] — neither case puts the agent list in a Console DB.
  3. restart rehydrates; restart ≠ recreate. With a durable StateStore driver, a process restart rehydrates the registry and the agent comes back with the same agent_id (a stable fleet view depends on this). The in-mem driver loses the registry on restart and is documented as dev-only — the "id changes on restart" behaviour is a dev-mode artifact, not the intended fleet posture. Teardown-and-recreate is distinct from restart: recreate genuinely mints a fresh agent_id because it is a new logical entity; restart keeps the StateStore record.
  4. The registry emits agent.* events (agent.registered, agent.restarted, agent.health, agent.drained, agent.deregistered) so the Console renders runtime state. See [[D-059]] for the three-ID model the registry owns and [[D-066]] for the fleet-control privilege tier.

D-061 — A Console DB holds Console-local state only; it is never a shadow source of truth for runtime entities

Date: 2026-05-14 Status: Settled Where it lives: RFC §7, CLAUDE.md / AGENTS.md §13 (forbidden practice), docs/glossary.md (Console DB).

Why: The instinct to track "which agents exist" in a Console-side database is exactly how the predecessor's Console drifted into re-implementing runtime APIs. The boundary is settled before any Console phase is authored.

  1. If a Console DB exists, it holds Console-local state only — saved views, dashboard layouts, per-operator preferences, annotations. It must never be the source of truth for runtime entities (agents, sessions, tasks, tools, events, artifacts). Those live in the Runtime and reach the Console exclusively through the Protocol's canonical events / state snapshots / control commands.
  2. Rationale: a Console DB as a shadow source of truth breaks the "Console is a Protocol client" rule (RFC §5, §7, CLAUDE.md §4.5). If the Console DB owned the agent list, a third-party Console would have a different agent list, and the Agents page would be a standalone app rather than a runtime lens. The Agent Registry ([[D-060]]) is the runtime-side owner; the Console renders it.
  3. A runtime-side control-plane client allowlist is the legitimate inverse and is a separate concern from a Console DB — see [[D-066]].

D-062 — Harbor Console is a 14-page observability + control plane organized as runtime lenses; Live Runtime ≠ Sessions; Agents ≠ chatbots; no Console page phase ships without its feeding Protocol-surface phase

Date: 2026-05-14 Status: Settled Where it lives: RFC §7 (expanded), docs/research/11-console-feature-surface.md, docs/rfc/assets/console-agents-page.png, master plan README.md (Console-wave re-decomposition note), CLAUDE.md / AGENTS.md §13 (forbidden practice — Console page without Protocol surface).

Why: The Console is not "the Playground plus widgets" — it is a full control/observability plane. Its information architecture is settled so the (currently under-scoped) phases 72–75 can be re-decomposed against a fixed target.

  1. Fourteen pages, five clusters, all runtime lenses. Runtime (Overview, Live Runtime); Execution (Sessions, Tasks, Agents, Tools, Events, Background Jobs); Resources (Flows, Memory, MCP Connections, Artifacts); Evaluation (Evaluations); Settings. Every page is a projection over state snapshots + realtime events + control commands — never a standalone app feature. The canonical Agents-page mockup is docs/rfc/assets/console-agents-page.png.
  2. Live Runtime ≠ Sessions. Live Runtime is the present-tense interactive workbench (initiate / observe / steer / debug a live execution — the spiritual replacement of the predecessor's Playground, with the chat as one panel among many). Sessions are the past-and-active durable execution records (replay / continue / clone / convert-to-eval). Conflating them produces two half-built versions of the same surface.
  3. Agents ≠ chatbots. Agents are runtime execution entities with planners, tool bindings, memory bindings, policies, task ownership, event streams, and operational health — not personas. The Agents page is fleet management, not an assistant gallery; it is a lens over the Agent Registry ([[D-060]]).
  4. Structuring rule: no Console page phase ships without its feeding Protocol-surface phase landing first or in the same wave. This is the §13 "no primitive without its consumer" rule read backwards — it keeps the Console honest as a Protocol client instead of letting it grow private hooks. The notification.* topic (Overview intervention queue) and search.* Protocol methods (global ⌘K) land as named acceptance criteria of their consuming page phases, not as free-floating primitives.
  5. MCP Apps DisplayMode (inline / fullscreen / pip) is a Protocol-level concern — the MCP app declares its preferred mode, the runtime forwards it, the Console honours it. DisplayMode lives in internal/protocol/types/, not in Console-only state.

D-063 — The Console Flows page is a view over engine graphs scoped to graph-family planners; V1 = read / run / inspect-history; authoring / versioning / import-export is post-V1

Date: 2026-05-14 Status: Settled Where it lives: RFC §7, docs/glossary.md (Flows), master plan README.md (Console-wave note).

Why: "Flows" risked being scoped as a new runtime subsystem when it is really a projection. Settled to bound it.

  1. Flows are engine graphs, not a new subsystem. A "Flow" in the Console is the graph structure that a graph-family planner (Graph / Workflow / Deterministic — RFC §6.2, §12) runs on. It is a view over internal/runtime/engine/ node graphs, filtered to agents whose planner is graph-shaped.
  2. V1 Flows page = read / run / inspect-run-history — a pure lens, needing only a Protocol method that exposes the engine graph structure + run history.
  3. Authoring / versioning / import-export is post-V1 — that is the part that may need a real subsystem, and it is deliberately deferred. This splits Flows along the same present-vs-authoring line as [[D-062]]'s Live Runtime ≠ Sessions.

D-064 — Evaluations is a post-V1 subsystem built as a §4.4 extensibility seam; it depends on fully-replayable sessions, which makes the durable event log (Phase 57) a hard dependency; a premium/hosted variant must be a driver, not a fork

Date: 2026-05-14 Status: Settled Where it lives: RFC §12 (post-V1 / future work), master plan README.md (phase 57 detail block, V1 cut line), docs/glossary.md (Evaluations).

Why: The Console IA lists an Evaluations page; that page implies a substantial runtime subsystem. Its scope and dependencies are settled so V1 does not foreclose it.

  1. Evaluations is a subsystem, not a page. "Eval suites, golden sessions, replay-based evaluation, regression diffs, baseline promotion" is a runtime program — an eval runner, eval storage, replay machinery — with the Console page as its thin front-end. It is explicitly post-V1.
  2. It is the foundation for post-V1 agent version-control — success-rate-over-version_hash ([[D-059]]), prompt evolution, tool evolution.
  3. It depends on fully-replayable sessions, so Phase 57 (durable event log) is a hard dependency. "Create eval from session" / "mark as test case" only work if a session's event log is durable and gap-free. Lossy V1 sessions (ring-buffer-only) would foreclose Evaluations entirely — you cannot retrofit completeness into already-shipped sessions. Phase 57's durability guarantees are therefore binding, not optional.
  4. Built as a §4.4 seam from day one — interface + drivers — so a premium / hosted / enterprise variant is a driver, not a fork of the runtime. This keeps a future monetization path open without polluting the V1 open-source surface.

D-065 — The session priority dimension is dropped from V1

Date: 2026-05-14 Status: Settled Where it lives: RFC §6.9 (sessions), docs/research/11-console-feature-surface.md.

Why: The operator Console mockup showed a "Priority: Normal / High / Low" field on the session detail panel. Brief 11 recommends dropping it from V1 unless real load patterns demand it.

  1. Dropped from V1. No session-level priority field; no router or task-registry plumbing for it. Task-level prioritization via the PRIORITIZE steering control (Phase 52/53) already exists and covers the concrete operator need.
  2. Revisit only on evidence. If post-V1 load patterns show a genuine need for session-level priority, it is a scoped phase touching sessions (RFC §6.9) and routers (Phase 14) — not a V1 retrofit.

D-066 — Fleet control is a distinct, more-elevated privilege tier than fleet observation; a runtime-side control-plane client enrollment allowlist is deferred ("decide later")

Date: 2026-05-14 Status: Settled Where it lives: RFC §6.16 (Agent Registry security), RFC §5.5 (Protocol authentication), docs/plans/phase-53a-agent-registry.md.

Why: A Console deployed to manage a fleet of Harbor runtimes is a control plane; the security model for that needs to be explicit so it is not discovered late.

  1. Fleet control is a distinct, more-elevated privilege tier than fleet observation. Observation (read events, view topology, list agents) and control (pause / drain / restart / force-stop) are different privilege tiers. Control requires a more-elevated scope claim than observation — this extends the §6.5 / §6 elevated-scope-claim concept to the fleet surface. A leaked read-only Console token must not be able to force-stop a fleet. Every fleet-control command is audit-redacted and emitted ("who restarted which agent, from which Console, when").
  2. The Console is just another Protocol client — it authenticates to each runtime with an operator-issued JWT (asymmetric algorithms only, §7), and the Protocol never accepts a request without an identity scope (§8). Deployment posture (private subnet, Console as the only reachable client, optional transport mTLS) is defense-in-depth and is mostly the operator's responsibility, not a runtime feature.
  3. A runtime-side control-plane client enrollment allowlist is deferred. A runtime recording "control-plane client with key-fingerprint F is authorized at scope S" is stronger than per-request JWT scope alone, but the JWT scope covers the core V1 need. This is a "decide later" item, not V1 scope. It is the legitimate inverse of a Console DB ([[D-061]]) — a runtime-side record of authorized controllers, not a Console-side record of agents.

D-067 — Pause/Resume Coordinator: opaque runtime-owned Token, process-local handle/pause registry at V1, durability rides on state.StateStore (no parallel persistence seam), §13 primitive-with-consumer obligation discharged by Phase 53

Date: 2026-05-14 Status: Settled Where it lives: RFC §6.3 + §3.3 + §3.4, docs/plans/phase-50-pauseresume-coordinator.md, internal/runtime/pauseresume/pauseresume.go (Coordinator interface + Pause / PauseRequest / Status value types + Token + Reason typedef bridge), internal/runtime/pauseresume/coordinator.go (process-local coordinator + New + Options), internal/runtime/pauseresume/checkpoint.go (checkpointRecord envelope over state.StateStore), internal/runtime/pauseresume/errors.go (sentinels), internal/runtime/pauseresume/events.go (pause.requested / pause.resumed payloads), test/integration/phase50_durability_test.go, scripts/smoke/phase-50.sh, brief 02 §3 + §4 + §5 + §6.

Why: Phase 50 lands Harbor's ONE pause/resume primitive (CLAUDE.md §7 rule 4) — the master plan flags it a highest-risk critical-path phase ("if it leaks abstractions to planner code, the swappable-planner property regresses"). Four design calls warrant a settled entry.

  1. Token is opaque, runtime-owned, ULID-encoded. RFC §6.3 specifies type Token string "opaque to clients; the runtime owns the encoding". Phase 50 mints tokens via ulid.MustNew(ulid.Now(), crypto/rand.Reader) — monotonic-ish, lexicographically sortable, crypto-strong entropy, concurrency-safe with a stateless entropy source. There is no exported parse/construct helper: clients receive a Token from Request and hand it back to Resume / Status verbatim. The encoding is an implementation detail the Protocol projection (a later phase) never exposes.

  2. The handle registry and the pause registry are both process-local at V1. RFC §6.3 already settled this for the handle directory: "V1: process-local. Resume must run in the same Runtime process. The seam for a distributed handle directory exists … but no production driver ships at V1." Phase 50 reuses Phase 43's trajectory.HandleRegistry (already an interface with a process-local sync.Map driver — D-049) rather than minting a parallel registry, and re-attaches the non-serialisable half of ToolContext through it on Resume — a lost handle surfaces trajectory.ErrToolContextLost verbatim, never a silent nil context. The Coordinator's OWN pause registry (Token → live pause record, behind a documented-invariant sync.Mutex per the D-025 concurrent-reuse contract) is likewise process-local. A distributed handle/pause directory is a post-V1 RFC concern (RFC §6.3 + RFC §12).

  3. Durability rides on the existing state.StateStore — no parallel persistence-driver seam. A literal reading of §4.4 might suggest pauseresume needs its own CheckpointStore interface + driver registry. Phase 50 deliberately does NOT mint one: state.StateStore (Phase 07) is ALREADY the §4.4 persistence seam, with three V1 drivers (in-mem / SQLite / Postgres) at conformance parity (CLAUDE.md §9 + internal/state/conformancetest). A second persistence seam would be the §13 "two parallel implementations of the same conceptual feature" smell. The Coordinator takes an OPTIONAL state.StateStore at construction via WithCheckpointStore; when present, Request serialises the pause record (via trajectory.Trajectory.Serializetrajectory.ErrUnserializable propagates verbatim, no half-persist) into a minimal checkpointRecord{FormatVersion int; ...} envelope keyed on a per-token Kind (pauseresume.checkpoint:<token>) with the Token doubling as the state.EventID so LoadByEventID(token) resolves a pause from a Token alone. The acceptance criterion is verbatim from the master plan: "pauses survive Runtime restart only when StateStore-backed checkpoint is configured." When NO store is configured, pauses are process-local only and a fresh Coordinator returns ErrPauseNotFound for the token. The FormatVersion field is the forward-compatibility hinge: Phase 51 authors the full RFC §6.3 format_version: 1 pause-state serialise contract by deepening this envelope's typed fields, not rewriting it.

  4. The §13 primitive-with-consumer obligation is discharged by Phase 53 — tracked, not forgotten. CLAUDE.md §13 names the unified pause/resume primitive explicitly: "Phase 50 (the primitive) cannot ship without at least one planner (or planner upgrade) emitting RequestPause for a real reason … in the same wave." The Wave 9 coordinator decided the first end-to-end RequestPause-driven-through-the-Coordinator consumer is Phase 53 (steering wiring), same wave, Stage 3 — when a planner emits the RequestPause Decision shape, Phase 53's steering/executor wiring calls Coordinator.Request, drives the protocol-level pause event, and resumes via Coordinator.Resume on an inbound RESUME steering control. Phase 51 (Stage 2) also consumes Phase 50's surface (the pause-record serialise contract on top of the checkpointRecord envelope). The producer side already exists: PauseStep (Phase 48, internal/planner/deterministic/steps.go) emits the planner.RequestPause Decision shape — Phase 53 closes the loop by wiring that emission into the Coordinator. Phase 50's own tests are NOT a substitute for the §13 consumer; they are the direct exercise of the primitive (round-trip, durability across all three StateStore drivers, Status, idempotent/concurrent Request/Resume). The obligation is satisfied at the wave level by Phase 53, which lands before Wave 9 closes.

The D-025 concurrent-reuse contract is pinned in concurrent_test.go: N=200 goroutines (≥100 per the contract) run the full Request → Status → Resume → Status lifecycle against one shared Coordinator under -race — distinct per-goroutine identity quadruples (a context bleed surfaces as a wrong triple or foreign payload), a pre-cancelled-ctx subset (no cross-cancellation), baseline runtime.NumGoroutine restored after join (no leak). A companion test races N=32 goroutines to Resume the same token and asserts exactly one success + N-1 ErrAlreadyResumed (idempotent, no double-apply). The §11 mandatory pause/resume serialisation test (TestRequest_FailsLoudlyOnUnserializableTrajectory) constructs a PauseRequest whose trajectory carries a live channel and asserts Request returns trajectory.ErrUnserializable with a non-empty field path — never a half-persisted checkpoint. Coverage on internal/runtime/pauseresume is 93.9% (master-plan target 90%).


D-068 — Agent Registry implementation calls: version_hash is SHA-256 over canonical JSON of AgentConfig; the connect-to-remote "handle" is a normal locally-minted ULID agent_id discriminated by Hosting; the registry consumes the existing StateStore seam (no registry-specific driver seam)

Date: 2026-05-14 Status: Settled Where it lives: RFC §6.16 (Agent Registry — the algorithm and handle encoding are implementation calls within the RFC's envelope), docs/plans/phase-53a-agent-registry.md (Risks / open questions), internal/runtime/registry/versionhash.go, internal/runtime/registry/registry.go (the Hosting discriminator + AgentRecord.AgentCardRef), internal/runtime/registry/registry_impl.go (StateStore-backed persistence via the generic (Quadruple, Kind, Bytes) surface).

Why: Phase 53a implements against the already-settled D-059 / D-060 / D-061 / D-062 / D-066 — the three-ID model, the in-process per-instance subsystem shape, the Console-DB boundary, the 14-page Console IA, and the fleet-control privilege tier are all design decisions that were NOT re-litigated. But the implementation surfaced three concrete calls the RFC deliberately left open; they are settled here so they do not get re-decided at the next refactor.

  1. version_hash is SHA-256 over a canonical JSON encoding of AgentConfig, hex-encoded. RFC §6.16 specifies a "deterministic content hash over (prompt set, tool set + schemas, planner config, model policy)" but pins neither the algorithm nor the canonicalisation rule. Phase 53a settles: the four configuration dimensions are folded into a canonicalConfig struct with fixed JSON field names; the Prompts slice and the Tools slice are sorted (their order is not semantic), and PlannerConfig / ModelPolicy maps are flattened to key-sorted {k,v} slices (so the encoding is reproducible independent of any encoder's map-key-ordering behaviour); the canonical form is json.Marshal-ed and sha256.Sum256-ed; the result is lowercase hex. This makes version_hash deterministic — same content in, same hash out, regardless of caller-side construction order — which is the property the post-V1 Evaluations program (success-rate-over-version_hash, D-064) depends on. The hash function is pure and holds no package-level state, so it is concurrent-safe by construction (D-025).

  2. The connect-to-remote "handle" is a normal locally-minted ULID agent_id, discriminated by Hosting, not a distinct type. D-060 says the local agent_id for a connect-to-remote agent is a "handle" whose canonical identity lives in the remote A2A AgentCard. Phase 53a settles the encoding: a RegisterRemote mints the same ULID agent_id as Register does, sets AgentRecord.Hosting = HostingRemote, and stores the remote operator's AgentCard reference in AgentRecord.AgentCardRef (empty for local agents). The handle is therefore not a separate type or a separate id-space — it is the same agent_id field, with Hosting as the discriminator and AgentCardRef as the pointer to the authoritative remote record. This keeps the three-ID model uniform across both creation cases (a remote agent still has agent_id + incarnation; version_hash is empty because the configuration is owned remotely) and avoids a parallel "handle table." It is runtime-instance-local and never assumed globally unique, exactly as D-060 requires.

  3. The registry consumes the existing state.StateStore seam — it does NOT define a registry-specific driver seam. The master plan says "StateStore-backed (in-mem / SQLite / Postgres, §4.4 seam)"; this could be read as "the registry needs its own registry/drivers/ seam." It does not. Driver pluralism already lives at the StateStore layer (D-027): the registry persists through the generic (identity.Quadruple, Kind, Bytes) surface — one per-identity agent.index document (the enumeration source for List, since the StateStore surface has no scan operation — the same typed-wrapper-owns-enumeration shape as sessions.Registry) plus one agent.record.<agent_id> document per agent. This is the same call D-027 settled for every persistence-shaped consumer; the registry is not special. "Rehydration on restart" is therefore automatic — a fresh *Registry over a durable StateStore (SQLite / Postgres) sees the prior process's agents because it reads the store on every operation; a fresh *Registry over a fresh in-mem store does not (the in-mem driver is dev-only and non-persistent, D-060).

None of these three calls reaches into RFC territory — they are implementation decisions within the envelope RFC §6.16 + D-059 / D-060 left open. Phase 53a's design is otherwise fully covered by the five pre-settled decisions; D-068 records only the implementation-level calls that warranted a durable home.


D-069 — Pause-state serialise contract closes on the pause-record envelope by SHARING Phase 43's reflective walker (exported as trajectory.ValidateEncodable), not forking a second fail-loudly serialiser; format_version: 1 is stamped by SerializeRecord and enforced by DeserializeRecord; the negative tests are the acceptance gate

Date: 2026-05-14 Status: Settled Where it lives: RFC §6.3 + §3.4, docs/plans/phase-51-pause-serialise-contract.md, internal/planner/trajectory/serialize.go (ValidateEncodable — the exported reflective walker), internal/runtime/pauseresume/pauserecord.go (FormatVersion + SerializeRecord + DeserializeRecord), internal/runtime/pauseresume/errors.go (ErrUnsupportedFormatVersion), internal/runtime/pauseresume/checkpoint.go (saveCheckpoint / loadCheckpoint routed through the pair), internal/runtime/pauseresume/coordinator.go (unconditional Payload-encodability check in Request), internal/runtime/pauseresume/pauserecord_test.go + pauserecord_contract_test.go (the negative-test gate), test/integration/phase51_pause_serialise_test.go (conformance-with-phase-43), brief 02 §4.

Why: Phase 51 closes the load-bearing predecessor-bug — the silent-context-loss path — for the pause record's OWN wire envelope. Phase 43 (D-049) closed it for the trajectory; Phase 50 (D-067) propagated trajectory.ErrUnserializable verbatim out of Coordinator.Request for the trajectory. But the pause record carries one more caller-controlled, JSON-tree-shaped field — Payload map[string]any — and Phase 50's checkpoint save reached it via a bare json.Marshal, which on a non-encodable leaf returns a plain *json.UnsupportedTypeError: technically loud, but without the actionable dotted field path RFC §3.4 mandates ("MUST return ErrUnserializable naming the offending field path"). Three design calls warrant a settled entry.

  1. The pause-record serialise contract SHARES Phase 43's reflective walker — it does NOT fork a second fail-loudly serialiser. The master plan's Phase 51 "Tests" line reads "Conformance with phase 43 Trajectory.Serialize" — a strong steer that Phase 51 must not re-implement the walker. Phase 51 exports Phase 43's walkEncodable as trajectory.ValidateEncodable(v any, root string) error — a pure, stateless, reusable primitive — and Trajectory.Serialize's own pre-flight pass is re-pointed at the exported entry so there is exactly ONE walker entry point. pauseresume.SerializeRecord pre-flight-walks the whole checkpointRecord envelope via trajectory.ValidateEncodable(rec, "PauseRecord") and propagates the SAME trajectory.ErrUnserializable struct sentinel verbatim. The observable proof the walker is shared, not copy-pasted, is asserted in TestSerializeRecord_SharesTrajectoryWalker and TestE2E_PauseSerialise_ConformsWithPhase43: a non-encodable leaf in EITHER the trajectory or the pause record's Payload surfaces the same error type out of Request. A second fail-loudly serialiser would be the CLAUDE.md §13 two-parallel-implementations anti-pattern — exactly the shape the Wave 8 §17.5 checkpoint audit's capfilter extraction (D-052) spent a chore PR killing. Exporting one function is the cheap, correct alternative to forking ~170 lines of reflective walker.

  2. format_version: 1 is stamped by SerializeRecord and enforced by DeserializeRecord. RFC §6.3 settles the pause-state serialisation format as "JSON with format_version: 1" (resolves brief 02 Q-2). Phase 50 shipped the checkpointRecord.FormatVersion int field as the forward-compat hinge but did not yet author the contract on it. Phase 51 settles: pauseresume.FormatVersion (= 1) is the single source of truth; SerializeRecord STAMPS it on every write regardless of the caller's value (so "what version did we write" is single-sourced, not caller-trusted); DeserializeRecord ENFORCES it on load — a record whose format_version is not FormatVersion (a zero/absent version from a corrupt or pre-contract write, OR a higher unknown version from a forward-incompatible newer-Runtime write) is rejected loud with ErrUnsupportedFormatVersion, never silently mis-decoded against the current schema. Bumping FormatVersion is an RFC change. Phase 51 ships the guard, not a multi-version decoder — V1 has exactly one format.

  3. The negative tests ARE the acceptance gate; the serialise check in Coordinator.Request is unconditional. The master plan's Phase 51 "Acceptance" line is verbatim: "Negative tests are the gate. CI fails on any silent-drop regression." Phase 51 takes this literally — the in-package pauserecord_test.go constructs checkpointRecord envelopes with non-encodable Payload leaves (function / channel / complex / nested-function) and asserts SerializeRecord returns (nil, trajectory.ErrUnserializable) with a PauseRecord.payload.<key> field path, never half-encoded bytes, never (nil, nil); the black-box pauserecord_contract_test.go asserts the same through the real Coordinator.Request surface. A consequential implementation call: the Payload-encodability check in Request is unconditional — it runs whether or not a checkpoint store is configured, before a Token is minted. Phase 50 only serialised the envelope when store != nil; that left a no-store pause free to silently carry a Payload field that could never round-trip. The Payload is the pause record's wire shape regardless of persistence, so RFC §3.4's "no silent degradation" applies unconditionally — a non-encodable Payload fails the Request loud either way, and no Token / pause / checkpoint is ever produced.

Phase 51 ships no new reusable artifact: SerializeRecord, DeserializeRecord, and ValidateEncodable are pure stateless functions with no receiver and no package-level mutable state — concurrent-safe by construction, no new D-025 test required. Phase 50's concurrent_test.go (N=200 goroutines against one shared Coordinator) now exercises the Phase 51 serialise path on every Request and still passes under -race. Coverage on internal/runtime/pauseresume is 94.0% (master-plan Phase 51 target 90%); the trajectory package stays at 90.8% after the ValidateEncodable export (the full Phase 43 suite passes unchanged — the export does not alter the trajectory's observable contract).


D-070 — Steering inbox: per-run Runtime-owned inbox + process-wide Registry, the nine-type control taxonomy is a fixed enum (no Register escape hatch), payload bounds enforced at the edge fail-loud (never truncate), per-event scope is a three-tier trust-based claim, the Phase-52-vs-53 boundary is taxonomy+inbox+validation here / run-loop wiring there

Date: 2026-05-14 Status: Settled Where it lives: RFC §6.3, docs/plans/phase-52-steering-inbox.md, internal/runtime/steering/taxonomy.go (ControlType enum + IsValidControlType / ControlTypes), internal/runtime/steering/validate.go (the five payload-bound constants + ValidatePayload), internal/runtime/steering/scope.go (Scope enum + requiredScope mapping + CheckScope), internal/runtime/steering/inbox.go (ControlEvent + per-run Inbox), internal/runtime/steering/registry.go (process-wide RegistryOpen / Lookup / Retire), internal/runtime/steering/events.go (control.rejected event + EmitRejection), internal/runtime/steering/errors.go (sentinels), test/integration/phase52_steering_test.go, scripts/smoke/phase-52.sh, brief 02 §2 + §3 + §4 + §6.

Why: Phase 52 lands the steering primitive — the control taxonomy + the per-run inbox + the Protocol-edge validation + the per-event scope checks. RFC §6.3 settles the what (the nine types, the payload bounds, the per-event scope mapping); five implementation calls warrant a durable home so they are not re-decided at the next refactor.

  1. The inbox is per-run and Runtime-owned; a process-wide Registry owns inbox lifecycle. RFC §6.3 says "the Runtime owns the inbox" and brief 02 §3 says the inbox is "per-run" with the planner observing "RunContext.Control only; it does not receive the inbox." Phase 52 settles the shape: a per-run Inbox (FIFO Enqueue + atomic Drain, identity-scoped to exactly one identity.Quadruple, mutex-guarded so N Protocol-edge goroutines may Enqueue while the run loop Drains) is minted / looked up / retired by a process-wide Registry keyed on the run quadruple. The run component is part of the key — two runs in the same session get distinct inboxes (TestRegistry_SameTripleDifferentRun). Open on a live run fails loud with ErrInboxExists rather than orphaning the first inbox's queued events; Lookup / Drain after Retire fail loud with ErrInboxNotFound. The Registry is the D-025 compiled artifact (the run→inbox map behind a documented-invariant mutex); the Inbox is itself concurrent-safe. Per-run state never leaks across runs — an event whose Identity is not the inbox's own quadruple is rejected with ErrIdentityRequired (the per-run isolation gate, CLAUDE.md §6).

  2. The nine-type control taxonomy is a fixed enum — there is no RegisterControlType escape hatch. RFC §6.3 names the nine types "(Settled)". Phase 52 reads "Settled" strictly: canonicalControlTypes is a fixed package-level map, not a write-once registry like events.RegisterEventType. A tenth control type is an RFC change, not a phase addition — so there is no registration seam to drift through. IsValidControlType is the O(1) gate; ControlTypes() returns the deterministic sorted snapshot for the Phase 54 Protocol allow-list. The wire strings are the RFC's verbatim uppercase identifiers (INJECT_CONTEXT, … USER_MESSAGE).

  3. Payload bounds are enforced at the edge and fail loud — they NEVER truncate. RFC §6.3 settles the five caps (depth ≤ 6, ≤ 64 keys, ≤ 50 list items, ≤ 4096 chars/string, ≤ 16 KiB total). Phase 52 ships them as named constants and ValidatePayload, with two calls settled: (a) the depth cap counts containers only — a scalar leaf inside a depth-6 map does not push the count to 7; a seventh nested map/list is the rejection (this matches "depth ≤ 6" meaning six nesting levels, not five-plus-a-leaf); (b) the key cap is per-map, not cumulative — two 64-key maps in one payload is valid, one 65-key map is not; the 16 KiB total-bytes cap (checked first, via the canonical JSON encoding) bounds cumulative size regardless. A string is rune-counted, not byte-counted. A leaf whose Go type is outside the JSON-shaped accepted set (chan, func, complex, a typed container) is rejected loud with ErrUnsupportedPayloadValue. There is no silent-truncation path (CLAUDE.md §5 "fail loudly") — ValidatePayload returns only an error and never mutates the caller's payload.

  4. Per-event scope is a three-tier, totally-ordered, trust-based Scope claim. RFC §6.3's per-event scope mapping (resolving brief 02 Q-3) is shipped as a Scope enum — ScopeSessionUser < ScopeOwnerUser < ScopeAdmin — and a requiredScope map: INJECT_CONTEXT / USER_MESSAGEScopeSessionUser; REDIRECT / CANCEL / PAUSE / RESUME / APPROVE / REJECTScopeOwnerUser (admin satisfies it by rank — RFC §6.3's "originating user/admin"); PRIORITIZEScopeAdmin. CheckScope also enforces "Cross-tenant steering requires admin" — a caller whose tenant differs from the run's tenant needs ScopeAdmin regardless of the per-type minimum, and an empty caller tenant fails closed. The Scope claim is trust-based at Phase 52, exactly as events.Filter.Admin is until Phase 61 Protocol auth; the control.rejected audit emit on every rejected submission makes abuse retroactively detectable. The Protocol edge (Phase 54) derives the Scope from the caller's JWT and maps ErrScopeMismatch to a 403 — Phase 52 ships the check and the audit emit, not the HTTP status.

  5. The Phase-52-vs-53 boundary: taxonomy + inbox + validation + scope checks here; engine run-loop wiring there. Phase 52 is the primitive. It does NOT drain the inbox in the run loop, propagate CANCEL, block on PAUSE, project onto RunContext.Control, or cap control-history — all of that is Phase 53 (Wave 9, Stage 3), the §13 first consumer, landing in the SAME wave. PAUSE / RESUME / APPROVE / REJECT are taxonomy + scope-check entries in Phase 52; their side effects wire onto the unified pause/resume primitive (internal/runtime/pauseresume, Phase 50) in Phase 53 — Phase 52 mints no parallel pause coordinator (CLAUDE.md §7 rule 4). Phase 52 also ships no §4.4 driver seam: an in-process per-run inbox has no plausible alternate backend, so a drivers/ tree would be ceremony. Phase 52's own tests exercise the primitive directly; the §13 obligation is discharged at the wave level by Phase 53.

The D-025 concurrent-reuse contract is pinned in concurrent_test.go: TestConcurrentReuse_Registry runs N=200 goroutines (≥100 per the contract) through the full Open → Enqueue → Lookup → Drain → Retire lifecycle against one shared Registry under -race — distinct per-goroutine run quadruples (a context bleed surfaces as a foreign RunID on a drained event), and baseline runtime.NumGoroutine restored after join (no leak). TestConcurrentReuse_SingleInbox races N=120 concurrent producers against a concurrent draining consumer on one shared Inbox and asserts every event is drained exactly once (no loss, no duplication). The auth-scope-per-event integration test (test/integration/phase52_steering_test.go) wires the real events.EventBus (in-mem driver) + the real patterns redactor on the seam, walks every one of the nine control types at min scope (accepted) and below-min (rejected → control.rejected audit event), and covers two failure modes (oversize payload, cross-tenant non-admin) plus cross-run isolation. Coverage on internal/runtime/steering is 96.6% (master-plan target 85%).


D-071 — Steering wiring: the RunLoop per-run planner-step loop is the §13 first consumer of BOTH the Phase 50 Coordinator and the Phase 52 inbox; drain-between-steps is the binding invariant; CANCEL is soft-by-default with an optional hard-cancel hook; RequestPause routes through the unified Coordinator; control-history is a per-session newest-wins ring

Date: 2026-05-14 Status: Settled Where it lives: RFC §6.3, docs/plans/phase-53-steering-wiring.md, internal/runtime/steering/runloop.go (RunLoop + NewRunLoop + RunSpec + Run + the WithXxx options + requestPause + mergeSignals), internal/runtime/steering/apply.go (applier + stepControl + applyEvent + the per-control-type side-effect functions), internal/runtime/steering/history.go (controlHistory + MaxControlHistory), internal/runtime/steering/inbox.go (Inbox.WaitForEvent + the coalesced notify channel), internal/runtime/steering/events.go (control.received / control.applied + ControlLifecyclePayload), internal/runtime/steering/errors.go (ErrNoPlanner / ErrRunLoopMisconfigured / ErrNoOutstandingPause / ErrMaxStepsExceeded), test/integration/phase53_steering_wiring_test.go, scripts/smoke/phase-53.sh, brief 02 §3 + §4 + §5 + §6.

Why: Phase 53 closes Wave 9's steering cluster — it wires the Phase 52 steering inbox and the Phase 50 pause/resume Coordinator into a real per-run planner loop. Five implementation calls warrant a durable home.

  1. Phase 53 builds the per-run planner loop (RunLoop) — there was no existing run loop to "wire steering into." The Phase 53 dispatch frame and several upstream phase plans carried an assumption that an engine-level planner run loop already existed. It did not: internal/runtime/engine is a typed graph executor (Phase 10–14), and the only code driving Planner.Next before Phase 53 was the Phase 49 conformance harness and per-planner unit tests. Phase 53 therefore builds the loop — RunLoop in internal/runtime/steering — as the wiring vehicle. This is a §4.3 reasonable plan deviation (a speculative framing turned out wrong once the code was read), not an RFC departure: RFC §6.3 §4 explicitly says "the runtime implements this loop", and RFC §3 lists internal/runtime/steering as the steering home. The loop lives in internal/runtime/steering for V1 because steering wiring IS its reason to exist; the graph engine stays the substrate for graph-family planners. Explicit exit condition (added by the Wave 9 §17.5 audit): RunLoop is really a planner-runtime component — it imports planner / pauseresume / tasks / events, and steering is only one of the ~five things it does — so leaving it in internal/runtime/steering is a layering smell, accepted for V1 only. Issue #81 tracks relocating RunLoop (+ apply.go, runloop.go, history.go) to a dedicated planner-runtime package (internal/runtime/runloop or internal/planner/runtime) at the next planner-runtime phase; that issue IS the named exit condition, not an open-ended "MAY relocate." No new top-level directory at V1; no RFC change.

  2. Drain-between-steps is the binding invariant — the drain happens exactly ONCE per step boundary, never mid-tool-call. brief 02 §6 + sharp-edge #2 are explicit: the predecessor's _apply_steering drained a SteeringInbox inside the planner loop and mutated the trajectory directly, so every alternate planner had to replicate it. RunLoop.Run drains the per-run Inbox once at the top of each step (after the prior decision finished executing, before the next Planner.Next), applies each drained event's side effect, then projects the result onto a fresh RunContext.Control — the planner sees ONLY RunContext.Control, never the Inbox. The integration test TestE2E_Phase53_NoEventAppliedMidToolCall pins the invariant: a control event enqueued mid-step is observed on the NEXT step, never the current one.

  3. CANCEL is soft-by-default; hard: true additionally fires an optional hard-cancel hook. RFC §6.3 + brief 02 §6 distinguish soft and hard CANCEL. Phase 53 settles: a soft CANCEL sets RunContext.Control.Cancelled and the planner returns Finish{Cancelled} at the next boundary; a CANCEL whose payload carries hard: true ADDITIONALLY fires a func(ctx, runID) error hook (WithHardCancelHook) that propagates a cancellation context into an in-flight decision execution. The hook is a functional-option seam, not a hard import of internal/runtime/engine — production wiring passes engine.Cancel, but RunLoop holds only the closure, keeping the step-loop family decoupled from the graph engine. A nil hook is tolerated (a hard CANCEL still terminates the run at the next boundary; the hook only accelerates an in-flight tool's teardown). A CANCEL that arrives while a run is paused terminates the run with Finish{Cancelled} — there is no point waiting for a resume that will never come.

  4. RequestPause routes through the unified pauseresume.Coordinator; PAUSE/RESUME/APPROVE/REJECT side effects converge on the same one primitive — no parallel pause coordinator. This is the §13 obligation and CLAUDE.md §7 rule 4. When a planner emits the planner.RequestPause Decision shape, RunLoop calls Coordinator.Request (issuing a Token, and — when a checkpoint store is configured — a durable checkpoint), then blocks the loop via Inbox.WaitForEvent (a coalesced 1-buffered notify channel — no busy-spin) until a steering control arrives. A RESUME or APPROVE calls Coordinator.Resume and the planner re-enters; a REJECT calls Coordinator.Resume with a rejected: true marker and terminates the run with Finish{ConstraintsConflict} — a rejected HITL gate is a constraint conflict the planner cannot resolve. RFC §6.3 originally left the reject-vs-re-enter question unpinned; the Wave 9 §17.5 audit flagged that settling an open RFC question in a phase-plan decision is RFC drift, so RFC §6.3 was amended (in the same audit chore PR) with a "Rejected HITL gate is terminal" paragraph that pins this behaviour — D-071 now records the implementation of an RFC-settled rule, not the settling itself. Phase 53 mints no second coordinator — D-070 §5 explicitly deferred these side effects to Phase 53, and Phase 53 closes that onto the existing Phase 50 primitive.

  5. Control-history is a per-session, capped, newest-wins ring. RFC §6.3 says "control-history capped per session." Phase 53 settles the shape: controlHistory keys an applied-control ring by SessionID (the steering-relevant scope — a session hosts the operator's steering attention across its runs), caps each ring at MaxControlHistory (default 256, overridable via WithMaxControlHistory), and evicts oldest-first on overflow. A failed side-effect apply is STILL recorded (with its error) — the history is the audit trail, and a silent drop would violate CLAUDE.md §5 "fail loudly". The cap is per session, not per run, because that is what the RFC line says and because a session is the coherent steering-attention scope. Accepted V1 limitation (Wave 9 §17.5 audit): each ring is capped, but the session-keyed map gains one entry per distinct session and is never pruned — controlHistory.forget is implemented + tested but not yet wired, because run-end (the RunLoop.Run defer) is the wrong signal to forget on (a session hosts multiple runs) and no session-end signal exists yet. Issue #79 tracks wiring forget to a real session-end signal at the next session-lifecycle phase; for V1 the per-session map growth is a slow, bounded-per-entry leak deemed acceptable.

§13 primitive-with-consumer — discharged here for BOTH Wave-9 primitives. Phase 53 is the §13 first consumer of (a) the Phase 50 pauseresume.Coordinator — the integration test TestE2E_Phase53_PauseRoundTrip_ThroughCoordinator drives the Phase 48 deterministic.PauseStep (the RequestPause-emitting consumer) through Coordinator.Request → block → APPROVE via the Phase 52 inbox → Coordinator.Resume → planner re-enters; and (b) the Phase 52 steering inbox + nine-type taxonomy — the integration test TestE2E_Phase53_NineEventMatrix exercises all nine control-event side effects through a real steering.Inbox. Both obligations are discharged in-wave (Wave 9, Stage 3) per the binding coordinator decision recorded in D-067 §4 and D-070 §5.

The D-025 concurrent-reuse contract is pinned in concurrent_test.go: TestConcurrentReuse_RunLoop runs N=120 goroutines (≥100 per the contract) each driving ONE distinct run to completion against ONE shared RunLoop under -race — distinct per-goroutine run quadruples (a context bleed surfaces as a foreign run_id in Finish.Metadata), a ~20% pre-cancelled-ctx subset (no cross-cancellation — cancelled runs fail with context.Canceled, non-cancelled runs finish cleanly regardless), and baseline runtime.NumGoroutine restored after join (no leak). RunLoop is a compiled artifact: every field is set once at construction; per-run loop state (stepControl, the outstanding pause Token) lives on the run's own goroutine stack, never on the struct. Coverage on internal/runtime/steering is 92.4% (master-plan Phase 53 target 85%).


D-072 — Protocol task control surface: internal/protocol/ is created with the types/methods/errors single-source layout; Phase 54 ships the transport-AGNOSTIC surface (the ten methods + wire types + the in-process ControlSurface dispatcher) and the SSE+REST wire binding is Phase 60; the ten methods map onto the already-shipped runtime — starttasks.TaskRegistry.Spawn, the nine controls → a steering.ControlEvent on the run's inbox; identity scope is enforced at the Protocol edge; no parallel pause coordinator, no §4.4 driver seam

Date: 2026-05-14 Status: Settled Where it lives: RFC §5.2 + §5.3 + §5.4 + §5.5 + §6.3, docs/plans/phase-54-protocol-control-surface.md, internal/protocol/types/version.go (ProtocolVersion pin), internal/protocol/types/control.go (IdentityScope + StartRequest / StartResponse + ControlRequest / ControlResponse wire types), internal/protocol/methods/methods.go (Method enum + the ten canonical method-name constants + IsValidMethod / IsControlMethod / Methods), internal/protocol/errors/errors.go (Code enum + the seven Protocol error codes + the Error wire type), internal/protocol/protocol.go (ControlSurface + NewControlSurface + ErrMisconfigured), internal/protocol/control.go (Dispatch + the per-method handlers + methodToControlType), internal/protocol/errors.go (the runtime-error → Protocol-code mapping), test/integration/wave9_test.go (the Wave 9 wave-end E2E), scripts/smoke/phase-54.sh, brief 02 §3 + §5, brief 06 §1 + §"Wire format", brief 07.

Why: Phase 54 creates the Harbor Protocol layer — internal/protocol/ did not exist before this phase — and ships its first surface, the task control surface (RFC §5.2 "Task control" row). It also closes Wave 9 (the steering / pause-resume cluster: 50 → 54) and authors the Wave 9 wave-end E2E. Five design calls warrant a durable home.

  1. internal/protocol/ is created with the types / methods / errors single-source layout from the start — Phase 58 is then a no-op formalisation, not a cleanup. CLAUDE.md §8 is binding: "All wire types live in internal/protocol/types/... Method names live in internal/protocol/methods/methods.go. No hardcoded method strings elsewhere... Error codes live in internal/protocol/errors/errors.go." Phase 54 lays the tree exactly per CLAUDE.md §3 — types/ (the ProtocolVersion pin + the four control wire types), methods/ (the Method enum), errors/ (the Code enum + the Error wire type) — so the Phase 58 lint ("Protocol types/methods/errors single source") finds nothing to fix. The protocol package itself defines NONE of those — it only consumes them. The transports/ subdirectory (CLAUDE.md §3) is deliberately NOT created here: it is Phase 60's home for the SSE+REST binding.

  2. Phase 54 ships the transport-AGNOSTIC surface; the SSE+REST wire binding is Phase 60. The master-plan acceptance line reads "all nine endpoints + start round-trip via SSE+REST (phase 60)" — a forward reference. RFC §5.4 settles SSE+REST as the current lean but explicitly says "the relevant phase blocks until [Q-1] resolves", and brief 06's open "Wire format" question confirms the transport is not locked. Phase 54 takes the explicit consequence: it ships ControlSurface.Dispatch(ctx, method, req) — a plain Go entry point, in-process-invocable and fully testable today — and leaves the HTTP/SSE adapter to Phase 60. A Phase 60 HTTP handler is a thin adapter that decodes a request, calls Dispatch, and encodes the response (or maps a *protocol/errors.Error onto an HTTP status). This is not a plan departure: the master-plan detail block, RFC §5.4, and brief 06 all point the same way. The smoke script's HTTP/wire assertions skip with a reason per the 404/405/501 → SKIP convention; the surface is exercised in-process by the package + Wave 9 integration tests. The §13 in-wave consumer of the Phase 54 primitive is the Wave 9 E2E (TestE2E_Wave9_ProtocolDrivenRun_AssembledSurface), which drives a HITL-gated run end to end entirely through ControlSurface.Dispatchstart → the RunLoop reaches a pause → inject_contextapprove → the planner re-enters and finishes. Phase 60 and the Console are the later consumers; the E2E is the in-wave one.

  3. The ten methods map onto the already-shipped runtime through the public Phase 20 / Phase 52 surfaces — starttasks.TaskRegistry.Spawn, the nine controls → a steering.ControlEvent enqueued on the run's steering.Inbox. Dispatch branches on the method: MethodStart builds a tasks.SpawnRequest (a KindForeground task, identity triple from the request's IdentityScope, RunID left empty — Spawn assigns the TaskID) and calls TaskRegistry.Spawn; each of the nine control methods is bridged to its steering.ControlType via the fixed methodToControlType map, a steering.ControlEvent is constructed, the run's inbox is resolved via steering.Registry.Lookup, and the event is handed to Inbox.Enqueue. The Protocol method names are lowercase snake_case (inject_context, user_message — RFC §5.2 verbatim); the steering ControlType wire strings are uppercase (INJECT_CONTEXT, USER_MESSAGE — RFC §6.3 verbatim). The two namespaces are kept distinct on purpose — the Protocol surface owns its own client-facing method vocabulary (brief 07's "the runtime owns the protocol it speaks"); methodToControlType is the single bridge, and an init() assertion keeps it in lockstep with the methods package. Phase 54 does NOT re-implement validation: Inbox.Enqueue runs the whole Phase 52 gauntlet — the identity-match gate, the canonical-type check, CheckScope (per-event scope + cross-tenant-requires-admin), ValidatePayload (the RFC §6.3 payload bounds). A second validator at the Protocol edge would be the CLAUDE.md §13 two-parallel-implementations anti-pattern. The surface's only job is to construct the event, hand it to Enqueue, and map the steering / tasks sentinel onto a stable Protocol error code (internal/protocol/errors.go is that one mapping site).

  4. Identity scope is enforced at the Protocol edge on every method; the scope claim is trust-based until Phase 61. RFC §5.5: "the Protocol rejects any request without an identity scope." Dispatch validates the identity triple before any method reaches the runtime — an incomplete triple fails closed with CodeIdentityRequired (CLAUDE.md §6 rule 9; there is no identity-downgrading knob). The nine control methods additionally require a non-empty Run (a steering control targets a specific run's inbox) and resolve the caller's steering.Scope from the request's IdentityScope.Scope claim — an unrecognised scope fails closed with CodeScopeMismatch, and Inbox.EnqueueCheckScope does the per-event minimum-scope + cross-tenant enforcement. The Scope claim is trust-based at Phase 54, exactly the posture events.Filter.Admin (Phase 05) and steering.CheckScope (Phase 52) hold until Protocol auth (Phase 61) lands — Phase 54 takes the already-derived triple + scope as request inputs and enforces them; it does not parse a JWT. The seven Protocol error codes (invalid_request, identity_required, scope_mismatch, payload_invalid, unknown_method, not_found, runtime_error) are the stable client-facing contract a transport adapter maps onto HTTP statuses; *protocol/errors.Error implements error so an in-process caller reaches the Code via errors.As until the wire transport lands.

  5. No parallel pause coordinator, no §4.4 driver seam. The pause-family control methods (pause / resume / approve / reject) do NOT reach pauseresume.Coordinator directly — they enqueue a steering.ControlEvent, and the Phase 53 RunLoop routes the side effect through the unified Coordinator (CLAUDE.md §7 rule 4; the same convergence D-070 §5 + D-071 §4 settled). Phase 54 mints no second coordinator. And the ControlSurface is an in-process handler with no plausible alternate backend — there is no internal/protocol/drivers/ tree; a §4.4 driver-registry would be ceremony, the same call D-070 / D-071 made for the steering primitives. The wire transport is a plausible alternate-implementation axis (gRPC vs SSE+REST vs WebSocket — RFC §5.4), but that pluralism lives at the transports/ layer Phase 60 builds, not at the ControlSurface dispatcher.

The D-025 concurrent-reuse contract is pinned in concurrent_test.go: TestConcurrentReuse_ControlSurface runs N=150 goroutines (≥100 per the contract) each driving a distinct identity quadruple through start + inject_context against ONE shared ControlSurface under -race — a context bleed would surface as a foreign triple on a drained inbox event or a wrong-tenant spawned task; baseline runtime.NumGoroutine is restored after join (no leak). ControlSurface is a compiled artifact: the TaskRegistry and the steering Registry are set once at NewControlSurface; Dispatch reads run-specific data from ctx + the request argument, never from the struct. The Wave 9 wave-end E2E (test/integration/wave9_test.go) wires real drivers across the full Wave 9 surface — pauseresume.Coordinator over a real in-mem state.StateStore checkpoint store, registry.AgentRegistry, steering.Registry + steering.RunLoop, the Phase 54 ControlSurface — and covers the Protocol-driven HITL round-trip, identity propagation through every layer, four fail-closed-at-the-edge failure modes, and an N=16 concurrency stress. Coverage on internal/protocol is 93.8%, internal/protocol/methods 100%, internal/protocol/errors 100% (master-plan Phase 54 target 85%).


D-073 — OTel traces: a Tracer wrapper deriving spans from events.Event (no public Start), W3C TraceContext propagation via three carrier idioms (traceparent HTTP / _meta MCP / HARBOR_TRACEPARENT env), and a §4.4 span-exporter driver seam (noop default + otlp)

Date: 2026-05-14 Status: Settled Where it lives: RFC §6.14, docs/plans/phase-55-otel-traces.md, internal/telemetry/tracing.go (Tracer + NewTracer + the SpanExporter interface + the exporter factory/registry + SpanFromEvent + LogAttrs + ErrTracerNotConfigured / ErrExporterUnknown), internal/telemetry/propagation.go (InjectHTTP / ExtractHTTP, InjectMeta / ExtractMeta, InjectEnv / ExtractEnv, EnvTraceparent / EnvTracestate), internal/telemetry/drivers/noop + internal/telemetry/drivers/otlp (the two self-registering exporter drivers), cmd/harbor/main.go (the two blank imports), test/integration/phase55_otel_test.go (the cross-subsystem E2E), scripts/smoke/phase-55.sh, brief 06 §1 + §2 + §6 + §"Key data shapes".

Why: Phase 55 closes the predecessor's "no OpenTelemetry in the runtime" gap — brief 06's lessons section is explicit that OTel traces must be a first-class derivation of the event bus, shipped from t=0, not retrofitted. Phase 04 already reserved trace_id / span_id as passthrough Logger attribute names for exactly this phase. Four design calls warrant a durable home.

  1. Tracer derives spans from events.Event — there is no public Start method. Spans are a derivation of the event bus, not a parallel instrumentation path (brief 06 §1). The Tracer's only span-creating entry point is SpanFromEvent(ctx, ev): the span name derives from ev.Type, the identity quadruple + ev.Extra (the bounded, low-cardinality, metric-label-safe map) become span attributes, and NO EventPayload bytes are stamped — payload content is not span-safe and the audit redactor is the only sanctioniser of payload bytes (D-020). A contributor cannot sprinkle tracer.Start(...) across subsystems and grow a second observability channel: subsystems emit events, the event-to-span bridge produces the spans. Run/step alignment falls out for free — an event carrying a run_id produces a run-attributed span, and a step-granularity event derived under the run span's ctx becomes a child span, so the trace tree mirrors the run/step hierarchy.

  2. W3C TraceContext propagation ships as three standalone carrier idioms, each with an Inject* / Extract* half. RFC §6.14 names all three: traceparent HTTP header (HTTP southbound), _meta.traceparent per-request map (stdio MCP), HARBOR_TRACEPARENT env var (stdio child-process spawn). All three encode the SAME W3C bytes — the stdio idioms are just different carriers. The helpers are standalone functions, NOT Tracer methods, so the southbound transport drivers (Phase 27 tools/HTTP, Phase 28 tools/MCP — both already shipped) can wire them in without holding a *Tracer reference. Extraction is best-effort by W3C design: a malformed or absent traceparent yields a ctx with no valid span context (no panic, no partial state) — SpanFromEvent then starts a root span instead of a child. The loud failure mode is reserved for exporter misconfiguration (ErrExporterUnknown), not for extraction of a remote trace id.

  3. The span exporter sits behind a §4.4 driver seam: noop (default) + otlp. NewTracer selects the exporter by TelemetryConfig.OTelEndpoint — empty → noop (spans are still created so in-process propagation works; they are just never shipped), non-empty → otlp (OTLP/gRPC, lazy-connect, insecure transport at V1). The SpanExporter interface lives in the telemetry package; the two drivers live in internal/telemetry/drivers/{noop,otlp}/ and self-register from init(); the factory dispatches by name and its ErrExporterUnknown message lists the registered drivers. The OTLP/gRPC exporter connects lazily, so NewTracer is fast and a collector can come up after the Runtime — the acceptance criterion "integration with a Jaeger/OTLP collector" is satisfied structurally (the integration test uses an in-memory recorder exporter via the documented WithSpanExporter test seam; a real collector run is an operator smoke, not a CI gate). The OTel SDK trace dependency is RFC-sanctioned — RFC §6.14 names go.opentelemetry.io/otel/trace.Tracer explicitly — and go.opentelemetry.io/otel/* was already an indirect dependency; Phase 55 promotes the trace SDK + OTLP/gRPC trace exporter to direct. No RFC PR needed.

  4. Tracer.LogAttrs(ctx) closes the logs↔traces correlation loop without a parallel channel. It returns the trace_id / span_id slog.Attr pair from the span context in ctx (empty slice when no span is active — composes cleanly with the Phase 04 Logger, which elides absent attributes). logger.With(tracer.LogAttrs(ctx)...) stamps trace correlation onto every log line, so logs and traces share the trace id rather than being parallel channels (brief 06 lessons). NewTracer sets the OTel global TextMapPropagator once to the W3C composite (TraceContext + Baggage) — write-once mutable SDK state, idempotent on repeated construction. TelemetryConfig is NOT changed — the OTelEndpoint + ServiceName fields already existed (Phase 02); Phase 55 consumes them. Phase 55 ships traces only — metrics / OTLP-metrics / the Prometheus exporter are Phase 56 (RFC §6.14 §11 Q-5); the metrics-cardinality discipline ("never tag metrics by trace_id / run_id") is Phase 56's concern.

The D-025 concurrent-reuse contract is pinned in internal/telemetry/tracing_test.go: TestConcurrentReuse_Tracer runs N=150 goroutines (≥100 per the contract), each with a goroutine-unique identity quadruple, driving SpanFromEvent + the three propagation round-trips + LogAttrs against ONE shared *Tracer under -race — a context bleed surfaces as a foreign quadruple on a recorded span, baseline runtime.NumGoroutine is restored after join + tracer shutdown (no leak). Tracer is a compiled artifact: every field (the SDK TracerProvider, the trace.Tracer, the TextMapPropagator) is set once at construction; per-span state lives in the returned ctx, never on the struct. The §17 integration test (test/integration/phase55_otel_test.go) wires the real events.EventBus (inmem) + the real telemetry.Logger + a real *Tracer backed by an in-memory recorder exporter — an event published on the bus → SpanFromEvent → a span carrying the event's identity quadruple; identity propagation ctx → event → span attributes → LogAttrs → a Logger line carrying the same trace_id; trace continuity across all three carriers; the ErrExporterUnknown failure mode; and an N=24 concurrency stress with no identity cross-talk and no goroutine leak. Coverage on internal/telemetry is 87.8%, internal/telemetry/drivers/noop 100%, internal/telemetry/drivers/otlp 85.7% (master-plan Phase 55 target 85%).


D-074 — Durable event log: a standalone "durable" events driver persists every event through StateStore keyed by (SessionID, Sequence) for exact gap-free replay across restarts; the events.Factory signature is unchanged (the registry-path factory opens its own StateStore from new optional EventsConfig.StateDriver/StateDSN fields); a missing StateStore degrades to a best-effort ring buffer LOUDLY (runtime.warning + slog.Warn); replayed payloads rehydrate as events.RedactedMap

Date: 2026-05-14 Status: Settled Where it lives: RFC §6.13 (the "durable log driver, StateStore-backed, Phase 57" line + the ring-buffer-vs-durable replay split), RFC §9 (the persistence triad the driver consumes), docs/plans/phase-57-durable-event-log.md, internal/events/drivers/durable/durable.go (the driver + the durable registry factory), internal/events/drivers/durable/record.go (the persisted-event + head-record codec), internal/events/drivers/durable/subscription.go (the live fan-out path), internal/config/config.go (EventsConfig.StateDriver / StateDSN), internal/config/validate.go (allowedEventDrivers gains "durable"; validateEvents validates the new fields), cmd/harbor/main.go (blank import), test/integration/durable_eventlog_test.go (the cross-StateStore-driver E2E), brief 06 §"Roadmap" item 4 + §"Replay semantics" + §"Persistence", brief 05 §"StateStore".

Why: Phase 57 makes replay-from-cursor exact and gap-free across a Runtime restart — the load-bearing dependency for the post-V1 Evaluations program (D-064), which is built on fully-replayable sessions. Six implementation calls warrant a durable home.

  1. The durable driver is a standalone §4.4 driver, not a wrapper over the inmem driver. It implements events.EventBus + events.Replayer directly, owning its own monotonic gap-free sequence counter, its own subscriber fan-out (drop-oldest + windowed bus.dropped), and its own persistence path. Wrapping the inmem driver was rejected: the inmem bus assigns Sequence internally and never returns it, and its internal sibling events (bus.dropped, bus.subscription_idle_closed) advance its counter — so a wrapper could not keep the persisted sequence in lockstep with the bus sequence. A standalone driver is the honest §4.4 shape and mirrors the independence of the internal/state drivers. The durable driver deliberately has NO idle reaper goroutine — idle-subscriber reaping is the inmem driver's concern; durability is this driver's concern.

  2. Keying scheme: the durable log is SESSION-scoped (matching events.Cursor = (SessionID, Sequence)), built from one mutable head record plus one immutable entry record per event, because StateStore has no list/scan method. Both record kinds are stored under the session triple with RunID="" — an event's own RunID is preserved INSIDE the persisted JSON, not in the storage key. The head record (Kind = "events.durable.head") holds the ordered list of bus-sequences persisted for that session; each entry record (Kind = "events.durable.entry/<zero-padded-seq>") holds the JSON-encoded event. Publish assigns the next bus sequence, writes the entry record, then read-modify-writes the head's sequence list — all under one publishMu acquisition, so the head list and the sequence counter never disagree and the persisted log is in strict sequence order. A torn write (entry persisted, head not yet advanced) never produces a gap in a served replay: Replay only ever returns sequences the head record lists, and the next Publish re-derives the head list. Replay reads from the StateStore — not an in-memory ring — so a late subscriber connecting after the Runtime was rebuilt against the same StateStore sees the full gap-free history.

  3. The events.Factory signature is unchanged; the registry-path factory opens its own StateStore from two new OPTIONAL EventsConfig fields. events.Factory is func(config.EventsConfig, audit.Redactor) (EventBus, error) — it carries no StateStore, and widening it would ripple through every existing driver and call site. Instead, EventsConfig gains StateDriver and StateDSN (both omitempty, StateDSN tagged secret:"true"), and the durable factory calls state.Open itself when StateDriver is set. This is a backward-compatible config addition (CLAUDE.md §10): every other driver ignores the fields, and an empty StateDriver is a valid config (it routes to the best-effort degradation, not a config error). validateEvents validates the pairing exactly as validateState does — a non-inmem StateDriver requires a StateDSN. Store ownership follows who opened it: the registry-path factory marks the bus as the store's owner (an unexported withOwnStore() option) so bus.Close closes the store; a caller that passes a store into the exported New owns the store's lifecycle and Close leaves it open.

  4. A missing StateStore degrades to a best-effort in-memory ring buffer LOUDLY — never silently. Brief 06 §"Persistence" says "without [the StateStore], replay degrades to ring-buffer-only" and the runtime "ships a usable in-memory experience without StateStore." Phase 57 honours that but tightens it per CLAUDE.md §13 "no silent degradation": when New is handed a nil store (or the registry factory sees an empty StateDriver), the driver runs in best-effort mode AND emits an slog.Warn at construction stating that replay is NOT durable across restarts. In best-effort mode ReplayBufferSize sizes the fallback ring and Replay applies the same ErrCursorTooOld / ErrReplayUnavailable semantics as the inmem driver. This is a strengthening of the brief finding, not a departure — there is no "Findings I'm departing from" entry on the phase plan because the brief is followed, just made loud.

  5. Replayed payloads rehydrate as events.RedactedMap, not their concrete typed shape. StateStore.Bytes is opaque; the durable driver JSON-encodes the event and, on replay, reconstructs the payload as events.RedactedMap{Data: ...} — the exact same generic post-redaction shape the inmem bus already produces for any payload that is not SafePayload. Concrete typed payloads are NOT round-tripped: reconstructing them would require a payload-type registry the durable log deliberately does not own. Replay consumers read fields via RedactedMap.Data. This matches the existing redaction-boundary contract (D-020 / D-028); revisit only if a future consumer needs typed replay.

  6. Persistence failures and bus-internal notices fail loudly / are transient, respectively. A StateStore.Save failure surfaces from Publish as a wrapped error — the event is NOT enqueued and nextSeq is NOT advanced (the failed sequence is retried by the next Publish, keeping the persisted log gap-free). Silently dropping a persistence failure would foreclose the gap-free guarantee Phase 57 exists to provide (CLAUDE.md §5 + §13). Bus-internal sibling notices (audit.admin_scope_used, audit.redaction_failed) are per-call observability, NOT session event history — an admin_scope_used event for a fully-admin filter does not even carry a complete identity triple, so it cannot be a StateStore record. Such notices are assigned a bus sequence (for live ordering) and fanned out, but are NOT persisted to the durable log; the durable log is the gap-free session history, and transient per-call notices are not part of it.

The D-025 concurrent-reuse contract is pinned in concurrent_test.go: TestConcurrentReuse_DurableBus runs N=120 goroutines (≥100 per the contract) each publishing a batch for a DISTINCT identity quadruple against ONE shared durable bus under -race, then replaying its own session — a context bleed surfaces as a foreign triple in a replayed event; a ~20% pre-cancelled-ctx subset proves no cross-cancellation; baseline runtime.NumGoroutine is restored after Close. The driver is a compiled artifact: every field is set once at construction; per-publish state lives under publishMu, per-subscriber state on the subscription, and nothing run-specific is stored on the struct. The cross-subsystem integration test (test/integration/durable_eventlog_test.go) wires the durable driver against all three real StateStore drivers (in-memory, SQLite, Postgres — Postgres t.Skips with a reason when HARBOR_PG_DSN is unset), covering the publish→teardown→rebuild→replay-no-gaps acceptance scenario, identity propagation through every layer, cross-tenant isolation across replay, a closed-store-mid-publish failure mode, and an N=16 concurrency stress across the events↔state seam. Coverage on internal/events/drivers/durable is 85.2% (master-plan Phase 57 target 85%).

Amended (Wave 10 audit fixes — PR #91 / D-082) after the CLAUDE.md §13 "Test stubs as production defaults on operator-facing seams" rule: the registry-path factory (events.Register("durable", ...)) NO LONGER auto-degrades to the in-memory ring when EventsConfig.StateDriver is empty — it returns a wrapped error naming the missing config key and pointing the operator at events.driver=inmem (the explicit non-durable path). An operator who configures events.driver=durable has signalled they want durability; silently producing a non-durable bus matches exactly the operator-confusion failure mode §13 forbids. The in-process durable.New(... store=nil ...) constructor still emits the loud slog.Warn + falls back to the in-memory ring — that constructor is retained as the test-only seam for exercising the degraded mode (and is not reachable from the registry path). The Wave 10 audit's WARN-1 surfaced this drift; the registry path now fails closed at boot.


D-075 — Protocol single-source enforcement: a go/parser AST-walking go test (internal/protocol/singlesource) is the build gate; it lints internal/protocol/ only (not all of internal/); it lints _test.go files too; the Error wire type is single-sourced in internal/protocol/errors (not types); the master-plan "§8" citation resolves to CLAUDE.md §8

Date: 2026-05-14 Status: Settled Where it lives: RFC §5.1 + §5.2 + §5.3, CLAUDE.md §8 + §13, docs/plans/phase-58-protocol-single-source.md, internal/protocol/singlesource/singlesource.go (ScanProtocolTree + Violation + CanonicalMethods + CanonicalWireTypes + the Kind* constants + scanFile + dirAllowsKind + isProtocolErrorsCodeType), internal/protocol/singlesource/singlesource_test.go (the build-gating clean-tree lint + the per-kind detection / no-false-positive / lockstep tests), internal/protocol/singlesource/internal_test.go (the unexported-predicate unit tests), internal/protocol/control.go + internal/protocol/errors_internal_test.go + internal/protocol/types/types_test.go (the consolidated pre-existing method literals), scripts/smoke/phase-58.sh, brief 06 §1, brief 07.

Why: Phase 58 formalises the Harbor Protocol single-source discipline CLAUDE.md §8 mandates and Phase 54 (D-072 §1) laid the foundation for — the canonical packages internal/protocol/methods, internal/protocol/errors, internal/protocol/types are the only definition sites for Protocol method names, error codes, and wire types. Phase 54 built the layout correctly; Phase 58 adds the mechanical gate so the discipline cannot erode as Phases 59–62 extend the Protocol surface. Five design calls warrant a durable home.

  1. The enforcement is a go/parser AST-walking go test, not a custom golangci-lint analyzer and not a shell script. The repo already proves the pattern: internal/planner/conformance/importgraph_test.go is a go/parser walk that gates the §13 planner-does-not-import-runtime invariant with zero external-tool dependency. Phase 58 reuses that shape. A golangci-lint plugin would need a separate build + a .golangci.yml entry (a new linter needs a PR rationale per CLAUDE.md §5) and would not be runnable as a plain go test. A shell grep could not be precise — a method name inside a comment, a doc string, or a struct tag is not a violation; only a real string-literal expression / const declaration / type declaration is. go/parser sees the AST, so the checker flags a BasicLit STRING whose unquoted value is a canonical method name, a GenDecl CONST of type protocol/errors.Code, or a TypeSpec redeclaring a canonical wire type — and nothing else. The checker (ScanProtocolTree) is a reusable pure function over a filesystem root with no package-level mutable state; the Phase 58 test is its first consumer, and a later phase (a harbor lint subcommand, Phase 59's versioning discipline) can call it without a second implementation.

  2. The method-literal lint is scoped to internal/protocol/ only — not all of internal/. Strings like "cancel", "pause", "reject", "user_message" are legitimate, unrelated domain vocabulary in other subsystems: tasks/groups.go's GroupAction / PatchAction, runtime/registry's agent-command strings, planner/trajectory's trajectory-entry kinds. A repo-wide scan for those literals would be all false positives. CLAUDE.md §8's "No hardcoded method strings elsewhere" is read as "elsewhere in Protocol-surface code" — the checker walks internal/protocol/ (skipping vendor/, testdata/, and its own singlesource/ package, which necessarily names the canonical methods in its CanonicalMethods set). If a future phase ever grew Protocol-method handling outside internal/protocol/ — which would itself be a layering smell — the checker's root would need widening; this is the named exit condition, not an open question.

  3. The checker lints _test.go files too. A Protocol method string hardcoded in a test is the same drift as one hardcoded in production — the importgraph_test.go precedent treats test files identically (an import is an import). Phase 58 surfaced and consolidated three pre-existing literals this catches: internal/protocol/control.go's dispatchStart hardcoded "start" (now reads methods.MethodStart), internal/protocol/errors_internal_test.go passed "cancel" / "start" as method-context arguments (now string(methods.MethodCancel) / string(methods.MethodStart)), and internal/protocol/types/types_test.go used Method: "pause" as a JSON round-trip fixture (now string(methods.MethodPause)). None were bugs — but each was a second textual definition site the lint now forbids. Consolidating them in the same PR is the §17.6 "fix what the lint finds" discipline.

  4. The Error wire type is single-sourced in internal/protocol/errors, not internal/protocol/types. Single-sourcing means "exactly one home", not "all wire types in the same directory". Phase 54 (D-072 §1) deliberately placed the Error wire struct in internal/protocol/errors/errors.go alongside the Code constants it carries — the error wire type and the error codes are one cohesive surface. So the checker's CanonicalWireTypes is a map[string]string (type name → home package), not a flat set with one assumed home: IdentityScope / StartRequest / StartResponse / ControlRequest / ControlResponse live in types, Error lives in errors. A TestSingleSource_CanonicalWireTypesInLockstep test parses both canonical packages and asserts every exported struct type they declare is recorded in the checker under the right home — so a wire type moving home, or a new one landing, fails CI.

  5. The master-plan Phase 58 row cites "§5, §8"; RFC-001 has no §8 — "§8" is CLAUDE.md §8. RFC-001-Harbor.md's section numbering stops at the runtime/protocol/console chapters; there is no RFC §8. The master-plan citation "§8" resolves to CLAUDE.md §8 "Harbor Protocol rules" — the binding operational spec this phase mechanically enforces ("All wire types live in internal/protocol/types/... Method names live in internal/protocol/methods/methods.go. No hardcoded method strings elsewhere... Error codes live in internal/protocol/errors/errors.go"). RFC §5 is the design anchor; CLAUDE.md §8 is the rule Phase 58 turns into a build gate. The phase plan's "RFC anchor" therefore lists RFC §5.1/§5.2/§5.3 (which resolve), with a documentation note explaining the "§8" resolution. This is a §4.3 citation clarification, not a design departure.

§13 primitive-with-consumer — N/A by construction. Phase 58 ships no primitive (no interface, no control instruction, no decision shape, no runtime mechanism) — it ships an enforcement checker over an already-shipped layout. The checker's first and only consumer is its own build-gating test, which exercises it against the real internal/protocol/ tree in the same PR. There is no §17 integration test: Phase 58 is a build-time static source checker that wires no runtime drivers and opens no cross-subsystem seam (CLAUDE.md §17.1 — a phase that consumes no shipped subsystem's runtime surface is exempt). There is no D-025 concurrent-reuse test: ScanProtocolTree is a pure function with no construction-time dependencies and no per-invocation goroutines — it is not a "compiled artifact" in the D-025 sense. Coverage on internal/protocol/singlesource is 94.5% (master-plan Phase 58 target 90%).


D-076 — OTel metrics: a MetricsRegistry deriving the canonical harbor_events_total counter from events.Event (labels event_type / producer / node only — ev.Identity is physically unreachable), the metric exporter behind a §4.4 driver seam (prometheus default + otlpmetric), a built-in Prometheus /metrics http.Handler, and a go/parser static cardinality-lint as the CI gate

Date: 2026-05-14 Status: Settled Where it lives: RFC §6.14, docs/plans/phase-56-metrics.md, internal/telemetry/metrics.go (MetricsRegistry + NewMetricsRegistry + the MetricExporter interface + the exporter factory/registry + RegisterEvent + PrometheusHandler + the PromGatherer contract + ErrMetricsNotConfigured / ErrMetricExporterUnknown / ErrPrometheusHandlerUnavailable), internal/telemetry/drivers/otlpmetric + internal/telemetry/drivers/prometheus (the two self-registering metric-exporter drivers), internal/telemetry/cardinalitylint (ScanMetricsTree + Violation + the forbidden-label set + the go/parser AST walk + the testdata/badmetric negative fixture), cmd/harbor/main.go (the two blank imports), test/integration/phase56_metrics_test.go (the cross-subsystem E2E), scripts/smoke/phase-56.sh, brief 06 §1 + §"Lessons from the predecessor" / "Metrics cardinality footgun" + §"Roadmap" item 7.

Why: Phase 56 closes the second half of the predecessor's "no OpenTelemetry in the runtime" gap (Phase 55 closed traces) and makes the brief 06 "metrics cardinality footgun" lesson mechanically un-violatable. RFC §6.14 settles the exporter set: OTLP default, a built-in Prometheus /metrics endpoint at V1 (resolves brief 06 Q-2). Five design calls warrant a durable home.

  1. MetricsRegistry derives the canonical counter from events.Event — there is no public Counter / Meter accessor. Metrics are a derivation of the event bus, not a parallel instrumentation path (brief 06 §1) — the same load-bearing decision Phase 55 applied to spans. The registry's only recording entry point is RegisterEvent(ctx, ev), which increments harbor_events_total with labels event_type (ev.Type), producer (ev.Extra["producer"], "unknown" when absent), and node (ev.Extra["node"], "" when absent). RegisterEvent reads NO field of ev.Identity — there is no code path on the registry that touches the run quadruple, so a metric tagged by RunID / TraceID is impossible by construction. This is the cardinality firewall: the predecessor's docs warn "Never tag metrics by trace_id"; Harbor makes the production boundary closed by structure, not by discipline. NewMetricsRegistry touches NO OTel global — it builds a private MeterProvider and the registry is passed to callers explicitly; a global MeterProvider would be an ambient parallel metrics channel.

  2. Producer / NodeName are realised as the reserved Event.Extra["producer"] / Event.Extra["node"] keys — not new events.Event struct fields. Brief 06's Event shape sketch has Producer / NodeName fields, but the Phase 05 events.Event doc explicitly reserves the Extra map "for Phase 56's bounded low-cardinality metric labels" — and Event.Extra is documented as "bounded, low-cardinality; safe for metric labels". Phase 56 consumes that reserved slot rather than widening the Event struct (which would ripple through every Publish call site and every driver). This is a §4.3 plan-shape clarification, not a brief departure: the two values stay inside the bounded Extra map, which is the same cardinality boundary the brief intends. The phase plan's "Findings I'm departing from" is "None".

  3. The metric exporter sits behind a §4.4 driver seam mirroring Phase 55's SpanExporter seam exactly: prometheus (default) + otlpmetric. NewMetricsRegistry selects the exporter by TelemetryConfig.OTelEndpoint — empty → prometheus (the built-in /metrics pull endpoint; no collector needed), non-empty → otlpmetric (OTLP/gRPC push, lazy-connect, insecure transport at V1, identical stance to Phase 55's otlp span driver). The MetricExporter interface lives in the telemetry package; the two drivers live in internal/telemetry/drivers/{otlpmetric,prometheus}/ as sibling dirs to Phase 55's {noop,otlp}/, self-register from init(), and the factory dispatches by name with an ErrMetricExporterUnknown message that lists the registered drivers. The prometheus driver builds a FRESH prometheus.Registry per NewMetricsRegistry (via WithRegisterer) — NOT the process-global prometheus.DefaultRegisterer — so two registries in one process never collide on the global, keeping the D-025 contract. telemetry.PrometheusHandler(reg) recovers that per-registry Gatherer through the PromGatherer contract and returns a promhttp http.Handler; called on an otlpmetric-backed registry it fails loudly with ErrPrometheusHandlerUnavailable (an OTLP-push registry has no pull surface — a genuine fact, not an optional-capability toggle). The OTel metrics SDK + the OTLP-metric/Prometheus exporters are RFC-sanctioned new direct dependencies — RFC §6.14 names the OTel Metrics SDK AND the built-in Prometheus /metrics endpoint explicitly; go.opentelemetry.io/otel/metric was already an indirect dependency.

  4. The cardinality discipline is enforced by a go/parser AST-walking go test (internal/telemetry/cardinalitylint), scoped to metric.WithAttributes(...) so span attributes are untouched. Brief 06 calls for "a static check that fails CI if any metric registers a label deriving from TraceID/RunID/free-form input". ScanMetricsTree reuses the proven repo pattern (internal/protocol/singlesource from Phase 58, internal/planner/conformance/importgraph_test.go): a precise AST walk, zero external-tool dependency, no golangci-lint plugin. It flags two kinds — KindForbiddenLabelKey (an attribute.* constructor whose literal key matches run_id / trace_id / span_id / task_id / the identity-triple keys) and KindIdentitySourcedLabel (an attribute.* constructor whose value argument is a selector ending in an events.Event Identity field). The critical scoping call: BOTH spans and metrics build attribute lists with the same attribute.String(...) constructors, and a span legitimately carries run_id (Phase 55 / D-073 stamps the run quadruple onto event-derived spans on purpose — correct trace correlation). So the checker only inspects attribute.* calls lexically nested inside a metric.WithAttributes(...) call; a span's attribute.String("run_id", …) inside trace.WithAttributes is left alone. TestCardinalityLint_TelemetryTreeIsClean is the build gate; a testdata/badmetric negative fixture (NOT compiled into the build) proves the checker actually catches both violation kinds AND does NOT flag the fixture's span-like attribute slice.

  5. The /metrics endpoint ships as the standalone telemetry.PrometheusHandler http.Handler constructor; the live Runtime server that mounts it is Phase 60+. There is no internal/server/ package and cmd/harbor is a stub until the Phase 09+/60 bootstrap. Phase 56 ships PrometheusHandler as a standalone constructor — the same pattern Phase 55's propagation carriers used (standalone helpers, wired by a later phase). The §13 primitive-with-consumer obligation is discharged by the Phase 56 unit + integration tests exercising the handler via httptest end-to-end. The master-plan "§11 Q-5" citation is a §4.3 clarification: RFC §11's Q-5 is the skill-versioning question — unrelated to metrics; the metrics-exporter question is brief 06 Q-2, resolved by RFC §6.14, and "§11 Q-5" is read as "the §11-tracked metrics-exporter question is settled".

The D-025 concurrent-reuse contract is pinned in internal/telemetry/metrics_test.go: TestConcurrentReuse_MetricsRegistry runs N=150 goroutines (≥100 per the contract), each with a goroutine-unique identity quadruple AND goroutine-unique Extra producer/node, calling RegisterEvent against ONE shared *MetricsRegistry under -race — a label cross-talk surfaces as a foreign producer/node series or an identity value leaking into a label; baseline runtime.NumGoroutine is restored after join + registry shutdown (no leak). MetricsRegistry is a compiled artifact: every field (the SDK MeterProvider, the reader, the Int64Counter) is set once at construction; per-call state (one event's attribute set) is built on the stack in RegisterEvent, never stored on the struct. The §17 integration test (test/integration/phase56_metrics_test.go) wires the real events.EventBus (inmem) + the real telemetry.Logger + a real *MetricsRegistry backed by the REAL prometheus driver — events on the bus → a real bus subscription → RegisterEvent → the /metrics httptest body carries harbor_events_total with the right per-label counts AND is identity-free even though the published events carried full quadruples (the cardinality firewall, end-to-end); the ErrMetricExporterUnknown + ErrPrometheusHandlerUnavailable failure modes; and an N=16 concurrency stress across the events↔metrics seam asserting no label cross-talk and no goroutine leak (the inmem bus is drop-oldest, so the stress asserts the seam's concurrency-safety contract, not lossless totals — the exact-count contract is the D-025 unit test's job). Coverage on internal/telemetry is 88.6% (master-plan Phase 56 target 85%); internal/telemetry/cardinalitylint 89.6%, internal/telemetry/drivers/prometheus 85.7%, internal/telemetry/drivers/otlpmetric 85.7%.

Amended (Wave 10 audit fixes — PR #91 / D-082) per CLAUDE.md §13: the events→metrics bridge helper was extracted from the test-only drainToMetrics goroutine into the production-side telemetry.BridgeBusToMetrics(ctx, bus, reg, filter) (stop func(), err error). This pins one canonical contract for the future Phase 64 harbor dev server bootstrap to consume rather than letting it reinvent the wiring — the existing integration test now consumes telemetry.BridgeBusToMetrics so the same code path is exercised end-to-end. The Wave 10 audit's WARN-6 surfaced this drift.


D-077 — Protocol versioning discipline: the ProtocolVersion string pin stays the RFC-change trip-wire; Phase 59 adds the parsed Version (semver Major/Minor/Patch, same-major Compatible), the structured Deprecation note format + empty Deprecations() registry, and the Capability set + VersionHandshake capability-negotiation shape — all in the single canonical home internal/protocol/types, no version bump

Date: 2026-05-14 Status: Settled Where it lives: RFC §5.3 (+ §5.2 the surface table the capability set mirrors), CLAUDE.md §8, docs/plans/phase-59-protocol-versioning.md, internal/protocol/types/version.go (ProtocolVersion pin unchanged + Version / ParseVersion / ErrInvalidVersion / CurrentVersion / Compare / Compatible; DeprecationKind + Deprecation / Validate / String / ErrInvalidDeprecation + Deprecations(); Capability / CapTaskControl / Capabilities / IsValidCapability; VersionHandshake / CurrentHandshake / Accepts), internal/protocol/types/version_test.go (the versioning-discipline unit suite), internal/protocol/singlesource/singlesource.go (CanonicalWireTypes += Version / Deprecation / VersionHandshake under home types), scripts/smoke/phase-59.sh, brief 06 §1, brief 07.

Why: Phase 59 turns the Harbor Protocol version pin — the ProtocolVersion string Phase 54 (D-072 §1) placed in internal/protocol/types/version.go — into a versioning discipline: the mechanism a Protocol surface needs to live with versions, deprecate elements, and let a client negotiate which surfaces are live. Four design calls warrant a durable home.

  1. The ProtocolVersion string constant stays the RFC-change trip-wire; the parsed Version is derived from it, never a second source. CLAUDE.md §8 + RFC §5.3 are binding: "The Protocol version is pinned in internal/protocol/types/version.go. Bumping the version is an RFC change." Phase 59 does not bump the version — it stays "0.1.0", and the Phase 54 TestProtocolVersion_Pinned trip-wire is untouched. The new Version struct (Major/Minor/Patch) is the parsed form a client uses to reason about a version — Compare for ordering, the same-major Compatible rule for skew detection — instead of string-comparing. CurrentVersion is mustParseVersion(ProtocolVersion) evaluated at package-init: one source (ProtocolVersion), one derived parse, and TestCurrentVersion_MatchesProtocolVersion pins CurrentVersion.String() == ProtocolVersion so the two can never drift. This is not the CLAUDE.md §13 "two parallel implementations" smell — it is a single source plus a derivation with a test gating the derivation. Compatible is same-major because a Major bump is the breaking change (which is why bumping it is an RFC change); Minor/Patch differences are backward-compatible by construction. ParseVersion fails loud with a wrapped ErrInvalidVersion on any malformed input (empty, wrong arity, non-numeric or negative component, surrounding whitespace, a pre-release suffix) — no silent zero-Version degradation (CLAUDE.md §5).

  2. The deprecation window gets a structured Deprecation note format with a single-home registry — even though the registry is empty at 0.1.0. RFC §5.3: "Breaking changes require a deprecation window so third-party Consoles aren't whipsawed." Phase 59 settles that window's format: a typed Deprecation wire struct (Subject / Kind / DeprecatedIn / RemovedIn / Replacement / Note) with Validate (fail-loud on an empty subject, an unknown DeprecationKind, or a RemovedIn not strictly after DeprecatedIn — an empty or inverted window is malformed) and a canonical String rendering (<kind> "<subject>" is deprecated in <deprecated_in>, removed in <removed_in>[; use <replacement>][ — <note>]). DeprecationKind is a fixed four-value enum — the four kinds of Protocol element the versioned surface exposes (method / error_code / wire_field / capability). Deprecations() is the registry — empty at Protocol 0.1.0 because the task control surface just shipped and nothing has been superseded — but it exists, with its consumer, from day one: per CLAUDE.md §13's primitive-with-consumer rule, shipping the Deprecation format without a home that returns it would let the format bit-rot. The first real deprecation lands in the registry in the phase that supersedes a Protocol element, populating the format Phase 59 settled rather than inventing a new free-text comment convention.

  3. Capability negotiation ships as the Capability set + the VersionHandshake wire shape — the vocabulary, not the enforcement. brief 06 §1's decoupling rule ("Console, third-party consoles, and harbor dev see exactly the same data shape") needs a negotiable surface: a third-party Console built against Protocol 0.1.0 must be able to ask the Runtime "which surfaces are live?" and get a structured answer, not discover a missing surface by a 404. Phase 59 ships Capability (a fixed string enum mirroring methods.canonicalMethodsCapTaskControl is the one V1 capability, the Phase 54 surface; RFC §5.2's other five surfaces add their constant as their phase lands), Capabilities() (the deterministic sorted advertised set), and VersionHandshake (ProtocolVersion + advertised Capabilities, with CurrentHandshake() building the Runtime's handshake and Accepts(cap) reporting advertisement). Phase 59 does not ship capability enforcement — a handler that rejects a request for an un-advertised capability is the Phase 60/61 transport + auth surface's job. Phase 59 gives them the vocabulary, transport-agnostically: a Phase 60 SSE+REST adapter serves CurrentHandshake(), and the harbor version subcommand (Phase 63) renders it; Phase 59 binds to no transport and adds no CLI subcommand (the master-plan acceptance line — "version constant returned on harbor version (after phase 63)" — is an explicit forward reference, not a Phase 59 deliverable).

  4. Everything lands in the single canonical home internal/protocol/types, and the three new wire structs are registered in the Phase 58 single-source checker's lockstep map in the same PR. CLAUDE.md §8: the version is pinned in internal/protocol/types/version.go; CLAUDE.md §13: no second definition site for Protocol wire types. Version, Deprecation, and VersionHandshake are exported struct types declared in internal/protocol/types, so D-075 §4's TestSingleSource_CanonicalWireTypesInLockstep — which parses the canonical packages and asserts every exported struct type is recorded in singlesource.CanonicalWireTypes under the right home — fails until the checker's map records all three under home types. That map edit is not scope creep: it is the mandatory CLAUDE.md §17.6 "fix what the lint finds" coupling — the Phase 58 lockstep test is designed to fail when a new wire type lands without updating the checker, and Phase 59 is the first phase to exercise that coupling. There is no §4.4 driver seam (Version / Deprecation / Capability are value types + pure functions — no plausible alternate backend, the same call D-072 §5 and D-075 made for the Protocol layer's other surfaces).

§13 primitive-with-consumer. Phase 59's primitives ship with their consumers in the same PR: the Version parse/compare/compatible surface is consumed by CurrentVersion (the derived pin) and the version_test.go skew/ordering suite; the Deprecation format is consumed by Deprecations() (its single-home registry — empty, but present, so the format cannot bit-rot) and the Validate / String / round-trip tests; the Capability set + VersionHandshake shape is consumed by CurrentHandshake() + Accepts and their tests. The later consumers — a Phase 60 SSE+REST negotiation endpoint, a Phase 63 harbor version subcommand — are forward references the master plan already pins; the in-PR consumers discharge the rule. There is no §17 integration test: Phase 59 ships additive value types + pure functions inside internal/protocol/types; it wires no runtime drivers and opens no cross-subsystem seam (CLAUDE.md §17.1 exempts a phase that consumes no shipped subsystem's runtime surface — the only thing Phase 59 consumes is the build-time Phase 58 single-source checker's lockstep map, a static-source coupling proven by re-running the Phase 58 suite, not a runtime seam). There is no D-025 concurrent-reuse test: Version / Deprecation / Capability / VersionHandshake are immutable value types and the functions over them are pure with no construction-time state and no goroutines — none is a "compiled artifact" in the D-025 sense. Coverage on internal/protocol/types is 86.1% (master-plan Phase 59 target 85%).


D-078 — Protocol wire transport: SSE for the event stream + REST/JSON for the control surface, both as http.Handlers under internal/protocol/transports/{stream,control} composed by NewMux; identity-scope enforced at the edge; the listen/shutdown lifecycle deferred to the harbor dev server phase

Date: 2026-05-14 Status: Settled Where it lives: RFC §5.4 (Q-1 RESOLVED 2026-05-14 — SSE + REST) + §5.5, CLAUDE.md §3 + §8, docs/plans/phase-60-protocol-wire-transport.md, internal/protocol/transports/transports.go (NewMux + the Option knobs), internal/protocol/transports/control (NewHandler + the REST/JSON control handler + status.go's errors.Code → HTTP status table + RoutePattern), internal/protocol/transports/stream (NewHandler + the SSE event handler + frame.go's SSE framing + the identity-carrier header names + RoutePattern), internal/protocol/transports/concurrent_test.go (the D-025 N≥120 concurrent-reuse + the goroutine-leak test), test/integration/phase60_wire_transport_test.go (the both-directions wire E2E), scripts/smoke/phase-60.sh, brief 06 §1 + §6, brief 07.

Why: RFC §11 Q-1 resolved on 2026-05-14 to SSE for the event stream + REST/JSON for the control surface (owner sign-off; RFC §5.4 + §11 Q-1 updated), so Phase 60 is a normal implementation phase, not a decision gate. It binds the transport-agnostic surfaces two prior phases shipped — Phase 54's protocol.ControlSurface and Phase 05's events.EventBus — onto the wire. Four design calls warrant a durable home.

  1. The two transports are sibling sub-packages under internal/protocol/transports/, composed by NewMux — there is no driver-registry / factory ceremony. CLAUDE.md §3 pins internal/protocol/transports/{stream,control}; Phase 60 fills exactly that layout: transports/control is the REST/JSON control handler over ControlSurface.Dispatch, transports/stream is the SSE event handler over events.EventBus, and transports/transports.go's NewMux wires both onto one *http.ServeMux. RFC §5.4 explicitly leaves WebSocket as an additive alternate transport; the seam that makes it additive is the package layout itself — a third sub-package (transports/websocket) plus one more mux.Handle line in NewMux, with neither control nor stream reshaped. This is NOT a §4.4 driver seam: there is no Register / factory / blank-import dispatch-by-name, because the transport set is small, closed, and mounted in code at boot — the same posture Phase 54 took for the ControlSurface (D-072 §3). A transports/drivers/ tree would be the ceremony §4.4's "no optional-capability ceremony" clause warns against.

  2. Identity-scope enforcement sits at a single per-transport choke point — ControlSurface.Dispatch for REST, resolveIdentity for SSE — and fails closed. RFC §5.5: "the Protocol rejects any request without an identity scope." On the REST transport the handler decodes the request and hands the whole request to ControlSurface.Dispatch, which already fails closed on an incomplete triple with CodeIdentityRequired (D-072 §2) — status.go maps that to HTTP 401. The handler does NOT re-validate identity (CLAUDE.md §13 forbids a second validator). On the SSE transport the triple travels in carrier headers (X-Harbor-Tenant / X-Harbor-User / X-Harbor-Session — headers, not a query string, so the triple is not logged in access logs by default) and resolveIdentity validates it via identity.Validate before any events.Subscription is opened — a missing component is HTTP 401, fail-closed. Both choke points are deliberately single: Phase 61's JWT validation replaces the header reads / inherits the Dispatch gate without reshaping either ServeHTTP. The identity claim is trust-based until Phase 61 — exactly the posture events.Filter.Admin and types.IdentityScope.Scope hold today; events.Filter.Admin (cross-tenant fan-in) is NOT exposed on the wire in Phase 60, so the SSE stream is always triple-scoped.

  3. SSE framing + keepalive + reconnect-cursor discipline. Each events.Event is one SSE frame: an event: line (the event type), an id: line (the per-bus monotonic Sequence — this IS the reconnect cursor), and a data: line (a flat, Protocol-owned wireEvent JSON projection — never a re-export of the internal events.Event struct, per the RFC §5.1 no-1:1-internal-mapping rule). An idle stream emits a : keepalive SSE comment on an interval (default 15s, under the common 30–60s proxy idle-timeout) so intermediaries do not reap the connection. On reconnect a client echoes the last frame's id: back as the standard SSE Last-Event-ID header; parseLastEventID maps it onto an events.Cursor and, when the bus driver implements events.Replayer, the handler replays everything strictly newer than the cursor before live-tailing — so the reconnecting client does not miss the gap. When replay is unavailable (the driver does not implement Replayer, the ring is configured off, or the cursor is too old) the gap is SURFACED with an explicit : stream.replay_unavailable <reason> comment frame — never a silent nil stream that looks complete but skipped events (CLAUDE.md §5 — fail loudly, no silent degradation). The keepalive path is testable without a time.Sleep-as-synchronisation antipattern because the keepalive frame is observable on the wire: a test supplies a short WithKeepalive interval and scans for the comment frame.

  4. Phase 60 ships the transport http.Handlers, NOT the server that listens. There is no net.Listener, no graceful-shutdown lifecycle, and no /healthz in Phase 60 — that is the harbor dev subcommand's job (Phase 64, master-plan Deps: 63, 60). NewMux returns a plain *http.ServeMux a future server mounts. Keeping the listen/shutdown lifecycle out of internal/protocol/transports/ means the transports are exercised end-to-end today via httptest — the package tests and test/integration/phase60_wire_transport_test.go open real httptest.Servers over a real ControlSurface (over a real inprocess tasks.TaskRegistry) + a real in-mem events.EventBus, submit start over REST, and observe the resulting task.spawned lifecycle event arrive on the SSE stream — both directions, no mocks at any seam. Consequently scripts/smoke/phase-60.sh runs those httptest-backed tests + static layout/single-source/Console-boundary guards, and skips the live-HTTP assertions per the 404/405/501 → SKIP convention until Phase 64 lands the server — the identical posture scripts/smoke/phase-54.sh took for the transport-agnostic surface ahead of its wire binding.

§13 primitive-with-consumer — discharged in-phase. Phase 60's wire transport is itself the consumer of Phase 54's transport-agnostic ControlSurface and Phase 05's events.EventBus — it ships no new primitive, it binds two existing ones onto the wire. The obligation is discharged by test/integration/phase60_wire_transport_test.go, which exercises both directions end-to-end (SSE event stream out, REST control in) against the real runtime surface, plus the missing-identity fail-closed mode and an N≥16 full-duplex concurrency stress under -race. The D-025 concurrent-reuse test (transports/concurrent_test.go, N=120 mixed REST + SSE requests against one shared mux) + the goroutine-leak test (baseline runtime.NumGoroutine restored after every stream drains and the server closes) cover the reusable-artifact contract — the mux and both handlers are compiled artifacts, immutable after construction, with every per-request goroutine joined before its ServeHTTP returns. Coverage on the three touched packages meets the master-plan Phase 60 target (85%).

Amended (Wave 10 audit fixes — PR #91 / D-082) after the CLAUDE.md §13 "Test stubs as production defaults on operator-facing seams" rule: transports.NewMux now requires an explicit auth choice — EITHER WithValidator(v) (the production posture; Phase 61 JWT bearer auth at the edge) OR WithoutValidator() (the explicit, test-only escape hatch for the Phase 60 trust-based posture). Omitting both fails closed at boot with ErrMisconfigured. The Phase 60 surface still serves the trust-based posture verbatim — WithoutValidator opts into it explicitly — but a future Runtime that builds a mux without considering auth no longer ships an unauthenticated production surface by default. transports.WithValidator(nil) is also rejected (treated as "WithValidator not supplied") rather than being silently treated as a no-op. The Wave 10 audit's WARN-2 surfaced this drift; the audit assertion is in internal/protocol/transports/transports_test.go::TestNewMux_MissingAuthChoice_FailLoud.


D-079 — Protocol auth: a JWT validator + http.Handler middleware at the Phase 60 transport edge; asymmetric algorithms only (RS256/RS384/RS512/ES256/ES384/ES512); HS* and none rejected at the parser level via jwt.WithValidMethods; (tenant, user, session) from claims into ctx via identity.With; scope claims (admin, console:fleet) gated through auth.HasScope; new CodeAuthRejected Protocol error; every rejection audited

Date: 2026-05-15 Status: Settled Where it lives: RFC §5.5 + §4.2, CLAUDE.md §7 (rule 1) + §6 + §8 + §13, docs/plans/phase-61-protocol-auth.md, internal/protocol/auth/auth.go (the Validator interface + the jwtValidator concrete + the eight typed sentinels + the KeySet interface + Verified + the WithIssuer/WithAudience/WithClock/WithLogger/WithRedactor options + the AllowedAlgorithms slice), internal/protocol/auth/middleware.go (the Middleware decorator + extractBearer + protocolErrorFor + reasonForWire), internal/protocol/auth/scopes.go (Scope + ScopeAdmin/ScopeConsoleFleet + WithScopes/ScopesFrom/HasScope), internal/protocol/auth/security_test.go (the algorithm-confusion + alg:none + scope-escalation + kid-substitution + expired + tampered-body + audit-no-leak suite), internal/protocol/auth/concurrent_test.go (D-025 N≥128 + goroutine-leak), internal/protocol/auth/testdata/ (the documented dummy RS256 + ES256 keypairs + their README), internal/protocol/errors/errors.go (the new CodeAuthRejected constant + canonicalCodes registration), internal/protocol/transports/transports.go (the WithValidator NewMux option), internal/protocol/transports/control/status.go (the CodeAuthRejected → 401 mapping), internal/protocol/transports/control/control.go (the assertBodyMatchesAuthedIdentity defence-in-depth check + the body-backfill path), internal/protocol/transports/stream/stream.go (the ctx-first resolveIdentity preference + the ?admin=1 scope gate), test/integration/phase61_auth_test.go (the end-to-end real-keypair test exercising every rejection mode + the N≥16 concurrency stress), scripts/smoke/phase-61.sh, brief 09 §"Identity-scoped JWT enforcement on resume", brief 07 §"the runtime owns the protocol it speaks", brief 06 §"server-enforced identity".

Why: RFC §5.5 settled the authentication primitive ("JWT, asymmetric algorithms only ... the triple (tenant, user, session) is in the JWT claims; the Protocol rejects any request without an identity scope") and CLAUDE.md §7 rule 1 + §13 made the algorithm allowlist a rejection-on-sight rule. Phase 60 left the actual JWT validation as Phase 61 work, with both transport handlers wired so that "Phase 61's JWT validation replaces the header reads / inherits the Dispatch gate without reshaping either ServeHTTP" (D-078 §2). Phase 61 makes good on that. Five design calls warrant a durable home.

  1. The validator + middleware split is the single transport-agnostic shape, sitting at the Phase 60 edge — not a per-handler reinvention. internal/protocol/auth/auth.go ships the Validator interface (Validate(ctx, raw string) (Verified, error)), a transport-agnostic surface that takes a raw JWT string and returns the verified identity + scopes. middleware.go is the net/http binding: it reads Authorization: Bearer <token>, calls Validator.Validate, and on success injects identity into r.Context() via identity.With + the verified scope set via auth.WithScopes. The middleware is the ONE choke point where identity transitions from "untrusted carrier" to "verified ctx claim" — and transports.NewMux wraps BOTH the Phase 60 control + stream handlers in it via the new WithValidator option (RFC §5.5: "the Protocol rejects any request without an identity scope" — so the gate is at the mux composition, not per-handler). When WithValidator is NOT supplied, the Phase 60 trust-based posture is preserved verbatim — the option is additive and opt-in, the same posture Phase 60 took for WebSocket as an alternate transport. This keeps every Phase 60 test passing without modification.

  2. The asymmetric-algorithm allowlist is enforced at the PARSER level via jwt.WithValidMethods, not post-parse. CLAUDE.md §7 rule 1 + §13 ban HS* and alg:none rejection-on-sight; the load-bearing question is where the rejection happens. golang-jwt/jwt/v5's parser, when configured with WithValidMethods([RS256,RS384,RS512,ES256,ES384,ES512]), rejects any token whose alg header is outside the list BEFORE the keyfunc runs — so the classical algorithm-confusion CVE family (an HS256 token whose verifier-side keyfunc would otherwise hand it the RSA public key as the HMAC secret) is structurally impossible: the HS-signed token never reaches the verification step. The keyfunc itself is belt-and-braces — it re-asserts isAllowedMethod(t.Method) AND structurally rejects a non-asymmetric public key (a *rsa.PublicKey or *ecdsa.PublicKey is the only acceptable shape) — but the load-bearing gate is the parser's, and security_test.go pins the exact CVE shape (HS256 token signed with the RS256 public key as the HMAC secret) plus alg:none plus a kid-substitution attack plus a tampered-body attack. The eight typed sentinels (ErrTokenMissing / ErrTokenMalformed / ErrAlgNotAllowed / ErrSignatureInvalid / ErrTokenExpired / ErrTokenNotYetValid / ErrUnknownKey / ErrIdentityClaimMissing, plus the optional ErrAudienceMismatch / ErrIssuerMismatch) cover every rejection path; mapParserError is the deliberate translation table from golang-jwt/jwt/v5's vocabulary to ours, with an order-sensitive cascade (our keyfunc-returned sentinels first, then the WithValidMethods message, then the alg:none message, then the standard signature/malformed cases) so a sentinel a Validator caller branches on is the one we documented.

  3. The new CodeAuthRejected Protocol error code lands ONLY in internal/protocol/errors/; the Phase 58 single-source checker is the gate. CLAUDE.md §8: "Error codes live in internal/protocol/errors/errors.go. Add new codes there and only there." Phase 61 adds exactly one new code (CodeAuthRejected = "auth_rejected") — distinct from CodeIdentityRequired (which signals an absent identity scope, what RFC §5.5 calls "request without an identity scope") because a present-but-invalid JWT is a different operator-actionable failure: a client that gets identity_required needs to attach a token, a client that gets auth_rejected has one but it failed cryptographic / structural verification. The HTTP-status mapping pins both at 401 (the request is unauthenticated at the Protocol edge); the Code is what a Protocol client branches on. The middleware's protocolErrorFor is the deliberate sentinel → Code map: ErrTokenMissing and ErrIdentityClaimMissingCodeIdentityRequired; everything else → CodeAuthRejected. The singlesource.CanonicalWireTypes lockstep map needs no update — Verified, Scope, KeySet live in internal/protocol/auth, NOT in types/ or errors/, and the lockstep test only audits the canonical packages; auth-internal types are not Protocol wire types.

  4. Identity flows JWT-claim → r.Context() via identity.With; the Phase 60 handlers prefer ctx-identity but fall back to their existing carriers — the Phase 60 surface is preserved verbatim. The middleware calls identity.With(ctx, verified.Identity) on success. The SSE handler's resolveIdentity prefers identity.From(r.Context()) when present, falling back to the X-Harbor-* carrier headers (Phase 60 trust-based) when no middleware ran — every Phase 60 test still passes. The control handler adds assertBodyMatchesAuthedIdentity: when ctx carries a verified identity, the body's IdentityScope MUST match (or be empty, in which case the handler backfills from ctx — the JWT IS the source of truth). A body claiming a different (tenant, user, session) than the JWT is rejected 401 before Dispatch runs — defence in depth so a caller cannot present a valid JWT for tenant T1 while submitting a control body claiming tenant T2. This is deliberately defence-in-depth, not the primary gate: the primary gate is the JWT verification; the body-match is the second perimeter. Together they discharge RFC §5.5's "the Protocol rejects any request without an identity scope" with no escape hatch.

  5. Scopes (admin, console:fleet) are a closed canonical set; an unknown scope on a JWT is silently dropped from the verified set, NOT honoured as a privilege. auth.Scope is a typed string with two constants — ScopeAdmin and ScopeConsoleFleet. IsValidScope is the membership check; WithScopes filters the supplied slice through IsValidScope so an attacker-injected unknown scope (a token claiming "future:scope") cannot reach HasScope and trigger a privilege check we did not document. The closed set means an attacker cannot grant themselves an undocumented privilege by inventing a scope name — every privilege check is against a constant the runtime knows. The SSE handler's ?admin=1 gate is the first consumer (RFC §6.13 admin subscriptions): a request with ?admin=1 AND a verified ScopeAdmin (or ScopeConsoleFleet) gets events.Filter.Admin = true; without the scope it is rejected 403. The Phase 50 unified pause/resume primitive's OAuth callback handler (Phase 30) will be the second consumer — admin-bound OAuth flows require ScopeAdmin on the agent's tenant per brief 09 §"Admin-scope authz on agent-bound flows". ScopesFrom returns a defensive copy so a downstream caller cannot mutate the in-context slice; the bare-context check returns (nil, false) so a Phase 60 (no-middleware) request explicitly has no scopes attached.

§10 dependency posture — golang-jwt/jwt/v5 was already an indirect dependency. The library was pulled transitively by aws-sdk-go-v2/credentials (which uses it for its STS token exchange). Phase 61 promotes it to a direct dependency via go mod tidy — no new module joins the build, no new license surface, no new transitive footprint. golang-jwt/jwt is the de-facto Go JWT library (pure Go, well-maintained, ~6k stars, used by Caddy, Hashicorp Boundary, Grafana, …) and satisfies CLAUDE.md §13's "Pulling in heavy frameworks ... additions require RFC update" via a documentation-only RFC expansion (the indirect dep was already in go.sum; the surface change is the import in internal/protocol/auth/auth.go, not a new module download). The validator implementation is small (≈400 LOC including the eight sentinel mappings) and could be written against crypto/rsa + crypto/ecdsa directly if golang-jwt/jwt/v5 is later removed — but the library's WithValidMethods parser-level allowlist is the load-bearing gate against the alg-confusion CVE family, and reimplementing that in-tree is exactly the silent-degradation risk CLAUDE.md §5 cautions against.

§13 primitive-with-consumer — discharged in-phase. Phase 61's Validator and auth.Middleware are both primitives; their consumers ship in the same PR. Validator is consumed by Middleware, by the integration test's real-key end-to-end suite, and by the security suite's five attack shapes. Middleware is consumed by transports.NewMux (via the WithValidator option), by the Phase 60 handlers' ctx-first identity resolution, and by test/integration/phase61_auth_test.go. The Scope + HasScope primitives are consumed by the SSE handler's ?admin=1 gate; a future Phase 30 OAuth callback handler is the second consumer the master plan already pins (Phase 30's Deps lists Phase 61). CodeAuthRejected is consumed by the middleware (as the rejection wire code), by the transport status table (status.go's 401 mapping), and by every rejection-mode test. The D-025 concurrent-reuse test (auth/concurrent_test.go, N=128 with distinct per-goroutine identity quadruples + a goroutine-baseline assertion) covers the reusable-artifact contract — Validator is a compiled artifact, immutable after NewValidator returns, with no per-call state on the struct (every per-call value lives on the function stack / the returned Verified). Coverage on the four touched packages meets the master-plan Phase 61 target (auth 90%, errors 100%, control 89.5%, transports 94.3%, stream 86.6% — all ≥ targets).

Amended (Wave 10 audit fixes — PR #91 / D-082) per CLAUDE.md §7 rule 6 + §13:

  • WithRedactor is mandatory. NewValidator now returns a wrapped ErrMisconfigured when an audit.Redactor is not supplied (the prior in-package noopRedactor permissive stub default was a §13 "Test stubs as production defaults on operator-facing seams" violation). The noopRedactor type moved to internal/protocol/auth/testhelpers_test.go and is reachable only from _test.go files. Production callers wire audit/drivers/patterns.New() as the redactor.
  • A new auth.rejected canonical event type (internal/protocol/auth/events.go) — registered via events.RegisterEventType in the package's init(). The WithEventBus(b) validator option (optional — a nil bus preserves the prior slog-only contract) makes the audit emit ALSO publish the canonical event onto the bus, so a Console subscribing through the Protocol's canonical event channel sees auth rejections alongside every other rejection-class signal. Conformance + Wave 10 integration wiring inject the bus by default; per-package tests remain slog-only. The Wave 10 audit's WARN-3 surfaced this drift; the assertion lives in internal/protocol/auth/events_test.go::TestValidate_BusEmit_PublishesAuthRejectedEvent (real bus, real subscriber, end-to-end with the published payload's Reason asserted).

D-080 — Protocol conformance suite: a single RunSuite(t, factory) shape under internal/protocol/conformance that exhaustively exercises every Protocol method, every error code, every documented event-filter shape, the Phase 59 VersionHandshake, and the Phase 61 auth pipeline against TWO transports (in-process ControlSurface.Dispatch AND the over-the-wire Phase 60 mux under httptest.Server); matrix exhaustiveness asserted at boot so a new method/code/capability lands in the same PR as its scenario or fails the suite

Date: 2026-05-15 Status: Settled Where it lives: RFC §5 (+ §5.2 + §5.3 + §5.4 + §5.5), CLAUDE.md §8 (single-source) + §11 (conformance suites + D-025) + §13 (no-primitive-without-consumer) + §17.5 (wave-end E2E) + §17.7 (wave delivery cadence), docs/plans/phase-62-protocol-conformance.md, internal/protocol/conformance/conformance.go (the Stack + Factory + RunSuite + the method matrix + the error-code matrix + the event-filter matrix + runVersionHandshake + runAuthPipeline + runWireStatusMapping + the D-025 runConcurrentReuse scenario + every helper), internal/protocol/conformance/conformance_test.go (the package-local consumer TestProtocol_Conformance), internal/protocol/conformance/internal_test.go (the helper-surface unit tests), test/integration/wave10_test.go (the Wave 10 wave-end E2E: the suite consumed against the assembled Wave 10 surface + the unknown-kid failure mode + the N=16 concurrency stress with full identity-isolation cross-checking + the VersionHandshake contract pin), scripts/smoke/phase-62.sh, docs/glossary.md (the "Protocol conformance suite" entry), brief 07 §"the runtime owns the protocol it speaks", brief 06 §"server-enforced identity".

Why: RFC §5 settled the Protocol surface ("streaming events, task control surface, observability APIs") and CLAUDE.md §8 settled single-source discipline (methods/errors/types live in their canonical packages and nowhere else). Phases 54 / 58–61 shipped the Protocol layer's primitives in waves; Phase 62 is Wave 10's primitive-with-consumer closer for the entire Protocol layer — a single binding pass/fail definition of "the Protocol surface works at version 0.1.0" that mechanically prevents silent surface drift. Four design calls warrant a durable home.

  1. One conformance suite under internal/protocol/conformanceRunSuite(t, factory) is the consumer entry point; a Stack carries the real-driver Protocol stack a scenario reaches the runtime through. The suite IS the consumer of Phase 58 (single-source) + Phase 60 (wire transport) + Phase 61 (auth) — the master plan pins it that way. The shape mirrors the StateStore / MemoryStore / RemoteTransport / planner conformance suites already in the repo: one package, one RunSuite, one Factory seam per consumer profile. A future Protocol transport (WebSocket, stdio) consumes the same suite via the same Factory — no second conformance implementation. The Stack is a D-025 compiled artifact: every field is set once at construction (the surface, the bus, the steering registry, the task registry, the mux, the per-instance token-minting closures, the cleanup), nothing is mutated after; runConcurrentReuse runs N=100 mixed-method invocations against ONE shared Stack under -race with distinct per-goroutine identity quadruples and the goroutine-baseline assertion. The default factory (NewDefaultFactory(testdataRoot)) wires real drivers everywhere on the seam (real tasks.TaskRegistry inprocess + real events.EventBus inmem + real state.StateStore inmem + real protocol.ControlSurface + real protocol/auth.Validator over the real ES256 keypair from internal/protocol/auth/testdata/ + real Phase 60 transports.NewMux) — no mocks at the boundary, exactly the §17.3 + §11 conformance discipline.

  2. The matrix asserts exhaustiveness at boot — a new method/code/capability landing without a corresponding scenario fails the suite at the top of RunSuite. assertMethodMatrixExhaustive walks methods.Methods() and pins the ten canonical methods (the Phase 54 set); a new canonical method that lands without a wantSet entry fails the suite. assertErrorCodeMatrixExhaustive does the same for the eight errorCodeMatrix entries (the Phase 54 + Phase 61 set); a new code that lands without a matrix entry fails the suite. runVersionHandshake pins types.Capabilities() returning exactly {task_control} at 0.1.0 and the deprecation registry being empty — a new capability or a new deprecation surface as a conformance failure, not a silent surface drift. The suite runs the same scenario bodies against TWO transports (the in-process ControlSurface.Dispatch and the over-the-wire mux under httptest.Server) so a conformance pass means the surface is consistent across the two consumer profiles a Console reaches the Runtime through — the same consistency property the §13 primitive-with-consumer rule exists to guarantee.

  3. The error-code matrix asserts each canonical code's wire-status mapping AND surfaces every code with at least one failure scenario. expectedHTTPStatus mirrors the mapping in internal/protocol/transports/control/status.go; runWireStatusMapping walks the matrix and asserts every code has a 4xx/5xx entry (a 2xx with an error body would be a silent-degradation shape banned by §13). Each canonical code is exercised by at least one named scenario: CodeInvalidRequest_NilStartBody, CodeIdentityRequired_MissingTriple, CodeScopeMismatch_PrioritizeWithSessionUser (PRIORITIZE's RFC §6.3 admin minimum vs. session_user), CodePayloadInvalid_OversizeString (the §6.3 4096-rune cap exceeded), CodeUnknownMethod_NonCanonicalName, CodeNotFound_GhostRunInbox (a steering control for a run with no live inbox), CodeAuthRejected_HS256Token_AlgConfusion (the classical alg-confusion attack — an HS256-signed token rejected at the parser). CodeRuntimeError is the catch-all for unclassified runtime-side failures; every production path lands on a typed sentinel, so the test pins the constant's presence in the canonical set + the 500 mapping in runWireStatusMapping rather than fabricating a coerced runtime fault (which would require a fault-injecting factory — out of scope for V1 + acceptable per §15 "smallest change that solves the problem").

  4. The Wave 10 wave-end E2E (test/integration/wave10_test.go) consumes the conformance suite from a different consumer profile against the assembled real-driver Wave 10 surface — per §17.5 step 5 of the wave delivery cadence. TestE2E_Wave10_Conformance_AgainstAssembledSurface calls conformance.RunSuite(t, conformance.NewDefaultFactory(wave10TestdataRoot(t))) — same suite, different cwd, real testdata path resolution. TestE2E_Wave10_VersionHandshake_ContractStable pins the negotiation contract end-to-end (types.ProtocolVersion == "0.1.0", types.Capabilities() returning {task_control}, deprecation registry empty). TestE2E_Wave10_FailureMode_UnknownKidTokenRejected is the §17.3 #3 failure-mode coverage — a JWT with an unknown kid is rejected at the auth edge with CodeAuthRejected before the runtime is reached, AND tasks.List under the rejected identity is empty (defence-in-depth assertion that the middleware short-circuits before Dispatch). TestE2E_Wave10_Concurrency_NoCrossTalk runs N=16 distinct identity stacks against one shared mux + asserts identity isolation by reading every spawned task back via tasks.Get and checking the tenant matches the originating goroutine's triple + checks the goroutine baseline is restored on teardown. Two consumer profiles (in-package + integration) is deliberate: a regression that affects only one profile's wiring (e.g. testdata-path resolution) surfaces here, not in a follow-up.

§4.3 deviation — realised statement coverage 81.2% vs. the master-plan 85% target. Matches the precedent set by internal/planner/conformance (Phase 49) which shipped at 70.8% under the same 85% target. Conformance suites are dominated by t.Fatalf rollback branches that fire only on assertion failure — branches that are correct production code but cannot be exercised by a passing test. Tooling-side attempts to lift the number further (helper consolidation via a deferred-rollback pattern in buildDefaultStack, branch combining in helper bodies) bumped the number from an initial 79.5% to 81.2%; the remaining gap is the irreducible floor of "assertion-rich test code." The assertion density — 10 methods × 2 transports × {happy, malformed}; 8 error codes × ≥1 failure path; every event-filter shape; the version handshake; the auth pipeline; the wire-status mapping; an N=100 D-025 stress — is the load-bearing surface, not the percentage. Documented per §4.3 + §15.

§13 primitive-with-consumer — closed for the entire Protocol layer. Phase 62 IS the consumer for Phase 54's ControlSurface + Phase 58's single-source enforcement + Phase 59's VersionHandshake + Phase 60's transports.NewMux + Phase 61's auth.Validator and auth.Middleware simultaneously. The conformance suite exercises all of these end-to-end against real drivers; the Wave 10 wave-end E2E consumes the same suite from a second profile. A future Protocol-surface phase (state snapshots, topology, artifacts, traces, metrics) extends the suite rather than adding a parallel surface to validate — the matrix-exhaustiveness check at the top of RunSuite is the trip-wire.

Amended (Wave 10 audit fixes — PR #91 / D-082) per the Wave 10 audit's WARN-7: the conformance matrix gained a TracePropagation scenario (runTracePropagation) that exercises the Phase 55 W3C TraceContext carriers end-to-end against the assembled Protocol stack — an inbound traceparent header rides a real REST start request through to a real task.spawned event, and tr.SpanFromEvent on the receiver side produces a span sharing the inbound TraceID. The matrix exhaustiveness side now derives the canonical error-code set from protoerrors.Codes() (the new accessor added in PR #91) rather than a hardcoded count, so a new code landing without a matrix entry surfaces by NAME. The event-filter matrix gained RunScoped_StreamOpens exercising the new X-Harbor-Run carrier header (WARN-5). The Wave 10 audit's WARN-4/-5/-7 surfaced these gaps; the assertions live in internal/protocol/conformance/conformance.go::runTracePropagation, runEventFilterMatrix/RunScoped_StreamOpens, and assertErrorCodeMatrixExhaustive (rewritten to use protoerrors.Codes()).


D-081 — Governance config consolidation: remove the pre-Phase-36a default_max_tokens / cost_ceiling_usd / rate_limit_tps knobs from GovernanceConfig; the loader emits a structured config.deprecated_field slog warning when the legacy YAML keys appear and drops the value; all enforcement flows through IdentityTiers

Date: 2026-05-15 Status: Settled Where it lives: CLAUDE.md §10 + §13 (the "Test stubs as production defaults on operator-facing seams" entry), internal/config/config.go (the GovernanceConfig field set — DefaultMaxTokens / CostCeilingUSD / RateLimitTPS removed, RepairAttempts / DefaultTier / IdentityTiers retained), internal/config/deprecations.go (the stripDeprecatedGovernanceKeys YAML AST pre-processor + the deprecatedGovernanceKeys closed set + the config.deprecated_field warning emitter + the deprecatedFieldReplacement / deprecatedFieldRemovedIn constants), internal/config/loader.go (LoadOption + WithLogger + Load / LoadFromBytes opts vararg + the strip-before-strict-decode wiring in loadFromBytesNamed), internal/config/validate.go::validateGovernance (the default_max_tokens / cost_ceiling_usd / rate_limit_tps fieldError calls removed), internal/config/deprecations_test.go (the per-field warning tests + the all-three-at-once test + the negative test + the identity_tiers-wins test + the llm.model_profiles[...].default_max_tokens-not-stripped test), examples/harbor.yaml (the governance block's legacy lines replaced with a migration pointer to identity_tiers), internal/config/testdata/{valid_minimal,invalid_enum,invalid_missing_required}.yaml (the governance.default_max_tokens line dropped from each fixture).

Why: The three legacy fields were validated-but-ignored stubs preserved (per the godoc comment they carried) "so existing yaml files don't break." The governance enforcement engine that landed in Phase 36a/36b reads exclusively from IdentityTiers — it has never consumed default_max_tokens / cost_ceiling_usd / rate_limit_tps from the GovernanceConfig block. An operator setting cost_ceiling_usd: 100 in YAML saw the value pass validation, saw it land in the in-memory *Config, and then saw silent no-op behaviour at runtime: no event, no warning, no enforcement. This is the same confusion trap CLAUDE.md §13's "Test stubs as production defaults on operator-facing seams" entry closes one layer up — there the concern was a stub LLM that the binary defaulted to, here the concern is a YAML knob that promises behaviour the runtime does not deliver. Validated-but-ignored fields on an operator-facing seam are an even purer expression of the failure mode: the validator's success message tells the operator the value is being consumed.

Two design calls and one explicit non-choice warrant a durable home.

  1. The three keys are removed from the Go struct AND from the strict-decode path; appearance in YAML is a deprecation warning, not a validation error. Keeping the fields on the struct with yaml:"-" would still be a confusion trap — an operator could set them in YAML, see the warning, AND see the value end up on cfg.Governance.X via env override or a hand-built test config; the post-decode consumer would still ignore them. Removing the fields is the only shape that makes the no-op behaviour structurally impossible. The strict decode rejects unknown keys, so the loader's deprecation path is a YAML AST pre-processor (internal/config/deprecations.go::stripDeprecatedGovernanceKeys): parse the byte stream with goccy/go-yaml/parser.ParseBytes, walk the top-level governance: mapping, drop each child whose key matches the closed deprecatedGovernanceKeys set, emit one slog.Warn("config.deprecated_field", field=..., replacement="governance.identity_tiers", removed_in="v0.x", source=...) per stripped key, and re-marshal the cleaned AST for the strict decode that follows. The AST walker is targeted by position (the top-level governance: block only), not by key name globally — a real llm.model_profiles[<name>].default_max_tokens field (a Phase 36b knob on ModelProfile, a different struct) is untouched. A pre-existing config that sets the legacy keys still loads cleanly today; an operator's logs surface one warning per legacy key per load (the strip path emits at the AST walk, not at every subsequent validator pass).

  2. Migration is via IdentityTiers, not via a "default tier" auto-promotion. The prompt for this work considered and explicitly rejected the alternative of consuming the three legacy fields as a synthesised default tier under IdentityTiers so a pre-Phase-36a config kept working without operator intervention. The reason is the same one §13 forbids parallel implementations of the same conceptual feature: a second config surface ("knobs that map onto a default tier") would inevitably diverge from the primary one (identity_tiers.<name>.{budget_ceiling_usd, max_tokens, rate_limit.{capacity, refill_tokens, refill_interval}}), and the divergence would re-introduce the confusion the removal is closing. The example yaml's governance block now shows the exact migration: build a default tier under IdentityTiers, place the equivalent values under max_tokens (was default_max_tokens) and budget_ceiling_usd (was cost_ceiling_usd), and convert the rate-limit-per-second knob into the token-bucket shape (capacity + refill_tokens + refill_interval). The token-bucket model is strictly more expressive than rate_limit_tps; there is no equivalence for a partial migration, which is the right call because the post-Phase-36b enforcement engine has no "TPS" notion at all — it has a per-(identity, model) token bucket.

  3. No suppression flag, no env-var escape hatch, no per-deployment opt-out for the warning. Operators wanting to silence the warning remove the field from their YAML — which is the point. A --no-deprecation-warnings CLI flag (or HARBOR_DEPRECATIONS_QUIET=1) would defeat the migration signal, and the §13 forbidden-practice entry on "identity-downgrading knobs" pins the general posture: capabilities are mandatory, and so are the warnings that surface a capability gap.

§14 pre-merge checklist — config-schema change posture. internal/config/config.go's GovernanceConfig field set changed, so the pre-merge checklist's "config schema changed" gate fires. Backward compatibility for pre-Phase-36a YAML is preserved via the strip-then-warn path: an existing operator YAML with governance.default_max_tokens: 4096 loads cleanly, the legacy value is dropped, the operator sees one warning per legacy key in their logs, and the rest of the config is honoured. Forward compatibility is unchanged: the IdentityTiers shape that landed in Phase 36a/36b is untouched, and the default_tier cross-check still requires the named tier to exist in the map. The example config (examples/harbor.yaml) is updated in this PR per §10's "Example configs in examples/ updated whenever the schema gains a top-level field" rule — applied here to a removal, which is the same surface change in reverse.

§13 primitive-with-consumer — discharged in-PR. This PR ships no new primitive; it removes three operator-facing fields and adds one in-loader pre-processor. The "consumer" of the pre-processor is the strict YAML decoder that runs immediately afterwards, exercised by internal/config/deprecations_test.go (per-field, all-three-at-once, none-present, identity_tiers-still-wins, and the cross-section "real model_profiles.default_max_tokens is NOT stripped" guard) and by every pre-existing config test that round-trips a YAML with no legacy keys present (the no-op path). The WithLogger option is the testable surface — callers capture warnings via an in-memory JSON handler, asserting the field / replacement / removed_in / source attrs match the documented shape exactly, so a future drift in the warning text fails the test. No new compiled artifact lands; no D-025 concurrent-reuse test is required (stripDeprecatedGovernanceKeys is a pure function over its inputs with no construction-time state). Coverage on internal/config is unaffected — the test suite is strictly enlarged.


D-082 — Wave 10 audit fixes: Phase 57 durable + Phase 61 WithValidator + Phase 61 noopRedactor flipped to fail-loud per the §13 amendment (PR #91); follow-up filed for Phase 55 carrier wiring

Date: 2026-05-15 Status: Settled Where it lives: CLAUDE.md §13 (the "Test stubs as production defaults on operator-facing seams" entry), internal/protocol/auth/auth.go (the WithRedactor mandatoriness + the WithEventBus optional bus injection + the removal of the in-package noopRedactor permissive stub), internal/protocol/auth/events.go (the canonical auth.rejected events.EventType + the AuthRejectedPayload shape + the events.RegisterEventType(EventTypeAuthRejected) init), internal/protocol/auth/testhelpers_test.go (the test-only testNoopRedactor + the withTestRedactor() helper — replaces the production stub), internal/protocol/auth/events_test.go (the end-to-end auth.rejected bus-emit assertion), internal/protocol/transports/transports.go (the WithValidator + new WithoutValidator() option + the mandatory auth-choice fail-loud in NewMux), internal/events/drivers/durable/durable.go (the registry-path factory fails loud when cfg.StateDriver == "" + the optWithOwnedStore() rename for clarity), internal/protocol/errors/errors.go (the new Codes() accessor mirroring methods.Methods()), internal/protocol/conformance/conformance.go (the exported FixedNow, the runTracePropagation scenario, the RunScoped_StreamOpens event-filter case, the protoerrors.Codes()-derived assertErrorCodeMatrixExhaustive rewrite, the WithEventBus wiring on the conformance Validator), internal/protocol/singlesource/singlesource_test.go (the lockstep test rewritten to surface "missing in checker map" / "extra in checker map" by NAME), internal/protocol/transports/stream/stream.go (the HeaderRun X-Harbor-Run carrier + the events.Filter.Run wiring), internal/events/events.go (the Filter.Run field + the Matches predicate update), internal/telemetry/metrics.go (the production BridgeBusToMetrics helper extracted from the test-only drainToMetrics + ErrBridgeMisconfigured), internal/telemetry/cardinalitylint/cardinalitylint.go (the PromGatherer scope-note expansion), test/integration/wave10_test.go + test/integration/phase60_wire_transport_test.go + test/integration/phase56_metrics_test.go + test/integration/phase61_auth_test.go (the consumer-side wiring updates that pass WithRedactor + WithEventBus / WithoutValidator / BridgeBusToMetrics + the conformance.FixedNow alias), README.md (Phase 61 / Phase 62 row order corrected), docs/decisions.md (the D-074 / D-078 / D-079 / D-076 / D-080 in-place amendments + this entry), docs/plans/phase-60-protocol-wire-transport.md (the X-Harbor-Run post-PR amendment note), docs/plans/phase-55-otel-traces.md (the DEFER-1 follow-up reference), docs/plans/README.md (the Phase 64 pre-plan note's "First production consumer of Phase 55's W3C carriers" paragraph + the issue #94 reference), GitHub issue #94 (the Phase 55 carrier-into-transport wiring follow-up).

Why: The Wave 10 checkpoint audit (§17.5) surfaced eight load-bearing issues — two FAIL, seven WARN, five NIT — across Phases 56–62. Three of the WARN items (WARN-1, WARN-2, FAIL-2 as it was reclassified) are the same shape one layer down: an operator-facing seam landed with a permissive default that silently degrades to a non-production posture (the durable bus's auto-degrade-to-ring on empty StateDriver; NewMux accepting no validator and producing an unauthenticated mux; NewValidator defaulting to a noopRedactor stub when WithRedactor was omitted). All three match the §13 "Test stubs as production defaults on operator-facing seams" entry (introduced in PR #91 as the §13 amendment): the trip-wire wasn't there when the phases originally shipped, so the §13 amendment is RETROACTIVELY APPLIED to those three seams in the same audit-fix PR. The remaining items (WARN-3 → WARN-7, NIT-1 → NIT-5) close adjacent drift the audit caught: an auth rejection that emitted only to slog instead of the canonical event bus; a conformance matrix exhaustiveness check hardcoding len(matrix) != 8; an SSE transport with no X-Harbor-Run carrier; a test-only events→metrics bridge that the Phase 64 server bootstrap would otherwise reinvent; a conformance suite that didn't observe trace propagation end-to-end; a README phase-order glitch; a name disambiguation; and two minor cleanups.

Three durable design calls warrant being recorded here so they don't get re-litigated.

  1. The §13 amendment retroactively applies to operator-facing seams shipped before its introduction. This audit IS the worked example. Before PR #91 amended §13, the audited seams (Phase 57's durable factory, Phase 61's noopRedactor default, Phase 60+61's optional-WithValidator) were drift-free by the rule-set in force at their shipping. The §13 amendment changed the rule-set. The retrospective fix is mandatory — §17.6 ("Fix what the integration test finds — no matter where the bug lives") applies the same way to a wave-end audit: when the audit surfaces a drift against a NEW rule, fix the OLD seam in the audit PR rather than filing follow-ups. The amended D-074 / D-078 / D-079 entries record the "amended after the §13 amendment" lineage so a future reader sees the rule's history.

  2. Events the runtime emits for auth-edge rejections use a sentinel identity triple (harbor-auth/auth-edge/auth-edge). Auth rejections happen BEFORE identity verification — there is no verified (tenant, user, session) to publish under. events.ValidateEvent requires the full triple. The choice considered was (a) skip the bus emit on missing identity (silent degradation — §13 violation), (b) use the request-claimed identity (echoes unverified claims back, lets an attacker confirm valid triples), or (c) a documented sentinel triple. (c) is the only shape that publishes loudly without leaking the unverified claim or skipping the emit; a Console subscribes via Admin filter (or directly by tenant harbor-auth) to surface auth.rejected events. The values are constants in internal/protocol/auth/auth.go::authEdgeIdentity so no operator data is mistaken for the sentinel.

  3. The carrier-into-transport wiring (Phase 55 follow-up) is RIGHTLY deferred to Phase 64 + sibling phases, not folded into this audit PR. DEFER-1 surfaced that Phase 55's standalone InjectHTTP/ExtractHTTP/InjectMeta/etc. helpers have no production consumer in the repo today — the Tracer's SpanFromEvent IS a consumer of the Tracer primitive, and the integration test exercises the carriers via unit-test-shaped round-trips, but the actual wiring into tools/drivers/{http,mcp,a2a} is the next-wave concern. Folding that into a checkpoint-audit PR would conflate two concerns (audit hygiene vs. a multi-phase wiring change) and would block the audit on a deeper refactor. The follow-up issue #94 is the canonical tracking surface; Phase 64's pre-plan note now names the issue explicitly so the plan author wires the carriers in the same PR as the server bootstrap.

§13 primitive-with-consumer — closed in-PR. No new primitive lands. The auth.rejected event type is the only new canonical surface; its first consumer is the same PR's internal/protocol/auth/events_test.go::TestValidate_BusEmit_PublishesAuthRejectedEvent (end-to-end real-bus assertion) plus the conformance suite's WithEventBus(bus) wiring on the default Validator factory. The BridgeBusToMetrics helper is also new but its production-side first consumer is the existing integration test (drainToMetrics now consumes the helper), discharging the §13 primitive-with-consumer obligation in-PR. The WithoutValidator() option is the explicit, grepable test-only escape hatch — its consumers are the Phase 60 + audit-rewritten transports + wave10 + phase60_wire_transport tests, all named in the "Where it lives" section above. Coverage on every touched package remains ≥ the master-plan target — the audit fixes are strictly additive (new tests, new helpers) and remove no existing assertions.


D-083 — Tool-side OAuth subsystem: TokenStore as a typed wrapper over state.StateStore (D-067 / D-068 precedent), AES-256-GCM encryption at rest with mandatory KEK, single OAuthProvider covering both binding scopes, ErrAuthRequired typed sentinel converges on the Phase 50 Coordinator

Date: 2026-05-15 Status: Settled Where it lives: docs/plans/phase-30-tool-oauth.md (the per-phase plan + the §4.3 deviation language), docs/plans/README.md (the Phase 30 row flipped Pending → Shipped + the detail block's "§4.3 deviation (shipped)" paragraph), docs/glossary.md (the seven new entries: auth.OAuthProvider, auth.TokenStore, auth.BindingScope, auth.ErrAuthRequired, PKCE, RFC 7591 dynamic client registration, tool.auth_required, tool.auth_completed), internal/tools/auth/auth.go (the OAuthConfig + Token + ErrAuthRequired + TokenStore + OAuthProvider interfaces), internal/tools/auth/sealer.go (the AES-256-GCM Sealer + EnvelopeVersion + KEKSizeBytes constants), internal/tools/auth/tokenstore.go (the stateStoreTokenStore typed wrapper + the tools.auth.access.<scope>.<subject>.<source> / tools.auth.refresh.<scope>.<subject>.<source> Kind shapes), internal/tools/auth/pkce.go (RFC 7636 verifier + S256 challenge), internal/tools/auth/provider.go (the concrete *Provider: Token / InitiateFlow / CompleteFlow / Revoke / Close + per-(scope, subject, source) single-flight refresh + .well-known/oauth-authorization-server discovery + RFC 7591 dynamic registration), internal/tools/auth/events.go (the tool.auth_required / tool.auth_completed events.EventType + ToolAuthRequiredPayload / ToolAuthCompletedPayload SafePayload shapes + the init() registrations), internal/tools/auth/conformancetest/conformancetest.go (the shared Run(t, factory) cross-driver suite: Put/Get round-trip both scopes, cross-tenant / cross-user / cross-agent isolation, mixed-scope coexistence, encryption-at-rest, delete idempotency, missing-identity fail-loud, tamper-rejection), internal/tools/auth/conformance_test.go (the in-mem leg call site), internal/tools/auth/concurrent_test.go (the D-025 N=128 concurrent-reuse test + the single-flight-refresh storm test), internal/tools/auth/{sealer,tokenstore,provider}_test.go (the unit-test coverage), test/integration/phase30_tool_oauth_test.go (the §17 wave-end integration test: cross-driver E2E for both binding scopes against in-mem + SQLite + Postgres, real httptest.Server authorization server emulating PKCE + RFC 7591 dynamic registration + metadata discovery, A2A AUTH_REQUIRED shape-parity, initiate-then-cancel goroutine-leak, cross-identity CompleteFlow failure mode, N=16 concurrency stress), scripts/smoke/phase-30.sh (the smoke counter), README.md (the Status table row Phase 30 → Shipped).

Why: The Phase 30 master-plan line — "TokenStore interface (InMem + SQLite + Postgres drivers) with encryption-at-rest" — taken at face value implies the standard §4.4 driver-registry shape (three init() blank-imports, a tokenstore.Open factory, a registry of named drivers). Brief 09's design sketch followed that shape verbatim. Three durable calls warrant being recorded here so they don't get re-litigated.

  1. TokenStore is a typed wrapper over state.StateStore, not a fresh §4.4 driver registry. The Phase 50 (D-067) and Phase 53a (D-068) decisions already settled this precedent for the runtime's persistence-shaped subsystems: when a subsystem's persistence needs are satisfied by (Quadruple, Kind, Bytes) slots, the state.StateStore §4.4 seam (D-027) is the §9 persistence triad — there is no need for a parallel driver registry. A second registry would be the §13 two-parallel-implementations smell ("a tokenstore driver registry AND a state-store driver registry, both saying 'three V1 drivers, three init() blank-imports'"). The shape Phase 30 ships:

    • One concrete *stateStoreTokenStore consumes whatever state.StateStore the binary opened at boot. NewTokenStore(store state.StateStore, sealer Sealer) (TokenStore, error) — no Open / Register ceremony.
    • The composite-key encoding lives in the Kind suffix: tools.auth.access.<scope>.<subject_id>.<source> for the access-token record, tools.auth.refresh.<scope>.<subject_id>.<source> for the refresh-token sibling. subject_id is user_id for ScopeUser, agent_id for ScopeAgent. The StateStore's (Quadruple, Kind) key gives identity-scoped isolation automatically; no Phase-30-specific WHERE clause math.
    • Refresh tokens encrypt under a separate Kind so a (post-V1) caller that reads only the access-token TTL does not pay the refresh-decode cost — and so a compromise of the access-token cache does not yield refresh capability (brief 09 §"Encryption at rest").
    • Driver pluralism (in-mem / SQLite / Postgres) is inherited from the state.StateStore triad; the Phase 30 conformance suite (internal/tools/auth/conformancetest) runs the same TokenStore assertions against every StateStore driver to prove parity. The cross-driver wave-end integration test (test/integration/phase30_tool_oauth_test.go) drives the suite three times — once per V1 StateStore driver, with the Postgres leg skipping per the existing HARBOR_PG_DSN convention. This is the precedent set by test/integration/phase50_durability_test.go (Phase 50's pause checkpoints) and test/integration/agent_registry_test.go (Phase 53a's registration records).
    • The §4.3 deviation language is recorded in the per-phase plan's "Findings I'm departing from" section AND in docs/plans/README.md's detail block.
  2. AES-256-GCM encryption at rest with a 4-byte version + 12-byte fresh-nonce envelope; KEK is mandatory at construction. The master-plan acceptance criterion is "token material is encrypted at rest (driver conformance asserts ciphertext on disk)." The shape:

    • Envelope: [4-byte BE version][12-byte fresh nonce][AES-GCM ciphertext + 16-byte tag]. EnvelopeVersion = 1. The version header is mandatory so a post-V1 KEK-rotation driver can decrypt legacy records before re-encrypting under a new KEK.
    • KEK is 32 raw bytes (AES-256). NewAESGCMSealer(kek []byte) returns wrapped ErrKEKMissing on a wrong-length input — a missing / empty / wrong-length KEK fails the boot loud per CLAUDE.md §13's "Test stubs as production defaults on operator-facing seams" amendment (PR #91 / D-082). There is no degraded-no-encryption mode.
    • NewTokenStore(store, sealer) also rejects nil store / nil sealer at construction. Encryption-at-rest is structurally mandatory; an operator wanting no encryption configures one (we do not ship a NoOpSealer).
    • The conformance suite (runEncryptionAtRest) plants a known marker string in the access token, calls Put, then peeks at the raw StateStore.Bytes and asserts the marker does NOT appear. The same assertion runs against in-mem + SQLite + Postgres; the SQLite leg implicitly proves ciphertext-on-disk because the raw bytes the conformance suite reads are the same bytes the SQLite driver stores.
    • The fresh-nonce-per-Seal invariant is pinned by TestSealer_FreshNoncePerCall: two Seals of identical plaintext produce different ciphertext. Nonce reuse with AES-GCM is catastrophic; the test is the trip-wire.
    • Tampered-ciphertext + wrong-KEK + bad-version + too-short blob all surface ErrTokenCipherCorrupt — never a half-decoded record. auth.IsCipherCorrupt(err) is the call-site convenience.
  3. Single OAuthProvider covers both binding scopes; BindingScope is a declared config field, never inferred. Brief 09 §"What Harbor must add" item 2 is explicit: bifrost models user vs server as two separate halves of one interface; Harbor's OAuthConfig.BindingScope is the single discriminator that drives lookup keying, pause-record targeting, and Console UX. The shape:

    • OAuthConfig.BindingScope is ScopeUser | ScopeAgent. Required at construction; an invalid value rejects the config via Validate.
    • For ScopeAgent, OAuthConfig.AgentID is also required (must match a registered agent in Phase 53a's registry). The check fails closed at config-validation time.
    • Agent-bound tokens key on (tenant, agent_id, source); user-bound on (tenant, user_id, source). Per CLAUDE.md §6 + D-059, agent_id is NOT an isolation principal; the isolation tuple stays (tenant, user, session). The composite-key suffix includes both BindingScope AND subject_id so a user-bound and agent-bound token for the same source coexist (the master-plan acceptance criterion the conformance suite's runMixedScope pins).
    • The *ErrAuthRequired typed sentinel carries BindingScope verbatim; the tool.auth_required event's payload exposes it as a string. The Console branches on the field to render either a user-facing prompt (ScopeUser) or an admin-targeted banner (ScopeAgent).
    • ScopeAgent flows (InitiateFlow / CompleteFlow / Revoke) require registry.HasControlScope(ctx) — Phase 53a's existing control-scope claim primitive. Token (the read path) does not require the claim: it returns *ErrAuthRequired to whomever asked, and the BindingScope on the error tells the Console which principal to prompt. The admin-scope authz gate fails closed (wrapped ErrAdminScopeRequired) with no opt-out.
    • The single-flight refresh gate keys on (scope, subject_id, source) per brief 09's mitigation for "concurrent refresh storm on agent-bound tokens shared across N sessions." The TestProvider_ConcurrentReuse_RefreshSingleFlight test asserts ≤ 4 /token round-trips for N=32 concurrent callers — a refresh storm would produce N round-trips.

§13 primitive-with-consumer — discharged in-PR. Phase 30 ships three new primitives:

  • The OAuthProvider interface + *Provider concrete. First consumer: test/integration/phase30_tool_oauth_test.go::TestE2E_Phase30_FullPauseResumeCycle_BothBindingScopes exercises the full pause/resume cycle end-to-end against real Phase 50 Coordinator + real audit Redactor + real events EventBus + an httptest.Server authorization server emulating PKCE + RFC 7591 + discovery. Both binding scopes covered.
  • The TokenStore interface + stateStoreTokenStore concrete. First consumer: the OAuthProvider itself (every Token / CompleteFlow / Revoke call routes through the store). The conformance suite at internal/tools/auth/conformancetest is the cross-driver consumer.
  • The tool.auth_required + tool.auth_completed event types. First consumers: provider.go::emitEvent (the producer) + provider_test.go::TestProvider_CompleteFlow_Emits_ToolAuthCompletedEvent (subscribes to the bus and asserts the payload shape end-to-end through real inmem.New + real patterns.New() redactor).

The §13 obligation is closed in-PR for every new shape; no consumer is deferred.

§17 integration test in same PR. test/integration/phase30_tool_oauth_test.go wires real drivers across every seam (CLAUDE.md §17.3 #1): real state.StateStore (in-mem + SQLite + Postgres), real audit.Redactor (the patterns driver), real events.EventBus (the inmem driver), real pauseresume.Coordinator, real httptest.Server authorization server. Identity propagation asserted via runOAuthCycle's tenant/user/agent checks (CLAUDE.md §17.3 #2). Failure mode covered: cross-identity CompleteFlowErrStateMismatch (CLAUDE.md §17.3 #3). Concurrency stress at N=16 distinct identity stacks (CLAUDE.md §17.3 final). The Postgres leg skips with reason when HARBOR_PG_DSN is unset — the standard Phase 16 / Phase 50 / Phase 53a convention.

Coverage on internal/tools/auth: go test -cover reports above the master-plan 85% target on the touched package — the test surface is dense (per-method happy path + per-method failure mode + cross-scope conformance + cross-driver conformance + D-025 + single-flight refresh + goroutine-leak).


D-084 — Harbor CLI skeleton: cobra-rooted binary registering the seven RFC §8 subcommands; only harbor version fully implemented; six stubs exit non-zero with structured CLIError{Code: "not_implemented", Hint} pointing to their implementing phase; CLI structured-error type single-sourced in cmd/harbor/errors.go (NOT under internal/protocol/errors); global --quiet / --json global flags; cobra promoted to direct go.mod dep; preflight tolerates the §13-mandated non-zero stub exit

Date: 2026-05-15 Status: Settled Where it lives: cmd/harbor/main.go (the cobra-rooted entry point — the driver blank-import block is preserved; NewRootCmd().Execute() replaces the old empty main), cmd/harbor/root.go (NewRootCmd + global flag wiring + the emitCLIError hook every stub body calls + HarborVersion = "v0.0.0-dev" pin), cmd/harbor/errors.go (the CLIError struct with the pinned JSON tags {"error","code","hint"} + the single sink PrintCLIError(w, jsonMode, err) + CodeNotImplemented constant), cmd/harbor/cmd_version.go (the only fully-implemented subcommand — assembles versionInfo{Harbor, Protocol, BuildHash} from HarborVersion + types.ProtocolVersion + runtime/debug.ReadBuildInfo's vcs.revision setting with "unknown" sentinel on absence), cmd/harbor/cmd_{dev,scaffold,validate,inspect_events,inspect_runs,inspect_topology}.go (the six stub subcommands), cmd/harbor/{errors_test,root_test,cmd_version_test,cmd_stub_test}.go (the test surface — CLIError shape, root golden + global-flag inheritance, version human + JSON, stub structured-error shape both modes), cmd/harbor/testdata/golden/help.txt (the harbor --help golden — regenerable via go test -update), go.mod (cobra promoted from indirect to direct: github.com/spf13/cobra v1.10.1; brings in indirect pflag v1.0.9 + mousetrap v1.1.0 — both already present as indirect via Bifrost), scripts/preflight.sh (the boot-detection block amended to recognise a structured "code":"not_implemented" stderr from a Phase 63+ bin/harbor dev and treat it as the stub posture, exactly as the existing clean-exit-zero branch already does), scripts/smoke/phase-63.sh (the new smoke — cmd/harbor tests under -race, harbor --help golden match, harbor version human shape + --json shape + protocol-version pin, every stub subcommand's non-zero exit + structured code: not_implemented + phase-hint regex, the direct-cobra go.mod guard, the no-internal/protocol/errors-import guard), docs/plans/phase-63-cli-skeleton.md (the Phase 63 plan), docs/plans/README.md (Phase 63 row PendingShipped), README.md ("Harbor CLI" prose updated; Status table Phase 63 row added), docs/glossary.md (the CLIError / Golden file (CLI) / Stub subcommand terms), this decisions entry.

Why: RFC §8 settles the seven Harbor CLI subcommands (dev / scaffold / validate / inspect-events / inspect-runs / inspect-topology / version) and pins cobra as the CLI library (RFC §10 stack table). The master plan splits the work across phases — Phase 63 is the skeleton (cobra root + the seven subcommand registrations + global flag conventions + the structured-error vocabulary + golden tests + the only fully-working subcommand, version), Phase 64 populates dev, Phases 65–70 the rest. The split exists because a single phase that shipped both the skeleton AND a working dev (which itself wires the LLM, the Phase 60 transports onto a listener, identity injection, hot-reload, draft saving) would be unbounded; brief 06 §7 #8 explicitly sizes the skeleton at ~1 phase.

Five durable design calls warrant being recorded so they don't get re-litigated.

  1. The CLI's structured-error type is cmd/harbor.CLIError, NOT a new protocol/errors.Code. Two different surfaces are at stake. internal/protocol/errors is the single-source home for Protocol wire error codes Protocol clients consume over REST/SSE (CLAUDE.md §8, D-075). The CLI's structured error is the operator-facing exit surface — stderr JSON + non-zero exit code from the harbor binary. The two surfaces evolve independently: a Protocol client reading protocol/errors.Code cannot meaningfully consume a CLI exit code (it never sees the harbor binary), and a script reading cmd/harbor.CLIError cannot meaningfully consume a Protocol error code (Protocol responses go over the wire, not via process exit). Mixing them — adding a not_implemented Protocol code to satisfy the CLI, or routing CLI exits through protocol/errors.Error — would conflate the surfaces and violate CLAUDE.md §8's single-source pin on Protocol error codes. The smoke script's static guard (grep -rIn '"github.com/hurtener/Harbor/internal/protocol/errors"' cmd/harbor/) makes the boundary mechanically enforced.

  2. Stub subcommands exit non-zero with a structured CLIError{Code: "not_implemented"}, NOT a clean os.Exit(0). The §13 amendment "Test stubs as production defaults on operator-facing seams" (introduced in PR #91, D-082) requires this posture: a harbor dev against a Phase 63 build that returned exit 0 with a "not yet implemented" stderr message would fool a deployment script into thinking the boot succeeded. The combination (non-zero exit + structured code: not_implemented + hint: "see phase NN — <slug>") makes the stub state unambiguous to both humans (the Error: ... stderr line) and scripts (the exit code + the structured-error JSON in --json mode). This is the §13 amendment's intended posture; Phase 64's real dev body will of course exit 0 on the happy path.

  3. scripts/preflight.sh is amended to recognise the §13-mandated stub exit code. Before Phase 63, preflight treated any non-zero harbor dev exit as a hard failure (the binary was either a clean-exit stub or a working server). The §13 amendment forces a non-zero exit on stub subcommands, which conflicts with the old preflight contract. The amendment adds a single grep on the captured server log: when the stub's structured error ("code":"not_implemented" OR the literal human-mode "not yet implemented (see phase 64" marker) appears, preflight treats the non-zero exit as the "stub binary" posture — same as the existing clean-exit-zero branch. This is forward-compatible: Phase 64's real harbor dev will not exit with that code, so preflight reverts to the original boot-and-wait posture without further changes. The alternative (treating the structured error as a hard failure and forcing Phase 63 to ship harbor dev as exit 0) was rejected: exit 0 + "not implemented" violates §13.

  4. The cobra dependency is promoted from indirect to direct, with no new modules. Cobra (github.com/spf13/cobra v1.10.1) was already a transitive dep before Phase 63 (Bifrost pulls in cobra-using internal packages); promoting it to direct is a go.mod cleanliness change, not a new dependency. The same posture Phase 61's golang-jwt/jwt/v5 promotion took (D-079): the module was already in the indirect set; declaring it direct documents the binary's intent and pins the version explicitly. Two new indirect deps come along (spf13/pflag + inconshreveable/mousetrap); both are MIT-licensed and CGo-free, so the static-binary invariant (CGO_ENABLED=0 go build -ldflags='-s -w') is preserved. RFC §10's stack table lists cobra as Settled, so this is not an RFC change.

  5. harbor --help is a golden-file test; the -update flag is the regeneration path. Brief 06 §6 explicitly names "CLI golden tests" as a Harbor CLI requirement. Phase 63 establishes the pattern: cmd/harbor/testdata/golden/help.txt is the golden, cmd/harbor/root_test.go::TestRoot_Help_MatchesGolden is the diff, the -update flag rewrites the golden in place (the standard go-test idiom). Every future phase that ADDS a subcommand (64 → 70) mutates the help golden in the same PR — a phase that lands without regenerating the golden fails its own CI. The golden is intentionally simple text (not JSON) because that is what harbor --help emits today and what brief 06 sized the test against; the test does NOT pin internal cobra render details.

§13 primitive-with-consumer — closed in-PR. The primitive Phase 63 introduces is the cmd/harbor.CLIError structured-error type + the PrintCLIError sink. Its first consumer is cmd/harbor.emitCLIError (the hook every subcommand body calls — six stub subcommands consume it on every invocation, exercised end-to-end by cmd_stub_test.go's table-driven assertions on both human and --json modes). The version subcommand is also a first consumer of currentVersionInfo + renderVersionHuman + renderVersionJSONcmd_version_test.go round-trips the JSON shape and pins the field labels. The CLI-as-Protocol-version-consumer surface (the types.ProtocolVersion constant) is also discharged in-PR: harbor version's --json .protocol field round-trips the value through the wire, which is what harbor inspect-* and a third-party Protocol client over Phase 60's wire would do later. The golden-file test pattern is the consumer of itself — a meta-primitive that future phases will inherit when they extend the help surface (the -update flag is the documented regeneration path).

§14 pre-merge checklist — cmd/harbor coverage 79.0% (target 70%); cobra direct-promotion documented; preflight amendment forward-compatible; no multi-isolation paths touched; no Protocol types changed; no config schema changed; no migrations added. The CLI is a one-shot process that does not load identity, so the multi-isolation checklist row is N/A. The CLI consumes internal/protocol/types.ProtocolVersion only — a pure constant with no I/O — so no integration test is required (the §17.1 trigger list does not fire; the cross-subsystem seam first opens in Phase 64). No reusable artifact lands: each bin/harbor invocation constructs a fresh cobra root in main() and exits; there is no long-lived state that crosses goroutine boundaries, so the D-025 concurrent-reuse obligation is N/A (Phase 64's long-lived server picks it up).


D-085 — Phase 71 harbortest test kit: public top-level package; deterministic default identity + Admin-scope capture; subsequence semantics on AssertSequence; RunID-ownership + reflective payload check on AssertNoLeaks; FIFO failure queue on FaultInjector; no stub LLM (CLAUDE.md §13 amendment posture)

Date: 2026-05-15 Status: Settled Where it lives: harbortest/doc.go + harbortest/agent.go + harbortest/testing.go + harbortest/eventlog.go + harbortest/runonce.go + harbortest/assertions.go + harbortest/reflect.go + harbortest/simulate.go (the public package surface), harbortest/agent_test.go + harbortest/assertions_test.go + harbortest/simulate_test.go + harbortest/concurrent_test.go + harbortest/extra_test.go + harbortest/testhelpers_test.go (self-tests + the deliberate-cross-session-bug regression), scripts/smoke/phase-71.sh (the smoke), docs/plans/phase-71-harbortest.md (the plan), docs/plans/README.md (the Status row flip), docs/glossary.md (the seven new entries — RunOnce, RecordedEvents, EventLog, AssertSequence, AssertNoLeaks, SimulateFailure, FaultInjector), README.md (Status row + testing pointer), CLAUDE.md §3 / AGENTS.md §3 (the new harbortest/ top-level entry in the canonical layout block).

Why: Phase 71 ships Harbor's first-class authoring surface for flow-level agent tests — the §6.13 / brief 06 §3 obligation. The acceptance criterion is binary: a flow-level test ten lines or fewer; AssertNoLeaks catches a deliberate cross-session bug in a regression test. The five public entry points (RunOnce, AssertSequence, AssertNoLeaks, SimulateFailure, RecordedEvents) are settled in the brief; the design calls below pin the shape choices that aren't fully specified there so a future reader doesn't relitigate.

Five durable design calls warrant a durable home so they don't drift.

  1. The package lives at the top-level (harbortest/), not under internal/. Go's toolchain restricts internal/ packages to importers inside the owning module subtree; a test kit that test-authors are meant to consume from their own modules CANNOT live under internal/. The brief 06 §3 wording ("a public harbortest package consumers import") is unambiguous on this. The precedent inside the Go ecosystem is consistent — golang.org/x/tools/go/analysis/analysistest lives at a top-level path inside its module; net/http/httptest is in the stdlib at a top-level path; every Go ecosystem testing kit follows the shape. CLAUDE.md §3's layout block previously enumerated cmd/ + internal/ + examples/ + test/integration/ + scripts/ + docs/ as the canonical top-level homes, with the explicit closing rule "Anything that doesn't have a home above is wrong. If you need a new top-level directory, propose it in the RFC first." The RFC PR for this addition is the present phase — the addition is justified, in scope for the phase plan, and aligns with the brief. Three alternatives considered and rejected: (a) a _test.go build-tag inside internal/runtime/... — defeated by the cross-module import requirement; (b) a pkg/harbortest/ subdirectory — the pkg/ convention is non-standard in modern Go and would require a separate layout-update RFC; (c) a testkit/ rename — the harbor* prefix is the project-naming convention (harbor binary, harbor-events rename, etc.), and harbortest is the brief-specified name. CLAUDE.md §3 is updated in this PR to document harbortest/ as a top-level package.

  2. Default identity is canonical "harbortest" across the triple, NOT randomised or unset. The kit's RunOnce builds a default identity.Identity{TenantID:"harbortest", UserID:"harbortest", SessionID:"harbortest"} when the caller passes no Deps.Identity. The choice considered against (a) randomised UUIDs per call and (b) a non-Validate-passing zero triple was: (a) UUIDs are unhelpful — test authors who want to predict identity in their assertions cannot grep for "harbortest" because every run is fresh, and the kit's identity-propagation tests need a stable value to assert against; (b) a zero triple fails identity.Validate and would force RunOnce to return ErrStackConstruction for every zero-Deps call, defeating the "flow-level test in ten lines" acceptance criterion. The canonical "harbortest" string is deterministic, grep-friendly, and unmistakeable in production audit logs if it ever leaks (it shouldn't — the kit is harbortest, not harborprod). Test authors who want a different identity supply Deps.Identity explicitly. RunIDs, separately, MUST differ across concurrent calls (the D-025 reuse test relies on this), so the package-level runCounter synthesises a fresh ID per call via harbortest-run-<seed>-<n>. The seed is monotonic per package init; the counter is mutex-guarded.

  3. RunOnce subscribes with events.Filter{Admin: true} — the kit's only way to observe events ACROSS identity triples. Brief 06 §4 documents the bus's identity-triple-mandatory subscription rule: a non-Admin filter without (tenant, user, session) is rejected with ErrIdentityScopeRequired. But AssertNoLeaks MUST see cross-triple events to detect leaks — if the kit subscribed only to its own triple, a leak FROM the kit's run TO a different triple would be invisible (the foreign-triple event would simply not arrive at the subscriber). The Admin subscription is the only filter shape that gives AssertNoLeaks the data it needs. The bus emits one audit.admin_scope_used event per RunOnce in response (CLAUDE.md §6 rule 5 + Phase 05) — the captured EventLog naturally contains this entry; test authors should expect to see it. The audit emit is documented in the EventLog's godoc and is part of the kit's contract; production code never subscribes Admin without a verified scope claim (Phase 61 wires the cryptographic verification; the test kit's Admin claim is unverified and audit-emitted just like any other Admin Subscribe — defence-in-depth).

  4. AssertSequence uses ordered-subsequence semantics, NOT strict prefix or strict equality. A captured EventLog from a real RunOnce contains the agent's emits PLUS the bus-internal audit.admin_scope_used event PLUS any bus.dropped / bus.subscription_idle_closed / runtime.warning events the bus or runtime layers emit. A strict-equality AssertSequence would force every test author to enumerate the full set every time — impractical and brittle. A strict-prefix variant would fail any test where the agent emits more than the caller asserted. The ordered-subsequence shape lets the caller name only the events they care about, in the order they care about, and the assertion succeeds if those events appear in that order (possibly interleaved with others). The semantics match the brief's "make a flow-level test ten lines or fewer" goal: a typical assertion is [tool.invoked, tool.completed] and the test author doesn't need to know what bus-internal events fire alongside. Test authors who want strict equality can grep log.All() themselves. The error message on a missing entry names the first unmatched type and the captured sequence so the diff is actionable.

  5. AssertNoLeaks uses RunID-ownership inference (first-publisher wins) PLUS reflective payload-identity inspection. The brief specifies "cross-tenant/session leakage detector" without prescribing the algorithm. The choice considered: (a) trust the caller to declare the runs and their owners explicitly — verbose, defeats "ten lines"; (b) accept the bus's monotonic Sequence as the authoritative "which triple first published under this RunID" signal — works for any well-formed log; (c) require every test to use distinct buses per identity — defeats Deps.Bus sharing entirely. (b) is the only shape that works for the typical sharing pattern. The walk has two arms: outer-triple-vs-RunID-owner (run-id cross-talk) and payload-vs-outer-triple (payload cross-talk). The reflective payload check (reflect.go::reflectQuadruple) looks for an exported Identity field of type identity.Quadruple (the Harbor canonical name) or identity.Identity (widened to a zero-RunID Quadruple) plus an optional IdentityQuadruple() identity.Quadruple method (the type-assertion fast path for payloads that want to be explicit). Both checks call t.Errorf naming the offending event index + the triple disagreement; the message includes the substring "cross-talk" so test authors can grep for it. The regression test (TestAssertNoLeaks_CatchesCrossSessionLeak) IS the acceptance-criterion fixture: an Agent under triple A publishes an event tagged with triple A but carrying triple B's RunID; the assertion fires.

  6. SimulateFailure wraps the catalog at the Resolve boundary; FIFO failure queue; class-typed errors. The brief specifies "SimulateFailure(toolName, code, n) (next n calls fail with code)." The implementation choices that aren't in the brief: (a) WHERE the wrapper sits — at the catalog Resolve boundary, NOT at the tool descriptor's Invoke registration. The Resolve boundary wraps every consumer of the catalog uniformly; wrapping at Register would only catch tools registered AFTER the wrapper is installed. (b) WHAT shape the error takes — class-typed so the production policy shell classifies the failure correctly. Permanent → wrap tools.ErrToolInvalidArgs (the policy shell classifies wrapped invalid-args as permanent); timeout → wrap context.DeadlineExceeded (the policy shell classifies wrapped deadline-exceeded as timeout); transient + 5xx + unknown → wrap a new package-local ErrSimulatedFailure sentinel (the policy shell's classifyError() falls through to ErrClassTransient for unknown wraps — the right default for "give me a transient failure"). (c) The queue is FIFO across multiple SimulateFailure calls on the same tool: SimulateFailure(inj, "x", transient, 2) then SimulateFailure(inj, "x", permanent, 1) yields transient, transient, permanent. (d) Defensive guards: nil injector, empty toolName, n<=0 are silent no-ops (the caller intent is unclear and silently doing nothing is safer than panicking). (e) A nil catalog at NewFaultInjector panics with a grep-friendly message — this is a test-author bug at the kit boundary, not a production fail-loud concern.

§13 amendment posture — no stub LLM, no silent fallback, no default-driver footgun. The Wave 10 §13 amendment (PR #91 / D-082) forbids test stubs as production defaults on operator-facing seams. The kit is a TEST surface but it is OPERATOR-FACING in the sense that real test authors consume it. Three concrete consequences for this phase:

  • No stub LLM ships with harbortest. A test-only LLM driver bundled in the kit would encourage tests that exercise the stub instead of the real runtime. Test authors that need a mock LLM build their own (an llm.LLMClient mock with hand-rolled Complete is one-page-of-Go); the kit's Agent interface is intentionally narrow so the test author owns the boundary.
  • RunOnce fails loud on stack-construction errors. Missing audit redactor (impossible — auditpatterns.New() is parameterless) is not a path; missing bus driver (the _ "github.com/hurtener/Harbor/internal/events/drivers/inmem" blank import in runonce.go registers it at package init) is not a path either. A future events.Open failure surfaces as ErrStackConstruction wrapping the underlying error with the failing component named (fmt.Errorf("%w: events.Open: %w", ErrStackConstruction, err)).
  • The test-only stubRedactor lives in *_test.go. harbortest/testhelpers_test.go defines stubRedactor as a Go-build-tag-gated test fixture; the production paths (harbortest/runonce.go's auditpatterns.New() call) use the real patterns redactor. The §13 trip-wire is clear.

§13 primitive-with-consumer — discharged in-PR. The phase ships five public functions plus one supporting type (FaultInjector) plus one supporting type (EventLog). The self-tests in harbortest/*_test.go are the first consumer of every public symbol:

  • RunOnceTestRunOnce_RoundTrip_CapturesEvents + TestRunOnce_DefaultIdentity_IsCanonical + TestRunOnce_FailsLoudly_OnNilAgent + TestRunOnce_FailsLoudly_OnInvalidIdentity + TestRunOnce_CustomIdentity_FlowsThrough + TestRunOnce_OwnsBusLifecycle_WhenDepsBusOmitted + TestRunOnce_AgentError_ReturnsLog + TestRunOnce_RedactorOverride_Honoured + TestRunOnce_ConcurrentReuse_NoCrossTalk (the D-025 stress).
  • EventLog.RecordedEventsTestEventLog_RecordedEvents_FiltersByRun.
  • AssertSequence → six scenarios (Happy, OrderedSubsequence_AllowsIntervening, Fails_OnMissingType, Fails_OnOutOfOrder, Empty_Want_Matches, NilLog_ErrorPath).
  • AssertNoLeaks → seven scenarios (Happy, CatchesCrossSessionLeak [the load-bearing regression], NilLog_ErrorPath, PayloadCrossTalk_QuadrupleField, PayloadIdentityHolder_TypeAssertionPath, PayloadIdentityTripleField).
  • SimulateFailure + FaultInjector → nine scenarios (FailsThenResumes, PermanentClass_WrapsInvalidArgs, TimeoutClass_WrapsDeadlineExceeded, PerToolIsolated, StacksFifo, NoInjection_PassesThrough, GuardsAgainstZeroAndNil, NilCatalog_Panics, UnknownTool_NotFound, Register_Forwards, List_Forwards, ConcurrentReuse — the D-025 N=100 stress).

Coverage: 88.4% statement coverage on harbortest/ against the 85% master-plan target. The race detector is the CI gate; every test runs under -race. The Phase 71 smoke script (scripts/smoke/phase-71.sh) runs go test -race ./harbortest/... so the kit's self-tests double as the phase's smoke surface.



D-086 — Phase 31 tool-side approval gates: sibling consumer of the Phase 50 Coordinator under internal/tools/approval; ApprovalGate reusable artifact + ApprovalPolicy interface + *ErrToolRejected typed sentinel + three SafePayload events (tool.approval_requested / tool.approved / tool.rejected); no silent-stub default at boot (§13 amendment); ORIGINAL args never on the bus

Date: 2026-05-15 Status: Settled Where it lives: internal/tools/approval/approval.go (the ApprovalPolicy interface + ApprovalRequest / ApprovalDecision + the *ErrToolRejected typed sentinel + all package sentinels), internal/tools/approval/gate.go (the *ApprovalGate concrete artifact + NewApprovalGate constructor + the RunGuarded entry surface + the ResolveApproval in-process helper + the per-pause waitingEntry registry), internal/tools/approval/events.go (the tool.approval_requested / tool.approved / tool.rejected events.EventType + ToolApprovalRequestedPayload / ToolApprovedPayload / ToolRejectedPayload SafePayload shapes + the init() registrations), internal/tools/approval/policies.go (the three bundled policies — AlwaysDenyPolicy for fail-safe-everywhere, AlwaysApprovePolicy for explicit dev-loop sandboxes, TaggedPolicy for the V1 production reference), internal/tools/approval/{approval,gate,events,policies}_test.go (the unit-test surface — 88.9% statement coverage), internal/tools/approval/concurrent_test.go (the D-025 N=128 concurrent-reuse stress), test/integration/phase31_approval_gates_test.go (the §17 wave-end integration test — full APPROVE + REJECT cycles + scope-gate failure + cross-identity failure + steering-inbox-shape contract + goroutine-leak + N=16 concurrency stress), scripts/smoke/phase-31.sh (the smoke), docs/plans/phase-31-tool-approval-gates.md (the per-phase plan), docs/plans/README.md (the Phase 31 row Status flip), docs/glossary.md (six new entries — approval.ApprovalGate, approval.ApprovalPolicy, approval.ErrToolRejected, tool.approval_requested, tool.approved, tool.rejected), README.md (Status row Phase 31 → Shipped).

Why: Phase 31's master-plan line — "synchronous 'approve this tool call' gates using the same pause/resume primitive — distinct from OAuth, simpler payload shape; APPROVE/REJECT round-trip via the protocol; reject path raises typed tool.rejected events" — is unambiguous on the surface but four durable design calls warrant being recorded here so they don't get re-litigated.

  1. The approval-gate package is a SIBLING of internal/tools/auth, not a subpackage. Two siblings under internal/tools/: auth/ for OAuth (Phase 30) and approval/ for HITL approval (Phase 31). They share the Coordinator + bus + redactor seams but nothing else: OAuth needs a TokenStore, an authorization-server URL, a PKCE verifier, dynamic client registration, metadata discovery, a Sealer for encryption-at-rest. Approval gates need NONE of that — just a policy, a pending-resolution channel, and a typed reject sentinel. Putting approval gates under auth/ would have forced operators to import the OAuth machinery to use an HITL gate; the sibling layout keeps each subsystem's surface area minimal. Three alternatives considered: (a) a subpackage internal/tools/auth/approval — defeated by the no-OAuth-baggage requirement; (b) a wrapping middleware in internal/runtime/dispatch — too tightly coupled to the dispatcher's evolving shape; (c) a tool-catalog hook — the catalog's interface is settled (RFC §6.4) and growing a per-descriptor approval hook would either re-shape the interface or grow optional-capability ceremony (forbidden by CLAUDE.md §4.4). The sibling-under-tools/ layout matches the natural conceptual grouping: tool-side authentication AND tool-side approval are both "things that gate a tool invocation."

  2. ReasonApprovalRequired, NOT ReasonExternalEvent. Phase 30 (OAuth) uses ReasonExternalEvent because the run waits on an external authorization-server callback. Phase 31 (HITL approval) uses ReasonApprovalRequired — the textbook RFC §6.3 reason for a HITL approval gate (brief 02 §"Pause-reason taxonomy"). Using ReasonExternalEvent for approval gates would conflate the two flows in audits / observability and make the four-reason taxonomy meaningless. The Coordinator's pause record carries the reason verbatim; observers branch on it. The smoke script's static guard pins this (grep ReasonApprovalRequired on gate.go).

  3. ORIGINAL args stay in the gate's pending map; the bus never sees them. The tool.approval_requested event carries an ArgsSummary field — the audit-redactor's output over a map[string]any shape of the args. The ORIGINAL json.RawMessage stays in the gate's pending[Token].req.Args. On APPROVE, the gate returns req.Args to the caller — the redactor's output is NEVER routed through to the executed tool invocation. This closes the failure mode where a redactor that elides a secret-shaped field would corrupt the post-approve tool call: the redactor's domain is audit / observability emit; the executed call's domain is the original caller's intent. Two-source-of-truth is forbidden in general (§13) but this is NOT two sources of truth for one piece of data — it is one piece of data (the args) at one site (the gate's pending map), with a separate-purpose redacted view (the event payload) derived from it. Brief 03 §"Audit redaction lives in the audit subsystem" is unambiguous: every payload runs through the redactor; the persisted artifact is the event, not the Go struct. The Phase 31 design composes cleanly with this — the persisted artifact (the event) is redacted; the in-memory execution path (the gate→tool invocation) uses the raw args.

  4. Resolution is double-gated: protocol/auth scope (admin OR console:fleet) + Coordinator identity scope. The §13 amendment dictates BOTH gates are fail-loud at every boundary. ApprovalGate.ResolveApproval enforces auth.HasScope(ctx, ScopeAdmin) || HasScope(ctx, ScopeConsoleFleet) — the same scope set Phase 61's events.Filter{Admin:true} subscriptions use. A leaked observer token (read-only) cannot approve a tool call (ErrApprovalScopeRequired fires loud). The Coordinator's sameScope check separately enforces identity-triple equality — a tenant-B admin cannot resolve a tenant-A pause (pauseresume.ErrScopeMismatch propagates). Two independent gates, two independent failure modes, two independent test scenarios. The §17 integration test covers both. The Phase 54 Protocol edge will also enforce scope at the JWT boundary in a later phase — defence in depth across three layers (transport + gate + Coordinator). The NewApprovalGate(GateDeps{}) nil-policy / nil-coordinator / nil-bus / nil-redactor rejection is the §13-amendment fail-loud at construction time; the runtime CANNOT silently boot with a gate that auto-approves.

§13 primitive-with-consumer — discharged in-PR. Phase 31 ships three new primitives:

  • The ApprovalPolicy interface + bundled AlwaysDenyPolicy / AlwaysApprovePolicy / TaggedPolicy concretes. First consumers: every gate-level test (gate_test.go::TestRunGuarded_*) exercises the policy's Required=true and Required=false paths end-to-end; policies_test.go exercises each bundled concrete directly.
  • The *ApprovalGate concrete artifact. First consumers: test/integration/phase31_approval_gates_test.go::TestE2E_Phase31_FullApproveCycle + TestE2E_Phase31_FullRejectCycle exercise the full pause/resume cycle end-to-end against real Phase 50 Coordinator + real audit.Redactor + real events.EventBus. The §17.3 failure modes (scope-gate + cross-identity) + goroutine-leak + N=16 concurrency stress are also covered.
  • The three event types tool.approval_requested / tool.approved / tool.rejected. First consumers: gate.go::publishApprovalRequested / publishApproved / publishRejected (the producers) + gate_test.go::TestRunGuarded_ApproveRoundTrip / RejectRoundTrip (the subscribers asserting payload shape end-to-end through real inmem.New + real patterns.New() redactor).

The §13 obligation is closed in-PR for every new shape; no consumer is deferred.

§17 integration test in same PR. test/integration/phase31_approval_gates_test.go wires real drivers across every seam (CLAUDE.md §17.3 #1): real audit.Redactor (the patterns driver), real events.EventBus (the inmem driver), real pauseresume.Coordinator, real steering.Registry (the Phase 53 surface — Phase 31's resolution shape is exactly what Phase 53's RunLoop will dispatch). Identity propagation asserted via the Event envelope's Identity field (CLAUDE.md §17.3 #2). Failure modes covered: ErrApprovalScopeRequired on unscoped resolver (§17.3 #3) + pauseresume.ErrScopeMismatch on cross-tenant resolver (§17.3 #3) + ErrApprovalCancelled on caller-ctx-cancel + goroutine-leak on initiate-then-cancel. Concurrency stress at N=16 distinct identity stacks (CLAUDE.md §17.3 final, N>=10).

§13 "Test stubs as production defaults" amendment — observed. NewApprovalGate(GateDeps{}) with a nil Policy field fails-loud at construction with ErrPolicyRequired. Approval gates with no policy attached would auto-approve every call (the worst-case posture for an HITL surface) OR pass-through with no gate (the §13 amendment's "silent stub default" footgun). The constructor REFUSES both. Smoke-script static guard grep ErrPolicyRequired && grep 'deps.Policy == nil' on gate.go mechanically enforces the trip-wire.

Coverage on internal/tools/approval: go test -race -cover reports 88.9% statement coverage on the touched package against the master-plan 80% target. The test surface is dense (per-method happy path + per-method failure mode + cross-scope conformance + D-025 + goroutine-leak + the integration test's wave-shape stress).



D-087 — Phase 67 harbor scaffold: single embedded minimal-react template; production-shaped harbor.yaml; acceptance proven against internal/config.Load + Validate directly (Phase 68 harbor validate sibling-shipping)

Date: 2026-05-15 Status: Settled Where it lives: cmd/harbor/cmd_scaffold.go + cmd/harbor/cmd_scaffold_test.go (the cobra body + cobra-driver tests), cmd/harbor/scaffold/{doc,scaffold,render,scaffold_test}.go (the binary-internal engine package), cmd/harbor/scaffold/templates/minimal-react/{go.mod,harbor.yaml,README.md,agent.go,agent_test.go}.tmpl (the embedded template), cmd/harbor/testdata/golden/minimal-react/* (the per-file golden the scaffold tests diff against), cmd/harbor/testdata/golden/help.txt (regenerated — scaffold Short shed the "(Phase 67)" suffix), cmd/harbor/root.go (Long updated to drop the "Only harbor version is fully implemented at this phase" claim now that two subcommands are real), cmd/harbor/cmd_stub_test.go (scaffold removed from the stub-table — it is no longer a stub), scripts/smoke/phase-67.sh (the smoke), docs/plans/phase-67-scaffold.md (the plan), docs/plans/README.md (the Status row flip + the §4.3 deviation note in the Phase 67 detail block), docs/glossary.md (the four new entries — harbor scaffold, Template (scaffold), minimal-react, Scaffold output), README.md (the Status row + the testing-section pointer).

Why: Phase 67 turns harbor scaffold from the Phase 63 stub into a real subcommand that materialises a Harbor agent project skeleton from an embedded template. The master-plan acceptance criterion is binary: "harbor scaffold my-agent creates a buildable project; harbor validate returns 0." Five durable design calls warrant a durable home so they don't drift.

  1. Phase 67 ships exactly one template (minimal-react); the template-registry surface is wired generically. Brief 06 §7 #11 sizes the scaffold work as "1–2 phases" and pairs it with the harbor dev draft-save flow (Phase 66 — depends on Phase 64). Shipping multiple templates today is premature: there is exactly one shaped Harbor agent (the worked example built around harbortest.Agent), and a second template would either fork the production-shape constraints (a regression risk) or duplicate them (a §13 two-parallel-implementations smell). The registry — embed.FS over templates/<name>/, Templates() enumerating the directory at runtime, --template flag's allowed-value list derived from the same call — makes adding a template a single-directory operation; no command-body change required. The trade-off considered against (a) hand-rolling a switch in the cobra body and (b) requiring operators to author out-of-tree templates: (a) is brittle (every new template re-touches the command body — a §13 "two parallel implementations" smell read sideways); (b) is post-V1 (the public template-author surface needs an RFC entry that doesn't exist yet — the §16 workflow gate fires). The embedded-only registry preserves the CGo-free / single-static-binary invariant and keeps the surface small.

  2. The scaffolded harbor.yaml demonstrates the production shape — bifrost LLM driver, env.NAME API key reference, sqlite state, real audit redactor — NOT the mock driver default. The Phase 64 pre-plan note ("Phase 64 — harbor dev v1") is binding for the entire wave: "harbor scaffold produces a project — that project's examples/dev.yaml or equivalent MUST demonstrate the production-shaped config, NOT a --mock LLM path." The §13 amendment forbids test stubs as production defaults on operator-facing seams; the scaffold's OUTPUT is precisely such a seam. Three concrete consequences: (a) llm.driver: bifrost (not mock) — the production driver Phase 33 shipped; (b) llm.api_key: env.OPENROUTER_API_KEY — the env.NAME reference form Phase 33's bifrost driver resolves at construction time, not a literal value (literals would be the §7 "no hardcoded secrets" trip-wire even though scaffold output is per-operator); (c) state.driver: sqlite with an explicit DSN — the durable single-node default per RFC §9 + the Phase 15 SQLite StateStore driver, not inmem (which Phase 64's pre-plan note treats as test-shape). The TestScaffold_RenderedConfig_DemonstratesProductionShape test fires on every CI run; a future template author who quietly flips back to driver: mock fails the test immediately. The §13 trip-wire is mechanical, not advisory.

  3. The acceptance criterion is verified against internal/config.Load + Validate directly, not via harbor validate. This is the §4.3 deviation. The master-plan Phase 67 detail block names harbor validate as the validation surface; at scaffold-time Phase 68 is sibling-shipping (its harbor validate subcommand is still a Phase 63 stub). Three options considered: (A) call internal/config.Load + Validate directly from a cmd/harbor/scaffold/scaffold_test.go test — this PR's choice; (B) gate Phase 67 on Phase 68 merge — rejected because the user dispatched these in parallel for a reason: the Go code surfaces are non-overlapping (cmd_scaffold.go vs. cmd_validate.go); (C) wait for Phase 68's PR to update Phase 67's smoke step — rejected because §17.6 explicitly says cross-phase fixes ride with whoever discovers them, and the cross-phase work here is "add a CLI integration step", which is Phase 68's surface to add. Option A is the cleanest. The §13 primitive-with-consumer rule is satisfied: the scaffold's consumer of the config validator is the in-tree internal/config package — a real, shipped subsystem — not a planned future CLI subcommand. The cross-phase CLI integration smoke step (running harbor validate ./harbor.yaml after a scaffold) lands in Phase 68's PR per §17.6.

  4. scaffold.Scaffold is a pure function — no goroutines, no shared state, no D-025 obligation. Each invocation builds a templateVars value, walks the embedded templates/<name>/ tree, renders each file's bytes via text/template.Execute, and writes the output. There is no long-lived reusable artifact, no compiled-once-invoked-many surface. The §5 / §11 / D-025 concurrent-reuse contract is vacuous. Phase 64's harbor dev will ship the long-lived server and pick up the engine-level D-025 dance for the runtime; this phase's surface is per-invocation. Concurrent invocations against distinct output dirs are safe by construction (no shared state); concurrent invocations against the SAME output dir race on os.Stat but the loser cleanly fails with ErrOutputDirExists (idempotent fail-closed posture).

  5. No-partial-writes contract: any post-MkdirAll failure cleans up the output dir before returning. The §13 fail-loud posture extends to filesystem state: an operator who runs harbor scaffold --name foo and sees a non-zero exit MUST NOT then find a half-rendered foo/ directory on disk (the next scaffold invocation would fail with ErrOutputDirExists against the partial tree, masking the real cause). scaffold.Scaffold's body: validate name → resolve template → validate output dir absence → render via renderTemplate(...) → on render failure, os.RemoveAll(absOut) before returning the wrapped error. The cleanup itself is wrapped (also failed to clean up partial output: %w) so a cleanup failure surfaces in the error chain rather than silently disappearing. The shipped minimal-react template renders cleanly by construction, so this path is exercised today only via the synthetic engine-level tests; a future template that can fail partway will inherit the contract.

  6. go.mod template carries no replace directive; the rendered module assumes a publicly-resolvable Harbor module. The scaffolded go.mod reads require github.com/hurtener/Harbor v0.0.0-dev. Phase 78 (release-engineering) will pin the first real semver; until then, operators who run go vet ./... on the scaffolded tree from outside the Harbor source checkout will see a module-resolution failure — that's expected and documented in the README. The §17.1 integration-test obligation is satisfied by TestScaffold_RenderedConfig_PassesConfigValidate (the config-validate seam) — there is no in-PR go vet ./... test because (a) it would require either a network call (excluded by §17.4) or a replace directive that is NOT representative of the shipped output. Pinning the semver + adding a real go.mod integration test is a Phase 78 follow-up.

Correction (v1.13.1, 2026-07-13 — reported by an external adopter). Item 6 is now obsolete in every clause: this is the pinning it deferred. The v0.0.0-dev require line and the "operators will see a module-resolution failure — that's expected" posture outlived their justification by thirteen minor releases (Harbor has published tagged module releases since v1.0.0), so the shipped scaffold's advertised golden path did not build without a hand edit — the §13 silent-degradation smell read as documentation. The scaffold now emits a real, proxy-resolvable require github.com/hurtener/Harbor vX.Y.Z, resolved (in cmd/harbor/scaffold/version.go) from: (1) the scaffolding binary's link-stamped release version (main.HarborVersion, threaded in via scaffold.Options.HarborVersion and devdraft.Options.HarborVersion), (2) the binary's embedded build info (the go install …/cmd/harbor@vX.Y.Z path), (3) scaffold.FallbackModuleVersion — the last PUBLISHED release, for un-stamped source builds. A regexp rejects the semver-ish-but-unresolvable shapes (the v0.0.0-dev sentinel; git describe derivatives such as v1.13.0-4-gdeadbee / …-dirty). The replace directive stays in the template but commented, as a contributor convenience for building against a local Harbor checkout — never a prerequisite. A generated project now satisfies go mod tidy && go build ./... off the module proxy with zero edits, and scripts/drift-audit.sh gates the pin (FAIL when it names no released CHANGELOG section, or trails the newest by two or more releases; a one-release trail is the deliberate merge→tag window, since a release's CHANGELOG section lands before its tag is cut).

Coverage: cmd/harbor at 82.1% (master-plan target 70%), cmd/harbor/scaffold at 78.2% (master-plan target inherited from cmd/harbor — 70%). The uncovered fraction is the os.MkdirAll / os.WriteFile / os.RemoveAll cleanup branches that fire only on a real filesystem failure (out-of-disk, permission-denied) — exercised today only via the cmd_scaffold_test.go happy path. The smoke script (scripts/smoke/phase-67.sh) runs five OK assertions against the built binary and the engine-level config-validate test; preflight is green.

§17.6 cross-phase fix — Phase 63 stub-table trim. Phase 63's smoke (scripts/smoke/phase-63.sh) iterates a fixed stubs=(dev scaffold validate inspect-events inspect-runs inspect-topology) table and asserts each emits CodeNotImplemented on a --json invocation. The real Phase 67 scaffold returns CodeInvalidProjectName on an empty-name invocation (the smoke's invocation shape) — so this PR drops scaffold from the table. Per CLAUDE.md §17.6, cross-phase smoke maintenance rides with the PR that moves the subcommand out of stub status. Future PRs that ship validate/inspect-* will trim the stubs array further; the in-script comment documents the pattern so subsequent maintainers don't need to grep this decision entry.

§14 pre-merge checklist — green. make drift-audit clean (RFC §8 + brief 06 references resolve; the smoke script exists; the mirror invariant holds). make check-mirror clean. go test -race ./... clean. make preflight clean (the Phase 63 cross-phase fix above closes the only FAIL the gate surfaced). No multi-isolation paths touched (the scaffold is a one-shot CLI that writes files; identity is N/A). No Protocol types changed. No config schema changed (the scaffold WRITES a config file but does not extend the schema). No migrations added. No new heavy dependencies (embed + text/template are stdlib).


D-088 — Phase 68 harbor validate: pre-boot CLI surface over internal/config's validator; stable error categories (config.parse / config.semantic / io.not_found / io.read); file:line precision from goccy AST + dotted-path lookup; tri-state exit codes (0/1/2); --json body is {error, code, hint, errors[]} parseable by jq; cross-phase Phase 67 integration deferred via SKIPing smoke step (§17.6)

Date: 2026-05-15 Status: Settled Where it lives: cmd/harbor/cmd_validate.go (the subcommand body — replaces the Phase 63 stub) + cmd/harbor/validate_test.go (golden-pinned tests per category) + cmd/harbor/main.go::exitCodeFor (the tri-state exit-code mapping) + cmd/harbor/cmd_stub_test.go (the stub-cases list updated to drop validate) + cmd/harbor/testdata/validate/*.yaml (the five fixtures) + cmd/harbor/testdata/validate/golden/*.{txt,json} (the twelve goldens — six categories × two modes; .txt rather than .out because the latter is gitignored), scripts/smoke/phase-68.sh (the smoke), cmd/harbor/testdata/golden/help.txt (regenerated — the validate Short description drops the "(Phase 68)" suffix), .github/workflows/ci.yml (the new harbor validate examples/*.yaml step in the preflight job — closes the master-plan acceptance "CI uses validate as a pre-flight check"), docs/plans/phase-68-validate.md (the plan), docs/plans/README.md (the Status row flip), docs/glossary.md (Validation error category + Validation file:line precision), README.md (Status row + CLI pointer paragraph).

Why: Phase 68 lands the real harbor validate body — RFC §8's settled CLI subcommand — replacing the Phase 63 stub. The master-plan acceptance is "Each error category produces a stable message; CI uses validate as a pre-flight check." Five design calls warrant a durable home so a future contributor doesn't relitigate them.

  1. harbor validate is a pre-boot tool, NOT a Protocol client. Every other CLI subcommand (inspect-events, inspect-runs, inspect-topology, dev) is or will be a Protocol client of a running Runtime — the brief 06 §3 posture ("All of this is implemented as protocol clients of the same runtime — no private hooks"). validate is the deliberate exception: its purpose is to detect breakage BEFORE the Runtime boots, so it MUST be invocable against a half-broken config that would prevent the Protocol from coming up at all. The implementation only imports internal/config and calls config.LoadFromBytes; it never opens a network port, never resolves an LLM provider, never touches the StateStore. This positioning is the right one for CI gates — harbor validate examples/*.yaml on every PR is cheap, deterministic, and catches the largest class of operator-facing config drift.

  2. Stable error categories are wire contracts, not implementation details. The four categories the V1 surface ships (config.parse / config.semantic / io.not_found / io.read) are pinned by goldens at cmd/harbor/testdata/validate/golden/*.{out,json} and by the smoke script's substring greps. Renaming any is a breaking change to the CLI surface that downstream scripts depend on. The taxonomy is intentionally narrow: config.parse covers anything goccy/go-yaml rejects (unknown field via yaml.Strict(), malformed YAML, type mismatch); config.semantic covers anything internal/config.Validate rejects (bad enum, missing required, out-of-range numeric); the two io.* categories cover anything os.ReadFile rejects. Skill / agent-definition categories will be added when those file surfaces materialise (they don't today — see §4 below). The categories are documented in the glossary (Validation error category) and the format is documented in the godoc of cmd_validate.go's validationFinding type.

  3. File:line precision via goccy AST + dotted-path lookup; missing-field errors fall back to line=0 with documented semantics. config.parse errors come from goccy/go-yaml's typed errors (SyntaxError, UnknownFieldError, etc.) which include a [<line>:<col>] marker at the start of Error(). We extract the line via the message-format probe in parseLineFromGoccyMessage (rather than importing goccy's internal errors package directly — keeping the dependency surface narrow). config.semantic errors arrive with a dotted field path embedded in the loader's wrapped message ("config: invalid configuration: config.<a.b.c>: <reason> (source: ...)"); we extract the path in extractFieldPath, then walk the YAML AST in lineForFieldPath to find the offending key's token line. A semantic error whose path resolves to a field the operator OMITTED (the reason IS "must not be empty") has no token to point at — we report line=0 and the operator greps the field path in the message. This is a deliberate trade-off, not a bug: threading per-field token positions down through internal/config/loader.go would require a separate phase to plumb. The fallback is documented in Validation file:line precision (glossary) and in the godoc of lineForFieldPath. Three alternatives considered and rejected: (a) pin token positions on the Phase 02 Config struct itself — high-touch, requires changes to every validateXxx helper; (b) call the loader twice (once to parse, once to validate with the AST in hand) — doubles parse cost on the happy path; (c) skip line precision for semantic errors entirely — defeats the master-plan acceptance ("Errors include file:line").

  4. Skills + agent-definition validation are deliberately out of scope today. The master-plan goal sentence is "Validate config / skills / agent definitions without booting." Today, only "config" has a file-shaped surface to validate. Skill definitions are imported via the Phase 37 internal/skills/importer from Markdown-with-frontmatter (not YAML; the importer is a runtime path consumed by harbor dev, not a standalone file format the operator hand-edits). Agent definitions live in the Agent Registry (Phase 53a) and are produced through the Registry API, not loaded from files. When either surface lands a standalone file format (a harbor-skill.yaml schema, an agent-def.yaml schema), a successor phase wires them into harbor validate <path> behind a --kind config|skill|agent-def flag (or content-sniffing). The CLI's positional path argument is forward-compatible — today it accepts a single config path; tomorrow it'll accept any of the three kinds. This deferral is explicit in the phase plan's "Non-goals" and in this decision so a future reader does NOT take it as silent drift.

  5. Tri-state exit codes (0/1/2) flow through main.go::exitCodeFor; the validation_internal_error code is the lever. Phase 63 shipped exit-1-on-any-error. Phase 68 introduces the distinction "validation found issues" (exit 1) vs "the tool itself couldn't run" (exit 2). The mapping is centralised in cmd/harbor/main.go::exitCodeFor keyed on CLIError.Code: validation_internal_error → exit 2, everything else → exit 1. A CI script can therefore distinguish "the operator's config is broken" from "the file you pointed me at doesn't exist" without parsing the body. The two new codes (CodeValidationFailed, CodeValidationInternal) are wire contracts pinned by the JSON-mode golden tests. Future subcommands that want exit-2 semantics add their codes to exitCodeFor's switch — the mapping is the single source of truth.

§13 amendment posture — validate is structural, not live. The Wave 10 §13 amendment (PR #91 / D-082) says "Fail loudly at boot when a required external dependency is missing." Phase 68 deliberately does NOT enforce that rule: a config without a reachable LLM API key, without reachable Postgres, without a reachable JWKS URL still passes harbor validate if the structural shape is right. The distinction is by design — harbor validate is the pre-flight, harbor dev (Phase 64) is the live check. Three concrete consequences:

  • Placeholder API keys pass validate. A scaffolded harbor.yaml (Phase 67 will produce one) with api_key: replace-me satisfies validate's structural check (the field is non-empty). The first harbor dev boot fails loudly when the LLM client tries to authenticate. This is the right split: a developer who types harbor scaffold && harbor validate should see green; a developer who runs harbor dev against the unfilled scaffold should see a loud failure naming the missing key.
  • No "is the URL reachable" check. The Phase 02 validator checks identity.issuer is non-empty and looks like a URL via jwks_url's string form; it does NOT issue a HEAD against the JWKS endpoint. Adding network reachability to validate would couple a pre-flight check to network state — exactly the coupling the brief 06 §3 wording rejects.
  • The --json body is validation_failed-coded, even for missing fields that harbor dev will also flag. Operator-facing scripts that want to differentiate "validate found N findings" from "dev couldn't boot" use the CLI Code field, not the file content.

This distinction is documented in the phase plan ("Goals" / "Non-goals") and in the godoc of cmd_validate.go.

Phase 67 cross-phase integration — deferred via SKIPing smoke step (§17.6). Phase 67 (harbor scaffold) is shipping in parallel with Phase 68. The master-plan acceptance for Phase 67 is "scaffolded output passes harbor validate" — a cross-phase integration that can only be exercised when both subcommands are real. The smoke step at scripts/smoke/phase-68.sh step 7 probes the scaffold subcommand; when scaffold still emits {"code":"not_implemented"} (Phase 67 not merged), the step SKIPs cleanly. When Phase 67 merges, the step will execute against the real scaffold output and assert exit 0. Per CLAUDE.md §17.6, the fix landed-or-deferred posture is documented up-front so a reviewer can see what is connected today vs what's connected by the merge. The cross-phase scaffold output schema (which subdirectory, which filename for the rendered config) is a Phase 67 implementor's call; this smoke uses find with a wildcard pattern that should match any sensible layout.

CI integration — harbor validate examples/*.yaml in the preflight job. The .github/workflows/ci.yml's preflight job already builds ./bin/harbor via make preflight. Phase 68 adds a one-line shell loop that runs ./bin/harbor validate "$f" for every examples/*.yaml, failing the build if any returns non-zero. This closes the master-plan acceptance "CI uses validate as a pre-flight check" with a one-liner change. Operators who add a new example YAML get free CI coverage of "the example fixture loads cleanly" as a result.

§13 primitive-with-consumer — discharged in-PR. The phase ships one subcommand body + four new helper functions (classifyLoaderError, lineForFieldPath, parseLineFromGoccyMessage, extractFieldPath) + two new error codes (CodeValidationFailed, CodeValidationInternal). The unit + golden tests in cmd/harbor/validate_test.go are the first consumer of every new symbol:

  • runValidate (the cobra RunE) → TestValidate_Human_PinnedByGolden + TestValidate_JSON_PinnedByGolden (six categories × two modes = twelve goldens) + TestValidate_DefaultArgPath (no-arg → harbor.yaml resolution) + TestValidate_QuietFlag_DoesNotSuppressErrors + TestValidate_QuietFlag_SuppressesSuccessLine.
  • parseLineFromGoccyMessageTestParseLineFromGoccyMessage (six shapes).
  • lineForFieldPathTestLineForFieldPath (ten paths: scalar, nested, sequence-indexed, map-indexed, missing).
  • extractFieldPathTestExtractFieldPath (five message shapes).
  • extractParseReasonTestExtractParseReason (parse + fallback).
  • exitCodeFor (in main.go) → indirectly verified by assertValidateExit in validate_test.go.

Coverage: cmd/harbor lands at ≥75% (master-plan target 75% — verified via go test -coverprofile). The race detector is the CI gate; every test runs under -race. The Phase 68 smoke (scripts/smoke/phase-68.sh) runs the package tests under -race plus the built-binary exit-code matrix and the Phase 67 cross-phase probe (SKIPing per §17.6 until scaffold merges).


D-089 — Phase 64 harbor dev v1: LLM-default flip + dev-only mock escape hatch + LLM-backed Summarizer + dev signer

Date: 2026-05-15 Status: Settled

Where it lives:

  • internal/llm/registry.go (flips DefaultDriver from "mock" to "bifrost")
  • internal/config/loader.go (defaults().LLM.Driver"bifrost")
  • internal/config/validate.go (driver-default comment updated; allowlist unchanged so test fixtures keep working)
  • internal/llm/summarizer/ (new: production LLM-backed memory.Summarizer + unit tests + D-025 N=100 concurrent reuse)
  • cmd/harbor/cmd_dev.go (real harbor dev implementation — boot stack, fail-loud, dev-token mint, graceful shutdown)
  • cmd/harbor/cmd_dev_test.go (unit tests for validateLLMProvider, parsePortFromBind, newDevSigner, SignDevToken, bootErrorToCLIError)
  • cmd/harbor/devauth.go (ephemeral ES256 keypair + auth.KeySet + JWT signer)
  • cmd/harbor/devmock.go (conditional mock blank-import + banner helper)
  • cmd/harbor/cmd_stub_test.go (dev removed from the stubs table)
  • cmd/harbor/testdata/golden/help.txt (dev short description updated)
  • examples/dev.yaml (new: canonical harbor dev config; demonstrates driver: bifrost + api_key: env.OPENROUTER_API_KEY)
  • examples/harbor.yaml (flipped from driver: mock to driver: bifrost)
  • scripts/preflight.sh (exports HARBOR_DATA_DIR; passes --config examples/dev.yaml; sets HARBOR_DEV_ALLOW_MOCK=1)
  • scripts/smoke/phase-63.sh (dev graduated out of the stub table; dev live-server assertions moved to phase-64.sh)
  • scripts/smoke/phase-64.sh (new: 6-assertion smoke including LLM-seam round-trip + fail-loud-no-config)
  • test/integration/phase64_harbor_dev_test.go + phase64_harbor_dev_helpers_test.go (cross-subsystem E2E)
  • docs/plans/phase-64-harbor-dev.md (per-phase plan)
  • docs/plans/README.md (Phase 64 row Status flipped to Shipped)
  • docs/glossary.md (4 new entries — harbor dev, HARBOR_DEV_ALLOW_MOCK, HARBOR_DEV_TOKEN, dev signer)
  • README.md (Status row Phase 64 → Shipped; Quick Start updated)

The decision (cluster, recorded so a future planner cannot relitigate):

  • llm.DefaultDriver = "bifrost". The Phase 64 pre-plan note's constraint #1 settles this. A binary built before this PR with no llm: block resolved "mock"; after this PR the same config resolves "bifrost". The mock driver is not blank-imported in cmd/harbor/main.go, so a production binary that does NOT also build cmd_dev.go would not even have the mock registered — but every binary DOES build the dev cmd (it's part of the unified harbor binary), so the mock IS in the registry; what gates it is the validateLLMProvider runtime check which rejects driver: mock unless HARBOR_DEV_ALLOW_MOCK=1 fires. A "stricter" alternative — build-tagging the mock package — was scoped out because every test importing internal/llm/mock (≈7 files across internal/llm, internal/governance, internal/planner, test/integration/wave7b_test.go, test/integration/wave8_test.go) would need the same build tag, expanding the blast radius beyond the phase's scope. A later refactor that prefers the strict path is one PR; the §13 amendment in spirit is satisfied today because the mock cannot run as the default and the only path is the explicit env-var escape hatch with a banner.
  • Escape hatch = env var, not flag. HARBOR_DEV_ALLOW_MOCK=1 (the env var) was chosen over --mock (the flag) because the preflight harness invokes ./bin/harbor dev without arguments — an env var lets the preflight gate work without changing the harness. The flag form is one diff away if a future operator surface demands it.
  • Banner emit is unconditional, single-source. When HARBOR_DEV_ALLOW_MOCK=1 fires, the banner [DEV-ONLY MOCK LLM — DO NOT USE IN PRODUCTION] is printed exactly once on stderr at boot. The emit lives in cmd/harbor/devmock.go::registerMockIfDevAllowMock. Tests can inject a bytes.Buffer as the stderr sink and assert the banner literal.
  • LLM-backed Summarizer lives in internal/llm/summarizer/ (in-package — not a new top-level directory). The Summarizer is a thin compose over the existing LLMClient + a versioned compaction prompt (PromptVersion = "v1"). When the operator picks memory.strategy: rolling_summary, the dev cmd reaches for inmem.New(cfg, deps, Options{Summarizer: summarizer.New(client)}) directly — the registry-path memory.Open does not accept a Summarizer injection (Phase 23 omission). Operators on sqlite / postgres memory drivers + rolling_summary are rejected at boot with a clear "not yet wired" error pointing to docs/plans/phase-25 — a follow-up issue.
  • EchoSummarizer stays in internal/memory/strategy/ (NOT moved to a testfixtures/ subdir). The §13 amendment's "test stubs as production defaults" gate is satisfied because: (a) the production dev cmd never imports internal/memory/strategy.EchoSummarizer; (b) the rolling_summary path runs through llm/summarizer.New instead. Tests that need EchoSummarizer keep working without changes. Moving the type to a build-tagged subpackage would force every consuming test to declare the tag — scope creep without a real safety win.
  • Dev signer is ephemeral ES256. A fresh keypair on every harbor dev boot. The matching default-identity dev token is printed to stderr as HARBOR_DEV_TOKEN=<jwt>. The dev key is in-memory only — never persisted, never exposed via any Protocol endpoint. The token's identity triple is (tenant=dev, user=dev, session=dev) plus admin + console:fleet scopes so an operator can subscribe to fleet events out of the box. ES256 (vs RS256) for fast keygen — the dev loop regenerates per boot.
  • Smoke exercises the LLM seam, not just /healthz. scripts/smoke/phase-64.sh boots harbor dev with HARBOR_DEV_ALLOW_MOCK=1, parses HARBOR_DEV_TOKEN out of the preflight server log, submits a start over /v1/control/start with the Bearer token, and asserts a non-empty task_id in the JSON response. The mock LLM is the deterministic, network-free driver underneath the safety + corrections + downgrade + retry + governance chain — same wiring path bifrost uses. A second assertion boots a fresh bin/harbor in a tmp dir with no config and asserts non-zero exit + named-field error (constraint #5 fail-loud half).
  • The Protocol mux mounts under /v1/. The dev cmd builds a fresh http.ServeMux exposing /healthz + /readyz + a catch-all /v1/ delegating to transports.NewMux(surface, bus, WithValidator(validator)). The auth middleware fail-closes any request without a Bearer token. The Phase 60 trust-based identity carrier headers (X-Harbor-*) are still read but treated as a fallback by the middleware.
  • Preflight env-var contract. scripts/preflight.sh now exports HARBOR_DATA_DIR so per-phase smoke scripts (Phase 64+) can read the dev server's log file (${HARBOR_DATA_DIR}/server.log) to parse the dev token. Older smokes ignore the env; new ones read it. This is the §17.6 fix-it-where-you-find-it pattern: the smoke gate needed a way to surface the token without a separate HTTP endpoint.
  • §17.6 cross-phase fixes landed in this PR: internal/llm/coverage_test.go::TestApplyDefaults_FillsZeros was pinned to the pre-Phase-64 default (mock); after the flip the test fixture needed Driver: "mock" explicitly. The PR includes the one-line fix.

Phase 64a — sibling phase for catalog OAuth + approval wiring (D-090). Pre-plan constraint #7 (tool catalog wires Phase 30 OAuth + Phase 31 approval gates from operator config — issue #104) was scoped out of Phase 64 because the tool catalog extension touches internal/tools/catalog.go + every tool registration site + a new operator-facing config block. The split was permitted by the master-plan pre-plan note's "may split into sibling phase" clause. Phase 64a is dispatched in the same wave (Stage 3b); its decision entry is D-090 (pre-assigned but not yet authored).

Departures from the pre-plan note:

  • Constraint #1 — kept the mock package importable from the test surface (no harbor_testfixtures build tag). The pragmatic justification is recorded above; the §13 amendment in spirit is satisfied because the mock cannot run as the default and the only operator path is the explicit env-var escape hatch with a banner.

§13 primitive-with-consumer rule discharge:

  • The Phase 60 SSE/REST mux primitive (no first production consumer until now) is the consumer of choice. cmd/harbor/cmd_dev.go::bootDevStack builds a transports.NewMux(...), mounts it under /v1/, and serves under a real http.Server.
  • The Phase 61 JWT auth validator primitive is the consumer of choice. The same dev stack builds a auth.NewValidator(devSigner.KeySet(), WithRedactor(red), WithEventBus(bus)) and passes it to transports.NewMux(..., WithValidator(validator)).
  • The memory.Summarizer interface (declared by Phase 23, no production implementation until now) is the consumer of choice. internal/llm/summarizer.New(client) is the first non-test implementation; harbor dev is the first non-test caller.

D-090 — Phase 64a tool catalog OAuth + approval wiring: per-tool tools.entries[] operator config + Builder + WrapWithApproval/WrapWithOAuth + approval-outermost composition order + §13 fail-loud on unknown policy/provider/tool + restart-required (hot reload deferred to Phase 65)

Date: 2026-05-15 Status: Settled

Where it lives:

  • internal/tools/catalog/catalog.go (new package: Builder + WrapWithApproval + WrapWithOAuth + Deps + sentinels)
  • internal/tools/catalog/catalog_test.go (unit + allowlist mirror tests)
  • internal/tools/catalog/concurrent_test.go (D-025 N=128 concurrent-reuse under -race)
  • internal/tools/tools.go (new CatalogReplacer optional interface)
  • internal/tools/catalog.go (in-memory *catalog gains Replace method)
  • internal/config/config.go (ToolsConfig.Entries + ToolEntryConfig + ToolApprovalConfig + ToolOAuthConfig)
  • internal/config/validate.go (tools.entries[] invariants + allowedApprovalPolicies + allowedOAuthBindingScopes)
  • internal/config/validate_test.go (per-entry validation cases)
  • cmd/harbor/cmd_dev.go (boot-stack wiring: constructs catalog + Coordinator, applies cfg.Tools.Entries, exposes catalog + Coordinator on devStack for future phases)
  • scripts/smoke/phase-64a.sh (7 assertions: package + integration + harbor validate accept/reject + static guards)
  • test/integration/phase64a_catalog_wiring_test.go (cross-subsystem E2E: APPROVE/REJECT round-trips + OAuth happy/auth-required + composition-order pin + concurrency stress + leak)
  • docs/plans/phase-64a-tool-catalog-wiring.md (per-phase plan)
  • docs/plans/README.md (Phase 64a row appended + pre-plan note constraint #7 marked closed)
  • README.md (Status row Phase 64a → Shipped)

The decision (cluster, recorded so a future planner cannot relitigate):

  • Operator config shape is tools.entries[]. Each entry is {name, approval?, oauth?}. The empty middleware block (approval AND oauth both nil) is rejected — an entry that wraps nothing is a configuration typo. Unknown policy / unknown binding scope / unknown OAuth provider / duplicate entry name / empty entry name all fail closed with a wrapped error naming the offending field path. Phase 68 harbor validate inherits every new rule through internal/config.Validate — no validator code in cmd/harbor.
  • Wrapper composition order: approval outermost, OAuth innermost. Rationale: approval is the gate operators expect to fire FIRST. A HITL "Approve call to <tool>?" prompt should pop BEFORE any OAuth dance starts; rejecting approval avoids consuming the user's OAuth-completion attention. OAuth's *ErrAuthRequired propagates UP through the approval wrapper unchanged (the gate's RunGuarded returns the inner tool's error verbatim post-APPROVE), so when OAuth is needed the planner still observes the typed sentinel and can pause. The reverse order (OAuth outermost) is rejected because it short-circuits the gate.
  • CatalogReplacer interface for atomic per-tool swap. The in-memory catalog adds a Replace([]ToolDescriptor) error method under its existing write lock — concurrent Resolve / List see either every old descriptor OR every new descriptor, never a partial mix. The optional interface ships on the ToolCatalog package surface; future catalog implementations either provide it OR document "no per-tool wiring at boot." Deregister is NOT added — the in-memory catalog stays write-once on Register; Replace is the seam for boot-time swap.
  • Builder is one-shot. New(entries, deps).Apply(ctx) runs once at boot; a second Apply returns ErrAlreadyApplied. Future hot reload (Phase 65) needs an UnApply path; the surface stays small for now.
  • AppliedGates out-channel for in-process resolution. The Deps.AppliedGates map is the optional surface that hands back the constructed gates keyed by tool name. The dev cmd captures this map so future phases (the dispatcher-side ApprovalDispatcher bridge) can route wire-side APPROVE/REJECT into the right gate's pending map. Phase 64a's integration test uses AppliedGates directly to drive the in-process ResolveApproval and exercise the wrapper end-to-end.
  • Wire-side APPROVE/REJECT bridge deferred to Wave 12 (tracked in issue #112). The Protocol approve / reject methods route through steering → Coordinator.Resume; the gate's pending channels are NOT yet observed by the steering apply path. Wiring them up is a dispatcher-side ApprovalDispatcher or a gate-side pause.resumed subscriber — both substantive enough to warrant its own phase. Phase 64a's integration test exercises the gate's in-process ResolveApproval to PROVE the wrapper composition works end-to-end. PR #110's wave-end E2E uses an in-test bridge (runWave11WireBridge) to substitute for production wiring; the Wave 11 §17.5 audit confirmed the gap and filed issue #112.
  • OAuth provider construction deferred to a later phase (tracked in issue #116). Phase 64a wires the BINDING side (tools.entries[].oauth.provider resolves to a provider in Deps.OAuthProviders). The tools.oauth_providers[] operator config + the OAuth-provider-per-source construction lands when the OAuth-callback Protocol method ships. For now, the dev cmd hands applyToolCatalogWiring an empty providers map; an entry that declares oauth will fail closed at boot — the §13 fail-loud is the design.
  • Identity is mandatory in every wrapper. Both WrapWithApproval and WrapWithOAuth read identity via identity.From(ctx) at the wrapper boundary; a missing triple returns approval.ErrIdentityRequired / auth.ErrIdentityRequired. Defence in depth — the gate and the provider also enforce this, but the wrapper surfaces the error early.
  • Coverage: internal/tools/catalog lands at 89.3% (target 80%). Concurrent-reuse test under -race runs N=128 invocations against a single shared wrapped descriptor across both wrapper shapes; no leaks, no cross-talk.

Departures from the pre-plan note:

  • Constraint #7 of the Phase 64 pre-plan note says the Wave 11 wave-end E2E exercises APPROVE/REJECT via the real transports/control HTTP handler. Phase 64a's integration test exercises the gate's ResolveApproval in-process — the wrapper composition + identity propagation + event emission are proven end-to-end, but the wire-side bridge from the steering apply path back into the gate's pending map is NOT in this PR. Rationale: that bridge is a substantive subsystem (a pause.resumed subscriber on the gate, OR a dispatcher-side ApprovalDispatcher owning gates) and its design touches the steering and pause/resume subsystems' contracts. Splitting it out keeps Phase 64a tractable. The Wave 11 wave-end E2E in Stage 4 is the right home for the wire-side round-trip.

§13 primitive-with-consumer rule discharge:

  • The approval.ApprovalGate primitive (Phase 31 / D-086) gains its first non-test catalog consumer — every tools.entries[].approval entry constructs a gate via approval.NewApprovalGate and wraps the descriptor.
  • The auth.OAuthProvider primitive (Phase 30 / D-083) gains its first non-test catalog consumer — every tools.entries[].oauth entry binds the descriptor to a provider via the WrapWithOAuth wrapper.
  • The Phase 50 Coordinator gains a second catalog-shaped consumer (the gate routes pauses through it).
  • The Phase 26 ToolCatalog interface gains a sibling CatalogReplacer for atomic per-tool swap at boot.

Wave-end E2E coupling:

  • Wave 11 Stage 4's test/integration/wave11_test.go will exercise APPROVE/REJECT through the real transports/control HTTP handler. That E2E closes issue #104's Protocol-wire round-trip half. Phase 64a closes the catalog-wiring half (constraint #7).

D-091 — Console deployment posture: separate harbor console subcommand serves the static build; harbor dev is headless at V1; shared chat module encapsulated for future packed dev UI

Date: 2026-05-15 Status: Settled (forward-binding — first consumer lands in the Console wave's harbor console phase)

Where it lives: CLAUDE.md §4.5 (updated in this PR) + AGENTS.md §4.5 (mirror) + docs/research/12-console-deployment-and-shared-ui.md (the supporting brief, landed in this PR) + docs/plans/README.md (Console-wave deployment + shared-library posture pre-plan note, landed in this PR). Future code lands at cmd/harbor/cmd_console.go, cmd/harbor/console_assets.go, web/console/, web/console/src/lib/chat/.

The decision (recorded so a future planner cannot relitigate):

  • Two surfaces, one stack. The full Console ships as a static SvelteKit build served by harbor console. A future single-agent developer UI (post-V1) ships embedded in harbor dev and reuses the Console's chat/playground components via a shared library. Both surfaces are Protocol clients (CLAUDE.md §4.5 #10); neither imports Runtime Go types.
  • harbor console serves the static build via embed.FS. One binary, no --static-dir flag in production (a developer-only escape hatch may be added later), no path-discovery bugs. The cost is a few-MB binary bloat — acceptable per CLAUDE.md §5 "Static binary." The subcommand stays foreground (ctrl-C exits, matches npm run dev's shape); a future --detach flag is one diff away if operator pressure demands it.
  • harbor console is multi-runtime by design. It reads a ~/.harbor/console.yaml listing runtime endpoints + auth, or --runtime <name>=<url> for ad-hoc additions, and bootstraps the browser-side multi-runtime context (Brief 11 §CC-1). The Console NEVER assumes a single runtime; the in-binary embedded build is identical to a remote-deployed build.
  • harbor dev does NOT serve the full Console. Phase 64 (shipped, D-089) is headless (Protocol + LLM seam only). Embedding the Console into harbor dev is rejected because it couples a developer's iteration loop to the operator-facing observability tool — wrong scope, wrong default. The future packed dev UI is a subset of the Console's surface (single-agent chat + traces + logs), shipped via a separate phase, opt-in via a harbor dev --ui flag (or equivalent). Post-V1.
  • Shared chat module: encapsulate first, extract on second consumer. The chat/playground/MCP-Apps-renderer module lives initially at web/console/src/lib/chat/ with two hygiene rules enforced by the introducing phase: (a) no imports of other Console internals from the chat module (only the typed Protocol client, design tokens, Skeleton primitives, and the chat-module's own internals); (b) the chat module exposes a typed ProtocolClient interface the caller injects, never imports a Console-specific singleton. When the packed dev UI phase lands, the extraction to web/shared/chat/ is mechanical (git mv). This pattern matches the §4.4 driver-seam rule: design as if multiple consumers exist; physically split when the second consumer arrives.
  • §13 primitive-with-consumer applies. The shared chat module's first consumer (the full Console's Playground or Live-Runtime page) lands in the SAME wave as the module itself; the module does not ship without its first call site. The future packed dev UI is the second consumer, not the first.
  • Auth-storage model (Brief 11 §"Open architectural questions" #6 resolved). Per-runtime JWTs stored in browser localStorage / IndexedDB, encrypted via WebCrypto with a passphrase the operator enters at first runtime-attach. Loss of passphrase invalidates stored tokens but does NOT corrupt other Console state. AES-GCM with PBKDF2-derived KEK is the obvious starting point; the harbor console phase plan owns the exact algorithm pin.
  • Cross-runtime fleet view is a Console-side aggregator for V1 (Brief 11 §"Open architectural questions" #7 resolved). The harbor console subcommand maintains N persistent Protocol connections; fleet views aggregate client-side. Gateway pattern is post-V1.

Why these specifics matter. The "embed the Console into harbor dev" trap is the natural-feeling default (one binary, one boot), but it violates CLAUDE.md §4.5 #2's decoupled-deployment principle and Brief 11 §CC-1's multi-runtime design. Pinning the posture now prevents a future Console-phase planner from re-deriving the same wrong-feeling-right answer.


D-092 — web/console/ pins Svelte 5 with runes mode; legacy Svelte 4 reactivity is forbidden

Date: 2026-05-15 Status: Settled (forward-binding — applies the first commit that creates web/console/)

Where it lives: CLAUDE.md §4.5 #1 (updated in this PR) + AGENTS.md §4.5 #1 (mirror). Future code lands at web/console/svelte.config.js (compilerOptions: { runes: true }) and web/console/package.json ("svelte": "^5.0.0" exact pin).

The decision:

  • Svelte 5 + runes mode is the only supported reactivity model in web/console/. Components use $state, $derived, $effect, $props exclusively. Legacy Svelte 4 syntax ($: reactive statements, top-level let as reactive state, export let props, store auto-subscription via $store in scripts) is rejected by svelte-check --fail-on-warnings.
  • Rationale. Svelte 5's runes model is the current major. Allowing a mixed codebase (some components in runes mode, some via <svelte:options runes={false}>) is the §13 "two parallel implementations" anti-pattern applied to reactivity. Pinning once at the start prevents parallel-agent dispatches from drifting silently: one agent uses let count = 0; $: doubled = count*2; another uses let count = $state(0); let doubled = $derived(count*2). Both compile, but the codebase fragments before anyone notices.
  • Mechanical enforcement. The first Console phase that creates web/console/ lands svelte.config.js with compilerOptions: { runes: true }; package.json pins "svelte": "^5.0.0"; npm run check in the frontend CI job uses --fail-on-warnings so any legacy-syntax usage fails the build.

D-093 — Protocol TypeScript client generated from internal/protocol/singlesource.CanonicalWireTypes; never hand-written

Date: 2026-05-15 Status: Settled — the "generate" half superseded in part by D-223 (the per-page TS split made the one-generated-file premise stale; D-223 ships a lockstep VERIFICATION gate instead and reserves the cmd/harbor-gen-protocol-ts name for the deferred full generator). The lockstep intent — no Go↔TS drift — stands and is now CI-enforced.

Where it lives: CLAUDE.md §4.5 #5 (updated in this PR) + AGENTS.md §4.5 #5 (mirror). Future code lands at cmd/harbor-gen-protocol-ts/ (the generator), web/console/src/lib/protocol.ts (generated artifact, committed), and Makefile (the protocol-ts-gen + protocol-ts-gen-check targets).

The decision:

  • Generated, not hand-written. The TS Protocol client is mirror-derived from Phase 58's CanonicalWireTypes registry (the Go-side single source). Hand-writing it creates the same mirror-drift trap §18 closes for AGENTS.mdCLAUDE.md, but worse — Go field renames silently break the TS client at runtime, not at compile time.
  • CI gate. make protocol-ts-gen-check (called in the frontend CI job) re-runs the generator and asserts git diff --exit-code is clean. Any drift fails the build. Pattern: the Go-side primitive's owner regenerates in the same PR that changes the Go type.
  • Generator scope. Emit: (a) one TS interface per CanonicalWireTypes registered struct; (b) one method-call stub per methods.go constant; (c) one constant per errors.go error code. Do NOT emit: rendering logic, runtime helpers, or anything Console-specific. The generator is pure shape-translation; UX wrappers live in web/console/src/lib/protocol-helpers.ts (hand-written, calls into the generated types).
  • §13 primitive-with-consumer. The generator (primitive) and its first consumer (protocol.ts imported by the first Console SvelteKit page) ship in the same wave.
  • Why not deferred. "Hand-write now, switch to generated later" is the same trap as "stub now, real impl later" the §13 amendment closes: by the time the hand-written client has 80 method calls, the cost of switching is N×rewrites + drift backlog. Generating from t=0 amortises the tooling cost across the project's lifetime.
  • Hand-edits forbidden. The generated file's header carries // CODE GENERATED BY cmd/harbor-gen-protocol-ts. DO NOT EDIT.. Any commit that modifies protocol.ts without a corresponding regeneration fails the CI check.

D-094 — harbortest/devstack helper extracted for integration test stack assembly

Date: 2026-05-16 Status: Settled

Where it lives:

  • harbortest/devstack/devstack.go (the new helper: Assemble + AssembleOpts + DevStack)
  • harbortest/devstack/devstack_test.go (unit tests pinning the four Skip* shapes + the catalog-wiring + identity-override paths)
  • harbortest/devstack/export_test.go (in-package _test.go exposing TryAssemble so error paths are unit-testable without faking *testing.T)
  • test/integration/wave11_test.go (buildWave11Stack rewritten to call devstack.Assemble)
  • test/integration/phase64_harbor_dev_helpers_test.go (buildPhase64TestStack rewritten to call devstack.Assemble with an LLMConfigSnapshot override)
  • test/integration/phase64a_catalog_wiring_test.go (buildPhase64aEnv rewritten to call devstack.Assemble with SkipAuth + SkipTransports + SkipSteering)
  • test/integration/phase31_approval_gates_test.go (buildPhase31Env rewritten to call devstack.Assemble with SkipAuth + SkipTransports + SkipCatalog)

Decision. Per-test dev-stack assembly is now centralised in harbortest/devstack.Assemble. The four integration test files that previously duplicated ~100–200 LOC each (wave11_test.go, phase64_harbor_dev_test.go, phase64a_catalog_wiring_test.go, phase31_approval_gates_test.go) now call Assemble(t, cfg, AssembleOpts{...}) and consume a typed DevStack struct.

Why. The Wave 11 §17.5 audit (issue #115) pinned this as a drift risk: when the production boot order in cmd/harbor/cmd_dev.go::bootDevStack changes — and it will, as the #112 / #114 follow-ups land — every test stack would silently drift away from production. The helper makes "tests track production" mechanically enforceable: a change to bootDevStack that breaks Assemble's contract surfaces immediately as a test build error rather than a wave-end audit finding two PRs later.

How to apply. New integration tests construct stacks via harbortest/devstack.Assemble, never inline. When you change cmd/harbor/cmd_dev.go::bootDevStack, update Assemble in the same PR — the helper's top-of-file comment names bootDevStack as the source of truth. The §17.6 "fix what the integration test finds — no matter where the bug lives" rule applies: a production boot-order change that does not also update Assemble is a deferred fix, not a clean PR.

Acceptance:

  • harbortest/devstack package exists with Assemble + AssembleOpts + DevStack types.
  • All four integration test files use the helper.
  • go test -race ./test/integration/... ./harbortest/... passes.
  • The helper's coverage is 85.2% (well above the 80% target). The four Skip* shapes each have a dedicated test asserting the expected non-nil / nil field set; the error-returning core (tryAssemble) is exposed via an in-package export_test.go so cfg-validation + duplicate-registration + Builder-failure paths are unit-tested without faking *testing.T.

Departures from the pre-plan note:

  • The issue's API draft proposed an AssemblePhase64Style factory; the implementation lands a single Assemble with SkipAuth / SkipTransports / SkipCatalog / SkipSteering knobs because all four target tests want different layer subsets and a single helper with skip flags is shorter than four named factories. The naming convention "phase64-style" is informal; the production source of truth is bootDevStack regardless.
  • phase31 and phase64a originally constructed events.EventBus directly via eventsInmem.New(EventsConfig{...}, redactor) with tight per-test buffer knobs. The migration synthesises a full *config.Config per test and reuses the matching cfg.Events knobs verbatim — behaviour is unchanged; the indirection is acceptable per CLAUDE.md §4.3 ("a phase plan that deviated permanently … reflects the deviation in the master plan's detail block").

§13 primitive-with-consumer. The helper (primitive) ships with four consumers in the same PR. No deferred consumer.


D-095 — tools.oauth_providers[] operator config + OAuth provider driver registry (closes #116, closes D-090's deferral)

Date: 2026-05-16 Status: Settled (shipping with this PR)

Where it lives: internal/config/config.go (ToolsConfig.OAuthProviders + ToolOAuthProviderConfig + ToolsConfig.OAuthTokenKEKEnv); internal/config/validate.go (allowedOAuthDrivers + the per-provider and cross-validation rules); internal/tools/auth/registry.go (driver registry); internal/tools/auth/drivers/oauth2/oauth2.go (V1 default driver); cmd/harbor/cmd_dev.go::applyToolCatalogWiring (the boundary that walks the config and populates the catalog builder's Deps.OAuthProviders); cmd/harbor/main.go (blank-import); examples/dev.yaml (operator-facing block).

Decision. OAuth provider construction now flows from the operator config: tools.oauth_providers[] declares named providers; each entry resolves to a driver via the §4.4 registry pattern (internal/tools/auth/drivers/<name>/). The V1 default driver is oauth2 — generic OAuth2/PKCE Authorization Code flow. cmd/harbor/cmd_dev.go::applyToolCatalogWiring walks the config and populates the catalog Builder's Deps.OAuthProviders map.

Why. D-090 deferred the provider-construction surface ("a tools.oauth_providers block lands in a later phase"). That gap meant any operator declaring tools.entries[].oauth got a fail-loud at boot — correct but useless: there was no way to actually configure a provider. Issue #116 from the Wave 11 §17.5 audit pinned the gap.

How to apply.

  • New OAuth flow types add a driver under internal/tools/auth/drivers/<name>/ following the §4.4 seam pattern: self-register via init() → auth.MustRegister(name, New) and add the name to internal/config/validate.go's allowedOAuthDrivers allowlist in the same PR.
  • Operators declare providers in harbor.yaml under tools.oauth_providers[]; each entry references its driver by name and uses env-var indirection for client_id_env / client_secret_env (§7 rule 2 — never hardcoded).
  • The KEK for AES-256-GCM token encryption at rest comes from one operator env var named in tools.oauth_token_kek_env. The dev stack constructs ONE shared auth.TokenStore + auth.Sealer and passes them into every factory call via auth.FactoryDeps.
  • Identity propagates through every provider call per §6; the registry never accepts a request without a triple.
  • Credentials enter via env-var indirection (client_id_env, client_secret_env); never hardcoded, never logged (§7).

Acceptance.

  • internal/config schema declares OAuthProviders[] + OAuthTokenKEKEnv with a validator that rejects unknown drivers, duplicate names, empty env-var fields, the missing-KEK-env-when-providers-set case, and unresolved entries[].oauth.provider references.
  • internal/tools/auth adds the Factory type + Register / MustRegister / Resolve registry + ProviderConfig boundary type + FactoryDeps.
  • internal/tools/auth/drivers/oauth2/ ships the V1 default driver with a fail-loud constructor (empty client_id / client_secret / endpoints / redirect_url all return typed errors) and a D-025 concurrent-reuse test (N≥128 concurrent invocations under -race).
  • cmd/harbor/cmd_dev.go::applyToolCatalogWiring populates the map from config; the function's godoc no longer carries the "Phase 64a does NOT yet construct OAuth providers" deferral note.
  • cmd/harbor/main.go blank-imports _ "github.com/hurtener/Harbor/internal/tools/auth/drivers/oauth2".
  • examples/dev.yaml documents the block with one realistic GitHub entry.
  • scripts/smoke/phase-64a.sh asserts both the unknown-provider error path (harbor validate rejects with the unknown-provider name in the message) and the missing-KEK-env error path.
  • D-090's "Deferred" note about OAuth provider construction is now closed by D-095 (this entry); D-090 itself is left untouched as a historical record.

§13 primitive-with-consumer. The primitive (registry + iface + driver) and its first consumer (cmd/harbor/cmd_dev.go::applyToolCatalogWiring populating the catalog builder's Deps.OAuthProviders) ship in the same PR. The smoke surface exercises both the validator (pre-boot) and the boot path (harbor dev constructs the providers from config or fails loud).

Source-binding scope (V1 simplification). Phase 30's *auth.Provider.Token(ctx, source) API keys by tools.ToolSourceID. The V1 oauth2 driver constructs ONE *Provider per tools.oauth_providers[] entry with a single OAuthConfig whose Source = ToolSourceID(cfg.Name). The catalog wrapper (internal/tools/catalog.WrapWithOAuth) passes the underlying tool's source ID, which may not match the provider name; the driver transparently retargets every Token / Revoke / InitiateFlow call onto the operator-configured source. Future per-vendor drivers (e.g. google-workspace, github-app) may implement more sophisticated multi-source mappings; the V1 default keeps the operator's mental model simple: one provider declaration → one OAuth attachment.


D-096 — PauseResumedPayload.Decision typed marker (closes #113)

Date: 2026-05-16 Status: Settled (binding — landed in the PR that closes issue #113)

Where it lives: internal/runtime/pauseresume/decision.go (the new typed enum), internal/runtime/pauseresume/events.go (the Decision Decision field on PauseResumedPayload), internal/runtime/pauseresume/coordinator.go (the extended Resume signature), RFC-001-Harbor.md §3.3 (the canonical-event-shape note).

Decision. internal/runtime/pauseresume.PauseResumedPayload gains a typed Decision field (values: approve, reject, resume, timeout). Coordinator.Resume's signature is extended to take the typed Decision parameter; all in-tree producers (steering.applier.advancePause for RESUME/APPROVE/REJECT controls; approval.ApprovalGate.ResolveApproval for HITL gates; auth.Provider.HandleCallback for OAuth flow completion) populate it. An unknown / empty Decision is rejected loud with the new ErrInvalidDecision sentinel — there is no untyped default. The wave-end E2E (test/integration/wave11_test.go) and any other consumer that previously parsed free-form Reason strings now switches on the typed field.

Why. PR #110's wave-end E2E worked around the gap by subscribing to tool.approved / tool.rejected and inferring the resolution kind from the per-tool event type. Wave 11 §17.5 audit (issue #113) pinned that as a §13 violation — overloading the typed event shape against a non-existent typed enum is a "parallel implementation of the same conceptual feature" smell read sideways: the typed enum that should exist gets simulated by tag dispatch on a sibling event. The runtime-level pause.resumed event is the canonical place to learn how a pause terminated; the per-tool events are routing surfaces, not classification surfaces.

Why a new enum (not reusing approval.ApprovalDecision). The approval package already exports ApprovalDecision with values approve / reject / pending. That enum is approval-specific by design — pending is the gate's implicit parked state. The pause/resume Coordinator needs a broader enum that covers tool-side OAuth completion (resume) and deadline-driven resumes (timeout) — neither belongs in an approval-decision vocabulary. Adding resume / timeout to ApprovalDecision would pollute approval-gate code with values that are nonsensical there; defining a parallel "ApprovalDecision vs PauseResumeDecision" split with overlapping approve/reject values would be the §13 two-parallel-implementations smell. The right factoring keeps the gate-internal enum narrow and the coordinator-edge enum broad; the approval gate maps its internal Approve/Reject onto pauseresume.DecisionApprove/DecisionReject at the Coordinator seam.

How to apply. New producers populating a pause.resumed event MUST set Decision. Wire consumers (the Console, third-party clients, integration tests) consume the typed value; do not regress to Reason-string parsing. The Reason field stays for human-readable context; Decision is the load-bearing type. Test stubs that implement pauseresume.Coordinator (e.g. steering.stubCoordinator) update their Resume method signature in the same PR — no parallel "with-Decision / without-Decision" overload.

Acceptance.

  • PauseResumedPayload carries Decision Decision.
  • Coordinator.Resume(ctx, token, decision Decision, payload map[string]any) is the new signature; an unknown/empty Decision is rejected with ErrInvalidDecision.
  • All in-tree producers populate the field: steering.applier.advancePause maps ControlResume/ControlApprove/ControlRejectDecisionResume/DecisionApprove/DecisionReject; approval.ApprovalGate.ResolveApproval maps ApprovalDecisionpauseresume.Decision; auth.Provider.HandleCallback populates DecisionResume.
  • test/integration/wave11_test.go subscribes to pause.resumed and switches on Decision, asserting the typed marker arrives alongside the per-tool events. The per-tool tool.approved / tool.rejected subscriptions are preserved because they carry the Tool name (the per-tool channel is orthogonal to the decision-discrimination channel); only the decision-discrimination workaround the audit flagged is removed.
  • pauseresume.IsValidDecision covers the four canonical values; a new unit test pins each emits the right typed marker on the pause.resumed event.
  • RFC §3.3 documents the typed Decision field as part of the canonical resume event shape.
  • All tests -race green.

Wave-11 cross-fix bundled. The wave-end test stack previously constructed pauseresume.New() with no WithBus(bus) option, so pause.requested / pause.resumed never landed on the bus — the same gap that motivated the audit's tool.approved / tool.rejected workaround. After PR #120 (D-094) extracted harbortest/devstack.Assemble, the omission moved from buildWave11Stack into the helper; this PR wired the bus into the Coordinator at the helper boundary (search for pauseresume.New(pauseresume.WithBus(bus)) in harbortest/devstack/devstack.go) so every devstack-built test inherits the fix (CLAUDE.md §17.6: integration tests fix what they find, regardless of which phase originally shipped the gap). The matching production wiring landed later as F1 of the Wave 11.5 §17.5 closeout audit — the test-only fix here perpetuated the test↔production divergence until F1 closed it in cmd/harbor/cmd_dev.go::bootDevStack.


D-097 — steering.RunLoop wired into harbor dev + bridges APPROVE/REJECT into ApprovalGate (closes #112 + #114)

Date: 2026-05-16 Status: Settled (shipping with this PR)

Where it lives: internal/runtime/steering/runloop.go (the new WithApprovalGates option); internal/runtime/steering/apply.go (applier.gates + routeThroughGate + wireGateTokenFromPayload + the option-A bridge wiring in advancePause); internal/runtime/steering/bridge_test.go (the in-package bridge tests against real gate + real Coordinator); cmd/harbor/cmd_dev.go::bootDevStack (originally constructed the planner via react.New(llmClient) as a hardcoded V1 default — closed by D-103, which moves the construction onto the internal/planner driver registry; the cmd_dev path now reads planner.Resolve(ctx, cfg.Planner, planner.FactoryDeps{LLM: llmClient})); cmd/harbor/cmd_dev_runloop.go (the new perTaskRunLoopDriver that subscribes to task.spawned and runs the RunLoop per spawned foreground task); cmd/harbor/cmd_dev_runloop_test.go (driver unit tests against real bus + real RunLoop); harbortest/devstack/devstack.go (mirrors the production wiring per D-094 — adds SkipRunLoop + RunLoop + RunLoopDriver fields, constructs both via newDevStackRunLoopDriver; the planner construction also moved to the registry per D-103 with a PlannerOverride test escape hatch); test/integration/wave11_test.go (the in-test runWave11WireBridge ~100 LOC bridge is GONE; replaced with startWave11RunLoopForRun — a small helper that constructs a production RunLoop with a scripted pausing planner so the production bridge fires); RFC-001-Harbor.md §3 (one-paragraph note on the bridge).

Decision. cmd/harbor/cmd_dev.go::bootDevStack now constructs a steering.RunLoop per spawned task: a per-stack RunLoop (shared, concurrent-safe per D-025) is wired with WithApprovalGates(appliedGates), and a new perTaskRunLoopDriver subscribes to task.spawned events bus-wide and launches a goroutine per spawned foreground task that calls runLoop.Run(ctx, spec) with the task's identity quadruple. The RunLoop's drain path observes ControlApprove / ControlReject from the steering inbox; for each event carrying a token key in its wire payload, the new routeThroughGate helper looks up the matching *approval.ApprovalGate in the gates map and calls gate.ResolveApproval(ctx, token, decision, reason). The map is sourced from applyToolCatalogWiring's AppliedGates out-channel (D-090).

Why. Two gaps closed at once:

  • #114: steering.RunLoop had zero production consumers — harbor dev advertised itself as a runtime but the planner-step loop was unwired. A start request reached tasks.TaskRegistry.Spawn and the task sat there forever (the wave-11 §17.5 audit finding A3, applied to the broader composition: §13's "test stubs as production defaults on operator-facing seams" amendment, read sideways).
  • #112: a Console operator approving a paused tool got 200 OK from the wire and a steering.applied event, but the gate's pending map never saw the resume — the wrapped tool's Invoke stayed parked forever. PR #110's wave-end E2E worked around this with a ~100-LOC in-test bridge (runWave11WireBridge).

The audit recommended these ship together because A1's bridge is naturally the RunLoop's drain; splitting them creates either a primitive-without-consumer window (Phase 114 ships with no gate resolution) or a parallel ApprovalDispatcher service that duplicates the inbox-drain.

Bridge shape (decided, not re-litigated). Issue #112 named three valid shapes:

  1. RunLoop owns the bridge (DECIDED — this entry's implementation). The drain path inside RunLoop.Runapplier.applyEventapplier.advancePauseapplier.routeThroughGate is the wiring. internal/runtime/steering imports internal/tools/approval for the gate type. Both are runtime mechanism; the boundary is acceptable.
  2. Gate subscribes to pause.resumed events (rejected — adds bus dependency to every gate; D-096's typed Decision marker makes shape 2 possible later if needed, but YAGNI).
  3. Separate ApprovalDispatcher service (rejected — duplicates RunLoop's inbox drain).

How to apply.

  • New steering-driven side effects (HITL approval, future OAuth callback completion, future A2A INPUT_REQUIRED) follow the same pattern: extend the apply path with a typed look-up, call the relevant resolver, identity flows via ctx.
  • The AppliedGates map handed into RunLoop is the SAME map the catalog Builder populates (no copy, no shadow). Tests that exercise the bridge can inject their own gates via the same surface.
  • harbortest/devstack.Assemble constructs the RunLoop by default; SkipRunLoop: true opts out for tests that don't need it.

Double-resume guard (option A — gate-owned resume). gate.ResolveApproval calls Coordinator.Resume for the gate's pause token (wireToken). The RunLoop's own outstanding pause (token) is a DIFFERENT pause — the planner-side RequestPause token. When the bridge routes through a gate, the direct Coordinator.Resume call below would EITHER resume the RunLoop's own pause (different token — safe; two separate Resumes) OR — when wireToken == token (the planner itself RequestPaused AND the planner happened to wrap a tool call in an approval gate, sharing the token) — would trigger pauseresume.ErrAlreadyResumed. The bridge guards against the second case with an explicit wireToken == token early-return after routing. For the common shape (planner runs idle, gate's pause is independent), both pauses resume cleanly in sequence: gate.ResolveApproval first, then the direct path on the RunLoop's token. The wire-side token payload key is the canonical channel — its absence means the APPROVE/REJECT targets the RunLoop's own pause (OAuth, A2A AUTH_REQUIRED), preserving the pre-D-097 behaviour.

Identity flow. The bridge runs in the steering apply path's identity ctx (the RunLoop hands runCtx carrying the run's quadruple). gate.ResolveApproval enforces protocolauth.HasScope(ctx, ScopeAdmin) || HasScope(ctx, ScopeConsoleFleet) as defence-in-depth. The Phase 54 Protocol edge already vetted the caller's scope at inbox-Enqueue time via CheckScope; routeThroughGate re-stamps the ctx with WithScopes([ScopeAdmin, ScopeConsoleFleet]) so the gate's check passes. The elevation is scoped to this single ResolveApproval call (a derived ctx, never propagated back to the caller). The Coordinator's identity-tuple scope check is unchanged — the run's triple already matches the gate's pause-identity tuple by construction.

§13 primitive-with-consumer. Stage B's primitive (the bridge in routeThroughGate + WithApprovalGates) ships with its consumer (production harbor dev wires it in bootDevStack; the wave-end E2E exercises it WITHOUT the in-test bridge). The new cmd_dev_runloop_test.go pins the driver-side wiring; the new bridge_test.go pins the apply-side routing against the REAL approval gate + REAL Coordinator. No deferred consumer.

Bundled cross-fix — RunLoop lifecycle gotcha. The driver subscribes to task.spawned events with events.Filter{Admin: true} (per CLAUDE.md §6 rule 5's runtime-internal fan-in carve-out — the driver listens across every (tenant, user, session) triple). Per-task RunLoop goroutines inherit the driver's subCtx; driver.Close cancels that ctx and waits for the WaitGroup to drain. Close is idempotent. The task FSM bridge — translating a RunLoop's Finish decision into Mark{Complete,Failed} on the task — was intentionally NOT in this PR; the deferral was filed as issue #123 with the intent to land in Wave 12. Closed by D-098 (the FSM bridge now lives in the same per-task goroutine that calls runLoop.Run; the dispatch-executor framing in this note was abandoned for the simpler driver-direct shape — see D-098's "Why shape 1 over shape 2" section).

Acceptance:

  • steering.RunLoop accepts Gates map[string]*approval.ApprovalGate at construction via WithApprovalGates.
  • cmd/harbor/cmd_dev.go::bootDevStack constructs and drives the RunLoop per spawned task, with the AppliedGates wired.
  • harbortest/devstack.Assemble mirrors the production wiring; SkipRunLoop knob added.
  • test/integration/wave11_test.go drops runWave11WireBridge entirely; the production path is exercised end-to-end.
  • New bridge_test.go asserts the wire-side APPROVE/REJECT routes through the gate against the REAL Coordinator with NO in-test substitution.
  • New cmd_dev_runloop_test.go asserts the driver picks up task.spawned, drives the RunLoop, skips background tasks, drains cleanly on Close.
  • D-090 §"Deferred" recorded the wire-side bridge gap; this entry closes it (D-090 is left untouched as historical record).

D-098 — perTaskRunLoopDriver translates RunLoop exits into TaskRegistry.Mark{Complete,Failed} (closes #123)

Date: 2026-05-17 Status: Settled (shipping with this PR)

Where it lives: cmd/harbor/cmd_dev_runloop.go::perTaskRunLoopDriver.runOne (the per-task goroutine that now also calls MarkRunning before runLoop.Run and MarkComplete / MarkFailed based on the Run exit shape); cmd/harbor/cmd_dev.go::bootDevStack (the perTaskRunLoopDriverOpts.tasks field is wired with the boot's taskReg); cmd/harbor/cmd_dev_runloop_test.go (new tests TestPerTaskRunLoopDriver_FSMBridge_MarksComplete / _MarksFailed_OnPlannerError / _MarksFailed_OnCtxCancel pin the three terminal-state paths); harbortest/devstack/devstack.go (DevStackRunLoopDriver mirrors the production bridge per D-094's helper-tracks-production rule — gains a tasks field and a runOne that calls the same Mark* sequence); test/integration/phase64_task_fsm_bridge_test.go (end-to-end coverage through the devstack helper using the real ReAct planner + mock LLM, asserting the FSM reaches a terminal state).

Decision. cmd/harbor/cmd_dev_runloop.go::perTaskRunLoopDriver now owns the task FSM bridge: the per-task goroutine that calls runLoop.Run ALSO calls tasks.MarkRunning before Run (advancing Pending → Running so the registry's FSM table — which forbids Pending → Complete — accepts the eventual terminal Mark) and translates Run's exit shape into the matching Mark* call after Run returns. Three exit shapes map to three Mark* calls:

  • Run returned nil with Finish.Reason == FinishGoalMarkComplete(ctx, taskID, TaskResult{}). The result Value is empty at V1; persisting Finish.Payload would require the LLM-edge heavy-content redaction + ArtifactRef shaping (D-026) which is a separate post-V1 concern.
  • Run returned nil with Finish.Reason ∈ {NoPath, Cancelled, DeadlineExceeded, ConstraintsConflict}MarkFailed with the FinishReason as the error code and a human-readable message naming the reason. The registry FSM has no "no-goal-but-not-failed" status; Failed is the closest terminal match for "the run terminated but did not satisfy the goal."
  • Run returned a non-nil errorMarkFailed with code runloop_error (or cancelled for context.Canceled — see "Cancellation handling" below) and the error string as the message.

Why. Closes the deliberate carve-out D-097 documented and issue #123 filed. Before this PR a foreground task spawned under harbor dev reached StatusPending, the perTaskRunLoopDriver picked it up and ran the planner to completion, and the task stayed at StatusPending forever — the FSM diverged from reality. A Console operator querying tasks.List(StatusPending) would see tasks accumulate even after their planners had finished cleanly. This is the same shape §13's "test stubs as production defaults" amendment closes one layer up: an operator-facing seam (the task FSM) that ships looking complete but is silently broken on the happy path.

Why shape 1 over shape 2 (driver-direct over bus-driven FSM bridge). Issue #123 named two valid shapes:

  1. perTaskRunLoopDriver calls TaskRegistry.Mark directly (CHOSEN). Driver imports internal/tasks and dispatches on the RunLoop exit. Pro: lives next to existing driver code; the driver already owns the per-task lifecycle (it constructed the goroutine that calls runLoop.Run); extending that goroutine to translate the return value into Mark* is one-step coupling at the same layer. Pro: the driver already imported internal/tasks (it reads tasks.TaskSpawnedPayload from the bus event); adding tasks.TaskRegistry.Mark* is a small additional surface, not a new layer.
  2. Bus-driven FSM bridge (rejected). Would require RunLoop to emit a typed exit event (steering.runloop.finished or equivalent) AND a separate subscriber AND that subscriber owns the task-keyed mapping the driver already has. More moving parts for marginal separation — and the only "win" would be decoupling the driver from internal/tasks, which it is already coupled to.

If shape 1 turns out wrong for a reason a future PR can articulate, the driver-direct path can be lifted into a bus-driven subscriber by introducing the typed exit event then — but until that reason materialises, shape 1 is the simplest correct shape.

How to apply. Future task-lifecycle subscribers (the Console, third-party Protocol clients, audit consumers) consume the canonical task.completed / task.failed events the TaskRegistry already emits when MarkComplete / MarkFailed fires — no new event type needed. The driver's bridge is internal mechanism; the canonical observability surface stays unchanged.

Cancellation handling. context.Canceled is the third terminal shape runLoop.Run can return (driver shutdown OR an explicit cancel of the run's ctx). The registry FSM has no "auto-cancelled by ctx" path — TaskRegistry.Cancel(ctx, id, reason) is the external-caller surface and requires a reason. We map ctx-cancelled runs to MarkFailed with code cancelled and the ctx error string as the message. Rationale: the run did not reach a goal; Failed is the correct terminal state. An operator who wants the deliberate-cancel semantics (which transition to StatusCancelled, not StatusFailed) calls TaskRegistry.Cancel directly — that path routes through the explicit Cancel API and uses StatusCancelled. The driver's ctx-cancel is a forced-shutdown signal (the binary is going down), not a deliberate cancel decision, so collapsing it onto MarkFailed{code=cancelled} is the truthful FSM state.

Mark* failures post-Run are logged, not escalated. If MarkComplete / MarkFailed errors after Run returns (e.g. the task was concurrently transitioned to StatusCancelled via the external Cancel path, or the registry is unhealthy), the driver logs Warn and returns. The per-task goroutine continues; the next spawned task is unaffected. Tearing down the driver on a per-task Mark* failure would be a denial of service: one race with an external Cancel would stop the whole runtime. This is the standard "fail loudly per-call, do not crash the shared artifact" shape D-025 names.

Identity propagation. The driver constructs taskCtx := identity.With(d.subCtx, q.Identity) once per run and passes it to MarkRunning / MarkComplete / MarkFailed. The TaskRegistry rejects calls missing the triple per §6 rule 9 (identity is mandatory); the explicit identity.With here is the same call site §6 mandates for every identity-scoped storage method. The driver does NOT call identity.MustFromd.subCtx is the long-lived subscription ctx that never carries identity, so we attach it explicitly per run.

§13 primitive-with-consumer. This PR closes a gap, not introduces a primitive. The primitive (TaskRegistry.Mark{Running,Complete,Failed}) already had consumers (the per-driver test suites under internal/tasks/drivers/inprocess/; the conformance suite under internal/tasks/conformancetest/); the new consumer added here is the production driver — the one that was previously missing and whose absence kept the FSM stuck at StatusPending. The "no primitive without its consumer in the same wave" rule is not relevant for this PR (the primitive is from Phase 20).

Bundled invariant — concurrent-reuse contract holds. The driver's per-task goroutines now block on runLoop.Run AND a post-Run Mark* call; both honour d.subCtx. Close cancels subCtx, waits for subLoopWG, then waits for runsWG. The Mark* call uses the SAME ctx-derived taskCtx so a cancelled subCtx cancels the registry call too — the WaitGroup-drain pattern from D-097's lifecycle gotcha still holds. The new TestPerTaskRunLoopDriver_Close_DrainsRunningRuns and _ConcurrentReuse_NoRaceUnderLoad tests pin this; both run under -race.

Bundled invariant — D-094 helper-tracks-production rule holds. harbortest/devstack/devstack.go was updated in the SAME PR as cmd/harbor/cmd_dev.go. DevStackRunLoopDriver now carries a tasks field and a runOne method that mirrors the production bridge. Skipping the helper update would have left the wave-end E2E (and every future integration test) silently divergent from production — the F1 failure mode §17.6 explicitly calls out.

Acceptance:

  • perTaskRunLoopDriverOpts.tasks is mandatory; newPerTaskRunLoopDriver fails loud on nil.
  • runOne calls MarkRunning before runLoop.Run and MarkComplete / MarkFailed based on the Run exit shape (FinishGoal → Complete; other FinishReason → Failed with the reason as the code; non-nil error → Failed with runloop_error or cancelled).
  • bootDevStack wires taskReg into the driver opts.
  • harbortest/devstack.Assemble mirrors the production wiring — DevStackRunLoopDriver gains a tasks field, the constructor fails loud on nil, and the per-task goroutine runs the same bridge.
  • New unit tests pin the three terminal-state paths: _MarksComplete, _MarksFailed_OnPlannerError, _MarksFailed_OnCtxCancel. Each asserts on reg.Get(taskID).Status reaching the expected terminal.
  • New integration test TestTaskFSMBridge_ProductionPath_ReachesTerminalState exercises the bridge end-to-end through devstack.Assemble with a real ReAct planner + mock LLM, asserting the FSM reaches a terminal state within a bounded timeout.
  • Issue #123 closed; D-097's "deliberate carve-out" note updated to point at D-098.
  • All tests -race green; make preflight PASS; make drift-audit clean; make check-mirror clean; npx markdownlint-cli2 docs/decisions.md 0 errors.

D-099 — harbor dev hot-reload supervisor (fsnotify-driven graceful-drain restart, Phase 65)

Date: 2026-05-17 Status: Settled (shipping with this PR)

Where it lives: cmd/harbor/cmd_dev_hot_reload.go (the supervisor + watcher + bus emission); cmd/harbor/cmd_dev.go::runDev (hands off to the supervisor when hot-reload is enabled; adds --no-hot-reload flag); internal/config/config.go (CLIConfig.DevHotReload + DevHotReloadConfig + policy constants); internal/config/loader.go::defaults (CLI.DevHotReload defaults); internal/config/validate.go::validateCLI (the new validator); cmd/harbor/cmd_dev_hot_reload_test.go (unit + in-package integration tests); harbortest/devstack/devstack.go (godoc carve-out note); scripts/smoke/phase-65.sh; docs/plans/phase-65-harbor-dev-hot-reload.md; go.mod (github.com/fsnotify/fsnotify v1.10.1).

Decision. harbor dev boots an fsnotify-driven hot-reload supervisor that wraps bootDevStack and serves the active devStack until a watched file changes OR ctx cancels. On a file change the supervisor: (1) emits dev.hot_reload.triggered on the active bus; (2) drains the active stack per cli.dev_hot_reload.policy (drain / cancel); (3) calls bootDevStack again with the original boot opts; (4) starts a fresh serve goroutine against the new stack; (5) emits dev.hot_reload.completed on the new bus. The supervisor owns the serve loop AND the rebuild loop in one goroutine; a debounced (250ms) channel collapses fsnotify event bursts so an editor save fires one rebuild, not N.

Hot-reload shape: in-process devStack rebuild (NOT binary re-exec). The §4.3 "smaller approach that still satisfies acceptance" carve-out applies. Acceptance criterion 1 is "new code picked up"; at dev-time granularity (operator edits a file, restart picks up the change), the in-process rebuild satisfies it for every config / scaffold change. Binary re-exec was considered and rejected:

  • It requires an out-of-process supervisor (the binary cannot re-exec itself without losing the current http.Server's connections).
  • It costs a Go build per cycle (~5s on a warm machine) — the developer feedback loop is the load-bearing UX here.
  • An operator iterating on a YAML config file does NOT need a binary rebuild; an operator iterating on Go source rebuilds + re-launches the binary manually (the same cycle they'd run today without hot-reload).

A binary-rebuild path can be layered on as a future opt-in (policy: rebuild or similar) without changing the supervisor's shape — the rebuild step becomes "run go build, then re-exec" instead of "re-call bootDevStack". V1 ships the in-process shape only.

Why. Phase 64 (harbor dev v1) closed the embedded-runtime + Protocol boot path; the §13 amendment for the LLM seam landed there. The remaining dev-loop UX item from RFC §8 is hot-reload: "watches the project directory for changes, hot-reloads on Go-source changes (graceful-stop in-flight runs first; configurable)." Without it, an operator iterating on a scaffolded agent kills + restarts harbor dev per change — slow, kills SSE subscribers, drops the dev token. The supervisor closes this surface.

Why fsnotify (RFC §10 confirmation). fsnotify is the de-facto pure-Go FS-watching library (no CGo, no platform-specific extensions in the consuming code; the library handles inotify on Linux, FSEvents on macOS, ReadDirectoryChangesW on Windows). It is the implicit RFC §10 candidate brief 06 §7 item 10 already named ("fsnotify watcher"). Added to go.mod as a direct dependency (was previously indirect via cobra/viper but cobra alone does not pull it).

How to apply.

  • Operators declare hot-reload behaviour under cli.dev_hot_reload in harbor.yaml: enabled (default true), policy (drain / cancel / disabled; default drain), drain_timeout (default 5s), watch_roots (default [".harbor/agents"]).
  • The CLI flag --no-hot-reload is the operator-facing escape hatch — overrides the config's enabled to false for that boot.
  • Wire consumers (the Console, integration tests, third-party Protocol clients) subscribe to dev.hot_reload.triggered / dev.hot_reload.completed on the canonical bus to observe the restart cycle. The events carry the dev identity triple (tenant=dev, user=dev, session=dev) so subscribers consume them via the standard triple-scoped filter or via the §6 rule 5 admin path.
  • A reboot failure returns up to runDev; the operator sees a CLIError with code boot_internal_error. Per §13, the supervisor does NOT silently degrade to "stay on the old stack" on a reboot failure — the old stack is already drained at that point and the operator's intent (pick up the new state) is unsatisfiable.

Bus events — canonical shapes. Both payloads are SafePayload by construction (every field is internal bookkeeping; no secrets).

text
EventTypeDevHotReloadTriggered = "dev.hot_reload.triggered"
DevHotReloadTriggeredPayload { Path string; Op string; Policy string }

EventTypeDevHotReloadCompleted = "dev.hot_reload.completed"
DevHotReloadCompletedPayload {
    Path string; Op string; Policy string;
    DurationMS int64; Success bool; ErrorMessage string
}

§13 primitive-with-consumer. The primitive (the supervisor + the canonical bus events) ships with its first consumer in the same PR: runDev constructs and runs the supervisor when hot-reload is enabled. The TestHotReloadSupervisor_FileChangeTriggersRebuild test is the first wire-side consumer of the canonical events — it subscribes to dev.hot_reload.triggered against a real bus and asserts the typed payload arrives. No deferred consumer; the supervisor is dead code without runDev's integration.

§13 silent-degradation discipline. Three fail-loud surfaces in the supervisor:

  1. fsnotify.NewWatcher errors fail the boot loud.
  2. watcher.Add(path) errors on a watch root fail the boot loud — except os.ErrNotExist, which is logged Info and skipped (the default .harbor/agents does not exist for first-time projects; failing-loud there would block the dev loop on day one).
  3. A reboot failure propagates up; the supervisor exits with the wrapped error. The operator sees the CLIError. No "silently keep the old stack" fallback.

D-094 helper-tracks-production carve-out. The supervisor wraps bootDevStack at the runDev layer (not inside bootDevStack itself). The harbortest/devstack helper mirrors bootDevStack per D-094's source-of-truth invariant, NOT the surrounding supervisor: a "helper that owns the rebuild loop" would duplicate the cmd-side orchestrator with no integration test consuming it. The devstack package's godoc documents this scope choice explicitly so the next contributor doesn't read the omission as drift. When the supervisor's shape next changes, both files are revisited together — same precedent as D-094's invariant, applied to a scope where the helper deliberately does NOT mirror.

Concurrency contract. The supervisor's Run method runs ONE goroutine that owns both the fsnotify event loop and the serve-goroutine spawning. A second serve goroutine is spawned per rebuild (against the new stack); the previous serve goroutine is cancelled before the rebuild starts and drained via the shared serveErr channel. At any instant exactly one serve goroutine is active (or zero, during a rebuild). Per CLAUDE.md §5 "Concurrent reuse contract": the supervisor IS a per-boot artifact (not shared across boots), so D-025's "N concurrent invocations against a single shared instance" does not apply — the relevant test is the lifecycle-drain test, which TestHotReloadSupervisor_CtxCancel_ReturnsCleanly covers.

Acceptance:

  • cli.dev_hot_reload config block + loader defaults + validator land in internal/config.
  • cmd/harbor/cmd_dev_hot_reload.go implements the supervisor; cmd/harbor/cmd_dev.go::runDev constructs and runs it when enabled.
  • --no-hot-reload flag added to harbor dev.
  • dev.hot_reload.triggered / dev.hot_reload.completed registered as canonical event types.
  • Unit tests + in-package integration test under cmd/harbor/cmd_dev_hot_reload_test.go.
  • scripts/smoke/phase-65.sh asserts the watcher log line + the --no-hot-reload flag + the canonical event-type strings in the binary.
  • docs/plans/phase-65-harbor-dev-hot-reload.md documents the phase per §16.
  • docs/plans/README.md + README.md flip Phase 65 to Shipped.
  • harbortest/devstack/devstack.go godoc documents the supervisor-scope carve-out.
  • go.mod adds github.com/fsnotify/fsnotify v1.10.1 as a direct dependency.
  • All tests -race green; make preflight PASS; make drift-audit clean; make check-mirror clean; npx markdownlint-cli2 docs/decisions.md 0 errors.

D-100 — Phase 66 harbor dev draft-save scaffolding: /v1/dev/drafts/ over the existing dev mux + identity-scoped on-disk layout + Phase 67 scaffold engine round-trip

Date: 2026-05-17 Status: Settled (shipping with this PR)

Where it lives: internal/devdraft/ (new package — devdraft.go Store + sentinels, events.go 5 EventTypes + SafePayload structs, http.go Handler + wire shapes + error mapping, path_safety.go §7 rule 5 helper mirror of internal/skills/importer/path_safety.go, devdraft_test.go + http_test.go + concurrent_test.go); cmd/harbor/cmd_dev.go::bootDevStack (the draftStore + draftHandler are constructed and mounted on the dev router at devdraft.RoutePrefix under the same auth.Middleware wrapper the Phase 60 transports use); harbortest/devstack/devstack.go (D-094 mirror — Assemble always constructs a DraftStore and mounts the handler when transports are enabled); test/integration/phase66_draft_save_test.go (cross-subsystem E2E through the devstack helper); scripts/smoke/phase-66.sh (live-binary smoke).

Decision. The draft scratchpad surface is an HTTP-only subsystem mounted on the existing harbor dev server at /v1/dev/drafts/. The on-disk layout is <root>/<tenant>/<user>/<session>/<draft_id>/ where root is <cwd>/.harbor/drafts by default — identity-scoped so concurrent harbor dev instances against the same operator working directory cannot collide. The save path round-trips through the Phase 67 scaffold engine: Store.Create invokes scaffold.Scaffold to seed the draft tree (so a freshly-created draft IS a harbor scaffold-shaped output); Store.Save runs internal/config.Load + Validate against the rendered harbor.yaml BEFORE any promoted output is written, then copies the draft tree byte-for-byte to the operator-supplied output dir (preserving the operator's PATCH edits). The HTTP surface is five endpoints:

  • POST /v1/dev/drafts/ — create a fresh draft from a name + template.
  • GET /v1/dev/drafts/{id} — list files + content for the Console editor.
  • PATCH /v1/dev/drafts/{id}/files/{path} — write a file's content.
  • POST /v1/dev/drafts/{id}/preview — validation-only dry-run.
  • POST /v1/dev/drafts/{id}/save — promote to scaffold layout.
  • DELETE /v1/dev/drafts/{id} — discard (idempotent).

Five lifecycle events land on the canonical event bus per round-trip — dev.draft.created, dev.draft.updated, dev.draft.previewed, dev.draft.saved, dev.draft.discarded — registered with internal/events's exhaustive registry at init(); SafePayload by construction (no file contents on the bus).

Why HTTP-only at V1 (no harbor dev draft ... CLI sub-CLI). The intended V1 consumer is the Console editor that lands in Phases 72–75; a CLI surface would add operator friction without solving a load-bearing use case at V1 (operators who want to script the flow can curl the JSON surface directly — the dev-token surface already supports it). A future PR can add a sub-CLI in cmd/harbor/cmd_dev_draft.go against the same Store without breaking the wire contract.

Why identity-scoped on disk (and not via opaque ULIDs alone). The operator's working directory is a shared filesystem surface: two concurrent harbor dev instances bound to different (tenant, user, session) triples MUST be able to author drafts under the same .harbor/drafts/ root without seeing each other's work. The <tenant>/<user>/<session>/ subpath enforces isolation at the filesystem layer — Store.Get cannot return another identity's draft because the path is composed from the identity before any file open. CLAUDE.md §6 rule 2 ("Every storage method that touches an identity-scoped table takes the triple and filters with the appropriate WHERE clause") applied to a filesystem-backed store: the equivalent of a WHERE clause is the path-component prefix.

Path-traversal safety (CLAUDE.md §7 rule 5). Every operator-supplied path component — the {path} in PATCH /v1/dev/drafts/{id}/files/{path}, the operator-supplied output dir on save, the identity-component subpath — is filtered through internal/devdraft.resolveSafe, which mirrors internal/skills/importer/path_safety.go's shape: filepath.Clean + lexical-prefix verification + a symlink-evaluation pass. Escape attempts fail loud with ErrUnsafePath (HTTP 400 + CodeUnsafePath on the wire). The importer helper is unexported so we duplicate; a future refactor that lifts the helper into a shared package collapses both call sites.

Pre-promotion validation (fail-loud at the seam). Store.Save runs internal/config.Load + Validate against the draft's harbor.yaml BEFORE any file is written to the operator-supplied output dir. An invalid draft is refused with ErrValidationFailed (HTTP 400 + CodeValidationFailed) and the wire envelope's hint points the operator at the preview surface. This closes the seam at the boundary instead of "save succeeds but the next harbor validate fails" — the §13 "fail loudly at boot" posture extended to the next operator-facing seam.

§13 primitive-with-consumer. The wave that introduces the draft endpoints (the primitive) ships the same wave's consumer that exercises every endpoint end-to-end — the test/integration/phase66_draft_save_test.go round-trip drives create → patch → preview → save → delete through the real HTTP handler under a real Bearer token, observes all five lifecycle events on the bus, and exercises the path-traversal + missing-bearer failure modes. The handler's wire contract is therefore validated against a real call site in the same PR that lands it.

§13 fail-loud (no test-stub-as-default). NewStore fails loud at construction when either Options.Root or Options.Bus is missing — a Store with no bus would silently drop the observability surface (the Wave 11.5 §17.6 F1 lesson applied here). The Store has no fallback to an in-memory backing store: the filesystem is the load-bearing surface (operators inspect drafts via their text editor; an in-memory fallback would silently break that affordance). NewHandler fails loud at construction when the Store is nil.

Composition order on the dev router. The handler is registered BEFORE the router.Handle("/v1/", mux) Protocol catch-all. Go's http.ServeMux resolves longest-prefix-match, so /v1/dev/drafts/... routes to the draft handler and the rest of /v1/ flows to the Protocol mux — the two surfaces are non-overlapping by design.

Auth-wrap inherited from the Protocol mux. The draft handler is wrapped in auth.Middleware(validator, auth.MWLogger(logger)) — the same wrapper the Phase 60 transports use. Every draft request requires a Bearer token; the middleware injects the verified identity into ctx; the Store's mustIdentity helper reads it via identity.From and rejects missing-triple requests with ErrIdentityMissing (HTTP 401 + CodeIdentityRequired). There is no "skip auth in dev" knob — the §13 amendment closes that surface.

D-094 helper-tracks-production rule. harbortest/devstack/devstack.go::Assemble now always constructs a DraftStore (under a per-test os.MkdirTemp root) and mounts the handler on the helper's router when transports are enabled — the helper mirrors production. The wave-end E2E (test/integration/phase66_draft_save_test.go) and every future integration test that touches the draft surface inherits the wiring; skipping the helper update would have left the test silently divergent from production. AssembleOpts.DraftRoot lets a test override the root when it needs to assert specific on-disk paths.

Concurrent-reuse contract (D-025). The Store is a compiled artifact — every field is set at construction and immutable afterwards (the entropyMu guards the ULID entropy reader, which is not goroutine-safe per its godoc; everything else is set-once). internal/devdraft/concurrent_test.go::TestStore_ConcurrentReuse_NoRaceUnderLoad runs N=128 concurrent invocations against one shared Store under -race, each goroutine creating + writing + previewing + getting + cross-identity-probing its own draft. The test also asserts runtime.NumGoroutine returns to baseline after every invocation returns (no goroutine leak).

dev.draft.previewed semantics. The V1 preview path is a config-validation pass against the rendered harbor.yaml — not a real dry-run that boots the draft against a sandboxed runtime. The bus event + the wire shape ({ok, errors[]}) are stable across a future upgrade that adds the dry-run; the surface is forward-compatible.

Acceptance:

  • Five HTTP endpoint shapes under /v1/dev/drafts/ with stable wire codes (identity_required, invalid_request, not_found, unsafe_path, unknown_template, output_dir_exists, validation_failed, internal_error).
  • On-disk layout <root>/<tenant>/<user>/<session>/<draft_id>/ per CLAUDE.md §6.
  • Cross-identity reads return ErrNotFound (pinned by TestStore_Get_CrossIdentityIsolation).
  • Path-traversal attempts return 400 + CodeUnsafePath (pinned by TestStore_WriteFile_RejectsPathTraversal + TestHandler_Patch_RejectsTraversal).
  • Save refuses to promote an invalid draft with ErrValidationFailed (pinned by TestStore_Save_RejectsInvalidYAML + TestHandler_Save_InvalidYAML_Returns400_WithCodeValidationFailed).
  • Save round-trips through the Phase 67 scaffold engine; the promoted harbor.yaml passes internal/config.Load (pinned by TestStore_Save_RoundTrip + TestE2E_Phase66_DraftSave_RoundTripThroughHTTP).
  • Five lifecycle events emit per round-trip (pinned by TestStore_LifecycleEvents + the integration test's bus-event drain).
  • scripts/smoke/phase-66.sh exercises the round-trip against the live binary; the 404/405/501 → SKIP convention keeps the smoke harmless on builds that pre-date Phase 66.
  • harbortest/devstack/devstack.go::Assemble mirrors the production wiring per D-094.
  • internal/devdraft/concurrent_test.go::TestStore_ConcurrentReuse_NoRaceUnderLoad passes under -race with N=128.

D-101 — Phase 69 harbor inspect-events + harbor inspect-runs: graduate the two Phase 63 inspect-* stubs into SSE Protocol-client consumers; auth via HARBOR_TOKEN env or ~/.harbor/token file; no new Protocol methods (consume existing Phase 60 surface)

Date: 2026-05-17 Status: Settled (shipping with this PR)

Where it lives: cmd/harbor/inspect_common.go (shared token discovery, endpoint composition, SSE parser, identity-header injection, fail-loud CLIError codes — CodeAuthRequired / CodeBindInvalid / CodeStreamFailed / CodeIdentityIncomplete / CodeRunNotFound); cmd/harbor/cmd_inspect_events.go (the SSE consumer + human/JSON renderers); cmd/harbor/cmd_inspect_runs.go (per-run aggregator for list mode + trajectory replay for single-run mode); cmd/harbor/testdata/golden/inspect-{events,runs}-*.txt (six new goldens locking the human + JSON shapes for both subcommands); cmd/harbor/inspect_common_test.go + cmd_inspect_events_test.go + cmd_inspect_runs_test.go (unit + golden coverage); test/integration/phase69_inspect_cli_test.go (wire round-trip: builds bin/harbor, exec's it against an httptest Protocol mux, asserts a real task.spawned arrives in the CLI's stdout); scripts/smoke/phase-69.sh (new smoke); scripts/smoke/phase-63.sh (graduated subcommands removed from the stubs array — §17.6 cross-phase fix); docs/plans/phase-69-harbor-inspect.md.

Decision. The Phase 63 stubs harbor inspect-events and harbor inspect-runs graduate to real implementations that consume the Phase 60 SSE event stream as Protocol clients. Two shape decisions land here:

  1. Bearer-token discovery: HARBOR_TOKEN env preferred; ~/.harbor/token file fallback; missing token at BOTH sources fails loud with auth_required. The CLI is a Protocol client of a running Runtime; the Runtime requires Authorization: Bearer <jwt> on every Protocol request (Phase 61 / D-079 — the auth.Middleware wraps both /v1/control and /v1/events). The discovery shape mirrors what every Protocol client will need; the file fallback is operator convenience (harbor dev prints HARBOR_DEV_TOKEN=... to stderr; the operator pipes that into the file once and the CLI works from any terminal). Failing loud per §13's "fail loudly when a required external dependency is missing" — no silent anonymous probe, no implicit fallback to a "dev mode" CLI that talks to an unauthenticated Runtime.

  2. No new Protocol methods — both subcommands derive their views by REPLAYING the existing SSE event stream. Phase 60 already shipped /v1/events with SSE replay-from-cursor (Last-Event-ID); both commands stand up an HTTP+SSE client against that, read the canonical wireEvent shape, and project. inspect-events is a thin pass-through (human or NDJSON); inspect-runs (list) aggregates events keyed by run identifier; inspect-runs <run-id> filters client-side by run identifier and projects each event into a trajectory step. The §13 primitive-with-consumer rule reads backwards here: don't ship a primitive (a runs.list / runs.trajectory Protocol method) without a consumer in the same wave. The Console (RFC §7's Sessions / Live Runtime pages) will eventually want richer per-run query methods, but THOSE pages' Protocol-surface phase is Phase 72+ — the Phase 69 CLI does not lead that primitive. The CLI is the consumer of an EXISTING primitive (the SSE stream) instead.

Why. Closes Phase 69 acceptance criteria 1 + 2 + the §13 amendment closure for the two stubs (operator-facing seams that previously exited with CodeNotImplemented). Gives operators wire-level debug observability against harbor dev — the same surface the Console will provide once the Console pages ship, but available today via the CLI for scripting / triage / smoke gates. The §17.6 cross-phase fix in scripts/smoke/phase-63.sh (removing the two graduated subcommands from the stubs array) is bundled per the rule.

Protocol additions. None. The CLI uses the existing GET /v1/events SSE handler (Phase 60), the existing X-Harbor-Tenant / X-Harbor-User / X-Harbor-Session / X-Harbor-Run / X-Harbor-Event-Type carrier headers (Phase 60 + Phase 61), the existing Last-Event-ID reconnect cursor, and the existing wire wireEvent shape. No new method registered in internal/protocol/methods/methods.go; no new error code in internal/protocol/errors/errors.go; no new wire type in internal/protocol/types/. The CLI-side CodeAuthRequired / CodeBindInvalid / CodeStreamFailed / CodeIdentityIncomplete / CodeRunNotFound are CLIError codes (operator-facing exit surface) — distinct from Protocol wire codes by design (CLAUDE.md §8 — the CLI structured-error surface is single-sourced in cmd/harbor/errors.go; D-084).

§13 primitive-with-consumer. The two subcommands ARE the first CLI consumers of the Phase 60 SSE stream. The Phase 60 wire-transport tests exercised the stream as a Go test client; this PR adds the first operator-facing wire consumer. No new primitive lands.

Why the run-id projection is client-side. The Protocol start method dispatches tasks.SpawnRequest with Identity: identity.Quadruple{Identity: id}RunID is empty at spawn time (the per-task RunLoop driver sets RunID = TaskID when it picks the task up, but the FIRST event, task.spawned, lands on the bus before that with Event.Identity.RunID == ""). The Phase 60 SSE handler filters server-side by events.Filter.Run (i.e. by Event.Identity.RunID), which would DROP the spawn event from a --run R filtered subscription. The CLI works around this by NOT passing X-Harbor-Run server-side; it filters client-side via a small projection helper (runIDFromEvent) that falls back to the payload's TaskID when the identity-tuple RunID is empty. This is the same projection the Console will need when its Sessions page ships; exporting the helper here documents the contract. A future Phase 60 stream upgrade that adds payload-aware run filtering on the server would let the CLI move to server-side filtering — tracked as a source-comment enhancement.

Acceptance:

  • harbor inspect-events --bind H:P --tenant T --user U --session S --type X --since C --follow=false snapshots the SSE replay and exits 0 on success, non-zero with auth_required / stream_failed / identity_incomplete on the documented failure modes.
  • harbor inspect-events ... --json emits NDJSON: one canonical wireEvent JSON object per line + a sentinel {"comment":"…"} line on :-comment frames (keepalive, replay-gap markers).
  • harbor inspect-runs --bind H:P --tenant T --user U --session S [--json] aggregates per-run rows from the SSE replay and emits either a human table or a single-line JSON array of {run_id, status, started_at, last_event_at, event_count, failure_code?}.
  • harbor inspect-runs <run-id> ... [--json] filters the replay to one run and emits a trajectory (one row per event) or a {run_id, steps[]} JSON object; CodeRunNotFound fires when no event with the target run identifier appears in the replay window.
  • Bearer JWT discovery: HARBOR_TOKEN env preferred, ~/.harbor/token fallback. Both empty → auth_required BEFORE any network call.
  • Identity triple (--tenant/--user/--session) is mandatory at the CLI edge — fails CLI-side with identity_incomplete so the error message names the missing flag rather than relying on the Runtime's generic 401.
  • Six new goldens (cmd/harbor/testdata/golden/inspect-{events,runs}-*.txt) lock both human and --json shapes for both subcommands. go test -update ./cmd/harbor/... regenerates them.
  • The Phase 63 stubs array in scripts/smoke/phase-63.sh no longer includes inspect-events / inspect-runs (§17.6 cross-phase smoke maintenance).
  • test/integration/phase69_inspect_cli_test.go builds bin/harbor, stands up an httptest Protocol mux over real events / state / tasks drivers, drives a real start, exec's the binary, and asserts the canonical task.spawned event surfaces in the CLI's stdout. Includes a fail-loud-no-token assertion.
  • All tests -race green; make preflight PASS; make drift-audit clean; make check-mirror clean; npx markdownlint-cli2 docs/decisions.md 0 errors.

D-102 — harbor inspect-topology renderer + trajectory-synthesised source + Wave 12 wave-end E2E (closes Phase 70)

Date: 2026-05-17 Status: Settled (shipping with this PR)

Where it lives: cmd/harbor/cmd_inspect_topology.go (the cobra body — replaces the Phase 63 stub); cmd/harbor/topology_render.go (the pure ASCII + JSON renderer); cmd/harbor/topology_synthesise.go (the wire-frame → Topology builder); cmd/harbor/topology_render_test.go (renderer + synthesiser unit tests + golden round-trip); cmd/harbor/cmd_inspect_topology_test.go (cobra-driver + transport-layer tests); cmd/harbor/testdata/golden/inspect-topology-happy.{txt,json} (the golden pinning the renderer shape — regeneratable via go test -update); cmd/harbor/testdata/golden/help.txt (regenerated — (Phase 70) suffix dropped from the subcommand's Short); cmd/harbor/cmd_stub_test.go (drops inspect-topology from stubCases); scripts/smoke/phase-70.sh (new); scripts/smoke/phase-63.sh (drops inspect-topology from the stub-subcommands array); test/integration/wave12_test.go (the new Wave 12 wave-end E2E per §17.7 step 5); docs/plans/phase-70-inspect-topology.md (the phase plan); docs/plans/README.md (Phase 70 flips to Shipped); README.md (Status table flip + Phase 70 row).

Decision. Two intertwined design calls land in this PR — the renderer shape AND the topology source. Both are documented here so a future PR cannot quietly retrofit them.

1. Renderer shape — indent-based ASCII, not box-drawing. Two valid shapes for run graphs: box-drawing (├──, └──, ) and indent-based (+--, plain spaces). Indent-based wins on three operational criteria the Phase 70 prompt asked us to settle:

  • Terminal portability: indent + +-- renders on every terminal — Windows cmd's CP437, Linux TTYs without ncurses, CI log capture tools that strip ANSI / Unicode. Box-drawing characters are multi-byte UTF-8 that some captures mangle.
  • Deterministic byte length: fixed-width ASCII makes the golden comparison trivial under diff; multi-byte UTF-8 bloats the golden's byte surface and complicates the truncation rule (we'd need rune-aware width math instead of len()).
  • Readability: the visual hierarchy is one space per level + the +-- connector; readers from a wider terminal family see the same shape as readers in an 80-col SSH window.

Sort order is (Sequence, EventID)Sequence is per-bus monotonic + gap-free (events.Event.Sequence), so two snapshots of the same run produce byte-stable ASCII. EventID (ULID-shaped) is the tie-break if a future driver ever issues parallel sequences. The renderer applies the sort defensively at render time so an out-of-order input slice still produces deterministic output (the renderer test TestRender_OutOfOrderNodes_SortedDeterministically pins this).

2. Topology source — trajectory-synthesised from existing events, NOT topology.snapshot. The master-plan Phase 70 goal cites "topology.snapshot events"; the canonical producer of those is Phase 74 (Console topology projection events, RFC §6.13), which has not yet landed. Two source options were on the table:

  • (a) Extend an existing event with topology fields — rejected. Topology is its own concern; bolting it onto e.g. tool.invoked couples two payload schemas and makes Phase 74's later canonical producer awkward (it would need a back-compat path against the bolt-on shape).
  • (b) Trajectory-synthesise from existing event types — CHOSEN. The synthesiser (BuildTopologyFromEvents) walks tool.invoked / tool.completed / tool.failed / tool.invalid_args / tool.approval_requested / tool.auth_required / task.spawned / pause.requested / planner.finish and produces a Topology value. Paired events merge (tool.invoked + tool.completed → one node with Status=ok); depth is inferred (tool.approval_requested is a child of the last open tool.invoked). When Phase 74 lands, the synthesiser gains one additional case branch for topology.snapshot, and the renderer prefers that source when present — the V1 path stays as a fallback for older runs.

The CLI is a Protocol client per CLAUDE.md §8 + RFC §7: it consumes the canonical wire shape (internal/protocol/transports/stream.wireEvent), not the in-process events.Event struct. The wire shape is re-declared as WireEventFrame in cmd/harbor/topology_synthesise.go so the cmd does not import internal/protocol/transports/stream — the contract is the WIRE shape, not the Go struct (a future third-party CLI in any language can synthesise the same topology from the same SSE bytes).

Why. Closes Phase 70 acceptance ("Sample run produces stable ASCII matching golden") AND closes the §13 primitive-with-consumer rule for the topology subsystem: shipping the renderer now (with a synthesise-from-existing-events source) means the operator-facing subcommand WORKS the day it lands, instead of waiting for Phase 74 and risking the renderer drifting from the design that motivated it. When Phase 74 ships, the renderer's existing test surface validates the new event-kind case.

Wave 12 wave-end E2E (per CLAUDE.md §17.7 step 5). test/integration/wave12_test.go boots the assembled dev stack via harbortest/devstack.Assemble (D-094) and exercises the composed Wave 12 surface end-to-end. Scenarios:

  • TestE2E_Wave12_InspectTopology_HappyPath — boot devstack; spawn a foreground task (POST /v1/control/start); the per-task RunLoop drives the planner; tool/finish events flow on the bus; inspect-topology against the live runtime produces non-empty ASCII naming the run id and at least one tool node.
  • TestE2E_Wave12_InspectTopology_CrossTenantIsolation — two distinct identity-triple stacks share the SAME assembled runtime; each tenant's inspect-topology sees ONLY its own run's events. Asserts the identity-tuple flow through SSE filter → bus subscribe → renderer header.
  • TestE2E_Wave12_InspectTopology_RunNotFound_FailureMode — invoke inspect-topology against a deliberately nonexistent run-id; assert the structured CodeInspectTopologyRunNotFound exit (≥1 failure mode per §17.3).
  • TestE2E_Wave12_InspectTopology_Concurrency_NoCrossTalk — N=10 concurrent operators run inspect-topology against the same runtime (each with their own identity tuple); assert no goroutine leak after all clients close, no cross-tenant data appearing in any client's output (the §17.3 concurrency-stress shape, ratchet from the §17.3 minimum N≥10).

Parallel-PR coverage caveat. Wave 12 also contains Phase 65 (hot-reload events), Phase 66 (draft endpoints), Phase 69 (inspect-events / inspect-runs), and #126 (cfg.Planner driver registry). Per the prompt's "Strategy" note, the wave-end E2E exercises ONLY the surface this PR ships (inspect-topology + the inherited Phase 60/63/64 dev stack). When the parallel PRs merge, the audit follow-up chore(checkpoint): wave-12 audit fixes PR extends the wave-end E2E with scenarios that exercise the merged surfaces — not in this PR, because the Stage-2 PRs may land in any order and a fragile cross-PR dependency here would block this PR's merge on theirs. Documented here so the audit owner knows to backfill.

§13 primitive-with-consumer. The renderer (the primitive) and the cmd (the consumer) ship in the same PR. The renderer's pure-function shape AND the cmd's transport-layer SSE integration are BOTH exercised end-to-end by the wave-end E2E. The §13 rule is discharged: no "renderer without consumer" or "cmd without renderer" window exists.

§17.6 cross-phase smoke maintenance. scripts/smoke/phase-63.sh's stub-subcommands array drops inspect-topology. Phase 69 (parallel-merge) will drop inspect-events / inspect-runs from the same array in its own PR; whichever PR lands first wins, and the second rebases. The array goes to empty when Phase 69 merges. cmd/harbor/cmd_stub_test.go's stubCases table mirrors the same drop.

Recurring-failure-mode pre-empt. The cmd's SSE fetcher (fetchSSEUntilIdle) owns ONE reader goroutine per invocation; that goroutine is joined via ctx-cancel when the fetcher returns. The wave-end E2E's N=10 concurrent invocations + post-test goroutine-baseline assertion is the D-025 stress proof for the cmd's reusable shape. No mutable state on any cmd-side artifact; the renderer + synthesiser are pure.

Acceptance:

  • cmd/harbor/cmd_inspect_topology.go::runInspectTopology graduates from the Phase 63 not_implemented stub; renders a run's node graph as deterministic ASCII (golden-pinned) and JSON (--json).
  • cmd/harbor/topology_render.go::Render + RenderJSON are byte-stable for a given input (the renderer test asserts forward vs reverse input ordering yields identical output).
  • Every failure mode emits a stable CLIError code (inspect_topology_bind_invalid, inspect_topology_width_invalid, inspect_topology_auth_missing, inspect_topology_run_id_missing, inspect_topology_connect_failed, inspect_topology_http_status, inspect_topology_run_not_found).
  • test/integration/wave12_test.go ships with this PR — real drivers, identity propagation, ≥1 failure mode, N=10 concurrency stress, all -race green.
  • scripts/smoke/phase-70.sh runs the cmd/harbor tests + binary-mode assertions + (when HARBOR_DEV_TOKEN set in env) live-server run-not-found check; OK > 0, FAIL = 0.
  • scripts/smoke/phase-63.sh drops inspect-topology from the stubs array; cmd/harbor/cmd_stub_test.go::stubCases mirrors the drop.
  • cmd/harbor/testdata/golden/help.txt regenerated; the (Phase 70) suffix drops from the inspect-topology row.
  • docs/plans/README.md Phase 70 row flips to Shipped; README.md Status table flips Phase 70 row to Shipped.

D-103 — cfg.Planner schema + internal/planner driver registry (closes #126, closes D-097's "future phases will read cfg.Planner" note)

Date: 2026-05-17 Status: Settled (shipping with this PR)

Where it lives: internal/config/config.go (PlannerConfig + the top-level Config.Planner field); internal/config/validate.go (allowedPlannerDrivers + validatePlanner); internal/config/loader.go::defaults (the Planner: PlannerConfig{Driver: "react"} default); internal/planner/registry.go (the driver registry — Factory + Register / MustRegister / Resolve / RegisteredDrivers + PlannerConfig + FactoryDeps boundary types + the four sentinel errors); internal/planner/registry_test.go (registry-bookkeeping tests); internal/planner/react/init.go (the react driver's self-registration via init() → planner.MustRegister("react", factory)); internal/planner/react/registry_allowlist_test.go (the drift guard between the planner registry and the config validator's allowlist); cmd/harbor/main.go (blank-import _ "github.com/hurtener/Harbor/internal/planner/react"); cmd/harbor/cmd_dev.go::bootDevStack (the hardcoded react.New(llmClient) call is replaced with planner.Resolve(ctx, plannerConfigFromConfig(cfg.Planner), planner.FactoryDeps{LLM: llmClient})); cmd/harbor/cmd_dev.go::plannerConfigFromConfig (the boundary mapper from config.PlannerConfigplanner.PlannerConfig); harbortest/devstack/devstack.go (mirrors the production wiring per D-094 — adds AssembleOpts.PlannerOverride for tests that need a stub planner; constructs via planner.Resolve otherwise); examples/dev.yaml and examples/harbor.yaml (operator-facing planner: block with the react default and a commented max_steps: knob); test/integration/phase_d103_planner_registry_test.go (end-to-end coverage); test/integration/wave11_test.go + harbortest/devstack/devstack_test.go (the new _ "github.com/hurtener/Harbor/internal/planner/react" blank imports the tests need now that devstack reaches the planner only through the registry).

Decision. The planner concrete is resolved at boot via the internal/planner driver registry: operators declare planner.driver: <name> in harbor.yaml; the binary's blank-import block in cmd/harbor/main.go self-registers each available driver; cmd/harbor/cmd_dev.go::bootDevStack calls planner.Resolve(ctx, cfg.Planner, planner.FactoryDeps{LLM: llmClient}) to construct the concrete. The V1 default driver is react (the reference LLM-driven ReAct planner — Phase 45 / D-051) and remains the no-config-needed default: cfg.Planner.Driver == "" resolves to "react" at both the loader-side defaults() and the validator-side default branch, so an operator config that omits the planner: block boots unchanged from the pre-D-103 hardcoded path. cfg.Planner.MaxSteps is the optional planner-side circuit-breaker step cap; zero means "use the driver's internal default" (the react driver's react.DefaultMaxSteps = 12); negative is rejected at the validator edge. The registry mirrors D-095's OAuth-provider registry structurally — same shape, same sentinel set, same Register / MustRegister / Resolve / RegisteredDrivers quartet, same allowlist-mirror pattern in the config validator. The structural precedent is deliberate.

Why. CLAUDE.md §1.3 names the swappable planner one of the three non-negotiable product properties of Harbor's runtime — yet the V1 boot path hardcoded the concrete via react.New(llmClient) in cmd/harbor/cmd_dev.go::bootDevStack. D-097's planner-construction site explicitly carried the note "the cfg.Planner schema + driver registry (per the §4.4 seam pattern that D-095 uses for OAuth providers) is tracked in issue #126 — until it lands, ReAct is hardcoded here." Issue #126 (surfaced by the Wave 11.5 §17.5 closeout audit's finding N2) is that gap. Closing it closes the product-property gap and the §13 "shipping a primitive without its first consumer" smell, twice: (a) the §4.4 seam pattern is now exercised on the planner subsystem the same way it's exercised on tools/auth, memory, state, artifacts, events, telemetry, and tasks; (b) the registry's first consumer (cmd/harbor/cmd_dev.go::bootDevStack's production planner construction) ships in the same PR as the primitive.

How to apply.

  • New planner concretes add a package under internal/planner/<name>/ and a func init() { planner.MustRegister(DriverName, factory) } block following the §4.4 seam pattern. The factory adapter maps the planner.FactoryDeps + planner.PlannerConfig boundary onto the concrete's option-applied constructor (the react driver's init.go is the reference shape).
  • The driver's canonical name MUST be added to internal/config/validate.go's allowedPlannerDrivers allowlist in the same PR. The internal/planner/<name>/registry_allowlist_test.go drift guard mirrors the D-095 OAuth-provider pattern (every test loads _ "internal/config" AND _ "internal/planner/<name>", then asserts the validator accepts the driver's name).
  • cmd/harbor/main.go blank-imports the new driver package: _ "github.com/hurtener/Harbor/internal/planner/<name>".
  • Operators opt into a new driver by setting planner.driver: <name> in harbor.yaml. The V1 reference planner (react) stays the no-config-needed default for the foreseeable future — flipping the default to a different concrete would be a §13 violation of the operator-stability contract.
  • Per-driver tuning knobs land in planner.PlannerConfig.Extra (opaque map[string]string); a driver that grows tuning beyond MaxSteps reads from Extra at factory time. Future drivers may petition for a typed Extras block on the YAML schema (the same shape D-095 considered for OAuth providers); the V1 register-side surface stays narrow on purpose.

§4.4 conformance. Interface lives in internal/planner/planner.go (Planner.Next). Drivers live in internal/planner/<name>/ — Phase 45's react/ is the V1 default; Phase 48's deterministic/ is the existing second concrete (deterministic planner — see D-073 + the spawn-await scenario test); finish/ ships the stub planner used by the conformance pack. Factory + registry live in internal/planner/registry.go. Blank-import in cmd/harbor/main.go fires the self-registration. The factory's error message lists registered drivers so misconfigurations are obvious. The internal/config package MUST NOT import internal/planner (§4.4 — drivers depend on interfaces, not the other way round). The allowedPlannerDrivers allowlist in the validator is a deliberate duplication; the internal/planner/react/registry_allowlist_test.go test catches drift between the two surfaces.

§13 fail-loud. Three loud-failure paths land in this PR:

  1. Unknown driver names rejected at internal/config/validate.go. The pre-boot validator surfaces a clear error (config.planner.driver: must be one of [react], got "...") so harbor validate flags the typo before the binary attempts to boot. The error message lists the allowed values so the operator sees the fix.
  2. Negative MaxSteps rejected at the same place. Zero is the documented "use driver default" sentinel; positive integers are honoured.
  3. The factory rejects missing required deps. The react driver's factory returns fmt.Errorf("planner/react: LLM client is required (FactoryDeps.LLM was nil)") when deps.LLM == nil; silent fallback to a stub is forbidden. The registry's own Resolve rejects unknown / empty driver names with ErrDriverUnknown and includes the registered-driver list in the error message.

§13 primitive-with-consumer. The primitive (the internal/planner driver registry — Factory + Register / MustRegister / Resolve + the PlannerConfig / FactoryDeps boundary types) lands with its first consumer (cmd/harbor/cmd_dev.go::bootDevStack's production planner construction) in the same PR. The wave-end E2E (test/integration/phase_d103_planner_registry_test.go) exercises the registry round-trip end-to-end through devstack.Assemble — the production wiring per D-094's source-of-truth invariant. No deferred consumer.

MaxSteps knob over per-driver Extras. The V1 schema ships a typed MaxSteps int field at the top level of PlannerConfig rather than burying it inside the opaque Extra map[string]string. Two reasons: (a) MaxSteps is the one knob the V1 reference planner already exposes via react.WithMaxSteps(n) — surfacing it as a typed field lets operators tune the circuit breaker without per-driver Extras ceremony; (b) future drivers (Plan-Execute, Workflow, Graph, Deterministic, Supervisor, MultiAgent, HumanApproval per RFC §6.2) will likely all carry a step-cap-equivalent knob, so the field captures a cross-driver universal rather than a react-specific quirk. Per-driver knobs (a deterministic planner's scripted step sequence, a supervisor planner's sub-agent list) land in Extras until a future RFC pulls them up.

Devstack mirror shape (PlannerOverride, not SkipPlanner). harbortest/devstack.Assemble reaches the planner only through the registry now. The test escape hatch is AssembleOpts.PlannerOverride planner.Planner — when non-nil, the helper uses the injected instance instead of calling planner.Resolve. There is NO SkipPlanner knob: skipping the planner entirely would leave stack.RunLoop / stack.RunLoopDriver nil even when the caller has not opted out via the existing SkipRunLoop / SkipCatalog / SkipSteering flags, which would be a confusing failure mode. The PlannerOverride shape is the minimal escape hatch tests that need a stub / scripted / pausing planner can use without re-implementing the wiring; production code never sets the override. The existing SkipRunLoop flag remains the way to opt out of the entire planner-RunLoop construction stack.

Acceptance.

  • internal/config/config.go declares Config.Planner + PlannerConfig{Driver, MaxSteps, Extra} with the documented defaults.
  • internal/config/validate.go adds validatePlanner to the validator chain + the allowedPlannerDrivers allowlist; rejects unknown drivers + negative MaxSteps; accepts empty Driver as the "use default" sentinel.
  • internal/config/loader.go::defaults populates Planner: PlannerConfig{Driver: "react"}.
  • internal/planner/registry.go adds the Factory type + Register / MustRegister / Resolve / RegisteredDrivers quartet + the four sentinel errors + the PlannerConfig / FactoryDeps boundary types.
  • internal/planner/react/init.go self-registers the react driver via init() → planner.MustRegister("react", factory). The factory adapter rejects nil LLM clients.
  • cmd/harbor/main.go blank-imports _ "github.com/hurtener/Harbor/internal/planner/react".
  • cmd/harbor/cmd_dev.go::bootDevStack replaces plnr := react.New(llmClient) with plnr, err := planner.Resolve(ctx, plannerConfigFromConfig(cfg.Planner), planner.FactoryDeps{LLM: llmClient}); the direct internal/planner/react import is dropped from cmd_dev.go (it's reached via the registry now).
  • harbortest/devstack/devstack.go mirrors the production wiring per D-094; adds AssembleOpts.PlannerOverride for tests that need a stub planner.
  • examples/dev.yaml and examples/harbor.yaml document the planner: block with the react default + a commented max_steps: knob.
  • internal/planner/registry_test.go pins the registry's bookkeeping behaviour (empty-name / nil-factory / duplicate-name rejection; unknown-driver / empty-driver loud failure with the registered-driver list; ctx-cancellation honoured; MustRegister panics on error; RegisteredDrivers sorted; factory dispatch).
  • internal/planner/react/registry_allowlist_test.go pins the validator↔registry drift guard: the validator accepts react, rejects unknowns, accepts empty as default, rejects negative MaxSteps; the registry's Resolve(react) returns a non-nil planner; the factory rejects nil LLM.
  • internal/config/validate_test.go adds six planner-specific assertions covering the same paths from the config side.
  • test/integration/phase_d103_planner_registry_test.go exercises the registry round-trip end-to-end through devstack.Assemble (the production wiring per D-094) — a config with planner.driver: react boots the devstack and produces a non-nil RunLoop; an unknown driver is rejected pre-boot; the direct planner.Resolve(react) call succeeds.
  • D-097's "Where it lives" paragraph + cmd/harbor/cmd_dev.go::bootDevStack's comment are updated to point at D-103 as the closure.
  • All tests -race green; make vet clean; go build ./... clean.

Structural precedents. D-095 (tools.oauth_providers[] + internal/tools/auth/registry.go) is the direct structural precedent — same shape, same sentinel set, same allowlist-mirror pattern, same Register / MustRegister / Resolve quartet. D-090 (tools.entries[] operator config + the Deps.OAuthProviders construction pattern) is the broader §4.4 boundary precedent. D-097 (the RunLoop wrap around the planner) is the consumer the new registry path feeds; D-098 (the per-task FSM bridge) is the downstream driver that closes the RunLoop's exit shape onto the task FSM. All three settled the cmd_dev wiring this PR retargets.


D-104 — preflight parallelisation + ephemeral-port allocation (closes #135)

Date: 2026-05-17 Status: Settled (shipping with this PR)

Where it lives: cmd/harbor/cmd_dev.go::devStack.serve (replaces http.Server.ListenAndServe with an explicit net.Listen + server.Serve(listener); emits the parseable HARBOR_DEV_BOUND=<host:port> line on stderr exactly once per boot; refreshes s.bindAddr to the OS-resolved address so host:0 binds report the actual port to subsequent loggers); scripts/preflight.sh (the orchestrator — classifies smokes by header, runs static-only + unit-tests batches in parallel before the dev boot, boots harbor dev ONCE on HARBOR_BIND=127.0.0.1:0, parses the bound port from the server log, exports HARBOR_BIND / HARBOR_PORT / HARBOR_BASE_URL / HARBOR_DEV_PORT / HARBOR_DEV_TOKEN to every live-server smoke, runs the live-server batch serially, tears down); scripts/drift-audit.sh (check 9 — every scripts/smoke/phase-*.sh MUST carry one of the three classification values, FAIL with a clear directive on miss); scripts/smoke/_template.sh (new # PREFLIGHT_REQUIRES: header line + a paragraph documenting the three values); every scripts/smoke/phase-NN.sh and phase-NNa.sh / phase-NNb.sh (78 files — the # PREFLIGHT_REQUIRES: header lands on line 2 of each; 6 are live-server, 11 are static-only, 61 are unit-tests); scripts/smoke/phase-64.sh (the fail-loud-no-config secondary boot now passes HARBOR_BIND=127.0.0.1:0 rather than --port 18198 so two sibling worktrees running preflight concurrently cannot collide on the pinned port); scripts/smoke/phase-69.sh (replaces the hardcoded --bind "127.0.0.1:${HARBOR_DEV_PORT:-18080}" with --bind "${HARBOR_BIND:-127.0.0.1:18080}" at the three inspect-events / inspect-runs invocations); scripts/smoke/phase-70.sh + scripts/smoke/phase-64.sh (docstring updates pointing at the ephemeral-port default).

Decision. Preflight's wall time and its port-pinning are the same problem viewed from two angles, and the fix is one structural change. Three intertwined design calls land here.

1. Ephemeral-port allocation by default. The harness binds 127.0.0.1:0 and reads the actual bound port back from the server log. The dev binary emits a parseable HARBOR_DEV_BOUND=<host:port> line on stderr immediately after net.Listen returns — this is a NEW contract on the dev binary, single-source: the line is emitted exactly once, with that exact prefix, on stderr. Preflight greps it out of the captured log and exports HARBOR_BIND, HARBOR_PORT, HARBOR_BASE_URL, and HARBOR_DEV_PORT (mirrored for any legacy reader) to every live-server smoke. scripts/smoke/common.sh::api_url already reads HARBOR_BASE_URL, so the existing live-server smokes flow through unchanged once the env is set. Two sibling worktrees running make preflight concurrently no longer collide because each binds an OS-assigned port. Operators who NEED a pinned port (an external integration test attaching to a known address) can still set HARBOR_DEV_PORT=18080 explicitly; the orchestrator honours the override and the resolution-from-log path works either way (the bound port matches the requested port when it's free; the harness reports whichever the OS hands back).

2. Smoke classification via the # PREFLIGHT_REQUIRES: header. Every scripts/smoke/phase-*.sh carries one classification on line 2:

  • static-only — pure file/text greps, golden compares, file-existence assertions. Runs in the parallel batch BEFORE the dev server boots; needs no shared state.
  • live-server — hits the booted dev server over HTTP (api_url, assert_status, skip_if_404, assert_json_path) or reads the preflight server log. Runs serially against the booted instance because the smokes observe and sometimes mutate shared dev state (SSE streams, the in-mem bus, the singleton draft store).
  • unit-tests — runs go test for one or more packages. Parallelisable at the bash-fan-out level; go test schedules its own internal parallelism on top.

The grammar is INTENTIONALLY inflexible: the orchestrator parses the header with a single grep + sed and demands one of the three exact values. A missing or unrecognised header fails preflight loud (and the same check fires in make drift-audit standalone). The fail-loud is per §13: silent classification defaults are forbidden because a server-mutating smoke misclassified as static-only would land in the parallel batch and produce nondeterministic flakes — exactly the failure mode the wave-end checkpoint audits (Wave 12 §17.5 N1) keep surfacing one PR late. Phases that ship a new smoke MUST classify it correctly in the same PR (the §4.2 phase-implementor contract grows item 12 in spirit; the template at scripts/smoke/_template.sh documents the convention).

3. Two-batch parallel driver in scripts/preflight.sh. The orchestrator:

  1. Builds ./bin/harbor (unchanged from pre-D-104).
  2. Classifies every scripts/smoke/phase-*.sh by header; FAILs loud on any missing / unrecognised header before doing further work.
  3. Runs scripts/drift-audit.sh (unchanged).
  4. Runs the static-only batch in parallel — no server needed, so the wall-time win is the full parallelism. Cap defaults to sysctl -n hw.ncpu (macOS) / nproc (Linux) / 4 (last-resort fallback). Override via MAX_PARALLEL_SMOKES=N. Outputs are captured per-smoke and replayed in sorted (deterministic) order after the batch finishes so the operator sees a consistent log layout.
  5. Runs the unit-tests batch in parallel using the same machinery. These run BEFORE the boot because they don't need it and the wall-time win compounds with batch 1.
  6. Boots ./bin/harbor dev ONCE on HARBOR_BIND=127.0.0.1:0 (or the pinned HARBOR_DEV_PORT if the operator set it). Parses HARBOR_DEV_BOUND= from the server log to discover the actual bound address. Waits for /healthz to return 200 against THAT address (not a hardcoded :18080). The pre-Phase-64 stub-binary branch is preserved unchanged — when the binary exits cleanly OR emits "code":"not_implemented" matching the Phase 63 stub, the boot is skipped without failure.
  7. Exports HARBOR_BIND, HARBOR_PORT, HARBOR_BASE_URL, HARBOR_DEV_PORT, HARBOR_DEV_TOKEN to every live-server smoke.
  8. Runs the live-server batch SERIALLY. Serial because the smokes observe shared dev-server state and a parallel run would produce nondeterministic order-dependent failures (an inspect-events snapshot from one smoke would carry the prior smoke's task.spawned events; a draft round-trip from one smoke would race against another smoke's draft GET). The N=6 live-server smokes finish in a few seconds combined; the wall-time win is concentrated on the parallel batches above.
  9. Tears down (graceful TERM, then KILL, then cleanup) — unchanged.

The bash-3.2-compatible fan-out (drain-head rather than wait -n) is deliberate: macOS still ships bash 3.2 as /bin/bash and a chunk of Harbor's contributors run there. The harness MUST work without a bash 4+ install.

Why. Two coupled operator-facing problems:

  1. Wall time. ~70+ phase smokes ran serially against one shared harbor dev instance. Each new wave added 1–5s per smoke; cumulative wall time was substantial, sometimes longer than the development cycle. Wave 12 §17.5 closeout audit's "Recommendations for Wave 13" pinned this as a structural item ("Recommend scheduling early in Wave 13 — every wave that lands without this added another 10–20s to the gate"). The parallel-batch path drops the wall time by the typical bash-fan-out factor (~3–5x on a 4-core laptop), which is the issue's ≥50% target.
  2. Port collision across worktrees. Two sibling worktrees running make preflight concurrently both tried to bind 127.0.0.1:18080 and one would fail. Wave 12 used HARBOR_PREFLIGHT_SKIP=1 three times for this reason (PRs #129, #130, #131); the Wave 12 §17.5 closeout audit itself couldn't run preflight cleanly. The ephemeral-port default removes the contention class entirely — N concurrent worktrees each get a distinct OS-assigned port.

Helper-tracks-production invariant (per D-094). cmd/harbor/cmd_dev.go::devStack.serve and harbortest/devstack.Assemble's test-side serve path use the same net.Listen + Serve(listener) shape. The dev binary is the only producer of the HARBOR_DEV_BOUND= log line; tests that need the bound port from the helper read it the same way the harness does. No second source of truth.

§13 fail-loud. Three loud-failure paths land in this PR:

  1. Missing classification header rejects preflight before the dev boot. Error message names the offending file(s) and points to scripts/smoke/_template.sh + CLAUDE.md §4.2.
  2. Unrecognised classification value rejects at the same gate with the same shape. Silent defaults to static-only would let a server-touching smoke leak into the parallel batch — exactly the nondeterministic-flake source we're closing.
  3. Drift between drift-audit and preflight. The same classification check runs in scripts/drift-audit.sh (check 9) so make drift-audit standalone surfaces the issue without needing a full preflight run. A header drift is caught at the cheapest possible gate.

§13 primitive-with-consumer. The primitive (the # PREFLIGHT_REQUIRES: header grammar + the orchestrator's batch dispatcher) lands with its first consumer (the orchestrator parses, classifies, and parallelises 78 existing smokes) in the same PR. The grammar is exercised end-to-end the moment the PR lands; there is no "header without consumer" window. The ephemeral-port primitive (the HARBOR_DEV_BOUND= log line + the net.Listen switch) lands with its first consumer (the preflight orchestrator reads the line and resolves the port from it) in the same PR — no orphan primitive.

CI matrix sharding (issue #135 step 4) deferred. The four-step plan in #135's body included a CI matrix-sharding step ("GitHub Actions can shard the static batch across runners"). Step 4 is deferred to a follow-up issue because (a) the primary win is local-dev wall time, where this PR's parallel-batch path delivers the bulk of the gain; (b) CI's preflight job already runs on a single runner without operator-perceived wall time (the GitHub Actions queue dwarfs the test runtime), so the marginal benefit of sharding is small; (c) sharding adds matrix-bookkeeping complexity (per-shard result aggregation, shared bin/harbor build, deterministic shard assignment) that is best evaluated standalone. The follow-up issue tracks the work without blocking the local-dev win.

Recurring-failure-mode pre-empts (per §17.7 step 3): the orchestrator uses the ${arr[@]+"${arr[@]}"} empty-array guard everywhere a classification bucket might be empty (so set -u doesn't trip on an absent STATIC_ONLY bucket — the same shape that bit Phase 63's smoke in Wave 12); the fan-out drain uses the drain-head pattern rather than wait -n (bash 3.2 compatibility on macOS); per-smoke output capture writes to a tempfile so SIGPIPE-shaped early-exit failures don't corrupt the aggregated log; the HARBOR_DEV_BOUND parse handles both IPv4 and IPv6-bracketed forms (the existing dev-bind LastIndex('😂 convention applies).

Acceptance:

  • scripts/preflight.sh boots harbor dev with HARBOR_BIND=127.0.0.1:0 by default; two sibling worktrees can run make preflight simultaneously without collision (the PR body documents the cross-worktree concurrency test).
  • Every scripts/smoke/phase-*.sh carries exactly one # PREFLIGHT_REQUIRES: live-server|static-only|unit-tests header on line 2.
  • A missing or unrecognised header fails make preflight AND make drift-audit loud, with an actionable error message naming the file(s).
  • The static-only and unit-tests batches run in parallel up to MAX_PARALLEL_SMOKES (CPU-count default); the live-server batch runs serially after the dev boot.
  • Total preflight wall time drops by ≥50% on a clean checkout (the PR body reports before/after numbers from time make preflight).
  • scripts/smoke/_template.sh documents the classification convention so new phases inherit the rule.
  • The HARBOR_DEV_BOUND=<host:port> line is emitted exactly once per harbor dev boot, on stderr, with that exact prefix.
  • CI matrix sharding is deferred to a follow-up issue; this PR's body links the issue.

D-105 — Phase 72 Console subscription protocol surface: events.subscribe canonical method + CodeIdentityScopeRequired wire code + closed-scope re-affirmation (D-079)

Date: 2026-05-19 Status: Settled (shipping with this PR)

Where it lives: internal/protocol/methods/methods.go (the new MethodEventsSubscribe Method = "events.subscribe" constant + its registration in canonicalMethods + the IsControlMethod exclusion — the streaming-events method is NOT a steering-control method, the Phase 54 control nine stays exclusive); internal/protocol/methods/methods_test.go (the new TestMethods_EventsSubscribe_Registered + the updated IsControlMethod exhaustiveness test + the wantMethods slice extension); internal/protocol/errors/errors.go (the new CodeIdentityScopeRequired Code = "identity_scope_required" canonical wire code + registration in canonicalCodes); internal/protocol/errors/errors_test.go (the new TestCodes_IdentityScopeRequired pinning the wire string + the wantCodes extension); internal/protocol/transports/stream/stream.go (the new writeProtocolError helper that emits the canonical JSON Protocol error envelope; the ?admin=1 reject path now returns CodeIdentityScopeRequired instead of free-form scope_mismatch: prose; the Subscribe-error switch maps events.ErrIdentityScopeRequired AND events.ErrAdminScopeRequired onto CodeIdentityScopeRequired at HTTP 403); internal/protocol/transports/stream/stream_test.go (the new TestServeHTTP_CrossTenantWithoutScope_Returns403 + the io import); internal/protocol/transports/stream/internal_test.go (the existing TestServeHTTP_SubscribeScopeRequired_* renamed to _403 + body-Code assertion + the new TestServeHTTP_BusReturnsAdminScopeRequired_Maps403 defensive case); internal/protocol/transports/stream/concurrent_scope_test.go (NEW — the D-025 contract: N=128 concurrent SSE subscribers against ONE shared Handler under -race, half triple-scoped, half admin-scoped (alternating ScopeAdmin / ScopeConsoleFleet); asserts no context bleed, audit.admin_scope_used emitted per admin subscribe, goroutine-baseline restored); internal/protocol/transports/control/status.go (the new CodeIdentityScopeRequired → 403 HTTP-status mapping); internal/protocol/transports/control/status_test.go (table extension + exhaustiveness check switched to derive from protoerrors.Codes() so a future code without a status entry surfaces by NAME + the new TestStatusFor_CodeIdentityScopeRequired_Returns403); internal/protocol/conformance/conformance.go (matrix exhaustiveness: methods.Methods() count to 11; errorCodeMatrix adds the new code; expectedHTTPStatus adds 403; new CodeIdentityScopeRequired_CrossTenantWithoutScope scenario in runErrorCodeMatrix + new EventsSubscribe_HappyPath top-level subtest; runMethodMatrixHappyPath + runMethodMatrixMalformedRequest skip MethodEventsSubscribe because the streaming-events method is served by the SSE transport, not REST control); internal/protocol/conformance/internal_test.go (errorCodeMatrix size pin updated 8 → 9); internal/protocol/control.go (the existing Dispatch rejects MethodEventsSubscribe at the REST surface with CodeInvalidRequest + a "wrong transport" message — the streaming-events vocabulary is NOT served by the REST control surface; the compile-time init() exhaustiveness check switched from if m == MethodStart to if !IsControlMethod(m) so non-control methods don't need a methodToControlType entry); internal/protocol/singlesource/singlesource.go (CanonicalMethods adds "events.subscribe" — the duplication-pin lockstep with internal/protocol/methods); test/integration/events_subscribe_scope_test.go (NEW — the §13 primitive-with-consumer discharge: six scope-degradation scenarios with real events/drivers/inmem + real protocol/auth.Middleware over the real ES256 testdata keypair + real transports.NewMux); scripts/smoke/phase-72.sh (already authored when the plan landed; covers the surface assertions per the plan); docs/plans/README.md (Phase 72 row Pending → Shipped + the detail block's new plan-file pointer); docs/plans/phase-72-console-subscription-scope.md (the binding spec — pre-existing); docs/glossary.md (extends the existing events.subscribe / identity_scope_required entries + adds Scope-degradation regression suite); README.md (the Phase 72 status row).

Decision. Phase 72 elevates the already-shipped events.subscribe substrate (Phase 05 bus + Phase 06 replay + Phase 60 /v1/events SSE + Phase 61 JWT scope-claim gate) into a first-class canonical Protocol surface. Four binding calls land here.

1. events.subscribe is a canonical Protocol method name. The wire-transport route is still GET /v1/events (Phase 60 SSE); the method-name constant methods.MethodEventsSubscribe = "events.subscribe" is the new wire-contract anchor third-party Console implementations branch on. It is NOT a steering-control method: IsControlMethod("events.subscribe") is false, the Phase 54 control nine stays exclusive, and the REST ControlSurface.Dispatch rejects events.subscribe with CodeInvalidRequest + "use the SSE transport at GET /v1/events instead" (the streaming-events vocabulary is served by the SSE transport, not the steering inbox). The wire string is final; bumping it is a Protocol version change (RFC §5.3).

2. CodeIdentityScopeRequired is the canonical wire-rejection code. Returned at the SSE edge when a Subscribe request's scope set is insufficient for the requested cross-tenant fan-in — typically a ?admin=1 request from a JWT lacking auth.ScopeAdmin or auth.ScopeConsoleFleet (D-079). HTTP status 403 (the request is authenticated; the scope set does not authorize the operation). Distinct from CodeIdentityRequired (missing triple, 401), CodeAuthRejected (token invalid, 401), and CodeScopeMismatch (reserved for the steering-control scope-claim path per RFC §6.3). The wire transport collapses BOTH events.ErrIdentityScopeRequired (Subscribe filter elided the triple AND Admin was false) and events.ErrAdminScopeRequired (Admin requested without the verified scope claim) onto this single Code: from the third-party Console's perspective the operator-actionable answer is the same — attach a scope-bearing token. The Go-level distinction stays available for in-process callers. The ?admin=1 reject path now emits the canonical JSON Protocol error envelope ({code:"identity_scope_required", message:...}) instead of the pre-Phase-72 free-form scope_mismatch: plain-text body — a third-party Console branches on body.code, not a prose grep.

3. The closed-scope set (D-079) holds — no new events.crosstenant scope. The Wave 13 decomposition §4 row 72 phrasing hinted at a dedicated events.crosstenant scope; this PR explicitly rejects that. D-079 settled the closed scope set as ScopeAdmin + ScopeConsoleFleet and brief 11 §CC-2 maps both to the cross-tenant case (admin = full fleet; console:fleet = fleet observation). Introducing a third scope would re-litigate D-079 without new evidence and would also leak into Phase 73's state-inspection methods (sessions.inspect, tasks.get, etc.) which are also scope-gated. If a finer-grained scope vocabulary is needed at some future point, that is an RFC PR + a new decisions entry, not a Phase 72 deliverable. The integration test pins this: both ScopeAdmin AND ScopeConsoleFleet satisfy the cross-tenant gate (scenarios 3 + 4); a JWT lacking both is rejected (scenarios 2 + 6).

4. §13 primitive-with-consumer discharge in-phase. Three same-PR consumers exercise the primitive end-to-end:

  • The conformance suite's EventsSubscribe_HappyPath + CodeIdentityScopeRequired_CrossTenantWithoutScope matrix scenarios — the wire transport carries a triple-scoped subscribe to 200 + an ?admin=1-without-scope subscribe to 403 + body-Code identity_scope_required, real httptest.Server, real ES256 keypair.
  • The D-025 concurrent-reuse test (concurrent_scope_test.go::TestStreamHandler_ConcurrentScopedReuse) — N=128 concurrent subscribers under -race, no context bleed, audit-emit-per-admin pinned, goroutine baseline restored. The test uses an in-package scopeMiddleware (header-driven) instead of a full JWT validator so the stress run doesn't have to mint 128 JWTs per iteration; the JWT path is exercised by the integration test below.
  • The integration test (test/integration/events_subscribe_scope_test.go) — six scope-degradation scenarios with real events/drivers/inmem + real protocol/auth.Middleware over the real ES256 testdata keypair (internal/protocol/auth/testdata/) + real transports.NewMux: triple-scoped no-cross-tenant-leak, ?admin=1 without scope → 403, ?admin=1 with ScopeAdmin → 200 + cross-tenant fan-in + audit.admin_scope_used observable, ?admin=1 with ScopeConsoleFleet → 200, expired token → 401 auth_rejected (auth layer fail-closes BEFORE the scope gate so the new Code is NOT reached), dropped-middleware shape → 403, body-vs-token identity mismatch (JWT wins per Phase 61's ctx-attached identity).

Filter-shape extensions deferred to Phase 72a. The plan's non-goals list is binding: event-type set / time-window / run-set predicates beyond the existing triple + types ship in Phase 72a per the Wave 13 decomposition §4 row 72a. events.aggregate (time-bucketed counts for sparklines) also lands in 72a. This phase is the scope-claim foundation 72a / 73c / 73d / 73g / 73j / 73k all compose on top of.

internal/protocol/conformance matrix amendment (drift guard). The matrix exhaustiveness check now expects methods.Methods() to return 11 entries (10 task-control + 1 streaming-events anchor); errorCodeMatrix exhaustiveness adds the new code; expectedHTTPStatus adds the 403 mapping; runMethodMatrixHappyPath + runMethodMatrixMalformedRequest skip MethodEventsSubscribe because its happy-path + reject-path live under dedicated subtests (the streaming-events method is served by the SSE transport, not REST control — a happy-path Dispatch against the control surface would be wrong). The runEventsSubscribeNegotiation subtest pins the wire round-trip + the "wrong transport" guard + the registration invariants.

Wire-status mapping decision: 403 not 401. Phase 61's ?admin=1 gate was already returning HTTP 403 with a free-form scope_mismatch: body string — Phase 72 preserves that status and adds the typed Code so a client can branch on the Code rather than the prose. 403 is correct semantically (the request IS authenticated; only the scope set is insufficient); 401 would imply the request lacks authentication entirely, which would be wrong.

Acceptance.

  • methods.MethodEventsSubscribe declared + registered + IsValidMethod returns true + IsControlMethod returns false + Methods() returns 11 entries in sorted order.
  • errors.CodeIdentityScopeRequired declared + registered + IsValidCode returns true + Codes() returns it in lexicographic order.
  • transports/stream maps events.ErrIdentityScopeRequired AND events.ErrAdminScopeRequired onto CodeIdentityScopeRequired at HTTP 403 with the canonical JSON Protocol error envelope. The ?admin=1 gate's pre-existing 403 reject path now emits the typed Code in the body.
  • transports/control/status.go maps CodeIdentityScopeRequired → 403; the exhaustiveness check derives from protoerrors.Codes() (D-082 amendment).
  • protocol.ControlSurface.Dispatch(MethodEventsSubscribe) returns CodeInvalidRequest with a "use the SSE transport" message — the streaming-events vocabulary is NOT served by the REST control surface.
  • internal/protocol/conformance matrix exhaustiveness includes the new method + the new code; EventsSubscribe_HappyPath + CodeIdentityScopeRequired_CrossTenantWithoutScope matrix scenarios pass against the assembled wire stack.
  • internal/protocol/singlesource.CanonicalMethods adds "events.subscribe" — the duplication-pin lockstep test passes.
  • test/integration/events_subscribe_scope_test.go exercises six scope-degradation scenarios under -race with real drivers everywhere on the seam.
  • internal/protocol/transports/stream/concurrent_scope_test.go::TestStreamHandler_ConcurrentScopedReuse exercises N=128 concurrent subscribers against one shared Handler + one shared EventBus under -race, asserts no context bleed + the audit-emit invariant + baseline goroutine count restored.
  • scripts/smoke/phase-72.sh (pre-existing, classified live-server per D-104) passes against harbor dev; the 404/405/501 → SKIP convention keeps it harmless on pre-Phase-72 builds.
  • docs/plans/README.md Phase 72 row Pending → Shipped + detail-block plan-file pointer.
  • README.md Phase 72 status row added.
  • docs/glossary.md extends the existing events.subscribe / identity_scope_required entries; adds Scope-degradation regression suite.

Structural precedent. D-079 (Phase 61 Protocol auth — ScopeAdmin + ScopeConsoleFleet + CodeAuthRejected) is the immediate predecessor and the source of the closed scope set Phase 72 consumes. D-077 (Phase 59 Protocol versioning + capability handshake) is the structural shape for the canonical-constant pattern — Capability + Method + Code all live in fixed package-level maps with NO registration escape hatch. D-082 (Wave 10 audit fixes) is the source of the protoerrors.Codes()-derived exhaustiveness check the new code's status mapping inherits.


D-106 — EventFilter wire shape + events.aggregate Protocol method consume the D-079 closed two-scope set (no events.crosstenant scope)

Date: 2026-05-19 Status: Settled (shipping with this PR — Phase 72a)

Where it lives: internal/protocol/types/events.go (the four new wire types — EventFilter, EventBucket, EventAggregateRequest, EventAggregateResponse); internal/protocol/methods/methods.go (the two new method constants — MethodEventsSubscribe, MethodEventsAggregate — and the new IsStreamingEventsMethod predicate that keeps IsControlMethod exclusive to the Phase 54 nine); internal/protocol/errors/errors.go (the new CodeIdentityScopeRequired canonical code, mapped to HTTP 403 in internal/protocol/transports/control/status.go); internal/protocol/types/version.go (the new CapEventsSubscribe capability constant + its canonicalCapabilities entry — the second Protocol surface advertised in VersionHandshake); internal/events/filter.go (the FilterFromWire converter that backfills the caller's tuple and flags RequiresAdminScope when the wire filter names a tenant other than the caller's, plus the pure MatchWire predicate that filters on header fields only — payload-byte predicates are out of scope per Brief 11 §CC-4 + D-026); internal/events/aggregate.go (the Aggregator compiled artifact + Aggregate(ctx, req) that snapshots the bus's Replayer, bucket-counts in Go, fails loudly on bad Window/Bucket pairs); internal/protocol/transports/stream/handlers.go (the AggregateHandler wire adapter mounted at POST /v1/events/aggregate); internal/protocol/transports/transports.go (the new WithAggregateClock test option + the AggregateHandler mounted alongside the existing control + stream handlers); internal/protocol/singlesource/singlesource.go (the lockstep map gains entries for both new method names + the four new wire types); internal/protocol/conformance/conformance.go (the method matrix bumps to 12, the error-code matrix gains CodeIdentityScopeRequired, the capability matrix gains CapEventsSubscribe; streaming-events methods are excluded from runMethodMatrixHappyPath / runMethodMatrixMalformedRequest via methods.IsStreamingEventsMethod); test/integration/events_filter_aggregate_test.go (the §13 primitive-with-consumer integration test under -race: real events/drivers/inmem + real protocol/auth.Middleware + real transports.NewMux, six scenarios — happy path, cross-tenant rejection without scope, cross-tenant acceptance with admin, cross-tenant acceptance with console:fleet, missing-bearer rejection, bad Window/Bucket rejection, N=16 concurrent clients under -race); test/integration/wave10_test.go::TestE2E_Wave10_VersionHandshake_ContractStable (extended to assert the post-Wave-13 capability set — task_control + events_subscribe); scripts/smoke/phase-72a.sh (real assertions over POST /v1/events/aggregate: surface probe, missing-bearer 401, happy-path 200 + 60-bucket length, bad-window 400, cross-tenant-without-scope SKIP-deferred-to-integration); docs/glossary.md (new entries: events.aggregate, EventFilter, EventBucket); docs/plans/README.md (Phase 72a row added at line 97 + detail block); docs/plans/phase-72a-events-filter-aggregate.md (the binding plan that pre-dated this implementation).

Decision. The Wave 13 Console surface needs two Protocol primitives the existing events.subscribe (Phase 60 / Phase 72) cannot supply: a structured filter shape that scoped Console clients pass at subscription time (event-type set + identity narrowing + time-window bounds), and a events.aggregate time-bucket method that powers the per-event-type stacked-area sparkline at the top of the Console Events page (page-events.md §12). Phase 72a ships both as a single coherent Protocol-surface addition.

Three intertwined design calls land here.

1. The wire filter is a four-axis-of-narrowing struct (event types + identity + window), not a free-form predicate. EventFilter carries: EventTypes []string, TenantIDs []string, UserIDs []string, SessionIDs []string, RunIDs []string, Since time.Time, Until time.Time. Empty axes default to "any" (or the caller's own component for identity axes). This shape composes naturally with the Phase 05 events.Filter (which is single-valued on triple components + admin-boolean for cross-tenant) — FilterFromWire resolves the wire request to a bus-facing Filter and flags RequiresAdminScope when the wire filter names a tenant other than the caller's. Substring/payload predicates are explicitly out of scope (Brief 11 §CC-4: high-cardinality runtime-side search is post-V1 because it would force the runtime to materialise heavy payloads through the D-026 LLM-edge safety net).

2. Cross-tenant filters consume the D-079 closed two-scope set, NOT a new events.crosstenant scope. The Wave 13 decomposition §4 row 72 hints at a "cross-tenant claim (D-079)" wording; the natural temptation is to mint a third scope (events:crosstenant) for the events surface specifically. We resolve this in the negative: D-079 already settled the closed scope set (auth.ScopeAdmin + auth.ScopeConsoleFleet), and Brief 11 §CC-2 maps BOTH to the cross-tenant fan-in case (admin = full fleet; console:fleet = fleet observation). Introducing a third scope here would re-litigate D-079 without new evidence and would also leak into the Phase 73 state-inspection methods (which are also scope-gated on the same closed set). The wire edge in internal/protocol/transports/stream/handlers.go gates on auth.HasScope(ScopeAdmin) OR auth.HasScope(ScopeConsoleFleet); a request lacking BOTH is rejected with CodeIdentityScopeRequired (HTTP 403). The operator may revisit at a future RFC PR + new decisions entry; not a Phase 72a deliverable.

3. events.aggregate snapshots the bus's Replayer and counts in Go. The aggregator is a D-025-safe compiled artifact: bus + clock are set once at construction, never mutated; each Aggregate(ctx, req) allocates its own bucket slice and returns when done. Bucket arithmetic is deterministic — Window % Bucket == 0 is mandatory (else ErrAggregateBadWindow → HTTP 400 / CodeInvalidRequest); every bucket is present even when empty so a rendering client sees a contiguous time axis. A bus that does not implement Replayer (or whose ring is disabled) fails loudly with ErrReplayUnavailable rather than producing an empty series that looks like "no events" (CLAUDE.md §5: fail loudly, no silent degradation). The concurrent-reuse contract (D-025) is pinned by an N=100+-goroutine test at internal/events/aggregate_test.go::TestAggregate_ConcurrentReuse + an N=16 wire-level test at test/integration/events_filter_aggregate_test.go::TestE2E_Phase72a_ConcurrentAggregateClients.

Why. The Console Events page (page-events.md) is the canonical Stage-2 consumer for both primitives. Without EventFilter, the Events page would either fetch every event in the runtime and filter client-side (the predecessor sharp edge Brief 11 §CC-4 names explicitly as "runtime-side high-cardinality") or paper over with a hand-rolled query-string convention on /v1/events. Without events.aggregate, the per-event-type sparkline would either be missing or hand-rolled from the live SSE stream (a heavy, latency-bound, and unrepresentative approach for the multi-hour windows operators actually care about). Both gaps would push Console-side persistence — exactly the shape D-061 forbids.

The §13 primitive-with-consumer rule binds in-phase: the integration test consumer (test/integration/events_filter_aggregate_test.go) exercises the full wire surface end-to-end with real drivers under -race, and the per-package conformance tests pin the filter matrix, bucket arithmetic, and concurrent-reuse contract. The Stage-2 Console consumer (Phase 73g Events page) lands in the same wave per the Wave 13 staging.

Helper-tracks-production invariant (per D-094). The AggregateHandler lives in internal/protocol/transports/stream/ alongside the SSE handler — same package, same identity-resolution helpers (resolveIdentity), same auth-middleware fallback contract. There is no second identity-resolution shape on the events.aggregate surface.

§13 fail-loud paths.

  1. Missing identity at the wire edge → CodeIdentityRequired (HTTP 401) before the handler runs (Phase 61 auth.Middleware) OR before any aggregate work (resolveIdentity failure).
  2. Cross-tenant filter without the closed-set scope claimCodeIdentityScopeRequired (HTTP 403). Distinct from CodeIdentityRequired (no identity at all) and CodeAuthRejected (token invalid).
  3. Non-dividing Window/Bucket pairCodeInvalidRequest (HTTP 400). The aggregator never silently rounds.
  4. Bus without Replayer (forward-only driver or ReplayBufferSize=0) → CodeRuntimeError (HTTP 500) with a clear "no historical aggregation" message. Never an empty series that looks like "no events".

§13 primitive-with-consumer. The four primitives (the wire types, the two method constants, the new error code, the new capability) all land with their first consumers in the same PR:

  • EventFilter + MatchWire are consumed by Aggregator.Aggregate (the per-event filter loop) AND by FilterFromWire (the bus-edge converter); the unit tests at internal/events/filter_test.go pin the predicate's full axis matrix.
  • events.aggregate + AggregateHandler are consumed by the integration test (TestE2E_Phase72a_*) over real httptest at the wire edge.
  • CodeIdentityScopeRequired is consumed by the cross-tenant-without-scope integration test scenario AND by the httpStatus mapping table in internal/protocol/transports/control/status.go (HTTP 403).
  • CapEventsSubscribe is consumed by TestE2E_Wave10_VersionHandshake_ContractStable (the wave-10 E2E gains a pin on the post-Wave-13 capability set) AND by the conformance suite's runVersionHandshake check.

Recurring-failure-mode pre-empts (per §17.7 step 3 + the locked-in coordinator-verify protocol):

  • Method matrix exhaustiveness. Adding two methods means bumping the assertMethodMatrixExhaustive count from 10 to 12 AND adding the new constants to the wantSet AND skipping streaming-events methods in runMethodMatrixHappyPath / runMethodMatrixMalformedRequest (they route through their own transports, not the REST control surface). The IsStreamingEventsMethod predicate is the structural way to express this — a future Protocol-surface phase that adds another non-control method extends the same predicate, never re-implementing the skip logic.
  • Error code matrix exhaustiveness. Adding CodeIdentityScopeRequired means bumping errorCodeMatrix count (the lockstep test in internal/protocol/conformance/internal_test.go::TestInternal_ErrorCodeMatrix_AllCanonical fails loudly until the count matches) AND adding the new entry to expectedHTTPStatus (HTTP 403) AND mapping it in internal/protocol/transports/control/status.go.
  • Singlesource lockstep. Adding two methods + four wire types means extending singlesource.CanonicalMethods (the duplicated set the checker uses) AND singlesource.CanonicalWireTypes (the type-home map). The TestSingleSource_CanonicalMethodsInLockstep / TestSingleSource_CanonicalWireTypesInLockstep lockstep tests fail loudly on drift.
  • Wave 10 handshake E2E drift. The pre-Wave-13 wave-10 E2E asserted len(caps) != 1 (task_control only); landing a new capability silently here would either fail the wave-10 E2E or — worse — keep the assertion green by accident. The wave-10 E2E gains a second pin (Accepts(CapEventsSubscribe)) AND a count of 2 in the same PR per §17.6 ("fix what the integration test finds — no matter where the bug lives").
  • Aggregator clock for tests. Production aggregator clock is real-time UTC; tests with backdated events need a deterministic clock. The WithAggregatorClock aggregator option + the new WithAggregateClock mux option close this — the integration test injects a fixedNowPhase72a instance and the wire-level bucket arithmetic is deterministic.

Acceptance:

  • internal/protocol/types/events.go declares the four wire types; the singlesource lockstep recognises them.
  • internal/protocol/methods/methods.go declares MethodEventsSubscribe + MethodEventsAggregate; IsValidMethod returns true for both; IsControlMethod returns false for both (they route through their own transports); IsStreamingEventsMethod is the predicate the transport router uses to classify.
  • internal/protocol/errors/errors.go declares CodeIdentityScopeRequired; the canonical-set ordering is stable; IsValidCode returns true; the HTTP-status map returns 403.
  • internal/protocol/types/version.go declares CapEventsSubscribe; the version handshake advertises it alongside CapTaskControl.
  • internal/events/filter.go::MatchWire is a pure predicate over event headers; the filter matrix unit test exercises every axis combination including the empty-filter no-op case.
  • internal/events/aggregate.go::Aggregator.Aggregate returns a deterministic bucket series; the bucket-arithmetic test pins the per-bucket counts; the concurrent-reuse test pins N≥100 invocations against ONE shared aggregator under -race.
  • POST /v1/events/aggregate is mounted on the wire mux; the integration test exercises happy + reject paths end-to-end against a real httptest.Server with real ES256-signed JWTs (no mocks at any seam per §17.3).
  • Cross-tenant requests without auth.ScopeAdmin OR auth.ScopeConsoleFleet return 403 + CodeIdentityScopeRequired. NO new events.crosstenant scope.
  • scripts/smoke/phase-72a.sh shows OK > 0 on a live Phase 72a build (the surface probe, missing-bearer 401, happy-path 200 + 60-bucket length, bad-window 400 assertions all pass) and SKIPs cleanly on pre-Phase-72a builds.
  • The wave-10 handshake E2E is extended to assert the post-Wave-13 capability set (task_control + events_subscribe).
  • docs/glossary.md carries the new vocabulary entries; docs/plans/README.md Phase 72a row flips to Shipped; docs/decisions.md carries this D-106 entry.

D-107 — Phase 72b IdentityScope admin-impersonation extension: Actor / Requester / Impersonating triplet on the wire; auth.ScopeAdmin gate at the Protocol edge; typed auth.AdminScopeUsedPayload on the existing audit.admin_scope_used event

Date: 2026-05-19 Status: Settled (shipping with this PR)

Where it lives: internal/protocol/types/control.go (IdentityScope.Actor / Requester / Impersonating as *IdentityScope pointers with omitempty JSON tags + the IsImpersonating() predicate); internal/protocol/types/types_test.go (JSON round-trip + omitempty regression + StartRequest impersonation round-trip); internal/protocol/auth/events.go (AdminImpersonationReason constant + IdentityTriple flat audit shape + AdminScopeUsedPayload typed payload composing events.SafeSealed); internal/protocol/auth/events_test.go (compile-time SafePayload assertion + shape pinning + sentinel stability); internal/protocol/transports/control/control.go (the impersonation gate assertImpersonationShape + the audit emit emitAdminScopeUsed + the WithEventBus / WithRedactor / WithClock handler options + the redactedString helper); internal/protocol/transports/control/impersonation_test.go (17-shape gate table covering the five primary cases + actor-mismatches-JWT + requester-diverges-from-actor + missing-actor + missing-requester + actor-incomplete + requester-incomplete + no-ctx-identity + bare-handler-no-bus + non-impersonation-mismatch-still-rejected + cross-method + redactor-error-paths); internal/protocol/transports/transports.go (WithRedactor mux option threading the redactor into the control handler); test/integration/identityscope_impersonation_test.go (REAL Phase 60 transport mux + REAL Phase 61 ES256 validator + REAL audit/drivers/patterns redactor + REAL events/drivers/inmem bus, 6 end-to-end scenarios under -race); scripts/smoke/phase-72b.sh (smoke assertions per the master plan; auto-SKIPs until the Protocol JSON-RPC stub lands); docs/plans/README.md (Phase 72b row added as Shipped); docs/plans/phase-72b-identityscope-impersonation.md (the plan); docs/glossary.md (the four impersonation entries already landed pre-PR); docs/decisions.md (this entry).

Decision. The Phase 72b extension introduces three new *IdentityScope pointer fields on internal/protocol/types.IdentityScopeActor / Requester / Impersonating — to carry the admin-on-behalf-of-user triplet on every Protocol request. The fields are mutually-required: an IdentityScope MAY carry zero impersonation fields (today's behaviour, the verified JWT identity IS the request identity) OR all three set (admin-on-behalf-of-user). The runtime rejects any other shape loudly at the Protocol edge — never silently degrades. Five gating invariants on the impersonation path:

  1. auth.ScopeAdmin is mandatory. A non-admin token with Impersonating set is rejected with CodeScopeMismatch (HTTP 403) before Dispatch runs. The closed scope set from D-079 is reused; no new scope is minted.
  2. The impersonated triple is identity. Impersonating.Tenant / User / Session must all be non-empty — identity is mandatory, with no identity-downgrading knob (CLAUDE.md §6 rule 9). A missing component rejects with CodeIdentityRequired (HTTP 401).
  3. The Actor MUST equal the verified JWT identity. The Actor is the audit anchor — faking it is a privilege-escalation attempt. Actor.Tenant / User / Session must equal the JWT's verified triple; a mismatch rejects with CodeScopeMismatch (HTTP 403).
  4. V1 invariant: Requester == Actor. Delegated-impersonation chains ("admin A acting on behalf of admin B's audited request") need the two fields to diverge; V1 supports single-hop impersonation only. A divergence at V1 rejects with CodeScopeMismatch. The field exists on the wire so post-V1 delegated impersonation does not require a wire-shape break.
  5. Top-level Tenant/User/Session == Impersonating triple. The run executes as the impersonated identity; the top-level triple carries that identity. A mismatch rejects with CodeIdentityRequired.

On accept, the transport emits a audit.admin_scope_used event with a typed auth.AdminScopeUsedPayload (Actor + Requester + Impersonating as flat IdentityTriple + Reason="impersonation" + Method=<protocol method>) onto the wired event bus. The payload runs through the wired audit.Redactor BEFORE the publish per CLAUDE.md §7 rule 6 + D-020 — the redactor walks a map[string]any of the fields, the gate extracts redacted strings, the typed payload is assembled and published. The event Identity is the IMPERSONATED triple so a Console subscribing to events for the impersonated session sees the audit emit alongside the run's own events; the Actor on the payload provides audit-side correlation.

Why. Brief 11 §PG-5 ("Run as another identity") names the verbatim triplet (actor=admin, requester=admin, impersonating=user_id). The Sessions page mockup (PR #138) needs an identity column that surfaces "who initiated this run" vs. "who the run executes as" — the wire primitive is the load-bearing extension that makes the column meaningful end-to-end. Brief 12 §"two-surface model" pins the wire-shape side: the triplet MUST live on internal/protocol/types.IdentityScope, not in web/console/, so a third-party Console implementing harbor console from scratch sees the same shape Harbor's own code does.

§13 primitive-with-consumer. The primitive (the Actor / Requester / Impersonating wire fields + the transport-edge gate + the typed audit payload) lands with its first consumer (test/integration/identityscope_impersonation_test.go — six end-to-end scenarios through the production wire path + the in-package gate table covering every defensive branch) in the same PR. Per the plan's non-goal carve-out, the Console UI consumer (the Sessions page identity column extraction stub) lands in 73c later in Wave 13 Stage 2.2; the same-wave consumer requirement is satisfied by the integration test consumer here. No deferred consumer.

§13 fail-loud. Five loud-failure paths land in this PR:

  1. Impersonating set without auth.ScopeAdmin rejects at the transport edge with CodeScopeMismatch BEFORE Dispatch runs. Defence in depth at the transport edge mirrors Phase 61 D-079 §4.
  2. Incomplete impersonation triple (any of Tenant / User / Session empty) rejects with CodeIdentityRequired. Identity is mandatory; the impersonated triple is identity too.
  3. Actor != verified JWT identity rejects with CodeScopeMismatch. The audit trail's accountability stands or falls here.
  4. Requester != Actor rejects with CodeScopeMismatch. V1 invariant; delegated impersonation is post-V1.
  5. Bus / Redactor not wired on the transport + an impersonation request → refuse with CodeRuntimeError. The audit emit is the load-bearing accountability surface; without it the gate refuses fail-closed rather than silently accepting (CLAUDE.md §13 "Silent degradation").

§13 closed scope set. No new scope is minted. The gate uses auth.ScopeAdmin from the closed two-scope set settled in D-079 (auth.ScopeAdmin + auth.ScopeConsoleFleet); a future expansion of the scope set is a Protocol-surface phase, not an ad-hoc addition.

Audit payload shape — typed auth.AdminScopeUsedPayload co-located with AuthRejectedPayload, NOT the pre-existing events.AdminScopeUsedPayload. The pre-existing emit site (the Phase 05 events.Subscribe admin-filter, internal/events/drivers/inmem) continues to use the lighter events.AdminScopeUsedPayload shape (Tenant / User / Session / SubscriberID). Phase 72b's impersonation emit needs a richer typed payload (Actor + Requester + Impersonating + Reason + Method), so a new auth.AdminScopeUsedPayload lives next to AuthRejectedPayload in internal/protocol/auth/events.go. The two payload types share the canonical audit.admin_scope_used event type; subscribers branch on the payload shape. Promoting the existing emit site to the new typed payload is a follow-up deferred to a Wave 13 audit cleanup PR.

IdentityTriple separate from identity.Identity. The audit payload uses a flat IdentityTriple{Tenant, User, Session} rather than re-using identity.Identity because the audit payload lives on the wire-adjacent bus surface, not on the runtime's identity-quadruple surface. Mirroring the runtime type 1:1 would couple the audit shape to internal storage refactors (the same anti-pattern RFC §5.1 names for the wire IdentityScope).

Defence in depth: assertBodyMatchesAuthedIdentity bypasses impersonation-shaped bodies. When a body carries Impersonating, the top-level Tenant/User/Session is the IMPERSONATED identity (deliberately != JWT). The Phase 61 body-vs-JWT check would otherwise reject the request. The impersonation gate is the authoritative check for that shape and runs immediately after the Phase 61 check; the Phase 61 check returns nil for impersonation-shaped bodies and lets the impersonation gate take over.

Backward compatibility. When all three impersonation fields are empty (Impersonating == nil), the behaviour is identical to today's StartRequest / ControlRequest surface — the verified JWT identity IS the request identity, no audit emit, no gate. Existing tests pass unchanged. New tests cover the impersonation paths; the bare NewHandler(surface) constructor still compiles and works for non-impersonation paths.

Acceptance.

  • internal/protocol/types/control.go extends IdentityScope with the three pointer fields + IsImpersonating() predicate; godoc pins V1 semantics.
  • internal/protocol/types/types_test.go JSON round-trip + omitempty regression + StartRequest cross-check.
  • internal/protocol/auth/events.go adds AdminImpersonationReason constant + IdentityTriple flat shape + AdminScopeUsedPayload typed payload.
  • internal/protocol/auth/events_test.go compile-time SafePayload + shape pinning + sentinel stability.
  • internal/protocol/transports/control/control.go extends ServeHTTP with the impersonation gate + the audit emit; adds WithEventBus / WithRedactor / WithClock handler options.
  • internal/protocol/transports/control/impersonation_test.go 17-shape table covering every gate edge case + every emit-path defensive branch.
  • internal/protocol/transports/transports.go adds WithRedactor mux option threading the redactor into the control handler.
  • test/integration/identityscope_impersonation_test.go runs the round-trip end-to-end through the REAL Phase 60 transport mux + REAL Phase 61 ES256 validator + REAL audit/drivers/patterns redactor + REAL events/drivers/inmem bus; six scenarios under -race; N=16 concurrency stress.
  • scripts/smoke/phase-72b.sh upgraded from skeleton to real assertions (auto-SKIPs until the Protocol JSON-RPC stub lands per the protocol_call convention).
  • Coverage on internal/protocol/transports/control: 90.7% (target 89.5%); internal/protocol/auth: 90.0% (target 90%); internal/protocol/types: 86.3% (the new code is 100% covered; the version.go drag is pre-existing).
  • make vet test lint build clean; go test -race -count=1 ./... green.
  • docs/decisions.md D-107 entry filed; docs/plans/README.md row 72b flipped to Shipped; docs/glossary.md already carries the four entries (impersonation, actor, requester, impersonating).

D-108 — Phase 72c search.* cluster (5 methods, one phase): runtime-side search over sessions / tasks / events / artifacts; search.query palette dispatcher fans out + merges; cross-tenant gated on auth.ScopeAdmin (D-079 reuse, NO new search.crosstenant scope); CodeScopeMismatch for the cross-tenant rejection (CodeAuthRejected stays pinned to 401 per D-079)

Date: 2026-05-19 Status: Settled Where it lives: RFC §5.2 (state snapshots row) + §6.13 (typed event bus) + §7 (Console layer), docs/plans/phase-72c-search-cluster.md, docs/plans/wave-13-decomposition.md §4 / §12 lock-in #4 ("keep as one phase"), CLAUDE.md §6 (multi-isolation) + §13 (primitive-with-consumer + identity-mandatory), internal/protocol/methods/methods.go (the five MethodSearch* constants + canonicalSearchMethods + IsSearchMethod), internal/protocol/types/search.go (the SearchRequest / SearchResponse / SearchResultRow / SearchFilter / SearchFacet / SearchArtifactRef wire shapes + the DefaultSearchPageSize=20 / MaxSearchPageSize=200 bounds), internal/protocol/singlesource/singlesource.go (the CanonicalMethods + CanonicalWireTypes extensions in lockstep), internal/protocol/search.go (the transport-agnostic SearchSurface dispatcher + mapSearchError), internal/protocol/transports/control/control.go (the WithSearchSurface option + the IsSearchMethod-routing path in ServeHTTP), internal/protocol/transports/control/search_handler.go (the REST decoder + Protocol-error wire mapper for the five methods), internal/search/ (the per-index seam + the Query aggregator + per-index packages internal/search/{sessions,tasks,events,artifacts}/), internal/sessions/registry.go (the new SessionLister capability ListSnapshots), test/integration/search_cluster_test.go (the §17.1 cross-subsystem integration test), scripts/smoke/phase-72c.sh, docs/glossary.md (the seven new entries), brief 11 §CC-4 (runtime-side vs Console-side split), brief 12 §"two-surface model".

Why: Brief 11 §CC-4 settled the design split: runtime-side search for the four high-cardinality entity classes (sessions, tasks, events, artifacts), Console-side adapters for the slow-moving catalog data (tools, agents, flows, MCP connections). The wave-13 decomposition doc §12 lock-in #4 settled the shape ("keep as ONE phase" — the methods share the same conformance surface). Six design calls warrant a durable home so a future auditor doesn't churn them.

  1. The five methods land together as one phase, not split into "primitive then UI" or "palette then per-index." §13's primitive-with-consumer rule reads here as "the palette dispatcher and the per-index Searchers are each other's first consumer." Splitting the cluster would create two halves where each half's tests would still pass (the dispatcher fans out to mocks; the per-index Searchers run standalone) but the seam between them would be unexercised. The decomposition doc §12 lock-in #4 ("Keep as one phase. The methods share the same conformance surface — identity filtering, redaction, pagination, scope claim") is the binding answer; this entry records the rationale for the next plan author who sees five methods and thinks "split."

  2. Cross-tenant gating reuses auth.ScopeAdmin (D-079), NOT a new search.crosstenant scope. Phase 72's plan Non-goals explicitly forbid a third scope; the audit in PR #142 closed the proposal. The closed two-scope set (ScopeAdmin + ScopeConsoleFleet) is sufficient — search reuses the same admin entitlement that events.subscribe Admin=true consults. Minting a per-subsystem scope per cross-tenant call site would re-fragment the auth surface into N entitlements where one suffices. The search.ErrCrossTenantRequiresAdmin sentinel surfaces in the API; the wire mapping is the next call.

  3. The cross-tenant rejection wire code is CodeScopeMismatch (HTTP 403), NOT CodeAuthRejected. The Phase 72c plan's acceptance criteria asked for "403 with CodeAuthRejected" — but CodeAuthRejected is pinned to HTTP 401 by D-079 (the wire mapping for a JWT that failed cryptographic verification). Two codes mapping to two different HTTP statuses cannot share one wire-line; the search subsystem's "authenticated but lacking the cross-tenant claim" shape is the same shape as the steering-control "authenticated but lacking the admin scope" rejection (RFC §6.3 PRIORITIZE), which is already CodeScopeMismatch (403). Reusing it keeps the wire taxonomy stable: 401 = "your token failed to verify"; 403 = "your token verified but you lack the privilege for this action." A reader of the plan should treat the "403 with CodeAuthRejected" line as the plan's intent (any 403 for the cross-tenant case is OK); the implementation realises it via the existing CodeScopeMismatch code. The Wave 13 audit can amend the plan text in-place if it surfaces this drift.

  4. Per-index searchers consume the existing read-side surface; no new Protocol method shapes for the underlying entities. Sessions search reads from a new sessions.SessionLister.ListSnapshots capability on *sessions.Registry (additive — the SessionRegistry interface is unchanged). Tasks search reads from the existing tasks.TaskRegistry.List per session, iterating sessions visible to the caller. Events search reads from the existing events.Replayer interface (Phase 06). Artifacts search reads from the existing artifacts.ArtifactStore.List with a scope filter. V1 ships in-memory linear-scan semantics; the wire shape (SearchRequest / SearchResponse) is index-strategy-agnostic, so a post-V1 FTS sidecar (SQLite FTS5 / Postgres pg_trgm) is an additive swap of the implementation behind the same Searcher interface (the §4.4 seam shape).

  5. Heavy-payload bypass at the row-construction site (D-026), not at the wire boundary. Every per-index Searcher calls search.RedactAndCapPreview(ctx, redactor, preview); the helper redacts, checks the byte-length against HeavyPreviewThreshold (32 KiB, mirroring the LLM-edge safety net), and either returns a capped preview (≤ PreviewMaxRunes=256 runes after redaction) or signals the caller to populate a *SearchArtifactRef instead. The artifacts index ALWAYS carries a Ref (artifacts are by-reference by construction); the other three indexes carry one only when the preview byte-length would breach the threshold. The wire layer never has to introspect row bytes — the row arrives correctly shaped.

  6. The search.query palette dispatcher carries no index of its own and emits no events. It is a pure aggregator: identity + scope validation runs at the aggregate edge; then concurrent fan-out to every selected index via per-index goroutines with a PerIndexTimeout=5s cap; then merge + sort + paginate the union. Per-index hard errors (identity / scope) propagate as request failures; per-index soft errors (an upstream blew up) degrade gracefully — the dispatcher returns the union of the surviving indexes' rows. This is the ONE deliberate exception to the §13 fail-loud rule, and only AFTER the aggregate identity + scope gates have passed: those rejections stay loud at the dispatcher's own boundary.

§13 primitive-with-consumer — discharged in-phase. The Searcher interface + SearcherRegistry + the four per-index implementations + the Query aggregator + the SearchSurface Protocol dispatcher + the WithSearchSurface transport option are all primitives; their consumers ship in the same PR. Per-package query-shape conformance tests (one per runtime-side index, plus the aggregate test for search.query) exercise each Searcher end-to-end with the real driver dependencies (real *sessions.Registry, real tasks.TaskRegistry, real events.Replayer, real artifacts.ArtifactStore). The test/integration/search_cluster_test.go consumes the full chain — real Protocol transport + real auth shape + real cross-subsystem fan-out — against the assembled surface. The D-025 concurrent-reuse test (internal/search/concurrent_reuse_test.go, N≥100 against the shared registry under -race) closes the reusable-artifact contract for the entire subsystem.

§4.4 seam posture. One Searcher interface per index, one implementation per index in V1, no driver pluralism. The §4.4 seam shape (interface + factory + registry) is present for the post-V1 FTS-sidecar swap; it is NOT used to add a "default" stub Searcher to the registry (that would violate the §13 "stubs as production defaults" rule). The aggregate dispatcher silently skips unregistered indexes (a partial deployment is acceptable); a missing per-method registry entry surfaces at the Protocol surface as CodeUnknownMethod ("no Searcher registered for index X on this Runtime"), not as a silent empty result.

Conformance suite extension is in-phase only for the matrix exhaustiveness check (internal/protocol/conformance/conformance.go::assertMethodMatrixExhaustive now expects 15 methods, the five search methods are explicitly registered in wantSet). The per-method happy-path / malformed-request scenarios for the search cluster are deferred to Phase 80 (the Phase 80 plan extends the suite per its existing scope); until then, the conformance suite's MethodMatrix_* runners explicitly t.Skip the five search methods with an issue-style reason ("phase-72c: search.* methods exercised by their per-package conformance + integration tests; conformance-suite scenario lands in Phase 80"). The skip is observable (per CLAUDE.md §5: no silent skips); the per-package + integration tests cover the surface in the interim.

Wave-13 staging note. This phase is in Wave 13 Stage 1 Batch A (the operator's locked answer to §9 question 2 in docs/plans/wave-13-decomposition.md §12 #2). It depends on the shipped Phase 60 (transport), Phase 61 (auth + ScopeAdmin), Phase 06 (events Replayer), Phase 08 (sessions registry), Phase 20 (tasks registry), Phase 17/18/19 (artifacts store). It does NOT depend on Phase 73 (state inspection) — the decomposition doc §4 lists 73 as a Deps entry, but the dependency is on the ALREADY-SHIPPED SessionRegistry / TaskRegistry interfaces; Phase 73 will extend them additively (the contingency, if 73 reshapes them backward-incompatibly, is recorded in the phase plan's Risks section).


D-109 — Phase 72d notification.* event topic: per-class topic naming + runtime-internal mapper/Subscriber + Stage-1 binding test consumer

Date: 2026-05-19 Status: Settled (shipping with this PR)

Where it lives: internal/runtime/notifications/notifications.go (the V1 event-type constants + RegisterEventType from init() + V1NotificationClasses / V1TriggerEventTypes snapshots); internal/runtime/notifications/payloads.go (NotificationPayload — non-SafeSealed so the redactor walks the caller-controlled Summary — plus IdentityRejectedPayload for the fail-loudly path); internal/runtime/notifications/mapper.go (the pure-function Map(ctx, ev) translator with one case per V1 trigger type — task.failed, tool.approval_requested, governance.budget_exceeded, tool.auth_required, pause.requested); internal/runtime/notifications/subscriber.go (the long-lived Subscriber that opens an Admin-scope subscription on V1TriggerEventTypes, runs Map per delivered event, and republishes the synthesised notification.* events through the same bus — fail-loudly on identity-rejection via the D-033 <missing> sentinel and on mapper errors via runtime.error); internal/runtime/notifications/errors.go (ErrUnmappable); internal/runtime/notifications/mapper_test.go (5 V1 mapping unit tests + unmapped-returns-nil-nil + ErrUnmappable + TestMap_ConcurrentReuse N=100 under -race); internal/runtime/notifications/subscriber_test.go (the BINDING §13 Stage-1 round-trip — TestSubscriber_TaskFailedSynthesisesNotificationTaskFailed — plus TestSubscriber_Run_GoroutineLeak + nil-bus / nil-log constructor guards); test/integration/notifications_topic_test.go (the §17 integration suite — every V1 mapping round-trip with identity propagation + the missing-identity fail-loudly mode + the N=20 concurrent-producers stress); scripts/smoke/phase-72d.sh (live unit-tests-classified smoke that runs the binding + mapper + leak + integration suites); docs/glossary.md (the seven notification.* taxonomy entries already landed via the wave-13 plan-doc PR); docs/plans/phase-72d-notification-event-topic.md (the binding phase plan); docs/plans/wave-13-decomposition.md (72d row annotated Shipped — D-109).

Decision. Three intertwined shape calls land here. All three are documented so a future PR cannot quietly retrofit them.

1. Per-class topic naming — notification.task_failed / notification.tool_approval_requested / notification.governance_budget_exceeded / notification.auth_required / notification.pause_requested. Two valid shapes exist for a notification family: per-class topics (one events.EventType per notification class) and per-instance emit (a single notification.emit event type with a class-discriminator field on the payload). The wire-shape decision was left open in the Wave 13 decomposition (PR #141 §12) for a Phase 72d call; per-class topic naming wins on three criteria:

  • Composes naturally with the existing event taxonomy — every other Harbor event family (tool.*, task.*, governance.*, pause.*, audit.*, bus.*) uses per-class topics. A per-instance shape would be the lone outlier and would force every Console / CLI / third-party consumer that already knows the taxonomy to special-case notifications.
  • Composes naturally with events.subscribe's topic-filter shape (Phase 72a) — subscribers narrow by event_types: [...], which gives them a built-in per-class filter for free. A per-instance shape would force the subscribe filter to consume the payload's class field (a payload-aware filter doesn't exist in V1) or to over-deliver and require client-side filtering.
  • Replay semantics line up — Phase 06's ring + Phase 57's durable log filter by EventType. Per-class topics give the durable log per-class indexing without payload-walks.

The tradeoff: adding a new notification class is a one-line RegisterEventType + one mapper case (visible in this PR's notifications.go + mapper.go). A per-instance shape would have grown a class enum on the payload instead; both are O(1) to extend. The compose-with-existing-taxonomy argument is the load-bearing one.

2. Runtime-internal mapper + Subscriber wire — pure function + long-lived bus consumer. The mapper (notifications.Map(ctx, ev) []events.Event) is a pure function with no I/O, no global state, and no time.Now() dependency (the bus's Publish path fills OccurredAt). The Subscriber is a long-lived component that opens an Admin: true filter on V1TriggerEventTypes() and republishes each match through the same bus. Two design consequences:

  • D-025 concurrent-reuse is trivially satisfiedMap has no state to share, so N concurrent invocations against a single instance are correct by construction. The mandatory N≥100 concurrent-reuse test still ships (TestMap_ConcurrentReuse) so the contract is observable and CI catches any future shape regression that introduces state.
  • The unified bus stays unifiednotification.* rides the same EventBus.Publish path every other event ships through. Brief 06 §1's "one bus, not two" rule is preserved; there is no parallel notification channel. The mapper does NOT introduce a parallel observability surface; it republishes onto the existing bus with a different event-type prefix.

The Subscriber's Admin-scope subscribe is necessary because the trigger events span the full identity space (every tenant's task.failed should map to a notification). The bus emits audit.admin_scope_used on every Admin-true Subscribe, so the Subscriber is observable as a privileged consumer the same way every other Admin-scope subscriber is.

3. NotificationPayload is events.Sealed (not SafeSealed) — Summary walks the redactor. The mapper derives a human-readable Summary string from each trigger's typed payload (e.g. Task task-abc failed (error_code=tool_invocation_failed)). The Summary is caller-controlled in the sense that the trigger's payload bytes feed into it — even though every V1 trigger payload is itself SafePayload, the principle of "caller-derived strings walk the redactor" stays load-bearing (CLAUDE.md §7 rule 6 / D-020). Consumers therefore see the post-redaction shape — events.RedactedMap{Data: {class, severity, summary, deeplink, origineventtype, origineventsequence}} — with the redactor's reflective field-name lowering (Severityseverity, OriginEventTypeorigineventtype, etc.). The IdentityRejectedPayload IS SafeSealed because every field is a bounded enum or a constant string ("Subscriber.Run" / "tenant_id <missing>" / known EventType); the typed shape survives the bus.

§13 primitive-with-consumer compliance — the Stage-1 binding test consumer is BINDING per Wave 13 decomposition §12 item 5. A notification.* topic with no consumer until 73a Overview's alert ribbon would have introduced a primitive-without-consumer window across stages 1 and 2 of Wave 13. The Stage-1 test consumer at internal/runtime/notifications/subscriber_test.go::TestSubscriber_TaskFailedSynthesisesNotificationTaskFailed closes this — it fires a deliberate task.failed through the real in-mem bus + real audit redactor and asserts a separately-scoped subscriber receives the synthesised notification.task_failed with the trigger's identity preserved. The UI consumers (73a Overview alert ribbon, 73m Settings notification-routing matrix) land in Stage 2 and cannot substitute for the Stage-1 runtime test consumer — that's the operator amendment locked in §12 item 5, and this PR honours it.

Why. Closes Wave 13 §12 item 5 + the 72d acceptance criteria. Lands the runtime side of Brief 11 §CC-3's "separate notification topic populated by a runtime-internal event→notification mapper for the small subset of event types that surface to users." A third-party Console implementation gets the same taxonomy out of the box (D-061 + D-091) because the mapper lives in the runtime, not the Console.

Protocol additions. None. notification.* is an event topic consumed via the existing events.subscribe Protocol surface (Phase 60 + Phase 72 + Phase 72a). Phase 72d ships zero new HTTP routes, zero new Protocol method names, and zero new wire types — the per-class subscribe-filter shape exists naturally because events.subscribe already accepts an arbitrary event_types filter.

Trigger types covered at V1. Five: task.failed (Phase 20), tool.approval_requested (Phase 31), governance.budget_exceeded (Phase 36a), tool.auth_required (Phase 30), pause.requested (Phase 50). Brief 11 §CC-3's starter list also names agent.credentials_expired and runtime.health_degraded; those event types are NOT shipped at V1, so the mapper's switch leaves them unmapped (the default branch returns (nil, nil) for any event type outside the V1 set, which is the expected outcome for the vast majority of bus traffic). Adding a mapping for either is a one-line change in a future phase when the input event type lands.

Identity-rejection fail-loudly path. When a trigger event arrives with the D-033 <missing> sentinel substituted into any identity component (which CAN happen if an upstream identity-rejection emitter — memory.identity_rejected, skill.identity_rejected — produced an event whose own identity carries <missing>), the Subscriber emits a notification.identity_rejected event mirroring MemoryIdentityRejectedPayload's shape AND logs at Error. It does NOT silently publish a malformed notification.* event with a <missing> identity, and it does NOT silently drop the input. This is the §13 fail-loudly contract applied at the boundary of the new subsystem; the integration test TestE2E_NotificationsTopic_MissingIdentityFailsLoudly pins the behaviour.

Mapper-error fail-loudly path. When Map returns a non-nil error (always wrapped ErrUnmappable — the trigger event was structurally invalid, e.g. the payload type doesn't match the declared event type), the Subscriber logs at Error AND emits a runtime.error event for audit observability. No notification.* event is synthesised for that trigger. This is the §13 fail-loudly contract applied to upstream invariant violations.

Acceptance:

  • The five V1 notification classes register from init(); events.IsValidEventType returns true for each; V1NotificationClasses() returns them in deterministic order; V1TriggerEventTypes() returns the five trigger types.
  • Map(ctx, ev) returns (synth, nil) for any V1 trigger event with the correct typed payload; (nil, nil) for any other event type; (nil, wrapped ErrUnmappable) for a V1 trigger with the wrong payload type.
  • Concurrent-reuse test (TestMap_ConcurrentReuse) runs N=100 concurrent Map calls against a single mapper instance under -race; every call returns the right output; baseline runtime.NumGoroutine() is restored.
  • The §13 binding round-trip test (TestSubscriber_TaskFailedSynthesisesNotificationTaskFailed) fires a deliberate task.failed, asserts a separately-scoped subscriber receives the synthesised notification.task_failed with the trigger's identity preserved + the correct severity + correlation back to the trigger's bus sequence.
  • The Subscriber's Run goroutine returns within 2s of ctx cancel; baseline goroutine count is restored after teardown.
  • NewSubscriber(nil, log) and NewSubscriber(bus, nil) panic loudly (no silent no-op consumer).
  • Integration suite (test/integration/notifications_topic_test.go) covers every V1 mapping round-trip with identity propagation, the missing-identity fail-loudly mode, and an N=20 concurrent-producers stress, all under -race.
  • scripts/smoke/phase-72d.sh runs the binding test + mapper unit tests + leak test + integration suite; OK ≥ 4, FAIL = 0.
  • All tests -race green; make vet clean; go build ./... clean.

Structural precedents. D-020 (audit redactor as the bus boundary) is the redaction contract NotificationPayload rides. D-025 (concurrent reuse contract) is the invariant the pure mapper trivially satisfies. D-028 (sealed EventPayload interface) is the seal NotificationPayload embeds. D-033 (the <missing> identity sentinel) is the convention the identity-rejection path mirrors. D-074 (durable event log) is the replay surface notifications inherit for free (per-class topic naming makes the index trivial). Brief 06 §1 (one bus, not two) is the rule the republish path honours. Brief 11 §CC-3 (notification topology) is the design source the wire-shape decision implements.

Out of scope (post-V1 follow-ups). Notification routing fan-out (email / Slack / web-push — lives in 73m Settings + Phase 72h's notifications_routing Console DB table); severity escalation policy; snooze / dismiss / mute-this-trigger user actions (Console DB only — D-061); anomaly detection (would consume events.aggregate from 72a and re-emit synthetic notifications); per-instance notification de-duplication. None of these is blocked by the V1 shape; each can land additively without breaking the per-class topic contract.


D-110 — Phase 72e pause.list snapshot: paginated identity-scoped projection of the unified pause/resume Coordinator; D-079 closed-scope reuse; D-026 heavy-content bypass

Date: 2026-05-19 Status: Settled (shipping with this PR)

Where it lives: internal/protocol/methods/methods.go (MethodPauseList = "pause.list" constant + the canonicalMethods map entry + the new IsPauseMethod predicate + pauseMethods set — IsControlMethod returns false for pause.list, keeping the Phase 54 steering-control nine exclusive); internal/protocol/types/pause.go (the wire shapes PauseSnapshot / PauseFilter / PauseListRequest / PauseListResponse / PauseSnapshotState / PauseArtifactRef + the DefaultPauseListPageSize (50) / MaxPauseListPageSize (200) pagination bounds — single source per CLAUDE.md §8 + D-002); internal/protocol/singlesource/singlesource.go (the five new wire types + the pause.list method string registered in CanonicalWireTypes / CanonicalMethods so the Phase 58 checker stays in lockstep); internal/runtime/pauseresume/pauseresume.go (the Coordinator.List interface extension + the ListRequest / ListFilter / ListResponse runtime-internal projection); internal/runtime/pauseresume/list.go (the List implementation — snapshots the in-memory registry under the mutex, filters/sorts/paginates lock-free, fails closed on identity / pagination / cross-tenant); internal/runtime/pauseresume/events.go (the pause.payload_artifact_routed event type + PausePayloadArtifactRoutedPayload); internal/runtime/pauseresume/errors.go (ErrInvalidPage + ErrCrossTenantScope); internal/protocol/transports/stream/pause_list_handler.go (the POST /v1/pause/list HTTP handler — identity at the edge, D-079 cross-tenant scope gate, D-026 heavy-content bypass per row); internal/protocol/transports/transports.go (the WithPauseList mux option + the route mount); cmd/harbor/cmd_dev.go + harbortest/devstack/devstack.go (the production + fixture wiring — both mount the route so the test fixture never diverges from production, CLAUDE.md §17.6); the test files list_test.go / list_concurrent_test.go / pause_list_handler_test.go / test/integration/pause_list_test.go; scripts/smoke/phase-72e.sh; docs/plans/phase-72e-pause-list-snapshot.md; docs/plans/README.md (the 72e row); docs/glossary.md.

What: pause.list is a read-only, paginated, identity-scope-filtered snapshot of currently-paused runs, projected from the shipped Phase 50 Pause/Resume Coordinator's in-memory registry. It is the snapshot half of the Console intervention-queue contract — live deltas continue to flow through events.subscribe on the existing pause.requested / pause.resumed topics; no pause.list_delta topic is minted (that would be the §13 two-parallel-implementations smell). pause.list does NOT mutate the registry, does NOT call Resume, does NOT clear checkpoints — resume actions stay on the Phase 54 resume / approve / reject control methods.

Why these calls:

  1. The unified pause/resume primitive is never bypassed. pause.list reads the shipped Coordinator state through a new read-only List method on the same interface — it does not reinvent pause coordination (CLAUDE.md §7 rule 4, §13). The Coordinator's in-memory registry IS the index (brief 05 — the runtime owns the index; the Protocol exposes a paginated method, never a client-side filter over a full dump).

  2. D-079 closed two-scope set, reused — no new scope minted. A cross-tenant filter (a TenantIDs value outside the caller's own tenant, or len>1) requires the verified auth.ScopeAdmin claim; the reject path returns CodeIdentityScopeRequired (HTTP 403). This mirrors the events.subscribe (D-105) / events.aggregate (D-106) cross-tenant posture exactly — one scope-claim story, not two. No pause.crosstenant scope.

  3. D-026 heavy-content bypass applied to Protocol read snapshots. A pause-record Payload whose json.Marshal-ed byte length meets or exceeds the configured HeavyOutputThresholdBytes is routed through the ArtifactStore; the snapshot row ships a PayloadRef (a flat PauseArtifactRef wire type) and the inline Payload is left nil. The runtime emits a pause.payload_artifact_routed observation event so the bypass is loud, never a silent truncation — the context-window safety-net principle applied to Protocol read snapshots, not just LLM prompts.

  4. Identity-mandatory + fail-loudly pagination. A request with an incomplete (tenant, user, session) triple is rejected 401 (CodeIdentityRequired); a PageSize of 0 defaults to 50, but a negative PageSize, a PageSize > 200, or a negative Page is rejected 400 (CodeInvalidRequest) — NEVER silently clamped, since a silent clamp would defeat the per-row identity boundary the integration test asserts.

Deviations from the phase plan (§4.3, documented):

  • Flat PauseArtifactRef wire type, not *artifacts.Ref. The phase plan's Public API surface sketch shows PayloadRef *artifacts.Ref. The established Protocol convention (RFC §5.1 / CLAUDE.md §13 single-source rule) — already followed by Phase 72c's SearchResultRow.Ref — is a flat wire type that never re-exports a runtime Go struct. pause.list follows that precedent: PauseArtifactRef is a flat subset mirror of artifacts.ArtifactRef. (internal/artifacts exports ArtifactRef, not Ref, anyway.)

  • No checkpoint-store enumeration fallback. The phase plan's acceptance text says List "falls back to checkpoint store enumeration when configured." The state.StateStore interface (Phase 07) is a key-value store — Load / Save / Delete by key, no enumeration method. pause.list therefore projects the in-memory registry only; a resumed record that aged out of the registry, or a pause from before a process restart, is signalled by the Truncated flag on a status=resumed query (operators inspecting historical resume activity use events.subscribe on pause.resumed). This matches the Phase 50 design ("pauses survive Runtime restart only when StateStore-backed checkpoint is configured" — and that survival is per-token Status rehydration, not enumeration).

  • Conformance-suite scenario deferred to the Phase 80 surface extension. pause.list routes through its own HTTP handler, not the REST ControlSurface, so the Phase 62 conformance suite's method-matrix happy-path / malformed-request runners t.Skip it with an explicit reason — the identical posture the suite already takes for the Phase 72c search.* cluster. pause.list is exercised end-to-end by pause_list_handler_test.go + test/integration/pause_list_test.go (the §13 primitive-with-consumer binding test). The conformance-suite exhaustiveness count moves 24 → 25.

§13 primitive-with-consumer compliance. The pause.list wire surface lands with its first consumer in the same PR — test/integration/pause_list_test.go exercises the method end-to-end at the wire boundary (two-tenant scope, the cross-tenant reject without the admin claim, the admin-claim accept path, the D-026 heavy-payload bypass with a bus assertion). The Overview-page intervention queue (Phase 73a, Stage 2) is the UI consumer; it lands in the next stage and does not substitute for the in-PR binding test.

Structural precedents. D-002 (single-source wire types). D-025 (concurrent-reuse contract — the List path snapshots under the mutex then works lock-free; list_concurrent_test.go pins N=128). D-026 (context-window safety net — the heavy-content bypass). D-067 (Phase 50 Coordinator + no second persistence seam). D-079 (the closed two-scope set). D-105 / D-106 / D-108 (the Wave 13 cross-tenant scope-gate posture pause.list mirrors). Brief 05 (runtime-side high-cardinality reads — runtime owns the index, Protocol paginates). Brief 11 §LR-4 / §CC-2 (the intervention sub-panel + identity-aware UI).


D-111 — Phase 72f runtime-posture surface: five read-only runtime.* / metrics.* Protocol methods on a sibling PostureSurface; CapRuntimePosture advertised; cross-tenant gated on the D-079 closed scope set

Date: 2026-05-19 Status: Settled (shipping with this PR)

Where it lives: RFC §5.3 (Protocol versioning) + §6.15 (runtime observability) + §7 (Console layer), docs/plans/phase-72f-runtime-posture.md, docs/plans/wave-13-decomposition.md §4 row 72f, CLAUDE.md §6 (multi-isolation) + §8 (Protocol single-source) + §13 (primitive-with-consumer + Console-never-reads-internals), internal/protocol/methods/methods.go (the five MethodRuntimeInfo / MethodRuntimeHealth / MethodRuntimeCounters / MethodRuntimeDrivers / MethodMetricsSnapshot constants + canonicalPostureMethods + IsPostureMethod + the IsControlMethod exclusion), internal/protocol/types/posture.go (the twelve posture wire structs — RuntimeInfoRequest + RuntimeInfo + SubsystemHealth + RuntimeHealth + RuntimeCounters + SubsystemDriver + RuntimeDrivers + NamedCounter + HistogramBucket + NamedHistogram + NamedGauge + MetricsSnapshot), internal/protocol/types/version.go (the new CapRuntimePosture capability + its canonicalCapabilities entry), internal/protocol/singlesource/singlesource.go (the CanonicalMethods + CanonicalWireTypes lockstep extensions), internal/protocol/posture.go (the transport-agnostic PostureSurface + PostureDeps + NewPostureSurface + the five handlers), internal/protocol/transports/control/control.go (the WithPostureSurface option + the IsPostureMethod-routing branch in ServeHTTP) + internal/protocol/transports/control/posture_handler.go (the REST decoder + identity backfill + Protocol-error wire mapper), internal/protocol/transports/transports.go (the transports.WithPostureSurface mux option), internal/protocol/conformance/conformance.go (method-matrix count 17 to 22 + the IsPostureMethod skip branches), test/integration/runtime_posture_test.go (the §13 same-PR consumer + §17.1 integration test), scripts/smoke/phase-72f.sh, docs/glossary.md (the eight posture entries landed via the wave-13 plan-doc PR), brief 11 §"Settings view" / §"footer counters" / §CC-1, brief 12 §"two-surface model", brief 06 §1 (one bus, decoupling rule).

Decision. Three shape calls land here.

1. PostureSurface is a SIBLING of ControlSurface, not an extension. The five posture methods are READ methods — they project the live Runtime's posture, they mutate nothing. The Phase 54 ControlSurface is a CONTROL surface; threading the build / health / counters / drivers / metrics seams through NewControlSurface would balloon its dependency set. The posture surface ships as its own type with its own Dispatch(ctx, method, req) entry point. The transport adapter (internal/protocol/transports/control) dispatches over the union via methods.IsValidMethod then IsControlMethod vs IsSearchMethod vs IsPostureMethod — the same per-surface branching the Phase 72c search cluster introduced. Each surface's dependency set stays narrow and reviewable, in line with §4.4's "no optional-capability ceremony."

2. The five methods carry a flat, Protocol-owned wire shape — never an internal Runtime Go re-export. RuntimeInfo / RuntimeHealth / RuntimeCounters / RuntimeDrivers / MetricsSnapshot are flat structs in internal/protocol/types/posture.go. In particular MetricsSnapshot is a Protocol-shaped PROJECTION over the Phase 56 telemetry.MetricsRegistry — flat counters / histograms / gauges as plain numbers — NOT a re-export of the OpenTelemetry SDK's metric types. internal/protocol/types/posture.go imports no go.opentelemetry.io/otel package; the Phase 72f static smoke guard pins it. This honours RFC §5.1's reject-on-sight smell ("a Protocol method that maps 1:1 onto an internal Go function signature") and brief 06 §1's "the Console NEVER reads runtime internals."

3. Identity-mandatory at the edge; cross-tenant reads gated on the D-079 closed two-scope set. Every posture handler fails closed with CodeIdentityRequired on an incomplete triple. A cross-tenant query — the request's Identity.Tenant differing from the caller's ctx-verified tenant — requires auth.ScopeAdmin OR auth.ScopeConsoleFleet (the D-079 closed set; NO new runtime.posture / posture.crosstenant scope is minted) and is otherwise rejected CodeScopeMismatch (HTTP 403). When no auth middleware ran (the Phase 60 trust-based posture, no identity on ctx) the cross-tenant gate is a no-op and the body identity is authoritative — the same posture every other Protocol surface holds. No new Protocol error code: the surface reuses CodeUnknownMethod / CodeInvalidRequest / CodeIdentityRequired / CodeScopeMismatch.

§13 primitive-with-consumer compliance. The Stage-2 page consumers (the Overview counter cards via runtime.counters, the Settings Runtime Info card via runtime.info + runtime.drivers) land as 73a / 73m. Phase 72f's same-PR consumer is test/integration/runtime_posture_test.go — it boots a real assembled Runtime via harbortest/devstack.Assemble (real inmem events / state / tasks / artifacts drivers + a real ES256 auth keypair), constructs a real PostureSurface, mounts it through the real transports.NewMux with WithPostureSurface, and probes every posture method end-to-end with identity propagation + the cross-tenant rejection/admission failure mode + an N=16 concurrency stress. The primitive is exercised end-to-end before any UI page ships.

Versioning. CapRuntimePosture is a backward-compatible capability addition (RFC §5.3 minor-class change) — the ProtocolVersion pin is NOT bumped; a Protocol client negotiates the surface via VersionHandshake.Accepts(CapRuntimePosture).

Why. Closes the 72f acceptance criteria + the wave-13 decomposition §4 row 72f. Lands the Protocol surface the Console's Overview counter cards and Settings Runtime Info card read (brief 11 §"Settings view" / §"footer counters" / §CC-1). A third-party Console implementation gets the same posture surface out of the box (D-061 + D-091) because the methods live in the runtime, not the Console.

Structural precedents. D-072 (Protocol single-source foundation) is the discipline the new methods / types / capability honour. D-077 (Phase 59 versioning discipline) is the capability-negotiation mechanism CapRuntimePosture plugs into. D-078 (Phase 60 wire transport) is the REST route table the posture methods extend. D-079 (the closed two-scope set) is the cross-tenant gate — no new scope. D-080 (Phase 62 conformance) is the matrix the method count extends. D-094 (harbortest/devstack) is the assembled-runtime fixture the integration test consumes. D-108 (Phase 72c search cluster) is the sibling-surface pattern this phase mirrors. D-025 (concurrent-reuse contract) is the invariant PostureSurface satisfies — posture_concurrent_test.go pins N≥150.

Acceptance. Five method constants registered + IsPostureMethod predicate; twelve posture wire structs round-trip through JSON; CanonicalWireTypes lockstep stays green; CapRuntimePosture registered + advertised in CurrentHandshake(); PostureSurface fails closed on incomplete identity + cross-tenant without admin; the wire transport route table grows the five methods; the integration test exercises all five end-to-end with real drivers; posture_concurrent_test.go pins N≥150 under -race; scripts/smoke/phase-72f.sh OK ≥ acceptance count, FAIL = 0; make vet test lint build clean; go test -race ./... green.

Out of scope (post-V1 follow-ups). A runtime.info mutation surface (rename runtime, change region — a control method, not a read method); synthetic deep-health checks (per-subsystem latency probes, dependency-reachability synth tests — V1 reports structural readiness only); the governance.posture / llm.posture tier rollups (Phase 72g's scope); high-cardinality metric labels in metrics.snapshot (the Phase 56 cardinality firewall + a projection-boundary re-check keep the labels low-cardinality).


D-112 — Phase 72g governance.posture + llm.posture: read-only posture Protocol methods folded onto the Phase 72f PostureSurface dispatcher + D-089 mock-mode capture path

Date: 2026-05-19 Status: Settled (shipping with this PR)

Where it lives: internal/protocol/methods/methods.go (the MethodGovernancePosture / MethodLLMPosture constants + their registration in canonicalMethods + canonicalPostureMethods + the IsPostureMethod predicate now covering all seven posture methods); internal/protocol/types/governance.go (GovernancePostureRequest / GovernancePostureResponse / IdentityTierView / RateLimitView); internal/protocol/types/llm.go (LLMPostureRequest / LLMPostureResponse); internal/protocol/posture.go (the two new seams on PostureDeps + PostureSurfaceGovernance / LLM / Redactor / Bus — plus the handleGovernancePosture / handleLLMPosture Dispatch branches); internal/governance/posture.go (PostureProvider + Snapshot — the deep-copied read accessor over governance.Config); internal/governance/events.go (EventTypePostureReadAdmin + PostureReadAdminPayload); internal/llm/posture.go (PostureProvider + PostureSnapshot + RegisterMockModeCaptured — the boot-time mock-flag capture); internal/llm/events.go (EventTypePostureReadAdmin + PostureReadAdminPayload); cmd/harbor/devmock.go (the reciprocal llm.RegisterMockModeCaptured call at the banner-emit call site); cmd/harbor/cmd_dev.go + harbortest/devstack/devstack.go (the single NewPostureSurface call site per binary — wires the 72f runtime seams AND the 72g governance / llm / redactor / bus seams into one PostureDeps); internal/protocol/singlesource/singlesource.go (the CanonicalMethods + CanonicalWireTypes lockstep entries); scripts/smoke/phase-72g.sh (the live smoke); docs/glossary.md (the governance.posture / llm.posture / GovernancePosture / LLMPosture entries); docs/plans/phase-72g-governance-llm-posture.md (the binding phase plan); docs/plans/README.md + docs/plans/wave-13-decomposition.md (72g row Shipped — D-112).

Decision. Three intertwined shape calls land here.

1. Posture is a third Protocol-method class — not a control method, not a search method — and governance.posture / llm.posture EXTEND the one Phase 72f PostureSurface, never a parallel dispatcher. Phase 54 shipped the task-control surface; Phase 72c added the search.* cluster; Phase 72f added the five runtime.* / metrics.* reads on a PostureSurface. Phase 72g adds two *.posture methods that are read-only projections of runtime configuration. They are NOT steering controls (IsControlMethod returns false) and route through the SAME PostureSurface dispatcher Phase 72f shipped — there is exactly one PostureSurface type and one NewPostureSurface call site per binary (§13 "no two parallel implementations"). The IsPostureMethod predicate now covers all seven posture methods. All seven share the one read-only *types.RuntimeInfoRequest envelope — the governance / llm reads are also identity-only, so reusing the envelope avoids threading two near-identical wire types (the same economy the search cluster took with SearchRequest). The standalone GovernancePostureRequest / LLMPostureRequest wire types remain declared in internal/protocol/types for documentation + singlesource completeness, but Dispatch accepts the shared RuntimeInfoRequest.

Correction (D-374). The final sentence above — that the standalone GovernancePostureRequest / LLMPostureRequest wire types "remain declared in internal/protocol/types for documentation + singlesource completeness" — no longer holds. Both types were REMOVED. Each declared exactly one field, tenant_id, that nothing ever decoded (the whole posture family decodes into the shared RuntimeInfoRequest envelope), so an admin naming another tenant silently received its OWN tenant's posture with a 200. They were removed rather than implemented, because the cross-tenant selector they duplicated already exists on identity.tenant, is gated, audited and tested in both directions — implementing a second spelling would be the §13 parallel-implementation shape. The rest of this entry stands: the seven posture methods still share the one RuntimeInfoRequest envelope, which is what this paragraph was really about. D-374 made the removal without citing this entry; the marker is attached by D-386.

2. governance.posture projects governance.Config, not the enforcement Subsystem. The governance.Subsystem interface is the enforcement seam (PreCall / PostCall); its three concrete enforcers each hold only the slice of Config their policy needs. The posture surface wants the WHOLE configured IdentityTiers shape, so its source of truth is the governance.Config value the binary built at boot. PostureProvider wraps that Config and exposes a Posture(ctx) accessor returning a deep-copied Snapshot — a caller mutating the returned map cannot reach back into the provider (D-025). The internal TierConfig shape is PROJECTED onto the wire IdentityTierView — never re-exported — so a future change to the internal struct cannot silently reshape the Protocol surface (single-source). The wire RateLimitView carries RefillIntervalMS int64 rather than a Go time.Duration (which marshals as a raw nanosecond integer a non-Go client cannot interpret).

3. llm.posture MockMode is captured ONCE at boot — never re-read at request time. D-089's dev-only mock escape hatch (HARBOR_DEV_ALLOW_MOCK=1) is a boot-time decision. llm.RegisterMockModeCaptured(bool) records the flag into a package-level atomic at the SAME call site in cmd/harbor/devmock.go::registerMockIfDevAllowMock that prints the [DEV-ONLY MOCK LLM — DO NOT USE IN PRODUCTION] stderr banner. The posture handler reads the captured atomic; it NEVER calls os.Getenv at request time. This keeps the banner and the LLMPosture.MockMode flag structurally reciprocal — a future PR that re-routes the dev-hatch path cannot desync one from the other without touching that one function. The package-level atomic is the §5-rule-5-permitted "write-once-at-boot" shape (the same posture as the driver registry).

Cross-tenant gating. Both methods are identity-mandatory (RFC §5.5). A request whose body Tenant differs from the caller's ctx-verified tenant is a cross-tenant read and requires auth.ScopeAdmin OR auth.ScopeConsoleFleet — the D-079 closed two-scope set, NO new posture-specific scope. The reject path returns CodeScopeMismatch (HTTP 403) — the same code the Phase 72f runtime reads use, folding the governance / llm reads onto the one cross-tenant gate. An accepted cross-tenant read emits a governance.posture_read_admin / llm.posture_read_admin audit event through the wired Redactor + Bus; an own-tenant read does NOT emit audit (matches the sessions.inspect convention). The five runtime.* / metrics.* reads never emit audit.

Request-envelope decision. The Phase 72f Dispatch asserts req.(*types.RuntimeInfoRequest) — a plain identity envelope shared by all five runtime methods. Phase 72g reuses that same envelope for the two governance / llm methods rather than branching the req assertion per-method: the governance / llm reads are also identity-only, the body's optional tenant_id is carried by RuntimeInfoRequest.Identity.Tenant, and one envelope keeps the transport adapter (servePosture) generic over all seven methods with zero per-method decode branches.

Read-only. No mutation method ships. Operators change governance ceilings or the LLM provider by editing harbor.yaml and restarting (RFC §6.15 "Hot-reloadable fields" carve-out + RFC §10 default). Post-V1 admin methods (governance.rotate_key, governance.swap_model) are separate phases.

§13 primitive-with-consumer compliance. The Stage-1 in-PR consumer is test/integration/phase72g_posture_test.go — it boots the real governance + llm posture providers, the real audit redactor, the real inmem bus, the real Phase 60 transport, and the real Phase 61 ES256 auth validator, and exercises the payload shapes end-to-end across two boot modes (production-shaped + HARBOR_DEV_ALLOW_MOCK=1) plus the cross-tenant + missing-identity rejection paths. The UI consumer (73m Settings Governance + LLM-Provider Posture cards) lands in Wave 13 Stage 2.

Deviation from the phase plan (§4.3). The plan named internal/protocol/transports/stream/posture_handler.go as the handler file. The posture methods route through POST /v1/control/{method} (the control transport), not the SSE stream transport — so the handler lives at internal/protocol/transports/control/posture_handler.go. The plan named internal/protocol/posture.go as a NEW standalone surface; Phase 72f landed first and shipped that file as the runtime-posture PostureSurface, so 72g EXTENDS the merged 72f surface (two new PostureDeps seams + two Dispatch branches) rather than redefining it — the §13 "no two parallel implementations" rule. The plan's llm.Registry.Posture becomes llm.PostureProvider.Posture because the llm package has no Registry type. All are like-for-like swaps that satisfy every acceptance criterion.

Why. Closes Wave 13 §4's 72g row + RFC §6.15 (governance posture) + RFC §7 (Console Settings page is a Protocol client). A third-party Console gets the same posture surface out of the box (D-061 + D-091) because the wire types live in internal/protocol/types, not a Console-private struct.

Structural precedents. D-111 (the Phase 72f PostureSurface this phase extends — one surface, not two). D-079 (closed two-scope set — admin + console:fleet, no new scopes). D-081 (the IdentityTiers shape the governance posture projects). D-089 (the LLM-default flip + dev-only mock escape hatch — the MockMode capture path). D-072 (the methods / errors / types single-source layout). D-082 (the conformance exhaustiveness lint over methods.Methods()). D-108 (the Phase 72c search cluster — the non-control-method routing posture). D-025 (concurrent reuse contract). D-020 (audit redactor as the bus boundary).

Out of scope (post-V1 follow-ups). Posture mutation methods (governance.set_ceiling, governance.rotate_key, governance.swap_model); a per-model llm.models.list registry projection; per-tenant LLM routing (V1 ships a single provider per Harbor instance — D-088). None is blocked by the V1 shape; each can land additively.


D-113 — Phase 72h Console DB local schema + SvelteKit scaffold: IndexedDB-backed eight-table Console-local datastore + the web/console/ scaffold introduction

Date: 2026-05-19 Status: Settled (shipping with this PR)

Where it lives: the new web/console/ directory tree — the SvelteKit scaffold (package.json pinning svelte ^5.0.0 + @sveltejs/adapter-static, tsconfig.json, svelte.config.js with compilerOptions: { runes: true }, vite.config.ts with the Vitest/jsdom block, eslint.config.js, .stylelintrc.cjs, src/app.html, src/lib/tokens.css, src/lib/protocol.ts generated stub, src/routes/+layout.{svelte,ts}, src/routes/+page.svelte); the Console DB module web/console/src/lib/db/ (index.ts factory + re-exports, schema.ts the eight-table shapes + validateRow + operatorIdOf, migrations.ts forward-only migration list, crypto.ts the WebCrypto AES-GCM/PBKDF2 envelope, driver.ts the ConsoleDB interface, errors.ts, drivers/indexeddb.ts the default V1 driver, tests/*.spec.ts the Vitest suite); scripts/smoke/phase-72h.sh (static-only smoke upgraded from skeleton); docs/plans/phase-72h-console-db-schema.md (the binding plan); docs/plans/wave-13-decomposition.md (72h row annotated Shipped — D-113); docs/plans/README.md + README.md (status rows); docs/glossary.md (the eight table entries + "Encrypted-at-rest auth profile" pre-landed via the wave-13 plan-doc PR — no new entries needed here).

Decision. Three shape calls land here so a future PR cannot quietly retrofit them.

1. Phase 72h introduces web/console/ and owns the SvelteKit scaffold (the A5 audit fix). The Wave 13 decomposition decomposed the Console wave into ~26 phases; every Stage-2 page phase needs the SvelteKit scaffold (package.json, svelte.config.js, vite.config.ts, tokens.css, .stylelintrc.cjs, protocol.ts, the +layout.svelte shell) in place. Rather than make those scaffold files a serial dependency among parallel Stage-2 phases, 72h — the Stage-1 phase that already creates web/console/src/lib/db/ — ships the scaffold as infrastructure. The dependency set in package.json is the union of what 72h's lib/db/ needs (TypeScript + Vitest + jsdom + fake-indexeddb) and what every downstream Stage-2 page needs (SvelteKit + Svelte 5 + Skeleton + Stylelint + ESLint). 72h itself ships no Stage-2 page routes and uses no Skeleton components — its own deliverable stays module-level (TypeScript + Vitest against lib/db/). The scaffold honours CLAUDE.md §4.5: Svelte 5 runes mode (D-092), npm only with a committed package-lock.json, web/console/build + node_modules + .svelte-kit gitignored, the design-token surface centralised in tokens.css with .stylelintrc.cjs rejecting raw color/spacing literals, and protocol.ts committed as a generated stub carrying the // CODE GENERATED BY cmd/harbor-gen-protocol-ts. DO NOT EDIT. header (D-093). web/console/ is a new top-level directory; CLAUDE.md §3 already anticipates it ("If it later monorepos into web/console/, the binding rules in §4.5 still apply") so no RFC change is needed.

2. The Console DB is an IndexedDB-backed eight-table datastore behind a ConsoleDB driver interface — Console-local state ONLY (D-061). The eight V1 tables — saved_filters, saved_views, profiles, runtime_registry, auth_profiles, pat_store, notifications_routing, keybindings — each hold Console-local state: the operator's saved filter chips, dashboard layouts, UI preferences, runtime address book, encrypted auth blobs, notification routing matrix, keybinding overrides. NONE mirrors a runtime entity (agents, sessions, tasks, tools, events, artifacts); those flow exclusively through the Protocol. The §13 / D-061 carve-out is mechanically enforced: schema.ts exports a FORBIDDEN_TABLE_NAMES list (the runtime-entity names), tests/schema-carveout.spec.ts fails the build if any forbidden name appears in the TABLE_NAMES registry, and scripts/smoke/phase-72h.sh re-scans the TABLE_NAMES block as defence in depth. The scan deliberately targets the TABLE_NAMES registry, not the whole file — LIST_PAGES legitimately contains page-enum values like 'agents' because the Console renders an Agents page from Protocol data; a page name is not a table. The driver is interface-first (§4.4): driver.ts declares ConsoleDB + TableScope<T>; drivers/indexeddb.ts is the V1 default; index.ts holds the factory + a write-once driver registry that dispatches by name and fails loud with ErrUnknownDriver (listing registered drivers) on a miss. V1 registers only "indexeddb"; the seam is ready for a post-V1 "server" driver without reshaping callers (every method takes operatorID first and returns Promise<...>, so an HTTP-backed driver fits the same shape).

3. Per-operator scoping is structural, and auth blobs are encrypted at rest with a fail-loud envelope. Every Console DB row carries operator_id = base64url(sha256(tenant_id || ':' || user_id)), keyed off the active Protocol identity. In the IndexedDB driver this is not an application-layer filter — each object store uses the compound key [operator_id, id], so an operator-A-scoped query (IDBKeyRange.bound([opA], [opA, []])) cannot reach operator B's rows by construction. upsert additionally rejects a row whose own operator_id does not match the write scope (ErrMissingOperator), and every method rejects an empty operatorID (no silent default — CLAUDE.md §5 fail-loud, the cross-operator-isolation analogue of the §6 multi-isolation contract). auth_profiles.encrypted_jwt_blob and pat_store.encrypted_token_blob are AES-GCM ciphertext: the KEK is PBKDF2-derived (≥100k iterations, SHA-256, 16-byte per-operator salt persisted on profiles.kdf_salt) from a passphrase the operator enters at first runtime-attach; the envelope is IV (12 bytes) || ciphertext+authTag. crypto.ts::decrypt raises ErrAuthDecryption loudly on a wrong key / corrupt blob — never a silent null. The Settings page (73m) MUST distinguish ErrAuthDecryption (re-enter passphrase / re-attach runtime) from a token-expired condition; treating it as "auth missing → redirect to login" would strand the operator's encrypted blobs.

§13 primitive-with-consumer compliance. Phase 72h ships a primitive (the Console DB schema + driver). Its first UI consumer is Phase 73f Tools' saved-filter chips, landing in Wave 13 Stage 2. To satisfy §13 within this PR, 72h ships the in-package integration test tests/integration.spec.ts — it opens the real IndexedDB driver against fake-indexeddb, runs migrations from empty, writes and reads back one row in each of the eight tables, asserts cross-operator isolation against a shared database (operator B's rows never surface to an operator-A read), round-trips the encrypted auth-profile + PAT blobs through crypto.subtle, and covers ≥1 failure mode (wrong-key decryption raises loudly; missing operatorID raises loudly). This is the §17 in-package integration test for 72h: real driver, real crypto, identity propagation, failure modes. The 73f saved-filter-chip handler in Stage 2 is the first UI consumer; it builds directly on the savedFilters TableScope this PR ships.

Why. Closes Wave 13 §9 item 8 + §12 item 6 (Console DB schema as Stage 1, first consumer 73f) + the A5 audit fix (72h owns the SvelteKit scaffold). Lands Brief 11 §CC-1 (multi-runtime registry), §CC-3 (notification routing matrix), §CC-4/§CC-5 (saved filters + keybindings) and Brief 12's auth-storage threat model (WebCrypto AES-GCM + PBKDF2 envelope) as the Console-local persistence foundation every Stage-2 page builds on. A third-party Console implementation gets the same D-061 carve-out discipline because the schema enumerates exactly what each table holds and disclaims the runtime entities it does NOT mirror.

Protocol additions. None. The Console DB is a browser-local datastore; it adds zero HTTP routes, zero Protocol method names, zero wire types. Runtime entities reach the Console exclusively through the existing Protocol surface (Phase 60 transport + Phase 72/72a events). The operator_id row-scope key is derived from the (tenant, user) identity the Protocol session already carries.

Deviations from the plan. Two minor, both documented here. (a) The plan's "Files added or changed" lists tests/*.spec.ts generically; the implementation ships crypto.spec.ts, schema.spec.ts, schema-carveout.spec.ts, migrations.spec.ts, integration.spec.ts plus shared fixtures.ts / idb-helpers.ts / setup.ts helpers — the named four specs from the test plan plus schema.spec.ts and the helpers. (b) The plan names Dexie as a possible IndexedDB wrapper "pinned in plan"; the implementation uses the native IndexedDB API directly — the ConsoleDB interface is the abstraction seam, and a thin native wrapper is sufficient for the eight-table CRUD shape, so adding the Dexie dependency would only enlarge the Console dependency surface for no interface-level benefit. Both deviations satisfy every acceptance criterion.

Out of scope (downstream phases / post-V1). The harbor console subcommand that boots the Console (D-091; bundled into Phase 73m Settings); the notification delivery transports (email / webhook / web-push — 72h persists only the routing matrix); a Console-side server-backed driver (the seam exists; no second driver lands here); cross-operator sharing of Console-local state (post-V1, and if it ever lands it ships as a Protocol surface, never a Console-DB cross-row read); any Stage-2 page UI.

Structural precedents. D-061 (Console DB is Console-local state only, never a shadow source of truth for runtime entities) is the carve-out this schema implements and mechanically enforces. D-091 (harbor console deployment posture + browser-local encrypted auth storage) is the deployment context the runtime_registry + auth_profiles + pat_store tables serve. D-092 (Svelte 5 runes mode) and D-093 (generated protocol.ts) are the scaffold conventions 72h's web/console/ honours. The §4.4 interface + factory + driver-registry pattern is the shape ConsoleDB + drivers/indexeddb.ts + the index.ts registry follow.


D-114 — Phase 74 Console topology projection: dual-surface (topology.snapshot method + topology.changed event) over an engine-scoped TopologyProjection; identity-mandatory + admin-cross-tenant gating

Date: 2026-05-19 Status: Settled (shipping with this PR)

Where it lives: internal/protocol/types/topology.go (TopologyProjection + TopologyNode + TopologyNodeKind + TopologyEdge + TopologySnapshotRequest + the three NodeKind* constants + SortDeterministic); internal/protocol/methods/methods.go (MethodTopologySnapshot + the canonicalTopologyMethods set + the IsTopologyMethod predicate); internal/protocol/singlesource/singlesource.go (CanonicalMethods + CanonicalWireTypes extended in lockstep); internal/events/events.go (EventTypeTopologyChanged registered from init() + TopologyChangedPayload — a SafePayload per D-028); internal/runtime/engine/topology.go (the pure buildProjection builder, the engine.Topology(ctx) accessor, the construction-time publishTopologyChanged emit, newEngineID); internal/runtime/engine/engine.go (Engine.Topology on the interface + the engineID field + the New construction-time emit); internal/runtime/engine/options.go (WithEventBus option); internal/protocol/protocol.go (TopologyAccessor interface, ScopeChecker type, WithTopologyAccessor / WithScopeChecker / WithEventBus options); internal/protocol/control.go (dispatchTopology + emitAdminScopeUsed); internal/protocol/errors.go (mapTopologyError); internal/protocol/transports/control/control.go (the decodeRequest topology branch + the assertBodyMatchesAuthedIdentity topology case); internal/protocol/conformance/conformance.go (the method matrix extended to include topology.snapshot); harbortest/devstack/devstack.go (AssembleOpts.TopologyAccessor + AssembleOpts.ScopeChecker); cmd/harbor/cmd_dev.go (the documented engine-less posture); the unit / concurrent / integration tests (topology_test.go per package, topology_concurrent_test.go, topology_emit_test.go, test/integration/phase74_topology_test.go); scripts/smoke/phase-74.sh; docs/glossary.md (the three topology entries re-anchored to D-114); docs/plans/phase-74-console-topology.md (the binding phase plan).

Decision. Three shape calls land here.

1. Dual-surface posture — a topology.snapshot method AND a topology.changed event, both over the same TopologyProjection wire type. The wave-13 decomposition left open whether topology is event-only or also a request method. Phase 74 ships BOTH because they cover disjoint consumer needs: the event covers in-flight updates (a consumer already subscribed sees every adjacency change) and the snapshot covers cold-start (a fresh consumer draws the canvas immediately, without waiting for the next edge change). An event-only surface would force every consumer to block on an edge change to render anything. The two surfaces carry the byte-identical TopologyProjection shape (deterministic sort makes them byte-stable), so a consumer composes them without a shape translation.

2. Engine-scoped projection, not run-scoped. The TopologyProjection carries the static node graph + live per-edge channel depth — both stable across runs of the same engine. Per-run overlays (node status, latency, selection state) are NOT in the projection; the consumer composes those from the existing event taxonomy (tool.invoked, task.spawned, pause.requested, …) exactly as the Phase 70 CLI already proves viable (D-102). Splitting the projection this way keeps the snapshot byte-stable and keeps the wire type small. Brief 11 §LR-1's "per-run vs per-session" open question resolves as engine-scoped projection + per-run overlay from existing events.

3. Identity-mandatory + admin-cross-tenant gating. engine.Topology(ctx) rejects an unscoped ctx with ErrIdentityRequired (CLAUDE.md §6 rule 9). The topology.snapshot Protocol method rejects an incomplete identity triple with CodeIdentityRequired; a cross-tenant call (caller's tenant ≠ the engine's tenant) requires the verified auth.ScopeAdmin claim per D-079 — NO new topology.crosstenant scope is minted (the closed two-scope set holds). The cross-tenant rejection uses CodeAuthRejected; the granted admin path publishes audit.admin_scope_used (RFC §6.13). An audit-emit failure on the admin path fails the read closed — an un-auditable admin read is rejected, never silently granted (CLAUDE.md §5).

Deviations from the phase plan. (a) The plan's pre-assigned decision number was D-106; that number was taken by a parallel Wave 13 phase, so this PR uses D-114 per the wave-cadence collision-free assignment. The glossary's three topology entries (landed earlier by the wave-13 plan-doc PR referencing D-106) are re-anchored to D-114 in this PR. (b) The plan specified NewControlSurface(taskRegistry, steeringRegistry, topology, opts...) — a positional accessor argument. This PR wires the accessor via the WithTopologyAccessor functional option instead: it satisfies every acceptance criterion, keeps the Phase 54 two-argument NewControlSurface signature stable (zero ripple through the existing callers), and matches the option-shaped extensibility the surface's Option type already documents. (c) The plan named CodeMethodNotSupported for the nil-accessor (engine-less Runtime) path; no such code exists in internal/protocol/errors, so the nil-accessor path returns CodeUnknownMethod — the route effectively does not exist on an engine-less Runtime, and CodeUnknownMethod maps to HTTP 404 which the smoke's 404 → SKIP convention picks up. (d) harbor dev's runtime is planner/RunLoop-shaped and hosts no engine.Engine node-graph, so its ControlSurface is built without a topology accessor — topology.snapshot returns CodeUnknownMethod there. This is the documented nil-safe posture, not a wiring gap (§17.6): the Phase 74 integration test wires a real engine through protocol.WithTopologyAccessor and exercises the surface end-to-end.

§13 primitive-with-consumer compliance. The topology.snapshot method + topology.changed event are primitives; their Stage-2 page consumers (the Live Runtime topology canvas — Phase 73b; the Playground trace toggle — Phase 73n) land in a later stage. test/integration/phase74_topology_test.go is the in-wave consumer that exercises both surfaces end-to-end: a real engine.Engine constructed with WithEventBus emits a real topology.changed onto a real in-mem bus that a real SSE-capable subscriber receives, and the same engine's projection round-trips through a real topology.snapshot RPC over the Phase 60 wire transport. The construction-time emit + the snapshot byte-stability + the cross-tenant gate (with + without admin) + the edge-delta are all asserted.

Why. Lands RFC §5.2's "topology" Protocol-surface row + RFC §6.13's observability surface. Closes the wave-13 decomposition's Phase 74 row. A third-party Console gets the topology surface out of the box (D-061 + D-091) because the projection is a canonical Protocol wire type, never a re-export of engine's private adjacency / channel internals (brief 06 "visualization couples to private state" — explicitly avoided).

Concurrent reuse (D-025). engine.Topology(ctx) is a pure read against set-once-at-New engine state (engineID + nodes + adjs + channels); internal/runtime/engine/topology_concurrent_test.go pins N=128 concurrent calls against one shared engine under -race with byte-stability + goroutine-baseline assertions. internal/protocol/concurrent_test.go extends the ControlSurface N≥100 stress to the topology.snapshot dispatch path.

Acceptance: see docs/plans/phase-74-console-topology.md — every criterion is covered by the unit / concurrent / integration tests + the phase-74 smoke. The Phase 70 CLI retrofit stays out of scope per D-102; a follow-up phase adds the topology.changed-preferred-source branch.


D-115 — Phase 75 Console e2e Playwright harness baseline: targets harbor console (not harbor dev); baseline-only scope; harness-vs-aggregator split (75 / 75a)

Date: 2026-05-19 Status: Settled (shipping with this PR)

Where it lives: web/console/playwright.config.ts (the single-source harness config — Chromium-only projects, deterministic workers: 1, list reporter, screenshot-on-failure; the webServer block is intentionally absent because the per-run Runtime + harbor console lifecycle lives in the fixture, not in a static config block); web/console/package.json (the three e2e npm scripts — test:e2e / test:e2e:install / test:e2e:ui — plus the @playwright/test devDependency pin); web/console/package-lock.json (committed lockfile so npm ci is reproducible); web/console/tests/fixtures/harbor-runtime.ts (the worker-scoped Playwright fixture — boots bin/harbor console --bind 127.0.0.1:0 on an ephemeral port per the D-104 pattern, reads the bound URL + dev token back from the HARBOR_*_BOUND= / HARBOR_DEV_TOKEN= stderr lines, tears the child process down with SIGTERM-then-SIGKILL; exposes the sync consoleSubcommandAvailable() probe so specs can gate their describe block at collection time); web/console/tests/fixtures/page.ts (the single import every per-page spec uses — extends Playwright's test with the runtime fixture + seedAuth + gotoPage helpers + the 14-page CONSOLE_PAGES IA list); web/console/tests/pages/base-page.ts (the page-object base class — typed selectors map + gotoSlug + waitForHydration); web/console/tests/helpers/protocol.ts (lazily loads the generated web/console/src/lib/protocol.ts typed client — D-093 — so the harness baseline type-checks before the Stage-1 SvelteKit scaffold lands); web/console/tests/helpers/identity.ts (the deterministic test isolation triple — makeTestIdentity / DEFAULT_TEST_IDENTITY); web/console/tests/harness.spec.ts (the meta-test — boots the fixture, asserts the index serves 200 + the SvelteKit app hydrates + a tokenless load does not 5xx; SKIPs cleanly when harbor console is absent); web/console/tests/README.md (the per-page-spec authoring guide); .github/workflows/ci.yml (the new frontend-e2e job — builds bin/harbor, installs Node + npm deps + Chromium, runs npm run test:e2e; skips gracefully when web/console/ is absent); .gitignore (Playwright output-dir ignores); scripts/smoke/phase-75.sh (the static-only smoke — was already a fully-authored skeleton from the wave-13 plan-doc PR; this phase makes its 16 assertions flip from SKIP to OK); docs/plans/phase-75-playwright-harness-baseline.md (the binding phase plan); docs/plans/README.md (Phase 75 row → Shipped, goal/Deps corrected, new 75a row + detail block); docs/glossary.md (the "Playwright harness" + "frontend-e2e CI job" entries — already landed via the wave-13 plan-doc PR, re-pointed from the placeholder D-105 to D-115).

Decision. Three intertwined calls land here. All three are documented so a future PR cannot quietly retrofit them.

1. The harness targets harbor console, NOT harbor dev — a correction to the original master-plan row. The Phase 75 master-plan row read "Playwright suite … runs against harbor dev." D-091 (settled in PR #138) pins that the Console static build is served exclusively by the harbor console subcommand — the Harbor Runtime ships headless, and harbor dev does NOT serve the Console. A harness that booted the dev-loop subcommand would be exercising a surface the Console never runs against in production. Brief 12 §"Why harbor console, not harbor dev, serves the Console" gives the three reasons (decoupling, multi-runtime, audience separation). The harness config + fixture therefore boot harbor console; the master-plan row's goal text + detail block are corrected in the same PR. This is a documented departure from the master-plan wording per CLAUDE.md §4.3 — the RFC (§7) and D-091 win over the stale row text.

2. Baseline-only scope — per-page specs land with their page phase; the wave-end aggregator is 75a. The Wave 13 decomposition (docs/plans/wave-13-decomposition.md §12 item 7, operator-locked) narrows Phase 75 to the harness infrastructure only: config, fixtures, page-object base, helpers, the meta-test, the CI hook. It ships ZERO page-specific assertions. Each of the 14 Console page phases (73a–73n) ships its own <slug>-page.spec.ts in the same PR as the page (the §13 primitive-with-consumer rule is satisfied trivially — the page IS the consumer). The wave-end aggregator suite that walks all 14 pages and asserts a matching spec exists for each is Phase 75a, bundled into the Stage-3 PR per CLAUDE.md §17.5. This split keeps the test infrastructure from waiting on every page to land first, and keeps each page phase honest (a page without a spec is a §13 rejection). The §13 primitive-with-consumer obligation for the harness itself is discharged by the meta-test (harness.spec.ts) — the harness's own self-test — plus the named same-wave first consumer, Phase 73a Overview's overview-page.spec.ts.

3. Graceful degradation when web/console/ and harbor console are absent — the directory-/subcommand-missing → SKIP pattern. Phase 75 lands in Wave 13 Stage 1 Batch B alongside Phase 72h, which owns the web/console/ SvelteKit scaffold (svelte.config.js runes mode, vite.config.ts, tokens.css, the generated protocol.ts). The harbor console subcommand itself lands later, in Phase 73m (Stage 2.3). The harness must therefore not break the build or the CI gate before either dependency exists. Four mechanisms enforce this: (a) the harness is pure TypeScript under web/console/tests/ — it does not touch the Go build; (b) scripts/smoke/phase-75.sh is static-only and SKIPs every assertion when web/console/ is absent, flipping to 16 OK once the scaffold + harness are present; (c) the frontend-e2e CI job detects web/console/package.json and skips its whole step chain when absent; (d) the meta-test gates its describe block on a synchronous consoleSubcommandAvailable() probe — Playwright instantiates the page fixture (launching the browser) BEFORE a test body runs, so a body-level test.skip() cannot prevent a browser launch on a runner with no browser; gating the describe block at collection time is the only correct skip. When web/console/ and harbor console both land, every SKIP flips to OK with no harness change.

Why. Closes the Phase 75 acceptance criteria + the Wave 13 §12 item 7 operator lock-in. Lands the test substrate Brief 11 §"Findings summary" pins ("every operator-facing flow shipped in a phase has a matching .spec.ts") without making the substrate wait on all 14 pages. A third-party Console implementation can adopt the same harness shape — it depends only on @playwright/test (external) and the generated protocol.ts (D-093), never on Console internals.

Protocol additions. None. The harness is build-time + test-time infrastructure; it consumes the existing Phase 60 Protocol surface via the generated protocol.ts client (D-093). Zero new HTTP routes, zero new Protocol method names, zero new wire types.

Decision-number lineage. The wave-13 plan-doc PR pre-wrote the "Playwright harness" + "frontend-e2e CI job" glossary entries and the phase plan against a placeholder D-105; D-105 was subsequently consumed by Phase 72 (the Console subscription protocol surface). This phase files the real decision as D-115 (the coordinator-assigned, collision-free number for the Batch B dispatch) and re-points the glossary entries + phase plan from D-105 to D-115 in the same PR.

Acceptance:

  • web/console/playwright.config.ts exists, declares a single Chromium projects entry, and does NOT reference the dev-loop subcommand string.
  • web/console/tests/ ships the fixtures (harbor-runtime.ts, page.ts), the page-object base (pages/base-page.ts), the helpers (protocol.ts, identity.ts), the meta-test (harness.spec.ts), and the authoring guide (README.md).
  • web/console/package.json declares test:e2e / test:e2e:install / test:e2e:ui and pins @playwright/test; package-lock.json is committed.
  • .github/workflows/ci.yml declares a frontend-e2e job that runs after go, builds bin/harbor, installs Node + npm deps + Chromium, runs npm run test:e2e, and skips gracefully when web/console/ is absent.
  • The meta-test (harness.spec.ts) lists 4 tests and SKIPs all 4 cleanly when harbor console is absent; once the subcommand lands it boots the fixture, asserts the index serves + hydrates, and exercises the tokenless-load failure mode.
  • scripts/smoke/phase-75.sh shows OK = 16, FAIL = 0 with web/console/ present; SKIPs cleanly when absent.
  • docs/plans/README.md Phase 75 row → Shipped, Deps → 60, 72, goal text corrected; new 75a row + detail block added.
  • docs/glossary.md "Playwright harness" + "frontend-e2e CI job" entries re-pointed from D-105 to D-115.
  • No .spec.ts (or any web/console/tests/ file) hand-rolls a raw browser HTTP call — all Runtime access goes through the typed Protocol client (CLAUDE.md §4.5 #11).

Structural precedents. D-091 (harbor console subcommand serves the Console) is the deployment posture the harness targets. D-092 (Svelte 5 runes mode) + D-093 (generated protocol.ts) are the scaffold contracts the harness consumes. D-094 (harbortest/devstack.Assemble) is the Runtime-assembly seam the fixture shells out to. D-104 (preflight ephemeral-port allocation) is the bound-port-from-stderr pattern the fixture reuses. Brief 11 §"Findings summary" (every operator-facing flow has a matching spec) is the rule the harness mechanises. Brief 12 §"Why harbor console, not harbor dev" is the design source for call #1.

Out of scope (Phase 75a / post-V1). Per-page Playwright specs (each lands with its 73a–73n page phase); the wave-end aggregator suite + the page-coverage check (Phase 75a); the Go-side test/integration/wave13_test.go (Phase 75a); visual-regression / screenshot golden compares (post-V1, Brief 11); the Firefox / WebKit browser matrix (post-V1 — the projects array is structured so adding a browser is a one-line change); Playwright trace-viewer recording in CI (Phase 75a wires the failed-only upload). None is blocked by the V1 harness shape; each lands additively.


D-116 — Phase 73f Console Tools page: seven tools.* Protocol methods + the Tools-page UI; admin methods gate on the D-079 closed scope set (no tools.admin scope)

Date: 2026-05-20 Status: Settled (shipping with this PR)

Where it lives: internal/protocol/methods/methods.go (+MethodToolsList / MethodToolsGet / MethodToolsDescribe / MethodToolsMetrics / MethodToolsContentStats / MethodToolsSetApprovalPolicy / MethodToolsRevokeOAuth + the IsToolsMethod / IsToolsAdminMethod predicates — IsControlMethod now excludes the tools cluster); internal/protocol/types/tools.go (the seventeen Tools wire types — Tool, ToolFilter, ToolListRequest / ToolListResponse, ToolAggregates, ToolGetRequest, ToolDescribeRequest, ToolManifest, ToolMetricsRequest / ToolMetrics, ToolContentStatsRequest / ToolContentStats, ToolContentBucket, ToolSetApprovalPolicyRequest / ToolSetApprovalPolicyResponse, ToolRevokeOAuthRequest / ToolRevokeOAuthResponse — single source per D-002); internal/protocol/singlesource/singlesource.go (the seven method strings + seventeen wire-type homes registered in CanonicalMethods / CanonicalWireTypes); internal/tools/protocol/ (the new package — protocol.go the seven-method Service, filter.go the facet predicate + aggregate fold, catalog_projector.go the V1 CatalogProjector over a tools.ToolCatalog + the optional Annotator seam, events.go the ToolsAdminActionPayload + audit.admin_scope_used emit); internal/protocol/transports/stream/tools_handler.go (the POST /v1/tools/{method} wire handler — identity at the edge, admin-scope gate, error classification); internal/protocol/transports/transports.go (the WithToolsService mux option); cmd/harbor/cmd_dev.go + harbortest/devstack/devstack.go (both production + devstack boot paths wire the Tools service over the live tool catalog — CLAUDE.md §17.6); web/console/src/routes/tools/+page.svelte + web/console/src/lib/components/tools/ (the seven page components) + web/console/src/lib/protocol/tools.ts (the typed ToolsClient) + web/console/src/lib/protocol/session.ts + web/console/src/lib/tools/export.ts + web/console/src/lib/db/saved_filters_tools.ts (the typed wrapper over Phase 72h's saved_filters table); web/console/tests/tools-page.spec.ts (the per-page Playwright spec); scripts/smoke/phase-73f.sh.

Decision. Two calls land here.

1. The Tools page ships its seven-method Protocol surface + UI in one phase; the page IS the primitive's consumer (§13). The five read methods (tools.list / tools.get / tools.describe / tools.metrics / tools.content_stats) project the registered tool catalog for the Console operator lens; the two admin methods (tools.set_approval_policy / tools.revoke_oauth) mutate runtime tool state. The wire surface and the SvelteKit page land together so the §13 primitive-with-consumer rule is satisfied trivially — the page exercises every method end-to-end, and test/integration/tools_page_test.go is the same-PR end-to-end consumer (real tools.ToolCatalog + real wire transport + real ES256 auth). The methods route through a dedicated Tools dispatcher (IsToolsMethod), a sibling of the task-control / search / posture / topology surfaces — never the steering inbox.

2. The admin methods gate on the D-079 closed scope set (auth.ScopeAdmin) — there is NO tools.admin scope. The Phase 73f phase plan and the Wave 13 decomposition table (line 92) were authored before D-079 closed the scope set to exactly two scopes (admin + console:fleet). They named a tools.admin control-scope claim. D-079 is the higher-priority artifact (a settled decision); minting a third scope would re-litigate it. Departure from the phase plan, resolved per CLAUDE.md §4.3 + §15 (the RFC / settled-decision wins over the plan): the two Tools admin methods gate on the verified auth.ScopeAdmin claim, exactly as pause.list / events.subscribe / the posture methods do. A non-admin caller is rejected fail-closed with CodeIdentityScopeRequired (HTTP 403). The decomposition-table cell + the phase plan's relevant lines are corrected in the same PR. Consequently the deliverable set is the seven methods named above — the phase plan's speculative tools.invoke ("Try this tool" form) is NOT shipped in 73f; it is a heavier developer-scope surface deferred to a follow-up (the page-spec §6 "Try this tool" row stays [wave-13-extends] for that follow-up).

Why. Closes the Phase 73f acceptance criteria + the Wave 13 §5 Stage-2.1 lock-in. The CatalogProjector is the §4.4 seam: the V1 implementation projects the planner-visible catalog (no-granted-scopes view) + reads OAuth / approval / metrics / content-stats through an optional Annotator; a future phase elevates it to the admin full-discovery view (page-tools.md §9). The saved-filter wrapper is a typed view onto Phase 72h's shipped saved_filters table scoped to page = 'tools' — it adds NO table, honouring D-061.

Protocol additions. Seven method names + seventeen wire types (listed above). One wire-transport route: POST /v1/tools/{method}. Zero new error codes — the surface reuses CodeIdentityRequired (401), CodeIdentityScopeRequired (403), CodeNotFound (404), CodeInvalidRequest (400), CodeUnknownMethod (404), CodeRuntimeError (500). Zero new scopes (D-079 holds).

Departures from the phase plan (CLAUDE.md §4.3). (a) Admin gating is auth.ScopeAdmin, not a tools.admin scope — D-079 wins (call #2). (b) tools.invoke is not shipped in 73f — the seven-method deliverable set is what the task scope + the closed-scope posture allow; the "Try this tool" form is a follow-up. (c) protocol.ts is NOT regenerated — the cmd/harbor-gen-protocol-ts generator (D-093) has not shipped yet (Phase 72h committed protocol.ts as a hand-written stub, and there is no make protocol-ts-gen target). The Tools typed client is therefore hand-authored in a SIBLING module (web/console/src/lib/protocol/tools.ts), keeping the generated stub untouched; when the generator lands, the types migrate into protocol.ts mechanically.

Acceptance:

  • internal/protocol/methods/methods.go declares the seven tools.* methods + IsToolsMethod / IsToolsAdminMethod; IsControlMethod returns false for all seven.
  • internal/protocol/types/tools.go is the single home for the seventeen Tools wire types; the singlesource lockstep test passes.
  • The seven methods enforce identity-mandatory (CodeIdentityRequired on an incomplete triple); the two admin methods fail closed with CodeIdentityScopeRequired without the verified auth.ScopeAdmin claim.
  • internal/tools/protocol ships an N≥100 concurrent-reuse test (D-025) + an identity-isolation test; test/integration/tools_page_test.go exercises the surface end-to-end with real drivers + ES256 auth + an N≥10 stress run.
  • The Tools page (web/console/src/routes/tools/+page.svelte) renders the catalog table + detail panel + right-rail cards + run-history strip; it talks to the Runtime only through the typed ToolsClient — no hand-rolled fetch.
  • web/console/src/lib/db/saved_filters_tools.ts is a typed wrapper over the shipped saved_filters table scoped to page = 'tools' — no new table.
  • web/console/tests/tools-page.spec.ts covers the catalog render + facet toggle + drill-down + Approve path; it SKIPs cleanly pre-Phase-73m.
  • scripts/smoke/phase-73f.sh round-trips all seven methods and asserts the admin-scope reject path.

Structural precedents. D-079 (closed two-scope set) is the scope contract call #2 obeys. D-002 (single-source wire types) + D-072 (single-source method strings) are the protocol-package discipline tools.go / methods.go follow. D-024 (ToolPolicy reliability shell) + D-026 (heavy-content threshold) + D-062 (MCP-Apps DisplayMode) + D-083 (tool-side OAuth binding scope) + D-086 (tool-side approval gates) are the runtime concepts the wire types project. D-061 (Console DB local-only) is the carve-out the saved-filter wrapper honours. D-091 (harbor console deployment) + D-092 (Svelte 5 runes) + D-093 (generated protocol.ts) are the Console contracts the page rides on. D-110 (pause.list) is the structural precedent for the one-shot request/response wire handler in the stream package.


D-117 — Phase 73i Console Flows page: six flows.* Protocol methods on a dedicated stream-package handler family + a flow.Registry source-of-truth + the read-only Flows-page UI

Date: 2026-05-20

Status: Accepted.

Context. Phase 73i ships the Console Flows page — the read-only viewer for the runtime's engine-graph flows (D-063). The Wave 13 decomposition (docs/plans/wave-13-decomposition.md §12) and docs/design/console/page-flows.md §12 pin six [wave-13-extends] Protocol additions: flows.list (catalog with aggregate metrics), flows.describe (engine-graph payload), flows.runs.list (run history), flows.runs.describe (per-run timeline), flows.run (one-shot invocation), flows.metrics (sparkline aggregates). The page is the consumer (§13 satisfied trivially). It consumes the shipped Phase 73 state.history posture for per-run detail — no same-wave Protocol dependency.

Decision.

1. The six flows.* methods are a dedicated Protocol surface, not steering-control methods. They are declared in internal/protocol/methods/methods.go and registered in canonicalMethods; a closed canonicalFlowsMethods sub-set + an IsFlowsMethod O(1) predicate route them through the Flows-page handler — the same pattern Phase 72e (pause.list) and Phase 74 (topology.snapshot) established. IsControlMethod returns false for all six (the steering inbox stays exclusive to the Phase 54 nine).

2. The wire types live in internal/protocol/types/flows.go only. Flow, FlowFilter, FlowDescription (nodes + edges + per-node FlowNodePolicy + per-flow FlowBudget per D-023), FlowRun, FlowRunDescription, FlowRunRequest, FlowMetrics and their request/response envelopes — all registered in singlesource.CanonicalWireTypes. The FlowNodePause wire value is "pause_point" (not "pause") so it never collides with the pause Protocol method name in the single-source checker.

3. The runtime side is a transport-agnostic flow/protocol.Surface over two interface seams (§4.4). Catalog (registered flows + run history projections) and Invoker (one-shot run launcher). The production Catalog is RegistryCatalog, backed by a NEW flow.Registry — a real runtime subsystem (registered Definitions + a bounded per-flow run-history ring), NOT a test stub. The production Invoker is FuncInvoker, adapting a runtime-supplied LaunchFunc that delegates to the task registry's SpawnTool path. cmd/harbor dev and harbortest/devstack both wire an empty flow.Registry at boot — a fresh stack with no graph-family agents correctly serves an empty catalog (the right "no flows registered" empty state, not a missing surface).

4. flows.run is the only mutating method; it is gated on auth.ScopeAdmin (D-079). No new scope is minted (D-079 closed two-scope set). The Surface fails closed with ErrRunScopeRequiredCodeScopeMismatch (HTTP 403) when the claim is absent. The other five methods are read-only; a cross-tenant catalog / run-history filter requires auth.ScopeAdmin and fails closed with CodeIdentityScopeRequired (HTTP 403) without it.

5. Heavy run outputs route by-reference through the ArtifactStore (D-026). flows.runs.describe ships a FlowArtifactRef for any run output meeting the configured heavy-content threshold — never inline bytes. The RegistryCatalog fails loud on a store failure.

6. The wire transport is a dedicated stream-package handler family. internal/protocol/transports/stream/flows_handler.go mounts six POST /v1/flows/* routes (list / describe / runs/list / runs/describe / run / metrics), wired via transports.WithFlows. Each dispatch emits a per-page audit event — flows.page_viewed for the five reads, flows.run_invoked for the mutating run — onto the canonical EventBus.

7. The Console UI is view-only (D-063). The Flows page (web/console/src/routes/flows/) renders the catalog table, Flow Metrics card, the read-only engine graph canvas, the Budget meter, the run-history table, and the selected-run summary panel. There is NO authoring affordance — Add node / Delete edge / Save graph / New flow do not render, by construction. The engine graph canvas (web/console/src/lib/components/graph/EngineGraphCanvas.svelte) is SHARED with the future Phase 73b Live Runtime topology view; this phase establishes the typed GraphInput interface. All Runtime access flows through the typed FlowsClient (web/console/src/lib/flows/client.ts) — no hand-rolled fetch in .svelte files.

Why. Closes the Phase 73i acceptance criteria + the page-flows.md §12 binding refinements. The dedicated-handler-family + interface-seam shape keeps the Flows surface testable with deterministic fixtures and decoupled from the task subsystem's concrete type, mirroring the proven Phase 72e / 74 patterns. The flow.Registry is a genuine runtime subsystem so the Console projects a real catalog, not a stub (CLAUDE.md §13).

Protocol additions. Six method names (flows.list / flows.describe / flows.runs.list / flows.runs.describe / flows.run / flows.metrics); the internal/protocol/types/flows.go wire-type cluster; six POST /v1/flows/* REST routes; two canonical event types (flows.page_viewed, flows.run_invoked).

Acceptance: see docs/plans/phase-73i-console-flows-page.md — every criterion is covered by the surface / catalog / handler unit tests, the N≥100 concurrent-reuse test, test/integration/flows_page_test.go, the Vitest suites, the Playwright spec, and scripts/smoke/phase-73i.sh.

Structural precedents. D-023 (Flow-as-Tool: Go-coded V1 + per-flow Budget) is the Budget surface the page reads. D-026 (context-window safety net) is the heavy-output bypass. D-061 (Console DB local-only) is the posture Save snapshot / Compare versions honour. D-063 (Flows page = view over engine graphs; authoring post-V1) is the view-only mandate. D-079 (closed two-scope set) is the scope the mutating flows.run reuses. D-091 / D-092 / D-093 are the Console deployment + Svelte 5 + generated-client contracts. Phase 72e (pause.list) and Phase 74 (topology.snapshot) are the dedicated-Protocol-surface precedents.

Out of scope (post-V1). Flow authoring / editor / versioning / import-export (D-063); flows.set_budget per-flow Budget edit (page-flows.md §10); declarative YAML flow descriptors (D-023 — V1.1); "Convert to evaluation" (D-064); the cross-runtime flows aggregator (D-091). None is blocked by the V1 Flows-page shape; each lands additively.


D-118 — Phase 73j Console Memory page: three read-only memory.* Protocol methods over the shipped MemoryStore.Snapshot surface; NO new memory scope (D-079 closed-set reuse — audit B1); per-turn projection model; D-026 heavy-value bypass mirrored at the memory-inspector edge

Date: 2026-05-20 Status: Settled (shipping with this PR)

Where it lives: RFC §5.2 (Protocol surface) + §6.6 (Memory subsystem) + §7 (Console layer), docs/plans/phase-73j-console-memory-page.md, docs/design/console/page-memory.md, internal/protocol/methods/methods.go (the MethodMemoryList / MethodMemoryGet / MethodMemoryHealth constants + the canonicalMemoryMethods set + the IsMemoryMethod predicate + the IsControlMethod exclusion), internal/protocol/types/memory.go (the thirteen wire structs — MemoryItem / MemoryFilter / MemoryListRequest / MemoryAggregates / MemoryListResponse / MemoryArtifactRef / MemoryMetadata / MemoryGetRequest / MemoryItemDetail / MemoryGetResponse / MemoryHealthRequest / MemoryHealthAggregate / MemoryHealthResponse — plus the MemoryScope / MemoryStrategyName / MemoryDriverName enums and the DefaultMemoryListPageSize 50 / MaxMemoryListPageSize 200 bounds), internal/memory/protocol/ (the List / Get / Health functions + the ErrContextLeak / ErrInvalidFilter / ErrPageOutOfRange sentinels + the per-turn projection helpers), internal/protocol/transports/stream/memory_handler.go (the three POST /v1/memory/{list,get,health} HTTP handlers), internal/protocol/transports/transports.go (the WithMemory mux option), internal/protocol/singlesource/singlesource.go (the CanonicalMethods + CanonicalWireTypes lockstep entries), internal/protocol/conformance/conformance.go (the matrix-exhaustiveness memory.* entries + skip branches), cmd/harbor/cmd_dev.go + harbortest/devstack/devstack.go (the production + fixture WithMemory wiring — §17.6 production-mirror), web/console/src/lib/protocol-memory.ts (the typed MemoryClient), web/console/src/lib/db/saved_filters_memory.ts (the typed wrapper over the Phase 72h saved_filters table), web/console/src/routes/memory/+page.svelte + web/console/src/lib/components/memory/ (the page route + eight components), web/console/tests/memory-page.spec.ts (the per-page Playwright spec), test/integration/memory_page_test.go, scripts/smoke/phase-73j.sh.

Why: Phase 73j is the Wave 13 Stage-2.1 Console Memory page — it bundles the per-page Protocol additions and the page UI into one phase per the decomposition doc §5. Four design calls warrant a durable home so a later auditor does not flag any of them as drift.

1. NO new memory scope — cross-tenant memory listing gates on the D-079 closed two-scope set (audit B1, binding). The phase plan originally proposed minting memory.read + memory.crosstenant scopes; the cross-reference audit's B1 finding closed that. D-079 settled the canonical scope surface as a CLOSED two-scope set — auth.ScopeAdmin + auth.ScopeConsoleFleet — and Phase 72's Non-goals explicitly forbid a third scope. Reading the caller's own identity quadruple requires only an authenticated JWT carrying (tenant, user, session); widening MemoryFilter.TenantIDs beyond the caller's own tenant requires the verified auth.ScopeAdmin (or auth.ScopeConsoleFleet) claim — exactly like every other Stage-2 page (72 / 72a / 72e / 72f / 72g / 73c). internal/auth/scopes.go is unchanged. A missing-claim cross-tenant request is rejected loudly with CodeIdentityScopeRequired (HTTP 403); a missing/incomplete identity triple fails closed with CodeIdentityRequired.

2. The per-turn projection model — memory.list lists conversation turns within an identity scope. The shipped memory.MemoryStore interface (Phases 23–25) is per-identity: it has no per-item enumeration method. It exposes Snapshot(ctx, id) — an opaque JSON memory.Record{Strategy, Turns} — and Health(ctx, id). Phase 73j's internal/memory/protocol package projects that record into the Console-page row shape: each conversation turn becomes one MemoryItem row, keyed by a deterministic content-addressed per-turn key (memTurnKey — SHA-256 over the identity quadruple + the turn ordinal + the turn timestamp). The rolling-summary text is folded into the strategy metadata, not surfaced as a separate row. This is the honest projection of the runtime's memory state; the alternative (a new MemoryStore.List method) would have widened the shipped Phase 23–25 interface for one consumer. Memory is session-scoped by default (CLAUDE.md §6 rule 4); the projected rows carry Scope = "session".

3. The D-026 heavy-value bypass is mirrored at the memory-inspector edge. memory.get MUST NOT return raw bytes ≥ the heavy-content threshold. The classification — the MemoryItem.HeavyContent flag — is computed ONCE in snapshotTurns (so memory.list and memory.get agree on which rows are heavy); a heavy row's value is routed through the shipped ArtifactStore and the detail ships a by-reference MemoryArtifactRef with the inline Value left empty (exactly one of Value / ValueArtifact is ever populated). A defence-in-depth branch in buildDetail fails loudly with ErrContextLeak when a value that was NOT classified heavy nonetheless carries heavy bytes — mirroring the LLM-edge enforcement pass in internal/llm/safety.go. The negative test (leak_internal_test.go) drives a deliberately mis-classified row through the internal BuildDetailLeakProbe seam and asserts the loud failure.

4. The 24-hour memory.* event counters are tenant-scoped, not triple-scoped. memory.list / memory.health derive the IdentityRejected24h / RecoveryDropped24h counters from the Phase 72a events.aggregate surface over the memory.identity_rejected (D-033) / memory.recovery_dropped (D-035) event types. A memory.identity_rejected event by construction carries a partial identity with <missing> substituted for the empty component(s) (D-033) — a triple-scoped filter would never match a rejection whose session was the missing component. The counters therefore scope to the caller's tenant only; the tenant stays the outer isolation boundary, and cross-tenant fan-in still requires the D-079 scope claim enforced at the wire edge before List runs. The rejection EVENTS still surface verbatim on the page's right-rail card; only the rolled-up 24h count is the tenant-scoped aggregate.

Findings I'm departing from. The page spec docs/design/console/page-memory.md §12 (the mockup-aligned refinements table) names the recovery-dropped event memory.overflow_drop_oldest. The actually-shipped runtime constant is EventTypeMemoryRecoveryDropped with wire string memory.recovery_dropped (per D-035 + internal/memory/events.go). Phase 73j uses the shipped wire string — renaming the shipped event would be a D-035 re-litigation and is explicitly out of scope. A follow-up docs(design) PR reconciles the §12 wording. This is a §12-mockup-refinement drift, NOT an RFC-level departure.

Deviations (CLAUDE.md §4.3). The phase plan's public-API sketch typed the events dependency as events.Store; no such type exists — the shipped surface is *events.Aggregator (a compiled artifact wrapping the EventBus). The internal/memory/protocol functions take *events.Aggregator (a like-for-like signature refinement). The plan also specified the wire types regenerated into web/console/src/lib/protocol.ts by cmd/harbor-gen-protocol-ts (D-093); that generator command does not yet exist (Phase 72h committed protocol.ts as a hand-authored empty-but-typed stub; the generator lands in a later Console-tooling phase). Hand-editing the generated stub now would corrupt the generated-file contract. The Memory-page typed wire client therefore lives in a dedicated hand-authored web/console/src/lib/protocol-memory.ts module, kept in 1:1 lockstep with internal/protocol/types/memory.go; when the generator lands the module folds into protocol.ts mechanically. Neither deviation reaches RFC territory.

Protocol additions. Three method-name constants (memory.list / memory.get / memory.health), thirteen wire structs in internal/protocol/types/memory.go, three HTTP routes (POST /v1/memory/{list,get,health}). No new error code (the existing CodeIdentityRequired / CodeIdentityScopeRequired / CodeInvalidRequest / CodeNotFound / CodeRuntimeError cover every path). No Protocol version bump (additive surface per RFC §5.3).

Acceptance: see docs/plans/phase-73j-console-memory-page.md — every criterion is covered by the unit / leak / concurrent-reuse / integration tests + the phase-73j smoke + the Console-side Vitest + the Playwright spec.

Structural precedents. D-110 (the pause.list snapshot method) is the closest sibling — a read-only projection method mounted in the stream transport package, gating cross-tenant on the D-079 closed set, applying the D-026 heavy-content bypass per row. D-033 (memory.identity_rejected with <missing> substitution) + D-035 (memory.recovery_dropped / OverflowDropOldest) are the event surfaces the page consumes. D-026 (the context-window safety net) is the heavy-value-bypass posture mirrored here. D-061 (Console DB local-only) is the contract the saved_filters_memory.ts typed wrapper honours. D-065 (no session-level priority) is the invariant the Memory table preserves — no priority column, the Pinned chip is a Phase 24 strategy. D-079 (the closed two-scope set) is the binding scope posture (audit B1). D-091/D-092/D-093 are the Console scaffold contracts the page rides on.

Out of scope (Phase 73 / post-V1). The memory mutation surface (memory.put / memory.delete, the manual add/edit/evict UI — the bulk-action toolbar renders disabled-with-tooltip); memory.strategy_trace (the strategy debugger); memory.promotions (the cross-session promotion-policy viewer); TTL-based bulk eviction UI; the cross-runtime memory aggregator. None is blocked by the V1 read surface; each lands additively.


D-119 — Phase 73k Console MCP Connections page: twelve mcp.servers.* Protocol methods on a sibling MCPSurface; mcp.Registry read API; mcp.raw_html_trust_toggled audit event; D-079 closed-scope reuse

Date: 2026-05-20 Status: Settled (shipping with this PR)

Where it lives: internal/protocol/types/mcp_servers.go (the twelve mcp.servers.* wire types — request/response shapes, all flat Protocol-owned structs, never re-exports of the mcp driver); internal/protocol/methods/methods.go (the twelve MethodMCPServers* constants + IsMCPServersMethod / IsMCPAdminMethod O(1) predicates); internal/protocol/singlesource/singlesource.go (the twelve method strings + the wire-type homes); internal/protocol/mcp.go (the MCPSurface dispatcher — a sibling of the Phase 54 ControlSurface and the Phase 72f PostureSurface — plus the narrow MCPAccessor / MCPOAuthAccessor seam interfaces); internal/protocol/transports/control/mcp_handler.go (serveMCP REST adapter — per-method decoder, identity backfill, error mapping); internal/protocol/transports/control/control.go + internal/protocol/transports/transports.go (the WithMCPSurface wiring + the IsMCPServersMethod route branch); internal/tools/drivers/mcp/registry.go (the new process-local mcp.Registry read API — ListServers / GetServer / ListResources / ListPrompts / RefreshDiscovery / Probe / Health / SetRawHTMLTrust, projection-only ServerView / ResourceView / ... shapes, D-025-safe under a sync.RWMutex); internal/events/events.go (the EventTypeMCPRawHTMLTrustToggled canonical event + the MCPRawHTMLTrustToggledPayload SafePayload); internal/mcpconsole/mcpconsole.go (the wiring-package adapters RegistryAccessor / OAuthAccessor that bridge the mcp driver + tools/auth provider to the protocol-owned interfaces — kept out of internal/protocol so the Protocol package stays driver-free); web/console/src/routes/(console)/mcp-connections/+page.svelte + [server]/+page.svelte (the list + six-tab detail views); web/console/src/lib/mcp-connections/api.ts + state.svelte.ts (the typed Protocol client + the Svelte 5 runes state owner); web/console/tests/mcp-connections-page.spec.ts (the per-page Playwright spec); test/integration/mcp_connections_page_test.go (the cross-subsystem integration test); scripts/smoke/phase-73k.sh (the live-server smoke).

Decision. Four calls land here.

1. The twelve mcp.servers.* methods dispatch through a sibling MCPSurface, not the task-control ControlSurface. The MCP-Connections methods reach the runtime's MCP driver registry + the tool-side OAuth provider, not the steering inbox — exactly the posture of the Phase 72f PostureSurface. IsMCPServersMethod is the O(1) routing predicate; ControlSurface.Dispatch rejects a stray MCP method loudly (CodeInvalidRequest — "dispatch through the MCPSurface instead") rather than silently routing it onto the steering inbox.

2. Phase 28 ships one Provider per MCP server — Phase 73k adds the process-local mcp.Registry. The plan assumed an existing mcp.Registry; Phase 28 in fact ships a single Provider per attachment with no fleet registry. Phase 73k adds Registry — it holds the named providers and tracks per-server runtime stats (state, latency, discovery counts, reconnect history, raw-HTML trust) behind a documented-invariant sync.RWMutex. It is a D-025 reusable artifact; registry_concurrent_test.go pins N=128. Documented departure per CLAUDE.md §4.3.

3. Control-plane verbs gate on auth.ScopeAdmin (D-079 closed set) — no new MCP scope. The three admin verbs (refresh_binding / revoke_binding / set_raw_html_trust) AND the two control-plane verbs (refresh_discovery / probe) require the verified auth.ScopeAdmin claim; a miss surfaces CodeScopeMismatch. No mcp.* scope is minted — D-079's two-scope set (auth.ScopeAdmin + auth.ScopeConsoleFleet) stays closed.

4. Raw HTML from an MCP server is untrusted by default; the per-server trust toggle emits a mcp.raw_html_trust_toggled audit event. Brief 11 §"Open architectural questions" #8 — default-deny, explicit per-source trust toggle, audit when toggled. A successful mcp.servers.set_raw_html_trust emits the new SafePayload audit event (server name + boolean + actor identity quadruple) through the wired Redactor + Bus. A failed audit emit fails the call closed (CodeRuntimeError) — an un-auditable trust toggle is refused, never silently applied. The event is registered in the canonical event taxonomy (internal/events/events.go, alongside topology.changed) — the plan referenced internal/audit/events.go, which does not exist; audit events live in the closed event-type registry.

Why. Closes the Phase 73k acceptance criteria + the page-mcp-connections.md §12 mockup-aligned refinements. The page IS the §13 first consumer of every Protocol method it introduces, end-to-end, in the same PR.

Protocol additions. Twelve new method names (mcp.servers.list / get / resources / prompts / refresh_discovery / probe / health / bindings.list / policy / refresh_binding / revoke_binding / set_raw_html_trust); the matching request/response wire types; the mcp.raw_html_trust_toggled canonical event. Zero new error codes — CodeIdentityRequired / CodeScopeMismatch / CodeNotFound / CodeInvalidRequest / CodeRuntimeError suffice.

Documented deviations (CLAUDE.md §4.3). (a) The plan assumed an existing mcp.Registry; Phase 73k adds it (call #2). (b) The plan referenced internal/audit/events.go; the audit event lands in internal/events/events.go (call #4). (c) D-093's generated protocol.ts + the cmd/harbor-gen-protocol-ts generator + the make protocol-ts-gen target do not yet exist in-repo — protocol.ts is the Phase 72h empty stub. web/console/src/lib/mcp-connections/api.ts is the hand-authored typed client stand-in: every type mirrors the Go wire shape verbatim, all Protocol calls funnel through one protocolCall choke point, and no .svelte file hand-rolls a fetch (the §13 rule the page satisfies today). When the generator lands, api.ts's types regenerate into protocol.ts. (d) The auth.Provider exposes no fleet-wide binding-enumeration API; OAuthAccessor.ListBindings projects the configured binding scope + the caller's own token freshness — page-mcp-connections.md §8 confirms non-admin operators see only their own ScopeUser binding regardless. (e) harbor dev hosts no MCP servers, so its mux leaves the MCP surface unwired — mcp.servers.* returns CodeUnknownMethod (404) and the smoke 404→SKIP convention keeps preflight green, same posture as Phase 74's engine-less topology accessor.

Acceptance:

  • internal/protocol/types/mcp_servers.go declares the twelve request/response wire types; all are listed in singlesource.CanonicalWireTypes.
  • internal/protocol/methods/methods.go declares the twelve MethodMCPServers* constants + the IsMCPServersMethod / IsMCPAdminMethod predicates; Methods() returns 38.
  • MCPSurface.Dispatch fails closed on a missing identity (CodeIdentityRequired), gates the admin/control verbs on auth.ScopeAdmin (CodeScopeMismatch), and maps an unknown server to CodeNotFound.
  • mcp.Registry exposes the seven-method read API + SetRawHTMLTrust; TestRegistry_ListServers_ConcurrentReuse runs N=128 under -race.
  • A successful set_raw_html_trust emits exactly one mcp.raw_html_trust_toggled event with a SafePayload body carrying the actor quadruple.
  • test/integration/mcp_connections_page_test.go wires real mcp.Registry + real auth.Provider + real control transport + real bus + real redactor; asserts identity propagation, admin-claim gating, the audit emit, the not-found failure mode, and N=16 concurrent SSE-style subscriber stress; runs under -race.
  • The Console MCP Connections list + detail pages render through the typed api.ts client (no hand-rolled fetch in .svelte); all colour/spacing values are design tokens.
  • web/console/tests/mcp-connections-page.spec.ts lands in the same PR; scripts/smoke/phase-73k.sh is upgraded from skeleton to real assertions.

Structural precedents. D-114 (Phase 74 topology) is the sibling-surface pattern this phase mirrors — a read-only Protocol surface dispatched outside the steering inbox, left unwired on the engine-less harbor dev stack. D-111 / D-112 (PostureSurface) is the dispatcher-sibling shape. D-079 (closed two-scope set) is the no-new-scope rule. D-083 (tool-side OAuth auth.BindingScope) is the binding-state contract the OAuth & Auth tab consumes. D-062 (MCP-Apps DisplayMode + canonical renderer registry) is the no-bespoke-renderer rule. D-061 (Console DB local-only) is the carve-out the raw-HTML trust runtime-mirror sits inside. D-093 (generated protocol.ts) is the contract api.ts stands in for until the generator lands.

Out of scope (post-V1). Adding/removing MCP servers from the Console (mcp.servers.register — needs a runtime-config-mutation surface); per-tool MCP-Apps renderer customization (forbidden — brief 11 §PG-3); cross-runtime MCP catalog aggregator (D-091); per-server scheduled health checks / alerting; editing ToolPolicy from the Policy tab; the bulk "Disable" action; a fleet-wide per-server OAuth binding catalog (needs an auth.Provider enumeration extension).


D-120 — Phase 73l Console Artifacts page: artifacts.list/put/get_ref Protocol surface on a sibling ArtifactsSurface; the canonical renderer-registry skeleton; CodePresignUnsupported fail-loud resolver

Date: 2026-05-20 Status: Settled (shipping with this PR)

Where it lives: internal/protocol/methods/methods.go (the three artifacts.* method constants + the canonicalArtifactsMethods set + the IsArtifactsMethod O(1) predicate + the IsControlMethod exclusion); internal/protocol/errors/errors.go (the two new canonical codes — CodePresignUnsupported / CodeRequestTooLarge); internal/protocol/types/artifacts.go (the eleven flat artifacts wire types — ArtifactScope, SizeRange, TimeRange, ArtifactRef, ArtifactRow, ArtifactsListRequest/Response, ArtifactsPutOpts/Request/Response, ArtifactsGetRefRequest/Response — plus the ArtifactSource closed enum); internal/protocol/artifacts.go (the ArtifactsSurface dispatcher — a sibling of the Phase 54 ControlSurface and the Phase 72f PostureSurface, not an extension — plus the artifacts.uploaded event type + ArtifactUploadedPayload); internal/protocol/transports/control/control.go + artifacts_handler.go (the WithArtifactsSurface option + the serveArtifacts REST adapter, which applies an 8 MiB transport-edge body cap for the upload payload); internal/protocol/transports/control/status.go (the CodePresignUnsupported → 501 / CodeRequestTooLarge → 413 status mappings); internal/protocol/transports/transports.go (the WithArtifactsSurface mux option); internal/protocol/singlesource.go (the lockstep map gains the three method names + the eleven wire types); internal/protocol/conformance/conformance.go (the method-count bump 26→29, the error-code matrix gains the two new codes + their status pins, the artifacts methods are excluded from the happy-path / malformed-request matrices the same way the search / posture / pause / topology clusters are); internal/config/config.go (the ProtocolConfig.MaxRequestBytes field + DefaultMaxRequestBytes (4 MiB) + ResolvedMaxRequestBytes); cmd/harbor/cmd_dev.go (the NewArtifactsSurface + WithArtifactsSurface boot wiring — the dev inmem artifact store has no Presigner, so artifacts.get_ref fails loud with CodePresignUnsupported); web/console/src/lib/chat/renderers/ (the canonical renderer-registry skeletonindex.ts dispatch table + the six MIME renderers markdown/code/image/pdf/audio/json + a fallback renderer + the README.md dispatch-contract one-pager); web/console/src/lib/protocol.ts (hand-extended with the artifacts wire types + the ProtocolClient interface + HTTPProtocolClient — see the deviation note below); web/console/src/routes/console/artifacts/ (the page route — +page.svelte + filter_bar.svelte + artifacts_table.svelte + right_rail.svelte + preview_pane.svelte + bulk_toolbar.svelte); web/console/tests/artifacts-page.spec.ts (the Playwright per-page spec); internal/protocol/artifacts_test.go + artifacts_concurrent_test.go (the surface unit + D-025 concurrent-reuse tests); test/integration/artifacts_page_test.go (the §17.1 integration test across the in-mem / SQLite / fs drivers); web/console/src/lib/chat/renderers/registry.spec.ts (the renderer-registry Vitest); scripts/smoke/phase-73l.sh (the live-server smoke); docs/plans/README.md + README.md (Phase 73l → Shipped); docs/glossary.md (the four new vocabulary entries).

Decision. Four calls land here.

1. The artifacts methods route through a sibling ArtifactsSurface, not the task-control ControlSurface. The three artifacts.* methods are not steering controls — they do not reach the task registry, they carry their own flat wire shapes, and the upload method carries a payload larger than the 64 KiB control-body cap. They follow the exact pattern the Phase 72c SearchSurface, the Phase 72f PostureSurface, and the Phase 74 topology dispatcher established: a dedicated dispatcher, an O(1) IsArtifactsMethod predicate, an IsControlMethod exclusion, and an additive WithArtifactsSurface transport option that preserves the 404 → SKIP smoke path on a build without the surface wired. The phase plan placed the surface at internal/protocol/handlers/artifacts.go; the codebase has no handlers/ sub-package — every Protocol surface (ControlSurface, SearchSurface, PostureSurface) lives at internal/protocol/. Phase 73l follows the established convention and places the surface at internal/protocol/artifacts.go — a documented CLAUDE.md §4.3 deviation; the RFC §5.1 single-source rule and the existing surface pattern win over the stale plan path.

2. artifacts.get_ref fails loud with CodePresignUnsupported on a non-S3 driver. The read-side presigned-URL resolver type-asserts the ArtifactStore to artifacts.Presigner (the optional capability only the Phase 19 S3 driver implements). A driver without the capability returns the new CodePresignUnsupported code (HTTP 501) — never a silent fallback to byte-streaming (D-022 fail-loud posture, CLAUDE.md §13). The Console renders the typed error as a "Preview not available — driver does not support presigned URLs" placeholder plus a Download fallback. Heavy bytes never cross the Protocol inline (D-026): artifacts.list returns metadata-only rows, artifacts.get_ref returns a presigned URL, artifacts.put accepts upload bytes on the request leg only and returns a reference.

3. The canonical renderer-registry skeleton is an extensible dispatch table — open for registration, closed for modification. Phase 73l is the FIRST in-staging consumer of the shared renderer registry (web/console/src/lib/chat/renderers/, the canonical path per Brief 12). It ships the dispatch core (index.ts — a first-match-wins ordered rule list + registerRenderer / dispatchRenderer / mimeIs / mimePrefix) plus six MIME renderers + a fallback. Phase 73n (Playground, Stage 2.3) is the SECOND consumer and EXTENDS the registry with chat-bubble / tool-call / diff renderers by calling registerRenderer from its own module init — it does NOT edit the dispatch core. This closes the Wave 12 R5 audit finding (a hard-coded per-mime switch would bit-rot at the second consumer). The Artifacts preview pane dispatches through dispatchRenderer; the route directory carries NO bespoke per-mime .svelte renderer (the Playwright spec asserts this statically).

4. The artifacts surface uses ONLY the D-079 closed scope set. No new scope is minted. A cross-tenant artifacts.list (request scope tenant differs from the caller's verified tenant) requires auth.ScopeAdmin or auth.ScopeConsoleFleet; a cross-tenant artifacts.put requires auth.ScopeAdmin. Identity is mandatory at every boundary — a missing tenant/user/session returns CodeIdentityRequired, fail-closed (CLAUDE.md §6 rule 9). The mutation surfaces (artifacts.delete / set-retention) are NOT shipped — the Console renders them disabled-with-tooltip per the page spec §10 deferred list.

Why. Closes the Phase 73l acceptance criteria + the Wave 13 decomposition row 73l. The page IS the consumer of the extended artifacts.list filter shape and the new artifacts.put method (the §13 primitive-with-consumer rule, satisfied in the same wave). The renderer-registry skeleton lands with its first consumer (the Artifacts preview pane) so the dispatch contract is validated against a real call site before Phase 73n extends it.

Findings I'm departing from. Two documented deviations. (a) The phase plan's internal/protocol/handlers/artifacts.go path — the codebase has no handlers/ sub-package; the surface lands at internal/protocol/artifacts.go matching the SearchSurface / PostureSurface convention (call #1 above). (b) web/console/src/lib/protocol.ts is hand-extended — D-093 specifies protocol.ts is generated by cmd/harbor-gen-protocol-ts, but that generator binary has not yet landed (Phase 72h committed protocol.ts as a hand-shaped stub and noted "Downstream Console phases regenerate it"). Phase 73l hand-extends the file following the stub's shape and keeps the CODE GENERATED … DO NOT EDIT header; when the generator lands it regenerates the file verbatim from the Go CanonicalWireTypes. Both deviations are recorded in the phase plan.

Protocol additions. Three method names (artifacts.list, artifacts.put, artifacts.get_ref), two error codes (presign_unsupported → 501, request_too_large → 413), eleven wire types, one canonical event type (artifacts.uploaded). No new capability constant — the artifacts surface follows the search-cluster precedent (the search.* cluster advertises no Cap* constant either). The wire-transport route is the existing POST /v1/control/{method} REST surface.


D-121 — Console design-system foundation: route group, app shell, shared components/ui/ inventory, 4-state PageState async contract, unified HarborClient, token reconciliation

Date: 2026-05-20 Status: Settled (shipping with this PR)

Where it lives: docs/design/console/CONVENTIONS.md (the new binding conventions doc — the authority every future Console page phase cites); CLAUDE.md / AGENTS.md §4.5 (a binding bullet pointing at CONVENTIONS.md as the Console design authority); web/console/src/lib/components/ui/ (the eleven-component shared inventory — PageHeader, FilterBar, SavedViewChips, DataTable, BulkActionBar, DetailRail, RailCard, StatusChip, Pagination, ConnectionFooter, PageState); web/console/src/lib/protocol/ (the unified HarborClient class + the injectable ProtocolClient interface + the single ProtocolError class — namespaces tools / memory / flows / artifacts / mcp ported from the five legacy hand-authored clients); web/console/src/lib/connection.ts (the single {baseURL, token, identity} resolver, null when unattached); web/console/src/lib/tokens.css (reconciled — four border tokens collapsed to one --border-hairline, three rail-width tokens to one --size-rail, raw hex de-hardcoded, phase-stamped comment blocks removed, back-compat aliases retained so the five existing pages still resolve); web/console/src/routes/(console)/+layout.svelte (the app shell — sidebar with the 14-page IA in four clusters, top-bar breadcrumb + identity/connection indicator, shared footer); the five merged page routes relocated under routes/(console)/ (tools/, memory/, flows/ moved in; console/artifacts/(console)/artifacts/; mcp-connections/ already under (console)/); web/console/src/routes/(console)/overview/+page.svelte (the placeholder redirect target); web/console/src/routes/+page.svelte (root //overview redirect).

Decision. A foundation audit of the first five merged Console pages (Tools, Memory, MCP Connections, Artifacts, Flows) found deep per-page drift: three conflicting route conventions plus broken cross-page links, no app shell, five incompatible async-state contracts, ~13 duplicated UI concepts, five hand-authored Protocol clients, fragmented design tokens. This PR lays the shared foundation; a later wave refactors each page's internals onto it. Six calls land here.

1. One route group. Every Console page is a top-level URL segment hosted under web/console/src/routes/(console)/, a SvelteKit route group whose sole purpose is to attach the shared app-shell layout. URLs carry no /console/ prefix and no group name. Detail views are (console)/<page>/[id]/+page.svelte uniformly. Root / redirects to /overview. All inter-page links use the unprefixed form.

2. One app shell. (console)/+layout.svelte renders the persistent sidebar (14-page IA in four clusters — Runtime / Execution / Resources / Settings; Playground is a session-level surface, not a sidebar entry), the top bar (breadcrumb + identity/connection indicator), the shared ConnectionFooter, and the content region.

3. One shared component inventory. web/console/src/lib/components/ui/ holds the eleven cross-page primitives, built on Skeleton, design-tokens-only, Svelte 5 runes. Page-specific components stay in components/<page>/. No two components share a name.

4. One four-state async contract. <PageState> owns Disconnected / Loading / Error / Empty as a mutually-exclusive if/else-if chain. Disconnected (no Runtime) is never conflated with Error. Loading renders a shape-matched skeleton. Error renders code: message plus a mandatory Retry button and suppresses any stale primary view. Detail rails get a nested PageState.

5. One typed client layer. web/console/src/lib/protocol/ ships the HarborClient class with method namespaces, the injectable ProtocolClient interface, and one ProtocolError class with uniform (code, message, status) — status is never dropped. connection.ts is the single {baseURL, token, identity} resolver. One fetch choke point. Each method targets whatever route the Runtime actually mounts for it.

6. One reconciled token scale. tokens.css is a single coherent scale extended in place — one hairline-border token, one rail-width token, no raw hex literals outside the base palette, no phase-stamped comment blocks. Back-compat aliases retain every existing var(--…) reference so the five relocated pages still build.

Why. Closes the foundation-audit findings. The Console grew page-by-page with no shared spine; the drift was cumulative and would compound with every future page. CONVENTIONS.md is the forcing function — every future page phase plan cites it in a mandatory "Console consistency" section, and a divergent page PR is rejected on sight. This is the §13 "two parallel implementations" rule applied to the Console: one route convention, one shell, one client, one token scale.

Findings I'm departing from. This PR relocates and links the five pages onto the foundation but does NOT refactor their internal logic — each page keeps its existing per-page components and legacy client for now; the internal refactor onto components/ui/ and HarborClient is an explicitly deferred later wave. The unified HarborClient and the legacy per-page clients coexist transiently until that wave; this is a deliberate, time-boxed exception to the §13 "two parallel implementations" rule, scoped to the foundation→refactor transition and recorded here so it is not mistaken for permanent drift.

Protocol additions. None — this is a Console-only consolidation. No new Protocol methods, error codes, or wire types. The HarborClient namespaces port the union of the five existing hand-authored clients' method surfaces against the routes the Runtime already mounts.


D-122 — Console Sessions page: sessions.list + sessions.inspect Protocol methods + the SvelteKit Sessions list/detail route

Date: 2026-05-20 Status: Settled (shipping with this PR)

Where it lives: internal/protocol/methods/methods.go (the two sessions.* method constants + the canonicalSessionsMethods set + the IsSessionsMethod O(1) predicate + the IsControlMethod exclusion); internal/protocol/types/sessions.go (the nine flat Sessions wire types — Window, SessionFilter, SessionsListRequest, SessionRow, SessionsListResponse, InterventionSummary, ArtifactRefSummary, SessionsInspectRequest, SessionsInspectResponse — plus the SessionStatus / SessionSort closed enums + the pagination-bound constants); internal/sessions/protocol/ (the Service — a sibling of the Phase 73f tools/protocol.Service — plus the Projector seam, the ListerProjector V1 implementation over the Phase 08 SessionLister, the opaque versioned cursor codec, the facet-filter predicate, and the SessionsAdminQueryPayload audit emit); internal/protocol/transports/stream/sessions_handler.go (the SessionsHandler wire adapter — POST /v1/sessions/{list,inspect}); internal/protocol/transports/transports.go (the WithSessionsService mux option); internal/protocol/singlesource/singlesource.go (the lockstep map gains the two method names + the nine wire types); internal/protocol/conformance/conformance.go (the method-count bump + the sessions-cluster skip, the same posture the search / pause / flows clusters take); cmd/harbor/cmd_dev.go (the sessions.New registry + NewListerProjector + NewService + WithSessionsService boot wiring); web/console/src/lib/protocol/client.ts + harbor.ts (the SessionsNamespace added to the unified HarborClient); web/console/src/lib/protocol/sessions.ts (the typed SessionsProtocol wrapper); web/console/src/lib/sessions/ (the wire types + the formatting helpers); web/console/src/lib/db/saved_filters_sessions.ts (the Console-DB saved-filter wrapper — page = 'sessions' scoped, NO new table); web/console/src/lib/components/sessions/ (the page-specific components — SessionFacetChips, SessionSummaryCard, RecentInterventionsCard, RecentArtifactsCard, BottomDockTabs, IdentityCell); web/console/src/routes/(console)/sessions/ (the list route + the [id]/ detail route); web/console/tests/sessions-page.spec.ts (the Playwright per-page spec); internal/sessions/protocol/protocol_test.go + concurrent_test.go (the Service unit + D-025 N≥100 concurrent-reuse tests); internal/protocol/transports/stream/sessions_handler_test.go (the handler unit tests); test/integration/sessions_page_test.go (the §17.1 integration test — real registry + real wire transport + real ES256 auth); web/console/src/lib/sessions/tests/format.spec.ts + web/console/src/lib/db/tests/saved_filters_sessions.spec.ts (the Console-side Vitest); scripts/smoke/phase-73c.sh (the live-server smoke); docs/plans/README.md + README.md (Phase 73c → Shipped); docs/glossary.md (the four new vocabulary entries).

Decision. Four calls land here.

1. The sessions methods route through a sibling Sessions handler, not the task-control ControlSurface. The two sessions.* methods are read-only projections — they do not reach the task registry, they carry their own flat wire shapes. They follow the exact pattern the Phase 73f tools.* handler and the Phase 73i flows.* handler established: a dedicated stream-package handler at POST /v1/sessions/{verb}, an O(1) IsSessionsMethod predicate, an IsControlMethod exclusion, and an additive WithSessionsService transport option that preserves the 404 → SKIP smoke path on a build without the surface wired. The phase plan placed the handler at internal/server/sessions_list.go; the codebase has no internal/server/ package — every Protocol wire handler lives under internal/protocol/transports/. Phase 73c follows the established convention — a documented CLAUDE.md §4.3 deviation; the RFC §5.1 single-source rule and the existing handler pattern win over the stale plan path.

2. sessions.list cursor pagination is opaque + version-prefixed; Truncated replaces a silent exact total. The cursor is a base64-url-encoded (version, sort-key, cost, session-id) tuple — opaque to clients, carrying a 1-byte version prefix so a future encoding change fails loudly with CodeInvalidRequest rather than silently degrading. The response emits Truncated bool (D-026 fail-loudly) — never an exact O(N) total under high cardinality. The forward-then-filter resolution for the Query field (the runtime forwards a free-text query to the search.sessions index first, the SessionFilter axes are post-search refinements) is pinned here per the phase plan's open question.

3. The sessions.inspect Row projection ships; the recent-interventions / recent-artifacts cards populate from the live event stream. The Phase 08 Session record does not model per-session cost / token / task / event counters or the agent binding — those are surfaced from the Console's own event-stream subscription on the detail route (the llm.cost.recorded aggregation is Console-local per the page spec §3). sessions.inspect ships the SessionRow projection + empty capped recent_interventions / recent_artifacts slices; the cards populate client-side from the event stream. This keeps sessions.list / sessions.inspect pure registry projections (no shadow aggregation store — D-061) and is a documented Phase 73c deviation. The phase plan framed sessions.inspect as an additive extension of a Phase 73 parent method; Phase 73 has not shipped sessions.inspect, so Phase 73c lands it whole.

4. The Sessions page uses ONLY the D-079 closed scope set. No new scope is minted. A cross-tenant sessions.list (a TenantIDs entry naming a tenant other than the caller's verified tenant) requires auth.ScopeAdmin (or auth.ScopeConsoleFleet); a missing claim fails closed with CodeScopeMismatch (HTTP 403) and a successful admin-scope query emits an audit.admin_scope_used event. Identity is mandatory at every boundary — a missing tenant/user/session returns CodeIdentityRequired, fail-closed (CLAUDE.md §6 rule 9). The bulk Cancel / Pause toolbar actions are control-plane verbs (D-066): the toolbar always renders them, disabled-with-tooltip — never a faked success string.

Why. Closes the Phase 73c acceptance criteria + the Wave 13 decomposition row 73c. The page IS the consumer of the new sessions.list method (the §13 primitive-with-consumer rule, satisfied in the same phase). The Sessions-page Identity column is the same-wave consumer of Phase 72b's IdentityScope impersonation triplet — it renders the verified actor triple plus a separate impersonating chip for admin-initiated runs, discharging Phase 72b's binding cross-reference.

Findings I'm departing from. Three documented deviations. (a) The phase plan's internal/server/ handler path — the codebase has no internal/server/ package; the handler lands at internal/protocol/transports/stream/sessions_handler.go matching the Phase 73f / 73i precedent (call #1). (b) sessions.inspect is shipped whole, not as an additive extension of a Phase 73 parent method that has not landed (call #3). (c) web/console/src/lib/protocol.ts is NOT hand-edited — D-093 pins it as generated, and the generator has not landed; Phase 73c follows the Phase 73i Flows-page precedent instead, placing the Sessions wire types in web/console/src/lib/sessions/types.ts and a typed SessionsProtocol wrapper over the unified HarborClient sessions namespace.

Protocol additions. Two method names (sessions.list, sessions.inspect), nine wire types, one reused canonical event type (audit.admin_scope_used — a new SessionsAdminQueryPayload). No new error codes — the four mapped codes (CodeIdentityRequired, CodeScopeMismatch, CodeNotFound, CodeInvalidRequest) are all canonical. No new capability constant — the sessions surface follows the search / tools / flows precedent. The wire-transport route is POST /v1/sessions/{verb}.


D-123 — Phase 73d Console Tasks page: tasks.list/tasks.get read surface on a sibling Tasks dispatcher; the kanban board as the primary view; the bulk toolbar consumes the shipped Phase 54 control verbs

Date: 2026-05-20 Status: Settled (shipping with this PR)

Where it lives: internal/protocol/methods/methods.go (MethodTasksList / MethodTasksGet constants + the canonicalTasksMethods set + the IsTasksMethod predicate); internal/protocol/types/tasks.go (the Tasks-page wire types — TaskRow, TaskFilter, TaskListAggregates, TaskListCursor, TaskListRequest, TaskListResponse, TaskDetail, TaskParentSessionRef, TaskParentTaskRef, TaskCostRollup, TaskCostStep, TaskPlannerSnapshotRef, TaskGetRequest + the TaskStatus / TaskKind wire enums); internal/tasks/protocol/ (the Tasks Protocol Service + the Projector seam + the V1 RegistryProjector + the Enricher seam + the TasksAdminActionPayload audit emit); internal/protocol/transports/stream/tasks_handler.go (the POST /v1/tasks/{method} wire handler); internal/protocol/transports/transports.go (the WithTasksService mux option); cmd/harbor/cmd_dev.go + harbortest/devstack/devstack.go (the production + devstack wiring); web/console/src/routes/(console)/tasks/ (the Tasks page route); web/console/src/lib/components/tasks/ (the kanban board + per-task action bar + detail tabs + rail-card bodies); web/console/src/lib/protocol/tasks.ts (the wire-type surface); web/console/src/lib/protocol/client.ts (the tasks + control HarborClient namespaces); web/console/src/lib/db/saved_filters_tasks.ts (the Console-DB saved-filter wrapper).

Decision. Phase 73d ships the Console Tasks page — the task-granularity counterpart to Sessions — as a Protocol client built on the D-121 design-system foundation. Three calls land here.

1. Two net-new READ methods, on a sibling Tasks dispatcher. tasks.list (paginated, faceted task-row projection + per-status aggregates + cursor pagination) and tasks.get (enriched single-task detail — parent-session ref, parent-task ref, per-step cost rollup, planner-snapshot ref). Both are READS. The wire-transport route is POST /v1/tasks/{method}, a sibling of tools.* / memory.*IsTasksMethod is the O(1) predicate the stream transport branches on, and IsControlMethod excludes the pair. Identity is mandatory (CodeIdentityRequired); a cross-tenant tasks.list fan-in requires the verified auth.ScopeAdmin claim (D-079 closed two-scope set — CodeScopeMismatch otherwise) and emits audit.admin_scope_used; a cross-tenant tasks.get returns CodeNotFound (existence is never revealed). No new error code, no new scope.

2. The bulk toolbar consumes the SHIPPED Phase 54 control verbs. The Tasks page's bulk-action toolbar + per-task action bar + card-drag-across-columns all invoke the EXISTING Phase 54 cancel / pause / resume / prioritize / approve / reject methods through the control transport (POST /v1/control/{verb}) — there is NO tasks.* mutating method (CLAUDE.md §13 "no parallel implementations"). The Console-side ControlNamespace on HarborClient is a typed thin wrapper over those routes; a verb targets a task by its run id carried in identity.run. Card drag is wired to the matching verb (Running to Paused = pause, Paused to Running = resume, Running to Failed = cancel); Pending to Running is server-initiated and the drag is a no-op with an inline toast.

3. The kanban board is the depth-bar primary view. Per the Tasks page spec the primary view is a 4-column kanban board (Pending / Running / Paused / Failed), not a flat DataTable — but it still mounts inside the shared app shell, routes async state through the four-state <PageState>, carries Console-DB-backed SavedViewChips + real Pagination + a DetailRail, and offers a list-mode DataTable toggle. The kanban pieces live in components/tasks/ and compose ui/ primitives underneath; the bulk-action toolbar is the shared BulkActionBar — not a forked per-page toolbar.

Why. The Tasks page answers "what's running across all sessions right now?" / "every task that failed in the last hour" — questions one notch below Sessions. tasks.list is high-cardinality and runtime-side (brief 11): the runtime owns the index, not Console-side substring matching. The two methods are reads because the page is observation + control, and control is already a shipped surface (Phase 54) — re-minting a tasks.cancel would be the §13 "two parallel implementations" violation. The page is the §13 primitive-with-consumer discharge for the Tasks Protocol surface — it lands in the same phase as the methods.

Findings I'm departing from. None against the briefs. The internal/protocol/types/tasks.go wire types are Protocol-local (the TaskStatus / TaskKind enums are NOT the runtime-internal tasks.TaskStatus / tasks.TaskKind) — internal/protocol/types does not import internal/tasks, keeping the Protocol layer's vocabulary its own (CLAUDE.md §8). The Phase 73d plan sketched TaskDetail.Task as the internal tasks.Task; this PR projects a flat TaskRow instead so the Console never reads an internal Go type (the same posture tools.go took). RegistryProjector scopes tasks.list to the caller's own session — the realistic V1 surface, since tasks.TaskRegistry.List is session-scoped; the Projector seam admits a future cross-runtime aggregating projector without reshaping the Service.


D-124 — Phase 73e Console Agents page: eight agents.* read-only Protocol methods on a sibling agents handler over a registry/protocol Service; control verbs stay the shipped registry.* surface

Date: 2026-05-20 Status: Settled (shipping with this PR)

Where it lives: internal/protocol/types/agents.go (the twenty-eight Agents-page wire types + the three string enums); internal/protocol/methods/methods.go (the eight agents.* method constants + canonicalAgentsMethods + IsAgentsMethod); internal/runtime/registry/protocol/ (the new package — Service over a Projector seam, the V1 RegistryProjector over a registry.AgentRegistry with an optional ConfigSource join); internal/protocol/transports/stream/agents_handler.go (the POST /v1/agents/{method} wire handler); internal/protocol/transports/transports.go (WithAgentsService); cmd/harbor/cmd_dev.go (the dev stack constructs the Agent Registry + the agents Service and mounts the route); web/console/src/lib/protocol/agents.ts + the agents namespace on HarborClient; web/console/src/routes/(console)/agents/ (list + [id] detail routes); web/console/src/lib/components/agents/; web/console/src/lib/db/saved_filters_agents.ts.

Decision. The Console Agents page consumes eight NEW agents.* Protocol methods. Three calls land here.

1. Eight read-only methods on a sibling handler, not the control surface. agents.list / agents.get / agents.tools / agents.memory / agents.governance / agents.skills / agents.permissions / agents.metrics are all read-only projections of the Agent Registry. They route through a dedicated POST /v1/agents/{method} wire handler in the stream package — the same posture as the Phase 73f tools.* cluster — not the task-control ControlSurface. IsAgentsMethod makes IsControlMethod return false for them so the steering inbox stays the Phase 54 nine.

2. Control verbs stay the shipped registry.* surface — Phase 73e mints NO control method. The five fleet-control verbs the Agents page exposes (Pause / Drain / Restart / Force-Stop / Deregister) are the EXISTING shipped registry.* control verbs (Phase 53a, D-066), gated on the elevated control-scope claim. Phase 73e adds no control Protocol method and no new wire type for them; the page renders the control buttons disabled-with-tooltip for an operator without the claim (CONVENTIONS.md §5 — no stubbed action). This is the §13 "no two parallel implementations" rule: the registry control surface already exists; the page consumes it rather than cloning it.

3. agent_id is NOT an isolation principal. Every agents.* method is identity-mandatory and filters by the (tenant, user, session) tuple read from the request context — never by agent_id (D-059, CLAUDE.md §6 clarifying note). The RegistryProjector delegates scoping to the registry's own tuple-scoped storage; a cross-tenant agents.get returns not_found, never another tenant's agent.

Why. Brief 11 / Brief 12 pin agent management as a binding V1 Console surface; the Agent Registry (D-059 / D-060) already owns the data. Splitting the page surface into eight specialised methods rather than overloading agents.get follows Brief 11's recommendation directly — each detail tab loads independently through its own nested PageState. Keeping the methods read-only and routing control through the shipped registry.* surface keeps the Console an honest Protocol client and avoids a parallel control path.

Findings I'm departing from. None on design. One implementation note: the registry persists only the version_hash of an agent's AgentConfig, not the config itself, so the configuration-derived projections (agents.tools / agents.memory / agents.governance / agents.skills and the AgentConfig on agents.get) join through an optional ConfigSource seam on the RegistryProjector. When no ConfigSource is wired the methods return an HONEST empty projection (an empty binding list, a zero-value memory binding) — they still validate identity and the agent's existence and still fail loud with not_found for a missing agent; this is not a stubbed success (CLAUDE.md §13). Production wiring supplies a ConfigSource as the subsystems that own that data (tool catalog, memory configs, Phase 36 governance, skills catalog) grow their join surfaces.

Protocol additions. Eight method names (agents.list / agents.get / agents.tools / agents.memory / agents.governance / agents.skills / agents.permissions / agents.metrics), twenty-eight wire types in internal/protocol/types/agents.go. No new error code (the agents surface reuses identity_required / not_found / invalid_request / runtime_error). No new capability constant. No new scope — agent control/admin gates on the existing auth.ScopeAdmin (D-079 closed two-scope set). The wire-transport route is the new POST /v1/agents/{method}.


D-125 — Phase 73g Console Events page: composition-only UI over shipped events.subscribe / events.aggregate / artifacts.get_ref; no new Protocol method

Date: 2026-05-20 Status: Settled (shipping with this PR)

Where it lives: web/console/src/routes/(console)/events/+page.svelte + +page.ts (the page route — served at /events, no /console/ URL prefix, per CONVENTIONS.md §1 / D-121); web/console/src/lib/events/ (the page-local lib module — filters.ts, sparkline.ts, export.ts, taxonomy.ts, subscription.svelte.ts, aggregate.svelte.ts, state.svelte.ts, saved-views.svelte.ts); web/console/src/lib/components/events/ (the page-specific components — EventFilterChips, EventRateSparkline, EventTable rendered inline via the shared DataTable, EventDetailRail, PauseStreamToggle, ExportMenu, TruncatedPayloadLink); web/console/src/lib/protocol/events.ts (the Console-side events.* wire types) + the new EventsNamespace on HarborClient; web/console/src/lib/db/saved_filters_events.ts (the typed wrapper over the shipped Phase 72h saved_filters table, scoped to page = 'events' — NO new table); web/console/tests/events-page.spec.ts (the per-page Playwright spec); test/integration/events_page_test.go (the Go-side §17.1 integration test).

Decision. The Console Events page is the runtime event-bus stream as a full-screen, query-driven investigative surface. Four calls land here.

1. Composition-only — NO new Protocol method. Phase 73g ships zero new Protocol methods, wire types in internal/protocol/types/, or method names in internal/protocol/methods/methods.go. The page is a pure UI consumer of already-shipped surface: events.subscribe (the GET /v1/events SSE table feed — Phase 72), events.aggregate (the POST /v1/events/aggregate sparkline feed — Phase 72a), and artifacts.get_ref (the heavy-payload Open artifact resolver — Phase 73l). The §13 primitive-with-consumer rule is satisfied trivially: 73g IS the consumer Phase 72a's primitives waited for.

2. The EventsNamespace joins the unified HarborClient. New page surfaces add a namespace, never a new top-level client (CONVENTIONS.md §6). client.events.aggregate(...) wraps the events.aggregate POST through the single Transport choke point; client.events.subscribeURL(...) builds the SSE EventSource target (a long-lived GET the request/response Transport does not model — the bearer token rides as a query param because EventSource cannot set an Authorization header). No .svelte file constructs the URL or the EventSource by hand.

3. Saved views, pause-stream, export, pagination size are Console-local (D-061). The saved-filter chips persist a JSON-encoded EventFacetState in the shipped saved_filters Console DB table scoped to page = 'events' — no new table, no Protocol round-trip. The Pause-stream toggle is a Console-local render gate, distinct from the runtime pause Protocol method (which is task-scoped — RFC §5.2): while paused the SSE cursor keeps advancing per D-029 and incoming events buffer; resume flushes them in cursor order. Export ▾ serialises the loaded page to NDJSON / CSV client-side.

4. Heavy payloads flow by reference (D-026) — cross-tenant gated on the closed scope set (D-079). A truncated event payload carries an artifact_ref, never inline bytes; the TruncatedPayloadLink resolves it via artifacts.get_ref. No Svelte component inlines heavy bytes. The Tenant ▾ facet is gated on auth.ScopeAdmin / auth.ScopeConsoleFleet — the D-079 closed two-scope set; NO events.crosstenant scope is minted (the PR #142 audit closed that). Cross-tenant fan-in emits audit.admin_scope_used, which the page's own table surfaces.

Why. Closes the Phase 73g acceptance criteria + the Wave 13 decomposition row 73g. The page validates Phase 72a's EventFilter / events.aggregate primitives against a real call site in the same wave, and routes every heavy payload through artifacts.get_ref so the D-026 leak shape is closed at the Console edge.

Findings I'm departing from. One documented deviation. The phase plan (authored before D-121 landed) lists the route at web/console/src/routes/console/events/+page.svelte. CONVENTIONS.md §1 (D-121) is the binding cross-cutting authority and pins the (console) route group with NO /console/ URL prefix; the page ships at web/console/src/routes/(console)/events/ accordingly. The phase plan's web/console/src/lib/events/components/ path is likewise corrected to web/console/src/lib/components/events/ to match the components/<page>/ convention CONVENTIONS.md §3 pins. Both corrections follow CLAUDE.md §15 (a plan that contradicts a higher-priority artifact yields to it).

Protocol additions. None — this is a Console-only page phase. No new Protocol methods, error codes, or wire types. The Console-side events.ts wire types mirror the shipped internal/protocol/types/events.go field-for-field.


D-126 — Phase 73b Console Live Runtime page: composition over shipped surfaces + the single tasks.list status-counter-strip aggregate; no new Protocol method

Date: 2026-05-20 Status: Settled (shipping with this PR)

Where it lives: internal/protocol/types/tasks.go (the new TasksListStatusCounterStrip wire type + the opt-in TaskListRequest.IncludeStatusCounterStrip field + the TaskListResponse.StatusCounterStrip field); internal/tasks/protocol/list.go (the server-side, identity-scoped aggregate computation); internal/protocol/singlesource/singlesource.go (the TasksListStatusCounterStrip CanonicalWireTypes entry); web/console/src/routes/(console)/live-runtime/ (the page route at /live-runtime + the [session_id] deep-link route — no /console/ URL prefix, per CONVENTIONS.md §1 / D-121); web/console/src/lib/components/live-runtime/ (the page components, incl. the composer/ subtree); web/console/src/lib/live-runtime/ (the pure strip.ts + topology-adapter.ts logic); web/console/src/lib/protocol/topology.ts + the TopologyNamespace on HarborClient; web/console/src/lib/db/saved_filters_live_runtime.ts (the typed wrapper over the shipped saved_filters table — NO new table); web/console/tests/live-runtime-page.spec.ts (the per-page Playwright spec); test/integration/live_runtime_page_test.go (the Go-side §17.1 integration test).

Decision. The Console Live Runtime page is the operator's present-tense execution workbench — the topology canvas as the centrepiece, a header status-counter strip, a tab strip (Topology / Timeline / Metrics / Health), a bottom-dock Event Stream + per-task detail / composer, and a session detail rail. Four calls land here.

1. Mostly composition-only — ONE net-new Protocol addition, and it is not a method. Phase 73b is overwhelmingly a UI consumer of already-shipped surface: topology.snapshot (Phase 74 / D-114), events.subscribe SSE (Phase 60 / 72), tasks.get + state.history (Phase 73), and the Phase 54 task-control verbs. The single net-new Protocol addition is the tasks.list status-counter-strip aggregateTasksListStatusCounterStrip (a five-count pending / running / completed / paused / failed struct), opt-in via the new TaskListRequest.IncludeStatusCounterStrip flag, carried on TaskListResponse.StatusCounterStrip. It is a single-source CanonicalWireTypes extension; internal/protocol/methods/methods.go is unchanged — no new method name. The strip is computed server-side over the FULL identity-scoped task set (not the filtered view — the header strip is session-wide present-tense posture) and is identity-scoped: a second session never sees the first's counts.

2. The events.subscribe run filter is composition-only — NO new filter type. The phase plan sketched an events.subscribe RunID filter field as a [wave-13-extends] addition. The shipped internal/protocol/types.EventFilter ALREADY carries RunIDs []string, and events.FilterFromWire ALREADY maps it onto events.Filter.Run (the D-082 X-Harbor-Run carrier's structured counterpart). Minting a parallel scalar RunID field would be the §13 "no parallel implementations" violation. Phase 73b therefore ships NO new events wire type — the bottom-dock Trace tab narrows its subscription via the already-shipped run carrier. This is a documented departure from the plan's acceptance criterion wording; the run-scoped filter still ships and is exercised end-to-end (the integration test's trace_tab_run_scoped_filter arm + the internal/events TestFilter_RunScoped_TraceTab unit test).

3. The topology canvas REUSES the shared <EngineGraphCanvas> — it is not forked. The Topology tab's primary view is the Phase 73i shared engine-graph canvas (components/graph/), fed through a thin <TopologyCanvas> adapter that maps a Protocol TopologyProjection onto the canvas's typed GraphInput contract (the pure mapping is $lib/live-runtime/topology-adapter.ts, Vitest-tested). The Timeline tab is a sibling projection of the SAME topology.snapshot data laid out as swimlanes — no parallel topology store.

4. The composer is NOT the chat module (D-091). The bottom-dock Start / Redirect / Inject context / User message / Cancel / Pause / Resume composer is built with non-chat Skeleton primitives (composer/run-composer.svelte) calling the shipped Phase 54 control verbs through the typed Protocol client directly. The canonical chat module's V1 first consumer is 73n Playground; a second in-V1 consumer would force extraction to web/shared/chat/ (out of V1 scope per CLAUDE.md §4.5 #11's "encapsulate first, extract on second consumer" rule). The page proves no import from $lib/chat/. There is NO session-level priority field anywhere on the page (D-065 — task-level priority via the shipped prioritize method is the only V1 priority surface, exposed on the per-task detail pane).

Why. Closes the Phase 73b acceptance criteria + the Wave 13 decomposition row 73b. The page is the §13 primitive-with-consumer discharge for the status-counter-strip aggregate (it lands in the same PR as its only consumer) and validates the Phase 74 topology projection + the Phase 60 run-scoped SSE filter against a real call site.

Findings I'm departing from. Two documented deviations. (a) The plan's acceptance criterion "events.subscribe gains a RunID filter field" — the shipped EventFilter.RunIDs + FilterFromWire ALREADY provide the run-scoped filter; minting a parallel field is a §13 violation, so Phase 73b is composition-only on the events surface (decision call 2 above). (b) The plan (authored before D-121 landed) lists the route at web/console/src/routes/console/live-runtime/; CONVENTIONS.md §1 (D-121) is the binding cross-cutting authority and pins the (console) route group with NO /console/ URL prefix — the page ships at web/console/src/routes/(console)/live-runtime/ accordingly (CLAUDE.md §15 — a plan that contradicts a higher-priority artifact yields to it).

Protocol additions. One wire-type extension: TasksListStatusCounterStrip (struct) + TaskListRequest.IncludeStatusCounterStrip (opt-in field) + TaskListResponse.StatusCounterStrip (field), all in internal/protocol/types/tasks.go, registered in singlesource.CanonicalWireTypes. No new Protocol method names, no new error codes.


D-127 — Phase 73a Console Overview page: composition-only UI over shipped runtime.counters / runtime.health / pause.list / events.subscribe / Phase 54 approve/reject; no new Protocol method

Date: 2026-05-20 Status: Settled (shipping with this PR)

Where it lives: web/console/src/routes/(console)/overview/ (the page route at /overview — no /console/ URL prefix, per CONVENTIONS.md §1 / D-121; the / redirect target); web/console/src/lib/components/overview/ (the page components — counter card + sparkline, health-chip strip, cost-rollup card, intervention queue, recent-activity feed, Quick Links grid, + New menu, footer); web/console/src/lib/overview/ (the pure aggregations.ts / activity.ts / cost.ts projection logic); web/console/src/lib/protocol/posture.ts + pause.ts (the runtime.* / pause.* wire types) + the RuntimeNamespace / PauseNamespace on HarborClient; web/console/src/lib/db/saved_filters_overview.ts (the typed wrapper over the shipped saved_filters table — NO new table); web/console/tests/overview-page.spec.ts (the per-page Playwright spec).

Decision. The Console Overview page is the operator's at-a-glance hub — the default route on a fresh attach. It composes the 4-card counter row (Events/min, Tasks Running, Background Jobs, MCP Connections), the sub-header health-chip strip, the cost-rollup card, the intervention queue, the recent-activity feed, the 2×3 Quick Links grid, and the + New quick-create menu, all inside the shared app shell. Five calls land here.

1. Composition-only — NO new Protocol method, NO new Go-side surface. Phase 73a is a pure UI consumer of already-shipped surface: runtime.counters + runtime.health (Phase 72f / D-111, posture methods routed through the control transport at POST /v1/control/runtime.{counters,health}), pause.list (Phase 72e / D-110 at POST /v1/pause/list), events.subscribe SSE (Phase 60 / 72), and the Phase 54 approve / reject control verbs. internal/protocol/methods/methods.go, internal/protocol/types/, and internal/protocol/singlesource/ are unchanged — there is no new Go code in internal/. The TS wire types posture.ts / pause.ts mirror the already-shipped internal/protocol/types/{posture,pause}.go field-for-field; the new RuntimeNamespace / PauseNamespace join the unified HarborClient (CONVENTIONS.md §6 — a new page surface adds a namespace, never a new top-level client).

2. The counter sparklines + recent-activity feed + cost rollup are folded CLIENT-SIDE off the events.subscribe cursor. Per page-overview.md §12 these are [shipped] subscription-derived surfaces — no new Protocol method. aggregations.ts buckets the SSE Event[] into a windowed per-minute rate series (1m / 5m / 15m windows; per-minute, not per-second, per the mockup); activity.ts projects the operator-relevant event subset into the feed; cost.ts folds llm.cost.recorded events into a per-agent (default) / per-tenant (admin) rollup. All three are pure, Vitest-tested, and drop a malformed / un-buckable event rather than mis-counting it (CLAUDE.md §13 — fail loudly, never silently mis-read).

3. The intervention queue's Approve / Reject invoke the SHIPPED Phase 54 control verbs — no parallel implementation. The queue composes the pause.list snapshot; each row's Approve / Reject calls client.control.approve / client.control.reject (the shipped Phase 54 verbs) against the paused run. There is NO pause.* mutating method — pause.list is read-only. The verbs are control-plane (D-066): without the admin scope claim the buttons render disabled-with-tooltip, never hidden into a fake-success state (CONVENTIONS.md §5 — no stubbed action presented as done); the runtime re-checks server-side regardless (CodeScopeMismatch).

4. The Quick Links grid is exactly six tiles — no Evaluations. Sessions / Tasks / Background Jobs / Agents / Tools / Settings, each an unprefixed Console route (CONVENTIONS.md §1). There is NO Evaluations tile — D-064 pins Evaluations as post-V1. The + New menu is a Console-local navigation surface only: each item deep-links into the create flow owned by that page's phase plan; the Overview provides the menu, not the flows.

Why. Closes the Phase 73a acceptance criteria + the Wave 13 decomposition row 73a. The page is the §13 primitive-with-consumer discharge for the Stage-1 posture + pause-snapshot surfaces (it is the first UI consumer of runtime.counters / runtime.health / pause.list from a Console page), built entirely on the D-121 CONVENTIONS.md foundation: the four-state <PageState> async contract (with nested PageState per panel — the health strip and the intervention queue each get their own), the shared components/ui/ inventory, the unified HarborClient + connection.ts, Console-DB-backed SavedViewChips, and tokens.css with no raw literals.

Findings I'm departing from. None on design. One path-resolution note: the phase plan's "Files added or changed" block (authored before D-121 landed) lists the route at web/console/src/routes/overview/+page.svelte; CONVENTIONS.md §1 (D-121) is the binding cross-cutting authority and pins the (console) route group with NO /console/ URL prefix — the page ships at web/console/src/routes/(console)/overview/ accordingly (CLAUDE.md §15 — a plan that contradicts a higher-priority artifact yields to it). The plan's web/console/src/routes/overview/+page.svelte reference and its smoke-script /console/overview route are corrected to the unprefixed (console)-group form.

Protocol additions. None. Phase 73a mints no Protocol method, no wire type in internal/protocol/types/, no error code. The TS-side posture.ts / pause.ts are client-side mirrors of already-shipped Go wire types; the RuntimeNamespace / PauseNamespace are client-side namespace additions to HarborClient.


D-128 — Phase 73h Console Background Jobs page: tasks.list filter/row-shape extensions, Console-side orphan detector, no bulk control endpoint

Date: 2026-05-20 Status: Settled (shipping with this PR)

Where it lives: internal/protocol/types/tasks.go (the TaskFilter.GroupID + TaskFilter.HasPendingApproval filter extensions + the TaskRow.Progress / TaskRow.Tags / TaskRow.LastActivityAt / TaskRow.IsBackground / TaskRow.HasPendingApproval row-shape enrichments); internal/tasks/protocol/list.go (the GroupID + HasPendingApproval server-side facet filtering); internal/tasks/protocol/registry_projector.go (the IsBackground / LastActivityAt projection + the ListGroups task→group reverse-index that populates TaskRow.GroupID); internal/tasks/list_filter.go (the new pure ListFilterFromWire wire→runtime TaskFilter translator); web/console/src/routes/(console)/background-jobs/ (the page route at /background-jobs — no /console/ URL prefix, per CONVENTIONS.md §1 / D-121); web/console/src/lib/components/background-jobs/ (the page components — QueueTable, BulkToolbar, OrphanBadge, RightRail, SavedFilterChips); web/console/src/lib/background-jobs/orphan-detector.ts (the pure Console-side detector); web/console/src/lib/db/saved_filters_background_jobs.ts (the typed wrapper over the shipped saved_filters table — NO new table); web/console/tests/background-jobs-page.spec.ts (the per-page Playwright spec); test/integration/background_jobs_page_test.go (the Go-side §17.1 integration test).

Decision. The Console Background Jobs page is the queue view for planner-spawned background tasks — a focused tasks.list projection with kinds=["background"], queue-shaped affordances, a per-job right rail, and a bulk-action toolbar. Three calls land here.

1. No new Protocol method — tasks.list is filter/row-shape-extended only. Phase 73d shipped tasks.list / tasks.get as the two tasks.* read methods. Phase 73h adds NO method name to internal/protocol/methods/methods.go. It extends the existing types.TaskFilter with two facets (GroupID for the per-job "Related Sessions" sibling drill-in; HasPendingApproval for the facet chip) and enriches types.TaskRow with five fields (Progress, Tags, LastActivityAt, IsBackground, HasPendingApproval). The background-job filter is the canonical plural Kinds []TaskKind slice from 73d set to ["background"] — never a type=background scalar. The RegistryProjector populates TaskRow.GroupID from a ListGroups task→group reverse-index (the registry's Task record carries no GroupID field; the projector resolves membership through the identity-scoped ListGroups read). All extensions are single-source types struct fields — no new CanonicalWireTypes registration is required (new fields on already-registered structs).

2. The AwaitTask orphan detector lives Console-side — no new Protocol field. A background job whose parent_task_id is non-empty and absent from the same tasks.list snapshot's id set is an orphan — a planner SpawnTask whose parent finished / was GC'd without joining via AwaitTask. The detector is a pure Console-side O(N) cross-check (detectOrphans(rows): Set<TaskID>) — it adds no Protocol field and issues no Protocol call. It surfaces, at the UI, the §13 binding that SpawnTask + AwaitTask MUST emit in the same phase (Phase 47 / D-056 closed this for ReAct); the page is the observability surface for that property, not a re-implementation of the runtime join. A runtime-side parent_alive boolean would be the obvious post-V1 lift if the per-render cost ever bites — but at V1 the Console-side cross-check needs no Protocol surface change.

3. The bulk-action toolbar invokes per-row Phase 54 control verbs — no bulk endpoint. The Cancel / Pause / Resume / Prioritize bulk actions invoke the SHIPPED Phase 54 control verbs (cancel / pause / resume / prioritize) ONCE PER selected row. A single-call bulk endpoint would be a §13 "no parallel implementations" violation. The toolbar gates on the operator's control scope claim (D-066 / D-079) and degrades to disabled-with-tooltip when the claim is missing (CONVENTIONS.md §5 — no stubbed action presented as done). Partial completion is rendered inline (per-row pass/fail), never a silent batch abort.

Why. Closes the Phase 73h acceptance criteria + the Wave 13 Stage 2.3 decomposition row 73h. The page is the §13 primitive-with-consumer discharge for the tasks.list filter/row-shape extensions (it lands in the same PR as its only consumer) and validates the orphan-detection property + the bulk-control degradation path against a real call site.

Findings I'm departing from. Two documented deviations, both yielding to a higher-priority artifact (CLAUDE.md §15). (a) The phase plan (authored before D-121 landed) lists the route at web/console/src/routes/background-jobs/ and serves it at /console/background-jobs; CONVENTIONS.md §1 (D-121) is the binding cross-cutting authority and pins the (console) route group with NO /console/ URL prefix — the page ships at web/console/src/routes/(console)/background-jobs/ served at /background-jobs. (b) The plan's "Files added" section (likewise pre-D-121) places page pieces under web/console/src/lib/pages/background-jobs/; CONVENTIONS.md §3 binds page components to components/<page>/ — the page components ship at components/background-jobs/ and the pure orphan-detector logic at lib/background-jobs/ (the Live Runtime page's lib/live-runtime/ precedent). (c) The plan pre-assigned D-114 to this phase; the dispatch reassigned D-128 to avoid a collision (D-114 is already taken by the Phase 74 topology decision) — this entry is D-128 and the glossary / plan references are reconciled to it.

Protocol additions. Filter extensions: TaskFilter.GroupID (string), TaskFilter.HasPendingApproval (*bool). Row-shape enrichments: TaskRow.Progress (*float64), TaskRow.Tags ([]string), TaskRow.LastActivityAt (time.Time), TaskRow.IsBackground (bool), TaskRow.HasPendingApproval (bool) — all in internal/protocol/types/tasks.go. No new Protocol method names, no new error codes, no new CanonicalWireTypes struct registrations.


D-129 — Phase 73m Console Settings page + the harbor console subcommand: one net-new auth.rotate_token method, the embedded-build subcommand, a TokenIssuer seam

Date: 2026-05-20 Status: Settled (shipping with this PR)

Where it lives: internal/protocol/methods/methods.go (the single new MethodAuthRotateToken constant + the IsAuthMethod predicate); internal/protocol/types/auth.go (the AuthRotateTokenRequest / AuthRotateTokenResponse wire types); internal/protocol/auth/rotate_token.go (the auth.TokenIssuer seam + the RotateSurface transport-agnostic handler); internal/protocol/transports/stream/auth_handler.go (the POST /v1/auth/{method} wire adapter); internal/protocol/transports/transports.go (the WithAuthSurface mux option); internal/protocol/singlesource/singlesource.go (the two CanonicalWireTypes entries + the CanonicalMethods entry); cmd/harbor/cmd_console.go (the harbor console subcommand); cmd/harbor/console_embed.go + cmd/harbor/consoledist/ (the embed.FS of the SvelteKit build); cmd/harbor/devauth.go (the dev signer's IssueToken — the V1 TokenIssuer); web/console/src/routes/(console)/settings/ (the page route at /settings, no /console/ URL prefix per CONVENTIONS.md §1 / D-121); web/console/src/lib/components/settings/ (the 12 section cards + the sub-nav rail + the mock-mode banner); web/console/src/lib/settings/ (the page state + Console-DB + saved-view controllers); web/console/src/lib/protocol/ (the PostureNamespace + AuthNamespace on HarborClient).

Decision. Phase 73m ships TWO bundled deliverables. Four calls land here.

1. The Settings page is a pure CONSUMER of the posture surfaces — exactly ONE net-new Protocol method. The 12-card Settings page composes 72f's runtime.info / runtime.drivers posture reads (D-111) and 72g's governance.posture / llm.posture reads (D-112), plus 72h's Console DB tables (runtime_registry / profiles / keybindings / notifications_routing / auth_profiles / pat_store). The ONLY net-new Protocol method is auth.rotate_tokeninternal/protocol/methods/methods.go's diff adds exactly one method name. 73m re-ships none of 72f / 72g / 72h's methods or tables.

2. auth.rotate_token is admin-gated and goes through a TokenIssuer seam. The method rotates the operator's Protocol-auth token: the Runtime re-mints a JWT for the caller's already-verified (tenant, user, session) identity, one-time-reveal. It requires the verified auth.ScopeAdmin claim (D-079 closed two-scope set — there is NO auth.admin scope); a request without it is rejected CodeIdentityScopeRequired (HTTP 403). Every successful rotation emits a redacted audit.admin_scope_used event. A Runtime does not in general mint its own tokens (a real deployment's tokens come from an external OIDC provider), so the re-mint goes through an auth.TokenIssuer §4.4 seam — the harbor dev / harbor console dev signer is the V1 implementation; a post-V1 release-engineering phase fits an RFC 8693 token-exchange issuer behind the same shape. When no TokenIssuer is wired the surface fails loudly — never a silent no-op.

3. harbor console is the embedded-build subcommand (D-091) — and is self-contained. harbor console bakes the static SvelteKit build into cmd/harbor via embed.FS and serves it at /. The Console build directory web/console/build/ is gitignored (CLAUDE.md §13); make console-build stages the bundle into the committed-.gitkeep-only cmd/harbor/consoledist/ directory the embed resolves, so a bare checkout still builds (harbor console then serves a synthesized "run make console-build" placeholder). harbor console boots the SAME embedded Runtime stack harbor dev boots (reusing bootDevStack) AND additionally mounts the Console assets — the result is a single, self-contained Console deployment that is already attached to a live Runtime; the operator can re-point it at any remote Runtime from the Settings page. The Console build is served ONLY by harbor console, NEVER by harbor dev (harbor dev --help advertises no console-serving flag — the binding D-091 rule, smoke-asserted). Zero-config harbor console (no harbor.yaml) boots an embedded in-memory + mock-LLM default and prints the §13 dev-mock banner.

4. The Settings page clears the CONVENTIONS.md §5 depth bar. The page routes under (console)/settings/, renders inside the app shell, composes the shared ui/ inventory (PageHeader / FilterBar / DataTable / DetailRail / RailCard / SavedViewChips / Pagination / ConnectionFooter / PageState), routes all async state through the four-state <PageState>, and talks to the Runtime only through HarborClient + connection.ts. The LIST_PAGES enum gains a 'settings' entry (an additive constant extension — no new Console DB table, no migration) so the section-bookmark SavedViewChips are Console-DB-backed. The Rotate token action either invokes the real auth.rotate_token method or renders disabled-with-tooltip when the connection lacks the admin scope claim — no stubbed action presented as done.

Why. Closes the Phase 73m acceptance criteria + the Wave 13 decomposition §12 item 9 (harbor console lock-in). The page is the §13 primitive-with-consumer discharge for auth.rotate_token (the method lands in the same PR as its only consumer) and for the harbor console subcommand (the Connected-Runtimes card is its first user-facing consumer).

Findings I'm departing from. Two documented deviations. (a) The phase plan (authored before D-079 was re-read) names the auth.rotate_token scope as console.admin; D-079's closed two-scope set is {admin, console:fleet} only — there is no console.admin scope, so the method gates on auth.ScopeAdmin (CLAUDE.md §15 — a plan that contradicts a higher-priority decision yields to it). (b) The plan sketches harbor console as a thin static-asset server; to give the e2e harness + the Connected-Runtimes card a live Protocol surface to attach to, harbor console reuses bootDevStack and serves the Protocol surface co-resident with the static build — D-091's "the Console can also run attached to a remote Runtime" stays true (the operator re-points it from Settings); the co-resident Runtime is the zero-config default, not a constraint, and the Console build is still served exclusively by harbor console.

Protocol additions. One method: auth.rotate_token (MethodAuthRotateToken). Two wire types: AuthRotateTokenRequest / AuthRotateTokenResponse in internal/protocol/types/auth.go, registered in singlesource.CanonicalWireTypes. No new error codes (auth.rotate_token reuses CodeIdentityScopeRequired / CodeIdentityRequired / CodeRuntimeError).


D-130 — Phase 73n Console Playground page: runs.set_overrides Protocol method + the shared chat module (first consumer) + the chat-bubble renderer extension

Date: 2026-05-20 Status: Settled (shipping with this PR)

Where it lives: internal/protocol/methods/methods.go (the runs.set_overrides method constant + the IsRunsMethod predicate); internal/protocol/types/runs.go (the RunOverrides / RunSetOverridesRequest / RunSetOverridesResponse wire types — single source, D-002); internal/runtime/runs/protocol/ (the runs.set_overrides Service + the in-process override Store); internal/protocol/transports/stream/runs_handler.go (the POST /v1/runs/set_overrides wire handler); internal/events/events.go (the runs.overrides_set audit event type + RunOverridesSetPayload); internal/protocol/singlesource/singlesource.go (the method + wire-type CanonicalWireTypes entries); internal/protocol/conformance/conformance.go (the matrix entry + the per-surface skip); cmd/harbor/cmd_dev.go + harbortest/devstack/devstack.go (the WithRunsService wiring); web/console/src/lib/chat/ (the shared chat module — ChatPanel / ChatComposer / MessageBubble / the cards / the injected ChatProtocolClient interface); web/console/src/lib/chat/renderers/chat_bubble.ts (the chat-bubble renderer extension over the Phase 73l registry); web/console/src/routes/(console)/playground/ (the page route at /playground + the [session_id] deep-link route — no /console/ URL prefix, per CONVENTIONS.md §1 / D-121); web/console/src/lib/components/playground/ (the page components); web/console/src/lib/db/saved_filters_playground.ts (the typed wrapper over the shipped saved_filters table — NO new table); web/console/tests/playground-page.spec.ts (the per-page Playwright spec); test/integration/playground_overrides_test.go (the Go-side §17.1 integration test).

Decision. The Console Playground page is a real Harbor session workbench — a chat-style stream, a multimodal composer, a right rail of Controls / Pending Interventions / Recent Artifacts / Trace. Five calls land here.

1. One net-new Protocol method — runs.set_overrides. It records the reasoning-effort / temperature / max-tokens / system-prompt override the operator applies to the NEXT message in a session. The override is session-scoped (keyed by the (tenant, user, session) triple in an in-process Store) and one-shot — it is consumed by the next user_message / start and is never retroactive. Identity is mandatory; an override whose session_id names a session other than the caller's verified session is rejected with CodeScopeMismatch. The method routes through its own stream-package handler (POST /v1/runs/set_overrides) — IsRunsMethod is its own predicate; it is NOT a control method.

2. The shared chat module — encapsulate-first, first consumer (D-091, CLAUDE.md §4.5 #11). The chat module ships self-contained at web/console/src/lib/chat/: it imports NOTHING outside $lib/chat/ and depends on an injected ChatProtocolClient interface the Playground page adapts over the Console HarborClient. The Playground is the FIRST consumer; the future packed harbor dev UI is the second, at which point the git mv $lib/chat → web/shared/chat is mechanical. Every message round-trips through the SHIPPED Phase 54 user_message method — there is NO parallel chat protocol.

3. The chat-bubble renderers EXTEND the Phase 73l registry — they do not fork it. Phase 73l shipped the canonical renderer-registry dispatch core (renderers/index.ts) plus six MIME renderers. Phase 73n adds tool-call-trace / diff / artifact-reference renderers by calling registerRenderer from renderers/chat_bubble.ts — the dispatch core is open for registration, closed for modification. There is exactly one registry.

4. The "Run as identity" header selector consumes Phase 72b's IdentityScope (Brief 11 §PG-5, D-107). When the operator carries the auth.ScopeAdmin claim, the header renders a "Run as identity" dropdown; selecting a target populates IdentityScope.Impersonating on the next user_message / start. Non-admin operators do not see the selector (rendered absent, not disabled). This lands the consumer alongside the 72b primitive (§13 primitive-with-consumer).

5. Heavy chat content flows by reference (D-026). A chat bubble never carries inline heavy bytes — artifacts ride as ChatArtifactRef and resolve to a presigned URL via artifacts.get_ref; the renderer fetches from that URL.

Why. Closes the Phase 73n acceptance criteria + the Wave 13 decomposition row 73n. The page is the §13 primitive-with-consumer discharge for both runs.set_overrides (its only consumer lands in the same PR) and the shared chat module (the Playground is the module's first real call site).

Findings I'm departing from. Three documented deviations. (a) The plan (authored before D-121 landed) lists the route at web/console/src/routes/playground/[session_id]/; CONVENTIONS.md §1 (D-121) is the binding cross-cutting authority and pins the (console) route group with NO /console/ URL prefix — the page ships at web/console/src/routes/(console)/playground/ accordingly (CLAUDE.md §15). (b) The plan names Shiki / KaTeX / Mermaid as renderer dependencies; those heavy frontend dependencies are not in web/console/package.json, and adding them is an RFC change (CLAUDE.md §13 "no heavy frameworks"). Phase 73n ships the same safe-text V1 posture the Phase 73l MIME renderers already take (code.svelte / markdown.svelte render raw text without a highlighter); the renderer-registry seam means a highlighter slots in later without a chat-module reshape. (c) The plan lists three .ts renderer files (tool_call_trace.ts / diff_view.ts / artifact_reference.ts); the shipped Phase 73l registry uses .svelte renderer components, so 73n ships three .svelte renderers plus one chat_bubble.ts registration module — same dispatch contract, matching the shipped registry shape.

Protocol additions. One method (runs.set_overrides), three wire types (RunOverrides / RunSetOverridesRequest / RunSetOverridesResponse), one event type (runs.overrides_set + RunOverridesSetPayload). No new error codes — the handler reuses CodeIdentityRequired / CodeScopeMismatch / CodeInvalidRequest / CodeUnknownMethod.


D-131 — Phase 75a Wave 13 wave-end suite: the console-build build-path fix, the dev-only fixture seeder, and the page-coverage gate

Date: 2026-05-21 Status: Settled (shipping with this PR)

Where it lives: .github/workflows/ci.yml (the frontend-e2e job's console-buildmake build ordering + the wave13-coverage-check step); cmd/harbor/devseed.go (the dev-only runtime-entity fixture seeder); cmd/harbor/cmd_dev.go (the HARBOR_DEV_SEED_FIXTURES boot hook); cmd/harbor/console_default.yaml (memory.strategy: truncation); web/console/tests/wave13.spec.ts (the wave-end Playwright aggregator); web/console/tests/fixtures/harbor-runtime.ts (the harness sets HARBOR_DEV_SEED_FIXTURES=1 + HARBOR_DEV_ALLOW_MOCK=1); test/integration/wave13_test.go (the Go-side wave-end E2E); scripts/console/check-page-coverage.sh + the make wave13-coverage-check target (the page-coverage gate); scripts/smoke/phase-75a.sh.

Decision. Phase 75a is the Wave 13 (Console) closeout. Three things land.

1. The frontend-e2e CI job builds the Console bundle before the binary. harbor console (D-091) serves the Console via embed.FS of cmd/harbor/consoledist/, which is gitignored except .gitkeep — the bundle is a build artifact (§4.5 #9). make console-build runs npm ci && npm run build and stages web/console/build/cmd/harbor/consoledist/. Phase 73m shipped harbor console but make build does not depend on console-build, and the CI frontend-e2e job ran make build without ever running make console-build — so the binary embedded an empty consoledist/, harbor console served the index but the SvelteKit app never hydrated, and ~50 Playwright page specs failed. The fix orders the job: install Node → make console-build (builds + stages the real bundle) → make build (embeds it). This is a §17.6 cross-phase fix of a Phase 73m build-pipeline gap.

2. A dev-only runtime-entity fixture seeder. A fresh harbor console runtime boots empty, so the per-page Playwright specs SKIP every data-shaped assertion. cmd/harbor/devseed.go adds seedDevFixtures, gated behind the explicit HARBOR_DEV_SEED_FIXTURES=1 env var (the §13 dev-only-escape-hatch posture — never the default; a production runtime boots empty; the binary prints a stderr banner when the hatch fires). It seeds sessions, agents, tasks, artifacts, tools, flows (+ run-history), and memory turns under the dev-token identity. The embedded console_default.yaml memory.strategy flips none → truncation so seeded memory turns persist and the Console Memory page renders rows. The e2e harness sets the env var when it spawns harbor console; the 25 SEED_DEPENDENT per-page skips are un-skipped and pass for real.

3. The wave-end suite + the page-coverage gate. web/console/tests/wave13.spec.ts walks all 14 V1 Console pages (Evaluations excluded — D-064), asserts the IA navigation, the scope-claim degradation, and the cross-page identity gate. test/integration/wave13_test.go exercises the consolidated Wave 13 observability seam (the SSE wire transport) with real drivers via devstack.Assemble (D-094): a wire-type identity round-trip, cross-tenant isolation, a missing-identity (D-033) failure mode, and an N=12 concurrent-SSE-subscriber stress. scripts/console/check-page-coverage.sh (via make wave13-coverage-check, wired into frontend-e2e) asserts every docs/design/console/page-<slug>.md has a matching web/console/tests/<slug>-page.spec.ts — the operator §12-lock-in-#7 binding rule expressed as a mechanical gate.

Why. Closes the Wave 13 §17.5/§17.7-step-5 wave-end E2E and the red frontend-e2e CI step. The seeder is the §13 primitive-with-consumer discharge for the e2e harness: the harness's runtime-entity seam was a no-op stub; this lands a real consumer.

Findings I'm departing from. Two documented deviations. (a) The 75a plan's coverage script expects web/console/tests/<slug>.spec.ts; the per-page specs that actually shipped (73a-73n) use the <slug>-page.spec.ts suffix, so check-page-coverage.sh matches the real shipped naming. (b) Six per-page tests (Live Runtime tab content ×2, Playground chat module ×3, Events pause-stream toggle ×1) are NOT un-skipped: they render inside <PageState>, which renders children only when status === 'ready', and ready requires run-trajectory data (a non-empty topology.snapshot, a session chat history, an established SSE subscription) that is projected from a live planner/engine run — a larger fixture seam than registry entity seeding. They carry an explicit §17.6 deferral skip naming the distinct (non-seeding) blocker; tracked as a Phase 75a follow-up.

Protocol additions. None — Phase 75a ships no new Protocol method, error code, or wire type.


D-132 — Wave 13 §17.5 checkpoint: search / notifications / runtime-posture wired into the live binary; Agents control buttons disabled pending a registry.* Protocol surface; D-093 protocol-ts generator formally deferred post-Wave-13

Date: 2026-05-21 Status: Settled (shipping with the Wave 13 §17.5 checkpoint audit-fix PR)

Where it lives: internal/protocol/transports/transports.go (the new WithSearch mux option + the searchSurface mux-config field); internal/telemetry/metrics.go (the new MetricsRegistry.Snapshot + the OTel-SDK-free MetricSnapshot / CounterPoint shapes); internal/runtime/posture/posture.go (the shared CountersProvider / MetricsProvider posture-seam constructors); internal/search/scope.go (AdminScopeFromAuth now honours console:fleet); cmd/harbor/cmd_dev.go + harbortest/devstack/devstack.go (the search-surface + notifications-Subscriber + MetricsRegistry-bridge + live Counters/Metrics wiring); cmd/harbor/devseed.go (the events-seeding step); docs/plans/README.md (the 72c/72d rows + the 73k Shipped flip); docs/plans/phase-72e-pause-list-snapshot.md (the conformance §17.6 deferral note); docs/plans/phase-75a-wave13-wave-end-suite.md (the live-planner-run trajectory §17.6 deferral block); docs/design/console/page-tools.md (the tools.invoke V1 deferral); web/console/src/lib/tokens.css, web/console/src/lib/protocol.ts, web/console/src/lib/protocol/artifacts.ts, and the Console Agents / Tools / Memory / Artifacts / MCP-Connections pages.

Decision. The Wave 13 §17.5 wave-end checkpoint audit found six FAIL-severity drift items and seventeen WARN-severity items. The audit-fix PR lands all of them. The load-bearing calls:

1. The search.*, notification.*, and runtime-posture surfaces are wired into the LIVE binary. Before this checkpoint, internal/protocol.NewSearchSurface, the notifications.Subscriber, and the posture Counters/Metrics seams existed but were referenced only by tests — the five search.* methods 404'd on harbor dev, the notification.* topic had no producer, and runtime.counters / metrics.snapshot returned empty stubs. The fix adds a transports.WithSearch mux option (threading the search dispatcher through control.WithSearchSurface), constructs + Run()s the notifications.Subscriber as a joined background goroutine, and constructs a telemetry.MetricsRegistry + BridgeBusToMetrics bridge — all in BOTH cmd/harbor/cmd_dev.go::bootDevStack AND harbortest/devstack.Assemble (the §17.6 source-of-truth invariant). The posture Counters/Metrics seams read live runtime state through the new shared internal/runtime/posture package; the runtime-posture integration test asserts against the PRODUCTION seam (a real spawned task → non-zero TasksRunning), never a fabricated one.

2. The Console Agents control buttons are disabled-with-tooltip pending a registry.* Protocol surface. The five fleet-control verbs (Pause / Drain / Restart / Force-Stop / Deregister) are exposed by the shipped registry.* IN-PROCESS Go API — there is NO Protocol method a Console client can call. The previous wiring set a controlFeedback string, a fake-success path (CLAUDE.md §13). ControlButtons.svelte now renders all five buttons disabled-with-tooltip REGARDLESS of scope claim. Re-enabling them is the job of a future fleet-control Protocol-surface phase that lands the registry.* methods AND flips the buttons live in the same wave (CLAUDE.md §13 primitive-with-consumer).

3. The D-093 cmd/harbor-gen-protocol-ts generator is formally deferred post-Wave-13. D-093 specified a generator that regenerates web/console/src/lib/protocol.ts from internal/protocol/singlesource.CanonicalWireTypes with a make protocol-ts-gen-check CI gate. The generator was never built — Phase 72h committed protocol.ts as a hand-shaped stub and later phases hand-extended it while the file carried a false // CODE GENERATED … DO NOT EDIT header. This checkpoint corrects the header to an accurate "hand-maintained — keep in lockstep with internal/protocol/singlesource.CanonicalWireTypes" notice; the per-page artifacts wire types move into web/console/src/lib/protocol/artifacts.ts mirroring every other page. Building the generator + the CI gate is tracked in issue #179. This is a documented amendment to D-093: the generator is a post-Wave-13 deliverable, not a Wave 13 one.

4. The tools.invoke Protocol method is a documented V1 deferral. The Console Tools page surfaces a disabled-with-tooltip "Try this tool" affordance naming the deferral rather than silently omitting it (CONVENTIONS.md §5). docs/design/console/page-tools.md §3 records the V1 deferral; the tools.invoke method + the live form land post-V1.

Why. Closes the Wave 13 §17.5 checkpoint punch list. The six FAILs were live-binary wiring gaps (the §17.6 "fix what the integration test finds" rule applied across phase boundaries — 72c / 72d / 72f surfaces shipped their seams but the boot path never connected them) plus a fake-success Console path and stale master-plan rows. The seventeen WARNs were Console hygiene (token-alias cleanup, component-name collisions, double footers, localStorage reads), a scope-predicate bug (search ignored console:fleet), and stale deferral references. The thirteen NITs are tracked in issue #180; the D-093 generator in #179; the live-planner-run trajectory e2e fixtures in #178.

Findings I'm departing from. None — this is a checkpoint audit-fix PR; it implements the punch list. The 72e plan's "conformance happy-path + malformed scenario in the same PR" line yielded to the §17.6 finding W9: the conformance matrix entry lands in lockstep, the scenario bodies defer to the Phase 80 conformance-Stack harness extension (the same architectural shape every other dedicated-surface method already takes in the suite).

Protocol additions. None — no new Protocol method, error code, or wire type. transports.WithSearch is a mux-wiring option over the already-shipped search.* methods; MetricsRegistry.Snapshot is a Go-internal read API.


D-133 — Phase 73 ("Console state inspection surface") was dissolved during Wave 13: consumed methods absorbed by the page phases, unconsumed methods deferred post-V1 per §13

Date: 2026-05-21 Status: Settled (shipping as the Wave 14 Stage-0 master-plan reconciliation)

Where it lives: docs/plans/README.md (the Phase 73 status-table row + detail block).

Decision. Phase 73 was scoped as a single "Console state inspection surface" phase bundling nine Protocol methods (sessions.inspect, tasks.get, state.history, state.list_trajectories, state.load_planner_checkpoint, artifacts.list, artifacts.get, artifacts.get_ref, artifacts.delete). It never landed as a standalone phase — there is no phase-73.md plan file and no phase-73 PR. During Wave 13 the surface was decomposed: each Console page phase that needed a slice landed that slice whole rather than depending on a separate Phase 73 PR. This was the correct application of CLAUDE.md §13 "no primitive without its consumer" — the methods shipped exactly when, and only when, a page consumed them. The decomposition was already recorded piecemeal in the Phase 73c / 72-cluster decisions entries ("Phase 73 has not shipped sessions.inspect, so Phase 73c lands it whole"); this entry consolidates it and reconciles the stale master-plan row.

What shipped (each absorbed by its consuming page phase, verified present in internal/protocol/methods/methods.go):

  • sessions.inspect — landed by Phase 73c (Console Sessions page).
  • tasks.get — landed by Phase 73d (Console Tasks page).
  • artifacts.list, artifacts.put, artifacts.get_ref — landed by Phase 73l (Console Artifacts page, D-120). Note artifacts.put was added by 73l and was not in the original Phase 73 list.

What did NOT ship, and why that is correct. state.history, state.list_trajectories, state.load_planner_checkpoint, artifacts.get, and artifacts.delete have no V1 Console consumer — no Wave 13 page needed them. Per §13, a primitive without a consumer must not land. They are therefore deferred post-V1: each lands additively in the same wave as the first Console surface that consumes it (a trajectory-inspector page, an artifact-detail/delete affordance, etc.). The runtime-side data they would project (StateStore history, trajectory records, the artifact store) is all already shipped; only the Protocol projection waits for a consumer.

Reconciliation. The docs/plans/README.md Phase 73 row flips from Pending to Shipped* with the asterisk resolved in the detail block: "dissolved — consumed methods absorbed by 73c/73d/73l; state.* + artifacts.get/artifacts.delete deferred post-V1 (no V1 consumer, §13)." This removes a stale Pending row that would otherwise read as an unshipped V1 phase blocking the V1 cut.

Why. CLAUDE.md §4.2 rule 11: "Stale Pending rows for shipped phases are a drift signal." Wave 14 is the V1-completion wave; an honest master plan is a precondition for the Phase 82 V1 cut. Leaving row 73 Pending would either falsely block the cut or force a misleading "all V1 phases shipped" claim.

Findings I'm departing from. None — this is a documentation reconciliation of an already-settled, already-executed decomposition. No code changes.

Protocol additions. None.


D-134 — Phase 76 cross-tenant isolation conformance harness: home, fast-vs-soak split, real-drivers-at-the-seam

Date: 2026-05-21 Status: Settled (shipping with Phase 76)

Where it lives: test/integration/isolation_conformance_test.go (the harness); .github/workflows/ci.yml (the isolation job); scripts/smoke/phase-76.sh; docs/plans/phase-76-cross-tenant-isolation-harness.md.

Decision. Phase 76 ships the master cross-tenant + cross-session isolation conformance harness — the V1 integrity gate (RFC §4.3). Three design calls are settled here.

1. The harness home is test/integration/, not a new test/conformance/ directory. AGENTS.md §3 is the binding repository layout; adding a top-level directory is an RFC change. AGENTS.md §17.2 already names test/integration/ as the canonical home for tests that span more than two subsystems — the harness spans six. It lives as a single _test.go file in package integration_test alongside the wave-end E2E suites it resembles. No new directory; no RFC churn.

2. The every-PR soak window is fast (~3 s); the master-plan 30 s soak is opt-in. The master plan specifies "100 sessions × random ops × 30 s under -race". A 30 s race-instrumented soak on every PR would dominate CI wall-clock. The split: the default window is isolationFastWindow (~3 s) — with 100 concurrent session-workers each running thousands of randomized op-cycles, a cross-scope leak surfaces with overwhelming probability inside it. The master-plan 30 s window is available via HARBOR_ISOLATION_SOAK=<go-duration>; testing.Short() (-short) forces the fast window regardless. Both windows drive the identical code path — only the soak duration changes; there is no "with-flag / without-flag" parallel implementation (AGENTS.md §13). The dedicated isolation CI job runs the fast window on every PR.

3. Real production drivers at every seam — no mocks (AGENTS.md §17.3 #1, §17.4). Every subsystem is opened through its production registry factory: state.Open, artifacts.Open, memory.Open, skills.OpenDriver, tasks.Open, events.Open. The harness drives the real V1 in-memory drivers for five subsystems; the SkillStore has a single V1 driver — localdb, SQLite-backed — which the harness runs against a :memory: DSN (the SQLite path is what operators ship; :memory: keeps the harness filesystem-free with identical isolation logic). A mock at the boundary would defeat the gate's purpose: the harness exists to prove the shipped drivers hold the isolation invariant under concurrent load against a single shared instance — the cross-subsystem composition of every subsystem's own D-025 + D-001 contracts.

Why. The per-subsystem conformance suites (internal/<subsystem>/conformancetest) each prove their own driver isolates correctly in isolation. They do NOT prove the six subsystems hold the invariant simultaneously — a shared-process race or a cross-subsystem identity-context bleed only surfaces when all six are hammered together under load. Phase 76 closes that gap with one gate that runs on every PR. A regression here is a security bug (master-plan Phase 76 "Risks"), so the gate is non-skippable and the harness fails loudly with a categorized breach report naming the subsystem and the expected-vs-observed identity.

Findings I'm departing from. None. The harness is a pure composition of patterns brief 05 (§"Concurrency tests", §"Cross-tenant isolation", §"Conformance test approach") and brief 06 (§124, §147) already established, plus the wave-end E2E shape from test/integration/wave*_test.go. It introduces no new design surface.

Protocol additions. None — Phase 76 ships no Protocol method, error code, REST endpoint, or wire type. It is a _test.go-only integration gate.


D-135 — Goroutine-leak conformance harness: one table-driven -race suite over every long-lived Runtime component

Date: 2026-05-21 Status: Settled (shipping with Phase 77)

Where it lives: test/integration/phase77_goroutine_leak_test.go (the harness); .github/workflows/ci.yml (the leak-harness job); scripts/smoke/phase-77.sh (static-only artefact smoke); docs/plans/phase-77-goroutine-leak-harness.md (the phase plan).

Decision. Phase 77 generalises the per-package goroutine-leak tests that Phases 10 / 12 / 13 / 50 / 52 each shipped individually into ONE table-driven conformance suite, TestE2E_Phase77_GoroutineLeakConformance. The load-bearing design calls:

1. The harness is table-driven — a future long-lived component is one new row. leakCases is a slice of {name, exercise} rows; each exercise closure constructs the real component with real drivers, drives a representative workload, and tears it down. The harness owns baseline capture, the bounded poll, and the assertion. V1 rows: runtime/engine.Engine, events/drivers/inmem.EventBus, events/drivers/durable.EventBus, sessions.Registry, tasks/drivers/inprocess.TaskRegistry — every long-lived component that starts goroutines and exposes Stop / Close / CloseRegistry. Components that are passive registries with no background goroutines (the pauseresume.Coordinator, the steering Registry / per-run Inbox, the per-run steering RunLoop) are deliberately NOT rows — they have no teardown seam to leak from; the Phase 50 dependency is satisfied by the pause primitive being exercised inside the Engine row's run lifecycle, not by a Coordinator Stop.

2. N cycles, not one. Each row runs leakCycles (12) construct → exercise → teardown iterations. A single cycle hides a slow leak (one stray goroutine per cycle); 12 cycles amplify a per-cycle leak to a delta of ≥ 12, far above the leakTolerance (4) absolute slack. A warm-up cycle runs before the baseline is captured so first-use lazy initialisation (driver registries, sync.Once globals) is not miscounted.

3. Bounded eventually-poll, never an instant snapshot. Go does not retire parked goroutines instantly; an instant runtime.NumGoroutine() check immediately after teardown is flaky (CLAUDE.md §17.4). The harness reuses the established bounded-poll pattern — a deadline plus a 10 ms interval plus runtime.Gosched — with the small absolute leakTolerance absorbing the test runner's own background goroutines. The harness does NOT call t.Parallel: NumGoroutine is process-global and a parallel sibling test would pollute the count.

4. CI runs it on every PR. A dedicated leak-harness job in .github/workflows/ci.yml runs the suite under -race on every PR. The job is isolated so a failure names the harness directly; the suite also runs inside the go job's make test.

Why. RFC §5 Go conventions require "goroutines started by long-lived components must be cancellable by a ctx and joined on shutdown"; RFC §3.5 guarantee #4 requires "no goroutine leaks — each invocation's goroutines are joined before the invocation returns". CLAUDE.md §11 made per-component leak tests mandatory but nothing asserted the contract across the whole component surface at once — a new long-lived component could ship without a leak test and nobody would notice. Phase 77 closes that gap with a single conformance gate that a future component opts INTO by adding a table row.

Findings I'm departing from. None. brief 01 (core runtime / streaming leak) and brief 05 (long-lived sweepers, background task goroutines) both flag the leak sources the harness pins; the harness follows them.

Leaks found. None — all five V1 component rows pass the conformance suite under -race on first run (make test and the dedicated leak-harness job). Had a component leaked, CLAUDE.md §17.6 (fix-where-you-find-it) would have required the fix in the Phase 77 PR.

Protocol additions. None — Phase 77 ships a test harness; no Protocol method, error code, wire type, REST endpoint, or CLI subcommand.


D-136 — Phase 79 performance benchmarks: the benchmark suite, the benchstat regression gate, and committed baselines

Date: 2026-05-21 Status: Settled (shipping with this PR)

Where it lives: test/benchmarks/ (the Benchmark* suite — engine_bench_test.go, bus_bench_test.go, memory_bench_test.go, doc.go); docs/perf/baseline.txt (the committed baseline numbers); scripts/perf/check-regression.sh (the regression gate); Makefile (the bench + bench-check targets); .github/workflows/ci.yml (the additive perf-regression job); docs/plans/phase-79-performance-benchmarks.md.

Decision. Phase 79 ships a go test -bench suite over Harbor's three hottest runtime seams plus a perf-regression gate. Four load-bearing calls.

1. The benchmark suite calls REAL components — no mocks (CLAUDE.md §13). BenchmarkEngineThroughput drives N concurrent runs (1 / 8 / 32) against a single shared engine.Engine — the D-025 concurrent-reuse shape — and reports a custom envelopes/sec metric; a companion BenchmarkEngineStreamingThroughput exercises the Phase 12 per-run capacity-waiter EmitChunk path (brief 01 §"Backpressure inside streaming"). BenchmarkBusFanOut sweeps subscriber counts {1, 8, 16} (capped at the default MaxSubscribersPerSession per brief 06 §"Filter expressions") against the real inmem EventBus driver wired with a real audit redactor, confirming brief 06 §"Fan-out"'s O(1)-publish claim empirically. BenchmarkMemoryStrategy covers truncation vs rolling_summary AddTurn latency against real inmem StateStore + EventBus drivers. The suite is itself the cross-subsystem integration exercise the §17 obligation requires — real drivers on every seam, identity propagated through every layer.

2. The regression gate uses benchstat confidence intervals + a noise-tolerant 30% threshold — so shared-CI-runner noise does not flake it. scripts/perf/check-regression.sh runs the suite with -count=6 (giving benchstat a sample to compute variance from), compares against the base run (point 4) via golang.org/x/perf/cmd/benchstat in CSV mode, and fails the build only on a delta that is both statistically significant (p < 0.05benchstat's ~ verdict is always a pass) and past the threshold. The master-plan acceptance criterion gives "> 10% slowdown blocks" as an illustrative example ("e.g."); the gate's default threshold is 30%, an empirical calibration. Measured during this phase's development, a self-comparison (same baseline, fresh run) on a contended developer machine produced apparent deltas of +80-100% on the bus benchmark from CPU contention alone — Go microbenchmarks of the concurrent engine/bus paths legitimately swing ±20-30% run-to-run. A literal 10% gate would flake on every PR; 30% stays above genuine jitter while still catching the regression class that matters — a refactor that halves throughput (-50%) or doubles latency (+100%). The full human-readable benchstat report is always printed so a reviewer can eyeball any sub-threshold drift the gate intentionally lets pass. The threshold is overridable via PERF_THRESHOLD. This is the master plan's own "design the gate to tolerate noise" directive, honoured honestly rather than shipping a gate that flakes.

3. benchstat is a dev/CI-only tool — it never enters the runtime binary's go.mod production require surface. The gate invokes benchstat via go run golang.org/x/perf/cmd/benchstat@<pinned-version>; the version is pinned in the script. This keeps the harbor binary's dependency surface untouched (CLAUDE.md §13 — heavy frameworks need an RFC, but a CI-only benchmarking tool invoked via go run is the reasonable, dependency-surface-neutral call). go run of a @version-pinned tool resolves into the build cache, not the module's require block.

4. CI compares base-vs-PR on the SAME runner — not against the committed baseline. Go embeds GOMAXPROCS in benchmark names (BenchmarkFoo-10 on a 10-core machine, BenchmarkFoo-4 on a 4-core runner); benchstat pairs rows by name, so a baseline file generated on different-core-count hardware cannot be paired against a CI run at all — the comparison yields zero rows, not a regression verdict. The perf-regression CI job therefore runs the suite twice on the one runner — once on the PR commit, once on the PR's base commit — and benchstat-compares those two same-hardware runs. scripts/perf/check-regression.sh takes the base run via PERF_BASE_FILE and the (pre-generated) PR run via PERF_PR_FILE; the CI job sets both. When the base commit has no test/benchmarks/ directory (the PR that introduces the suite — i.e. this one), there is no prior baseline to regress against and the comparison step is honestly skipped. The committed docs/perf/baseline.txt is retained as the local-dev reference: make bench-check with no env override compares a fresh run against it, valid on the machine that generated it (make bench > docs/perf/baseline.txt). The baseline is refreshed deliberately by a human in a reviewed PR — never auto-rewritten, since a silent auto-refresh would erase the very regression the gate exists to catch.

Why. Closes the Phase 79 master-plan acceptance loop ("Baseline numbers committed; perf regression threshold gates PRs"). The three benchmarked seams (engine §6.1, bus §6.13, memory §6.6) are the runtime's hot paths; a regression gate on them catches a whole class of "a refactor quietly halved throughput" drift that unit tests and coverage gates miss.

Findings I'm departing from. The master-plan acceptance line "perf regression threshold gates PRs (e.g. > 10% slowdown blocks)" — the gate ships with a 30% default threshold, not 10%. This is not a design departure but a calibration of the master plan's own explicitly-illustrative "e.g." figure against measured reality (see point 2). The master plan's binding requirement is "perf regression threshold gates PRs" and "design the gate to tolerate noise"; a 10% gate cannot satisfy the second clause. The 30% default is documented in the phase plan §Risks and is overridable via PERF_THRESHOLD.

Protocol additions. None — Phase 79 ships no Protocol method, error code, or wire type, and touches no production code. It adds a benchmark suite, a CI gate, and two Makefile targets.


D-137 — Phase 78 chaos / fault-injection harness: the harness home, the fault-injection-via-decorator approach, and the five failure modes

Date: 2026-05-21 Status: Settled (shipping with this PR)

Where it lives: test/integration/phase78_chaos_fault_injection_test.go (the harness — the table-driven TestE2E_Phase78_ChaosFaultInjection); test/integration/phase78_faults_test.go (the fault-injecting decorators); scripts/smoke/phase-78.sh (the static-only smoke); .github/workflows/ci.yml (the additive chaos job); docs/plans/phase-78-chaos-fault-injection-harness.md.

Decision. Phase 78 ships the master chaos / fault-injection harness — a table-driven -race integration suite that injects each of the five master-plan-named failure modes against the real Runtime components and asserts every fault produces its documented loud error / event AND the documented recovery path. Three load-bearing calls.

1. The harness lives in test/integration/, not a new top-level directory. The master plan says the harness is "used in integration tests; not on hot path" — it is test-scoped code, not production runtime code. Phases 76 (cross-tenant isolation, D-134) and 77 (goroutine-leak, D-135) each established the test/integration/ home for a master conformance harness; Phase 78 follows that precedent exactly. A new top-level directory would need an RFC change (CLAUDE.md §3); none is warranted — a chaos harness is an integration test, and test/integration/ is its canonical home. The harness is one *_test.go file plus a fault-injecting-decorator helper file in the same integration_test package.

2. Faults are injected by THIN DECORATORS over the real production components — this is the §17.3 "real drivers at the seam" pattern with a fault overlay, NOT the §13 "test stub as production default" anti-pattern. The harness opens every component through its production registry factory / constructor (events.Open, state.Open, engine.New, pauseresume.New, retry.Wrap). Where a fault must be induced, the harness wraps the real component in a thin decorator (faultyStateStore decorates a real state.StateStore; the kill-mid-run row uses a blocking node closure; the provider-quirk row wraps a quirkLLMDriver in the real retry.Wrap retry-with-feedback layer). The decorators DECORATE — they delegate every non-faulting call verbatim to the real driver — they never re-implement subsystem behaviour, and the fault auto-clears after a bounded number of calls so a single row can assert both the loud-failure half AND the recovery half. Crucially, the decorators live in *_test.go files in the integration_test package: they are never registered with a driver registry, never a DefaultDriver, never reachable by the harbor binary — the runtime resolves only real drivers at boot. CLAUDE.md §13's "test stub as production default" forbids a stub being the only shipped implementation an operator's binary resolves; it does not forbid a test-tree decorator that wraps a real driver to induce a controlled failure for an integration test. That is exactly what §17.3 #3 ("≥1 failure mode") asks for. The dispatch prompt's framing is adopted verbatim: a decorator that wraps (not replaces) a real driver, lives in the test tree, and is never a registry default is §13-compliant.

3. The five failure modes are the master plan's, each asserting the documented event/error AND the documented recovery path (CLAUDE.md §13 — no silent degradation). (a) Kill mid-run — a run is held in-flight by a blocking node, then cancelled; the row asserts the engine's RunCancelledHandler seam fires (the production wiring publishes runtime.run_cancelled from this notice), FetchByRun observes ErrRunCancelled, and Engine.Stop tears down cleanly within a bounded deadline with no goroutine leak. (b) Drop messages — a tiny-buffered subscription is saturated so the inmem bus's drop-oldest backpressure fires; the row asserts the typed bus.dropped event is delivered carrying a non-empty dropped sequence range. The notice is windowed (DropWindow), so the row publishes a burst, lets the window elapse, then publishes exactly ONE trigger event — one trigger, not a second burst, keeps the just-landed notice from being displaced back out of the small buffer. (c) Provider quirks — a quirkLLMDriver returns malformed output; wrapped in the real retry.Wrap with a rejecting Validator, the row asserts the llm.retry_with_feedback event fires AND the call exhausts loudly with llm.ErrRetryExhausted when the quirk persists, plus a recovery sub-case where one bad response then a good one succeeds after one retry. (d) StateStore disconnect — the faultyStateStore decorator returns a transport error; the row asserts the error surfaces loudly out of Save/Load (never silently swallowed) and the reconnect recovery path works once the fault budget clears. (e) Pause-deserialize failure — a PauseRequest whose trajectory's LLMContext carries a live channel fails Coordinator.Request loud with trajectory.ErrUnserializable naming a non-empty field path (the D-069 / RFC §3.4 fail-loud contract — never a half-persisted checkpoint, never (nil, nil)), plus a recovery sub-case where a clean trajectory Requests + Resumes successfully. A dedicated chaos CI job runs the suite under -race on every PR.

Why. Closes the Phase 78 master-plan acceptance loop ("Each failure mode produces the documented event + recovery path"). Phases 76 / 77 prove the runtime holds a happy-path invariant under stress; Phase 78 is the complementary gate — it proves the runtime behaves correctly UNDER FAILURE, surfacing every fault loudly (never a silent degradation — CLAUDE.md §13) and recovering on the documented path. A chaos harness catches the class of resilience regression — a refactor that swallows a StateStore error, drops a cancellation event, or silently returns a malformed LLM response — that unit tests and the isolation / leak harnesses miss.

Findings I'm departing from. None.

Protocol additions. None — Phase 78 ships no Protocol method, error code, or wire type, and touches no production code. It adds an integration-test harness, a CI job, and a smoke script.


D-138 — Phase 80 documentation-hygiene polish: the enforced revive lint gate, the worked examples, and the recipe docs

Date: 2026-05-21 Status: Settled (shipping with this PR)

Where it lives: .golangci-revive.yml (the dedicated revive-only lint config); .golangci.yml (the revive exported rule gains disableStutteringCheck; an A2A var-naming exclude); Makefile (new lint-revive target); .github/workflows/ci.yml (the lint job now installs golangci-lint and runs make lint-revive; a new examples job); examples/agents/echo/ + examples/tools/weather/ + examples/README.md (worked examples); docs/recipes/ (recipe how-to docs); scripts/smoke/phase-80.sh (the static-only smoke); docs/plans/phase-80-documentation-hygiene-polish.md.

Decision. Phase 80 closes the documentation-hygiene loop the master plan names: every package has a doc comment, every exported symbol has godoc, the revive lint rules that enforce that are actually run in CI, and examples/ plus docs/recipes/ give a reader runnable, real-API-grounded entry points. Four load-bearing calls.

1. The lint gate is revive only, run via a dedicated .golangci-revive.yml, NOT the full make lint. The master plan's Phase 80 acceptance is worded precisely — "golangci-lint's revive exported and package-comments clean" — not "all linters clean". An audit found the repo-wide make lint carries ~1000 pre-existing issues across ~20 linters (govet fieldalignment, errcheck, gofmt, errorlint, …). That backlog accumulated because the CI lint job never actually ran the linter: it called make lint, whose command -v golangci-lint guard silently skipped because CI never installed the binary. Phase 80 fixes the silent skip (CI now installs golangci-lint v1.64.8) but scopes the enforced gate to revive — the documentation linter the phase mandate names. Clearing the broader backlog is a separate release-hardening effort (several errcheck fixes change error-handling behaviour, out of scope for a docs phase). The gate runs via a committed .golangci-revive.yml rather than a command-line --enable-only revive flag because --enable-only was found to bypass issues.exclude-rules processing in golangci-lint v1.64; a dedicated config file is the reliable way to run exactly one linter with its settings and excludes honoured. make lint (all linters) is kept as-is for local use and future hardening.

2. revive's exported rule keeps godoc enforcement but disables the stutter naming sub-check (disableStutteringCheck). revive's exported rule does two things: (a) flag any exported identifier missing a godoc comment — the actual Phase 80 mandate — and (b) flag exported type names that "stutter" against their package (state.StateStore, react.ReActPlanner, …). The repo had ~20 stutter hits and ZERO missing-godoc-on-types hits: the codebase already documents its exported surface well. Acting on the stutter sub-check would mean renaming ~20 exported types across package boundaries — explicitly out of scope for a documentation phase ("do not rename code") and a wide API-churn risk. disableStutteringCheck switches OFF only the naming-opinion sub-check; the godoc-presence enforcement (the binding mandate) stays fully on. The genuine documentation gaps the rule DID surface — a handful of exported const/var blocks missing a block comment, two malformed package comments, eight detached package comments — were all fixed in this PR.

3. The worked examples are buildable Go, not just config. examples/ previously held only two annotated YAML configs. Phase 80 adds examples/agents/echo/ (a worked harbortest.Agent + test — the same shape harbor scaffold produces) and examples/tools/weather/ (a worked inproc.RegisterFunc in-process tool + a register→resolve→invoke test). Both build and their tests pass under -race; a new CI examples job runs go build ./examples/... + go test -race ./examples/..., so a drift in a public surface (harbortest, the tool catalog) that breaks an example fails the build. The examples deliberately have trivial behaviour (echo input, return canned weather) — the value is the SHAPE a reader copies, not the data.

4. Recipe docs live under docs/recipes/, grounded in real current APIs. docs/recipes/ is a new subdirectory of the already-permitted docs/ tree (CLAUDE.md §3) — no new top-level directory, no RFC change. It ships five task-oriented how-to guides (scaffold an agent, define a tool, configure a planner, run harbor dev, test an agent). Every recipe references only symbols and flags that exist in the tree at this phase (the harbor dev flag set, inproc.RegisterFunc, the planner config block, the harbortest surface) — a recipe that cited a non-existent symbol would be worse than no recipe.

Why. Phase 80 is the documentation-hygiene gate for the V1 cut. The silent-skip discovery is the load-bearing finding: a lint gate that never runs is not a gate. Fixing the skip and scoping the enforced rule set to the phase's stated mandate gives Harbor a real, enforced godoc/package-comment gate without conflating it with a much larger pre-existing lint backlog. The worked examples and recipes give a first-time reader a runnable, real-API on-ramp that CI keeps honest.

Findings I'm departing from. None.

Protocol additions. None — Phase 80 ships no Protocol method, error code, or wire type. It changes no runtime behaviour: the production-code edits are documentation comments and whitespace only.


Date: 2026-05-21 Status: Settled (shipping with this PR)

Where it lives: cmd/harbor/root.go (HarborVersion becomes a var); cmd/harbor/cmd_version.go (header doc — the product-vs-Protocol distinction); CHANGELOG.md (the Keep-a-Changelog changelog); scripts/release-build.sh + scripts/release-dryrun.sh (the release tooling); Makefile (release-build / release-dryrun targets); .github/workflows/release.yml (the v*-tag release workflow); scripts/smoke/phase-81.sh (the static-only smoke); docs/plans/phase-81-release-engineering.md.

Decision. Phase 81 ships Harbor's release engineering — the tooling that turns a pushed v* git tag into a published release artifact, ahead of the Phase 82 v1.0.0 cut. Five load-bearing calls.

1. The product release version is stamped at link time via -ldflags -X 'main.HarborVersion=…'; HarborVersion changes from a const to a var. A Go const cannot be overridden by -ldflags -X — the linker can only rewrite a package-level string var. Phase 63 pinned HarborVersion as a const "v0.0.0-dev" and explicitly anticipated "a later release-engineering phase injects a real semver via -ldflags"; Phase 81 is that phase. The conversion is a one-symbol change — same name, same type, same default value — so the existing cmd_version_test.go (which reads the symbol, never assumes const-ness) passes unchanged. An un-stamped build (go build, go run, go test, a plain make build) keeps the v0.0.0-dev default — the load-bearing operator signal that "this is not a release artifact", the same fail-loudly sentinel discipline buildHash() already follows (CLAUDE.md §5). The version is derived from git describe --tags (or HARBOR_RELEASE_VERSION when the release workflow sets it from the pushed tag ref).

2. The product release version is STRICTLY DISTINCT from the Harbor Protocol version. HarborVersion is the binary's own product semver; internal/protocol/types.ProtocolVersion (RFC §5.3, D-077) is the Runtime↔Console wire-contract version. They are different things versioned independently: a Runtime refactor that bumps the release version need not bump the Protocol version, and a Protocol-surface addition need not bump the release version. harbor version already prints both as separate labelled fields (harbor / protocol, D-084); Phase 81 only makes the harbor field carry a real release value. The two are NOT conflated anywhere — scripts/release-build.sh stamps only main.HarborVersion, never the Protocol constant (whose bump is an RFC change). The cmd_version.go and CHANGELOG.md headers document the distinction so a future contributor does not collapse them.

3. The CHANGELOG follows Keep-a-Changelog and lives at the repo root. CHANGELOG.md is the conventional discoverable home for a release history; the Keep-a-Changelog format (an [Unreleased] section, ### Added / ### Changed / … subsections, version-link references) is the widely-understood default and needs no tooling. Content is grouped by delivery wave / subsystem (foundations, events/state/sessions, runtime engine, persistence, tools, LLM, skills, planner, steering, observability, Protocol, CLI, Console, release hardening) rather than as a flat 90-entry phase list — the wave grouping is how Harbor was actually built (CLAUDE.md §17.7) and how a reader best understands the V1 surface. Every V1 phase (01–81 plus the lettered 26a/33a/36a/36b/53a/64a/72*/73*) is covered. The [Unreleased] section is the living record; Phase 82's v1.0.0 cut moves it under a dated [1.0.0] heading.

4. The release build logic has ONE home — scripts/release-build.sh — consumed by both the Makefile target and the workflow. The -ldflags -X stamping incantation, the CGo-free static-build flags (CGO_ENABLED=0, -ldflags='-s -w', -trimpath), the version-resolution priority chain, the checksum emission, and the post-build stamp-verification all live in exactly one shell script. make release-build, make release-dryrun (via scripts/release-dryrun.sh), and .github/workflows/release.yml all delegate to it — there is no second copy of the build incantation, avoiding the CLAUDE.md §13 "two parallel implementations" smell. scripts/release-dryrun.sh is the master-plan "release dry-run" test: it runs the exact release-build path with a synthetic version and asserts the artifact + checksum exist, the checksum verifies, and the stamped binary's harbor version reports the stamped string — plus that an un-stamped build still reports v0.0.0-dev (the stamp is opt-in, never silently applied). No heavyweight release framework (goreleaser) is introduced — stdlib go build + a shell script + a GitHub Actions workflow is the deliberate dependency-light surface (CLAUDE.md §13).

5. SLSA-style build provenance lands NOW, not as a post-V1 deferral. The master plan names SLSA-style attestations as a stretch — "include it if it's clean to add, otherwise document the deferral". It is clean to add: GitHub's native actions/attest-build-provenance@v1 action generates a signed, verifiable provenance attestation for the release artifact with no extra runtime dependency and no framework — it needs only the id-token: write / attestations: write job permissions. Because the stretch lands cleanly with a first-party action, deferring it would be gratuitous; the release workflow attaches provenance to the artifact on every v* tag push. The release workflow also exposes a workflow_dispatch path that runs the dry-run, so the release build can be exercised in CI without a tag. Phase 81 itself creates NO v* tag — tagging is the operator's job in Phase 82.

Why. Phase 81 is the last build phase before the v1.0.0 cut. It closes the master-plan acceptance loop — a pushed v1.0.0-rc.1 tag produces a release artifact, and the CHANGELOG covers every V1 phase — without conflating the product release version with the Protocol wire-contract version, without a heavyweight release framework, and with build provenance attached from day one. The const → var conversion is the minimal production-code change that makes link-time version stamping possible; everything else is build tooling and documentation.

Findings I'm departing from. None.

Protocol additions. None — Phase 81 ships no Protocol method, error code, or wire type. The single production-code change is the const → var conversion of cmd/harbor.HarborVersion; it changes no runtime behaviour and no Protocol surface.


D-140 — Wave 14 §17.5 checkpoint: zero-FAIL V1-readiness verdict, research-brief predecessor-name scrub, drift-audit scan extension, and CI examples-job CGo alignment

Date: 2026-05-22 Status: Settled (shipping with this PR)

Where it lives: docs/research/01-core-runtime.md07-code-level-tool-calling.md (the seven source-distilled briefs); scripts/drift-audit.sh (forbidden-name scan file set); .github/workflows/ci.yml (the examples job test step).

Decision. Wave 14's read-only V1-readiness checkpoint audit (§17.5) returned zero FAIL, one WARN, and one actionable NIT. This entry records the verdict and the audit-fix PR that closes the WARN and the NIT. Three load-bearing calls.

1. Wave 14 is clean — zero FAIL. The checkpoint audit read every shipped phase in the wave (source, tests, plan, RFC reference) hunting for wiring gaps, RFC drift, depth issues, weak tests, and hygiene regressions. It found no FAIL-class issue: the V1 surface holds together. The only blocking-adjacent finding was a hygiene WARN (below); the rest of the wave passed without remediation.

2. The predecessor project name is scrubbed from the research briefs, not carved out. The WARN: the predecessor project's name appeared 62 times across the seven source-distilled briefs (docs/research/0107), almost entirely as external source-path citations (~/Repos/<name>/.../core.py:1557) and a "Source path" table column. The scripts/drift-audit.sh forbidden-name scan deliberately excluded docs/research/, which is why the leak survived — CLAUDE.md §13 forbids the predecessor's name and any synonym ("the prior project", "the predecessor", "the reference implementation", "the source", abbreviations, author names) anywhere in committed text. The operator's call was explicit: scrub, do not carve out. Every name occurrence and every external repo-path citation is removed; each brief's actual design finding is kept and re-expressed as a standalone Harbor design statement (a finding attached to a source path becomes the finding alone — e.g. "DeadlineAt is wall-clock, not duration" with the trailing path dropped); "Source map" tables become non-referential "Concept map" tables. The briefs now read as Harbor's own design research with no allusion to any specific prior project. A case-insensitive scan for the predecessor's name across docs/research/ returns nothing.

3. The drift-audit forbidden-name scan now covers docs/research/*.md; the CI examples job pins CGO_ENABLED=0 on its test step. The scan-extension is the structural fix that makes the scrub permanent: scripts/drift-audit.sh previously scanned rule files, phase plans, indices, and Go source but not the research briefs — so the leak could recur silently. The scan now globs every docs/research/*.md brief, and the success message names the wider scope. The NIT: the examples job's go test -race step had no explicit CGO_ENABLED while its sibling go build step pinned CGO_ENABLED: '0'; the test step now pins it too, aligning with the repo-wide CGo-free discipline (CLAUDE.md §5). On Go 1.26 the race detector runs cgo-free, so the pin is harmless and consistent.

Why. The §17.5 checkpoint audit gates the next wave's planning. Recording the zero-FAIL verdict closes Wave 14; scrubbing the briefs and widening the drift-audit scan turn a one-time cleanup into an enforced invariant so the predecessor's name cannot leak back into the research tree.

Findings I'm departing from. None.

Protocol additions. None — this is a checkpoint audit-fix PR. It changes documentation (research briefs, this log), one CI workflow step, and one drift-audit shell script; it ships no Protocol method, error code, wire type, or runtime-behaviour change.


D-141 — Lint hardening before the v1.0.0 cut: govet drops fieldalignment + shadow; the full make lint becomes the enforced CI gate

Date: 2026-05-22 Status: Settled (shipping across the Wave 14 lint-hardening PRs)

Where it lives: .golangci.yml (the govet.disable list); .github/workflows/ci.yml (the lint job, flipped to the full make lint once the backlog clears); the fix(lint): ... burn-down PRs.

Context. Phase 80 (D-138) discovered the CI lint job had been a silent no-op since the project's start — golangci-lint was never installed on the runner, so make lint's command -v guard skipped it. Phase 80 fixed the silent skip but scoped the enforced gate to revive only (the doc-hygiene linter it named), tracking the wider backlog in issue #190. The operator's call for the v1.0.0 cut: burn the full backlog down and make the complete make lint the enforced gate.

Decision. Two load-bearing calls.

1. govet no longer enables fieldalignment or shadow. .golangci.yml carried govet.enable-all: true, which force-enables every govet analyzer including the two that are widely left off by deliberate choice:

  • fieldalignment reorders struct fields to minimise memory padding. Its autofixer destructively strips struct-field doc comments when it reorders — a burn-down agent measured 761 comment lines deleted from internal/protocol/types/ wire structs alone. The memory-packing win is negligible on structs that exist to be JSON-serialised onto the wire, and byte-packing actively fights logical field grouping and readability. Enforcing it trades documentation and clarity for a non-benefit.
  • shadow flags variable shadowing, including the idiomatic Go if err := f(); err != nil / per-iteration err :=. The mechanical "fix" (:==) reintroduces data races when the shadowed variable is an err inside a goroutine body — each goroutine needs its own binding. A linter whose fix introduces races is net-negative.

Together these accounted for ~180 of the ~327 raw backlog issues — the low-value, harmful-to-enforce half. They are disabled via govet.disable; every other govet analyzer stays on. The remaining ~147 issues (errcheck, unparam, unused, gocritic, gosec, errorlint, nilerr, stylecheck, ineffassign, staticcheck, copyloopvar, intrange, …) are genuinely worth fixing and are burned down to zero.

2. The full make lint becomes the enforced CI gate. Once the backlog is zero, the CI lint job flips from make lint-revive (the Phase 80 interim narrow gate) back to the full make lint. The revive doc-hygiene rules remain part of that full run. This closes issue #190 — the gate can no longer silently rot, because every linter in .golangci.yml now runs on every PR.

Why. A v1.0.0 framework should enforce the lint rules that catch real defects and not enforce micro-optimisation noise whose autofix damages the codebase. Disabling fieldalignment/shadow is not lowering the bar — it is removing two rules that were never a quality signal, so the gate that remains is entirely load-bearing.

Findings I'm departing from. The naive reading of "burn the whole backlog down" would have hand-reordered 42+ structs for fieldalignment and rewritten ~90 shadow sites. That path destroys Protocol wire-type godoc and risks races; this decision rejects it in favour of disabling the two analyzers — the reasonable-deviation call (CLAUDE.md §4.3), recorded here because it is a permanent .golangci.yml policy change.

Protocol additions. None — .golangci.yml + .github/workflows/ci.yml only; no Protocol method, error code, wire type, or runtime-behaviour change.


D-142 — The v1.0.0 cut: a framework-quality root README, a de-jargoned CHANGELOG, and the release surfaces

Date: 2026-05-22 Status: Settled (shipping with the Phase 82 v1.0.0 cut)

Where it lives: README.md; CHANGELOG.md; docs/announcements/v1.0.0.md; docs/plans/phase-82-v1-cut.md; the v1.0.0 git tag.

Decision. Phase 82 cuts v1.0.0 — the line at which the V1 surface is complete and stable. Three calls are settled here.

1. The root README is rewritten as a framework front door, not a build log. The organically-grown README had become a ~100-line phase-by-phase status table, each row a paragraph — a development artifact, not a product entry point. The v1.0.0 README leads with positioning and a three-command quickstart, then the four-layer architecture, the usage path, documentation pointers, and an honest V1 status; it carries the Harbor logo and a five-badge row (CI, release, Go Reference, Go version, license). The phase-status table is deleted — the master phase plan (docs/plans/README.md) is the canonical execution index, and the README links to it rather than mirroring it.

2. Public release surfaces carry no internal "phase" vocabulary. "Phase NN" is Harbor's internal development jargon. It belongs in docs/plans/, docs/decisions.md, and the per-phase artifacts — never in the README, the CHANGELOG, release notes, or the launch announcement. The CHANGELOG.md [1.0.0] section is grouped by subsystem and describes the product in feature terms; its section headers dropped the (Wave N, phases XX–YY) parentheticals. A scripts/smoke/phase-82.sh check enforces that the CHANGELOG carries no phase-N token.

3. v1.0.0 is the initial release — no migration notes. The master-plan Phase 82 goal lists "migration notes (if any)"; there is no prior released version to migrate from, so none apply. This is recorded rather than left as an open question.

The v1.0.0 git tag is operator-run from main after this PR merges and main CI is green. The Phase 81 release.yml workflow then builds the version-stamped CGo-free static binary, attaches the SHA-256 checksum and SLSA build provenance, and publishes the GitHub Release.

Why. Harbor is a real product; its front door has to look the part, and its public change record has to read for a user, not a maintainer. The README rewrite and the CHANGELOG de-jargoning are the difference between a repo that looks shipped and one that looks mid-build.

Findings I'm departing from. None.

Protocol additions. None — Phase 82 is the release cut. No Protocol method, error code, wire type, runtime behaviour, or CLI subcommand changes.


D-143 — The ReAct system prompt is twelve XML-tagged structured sections; no reasoning field, no rich-output fields

Date: 2026-05-22 Status: Settled (shipping with Phase 83a — the foundation phase of the 83-band)

Where it lives: internal/planner/react/prompt.go (defaultBuilder.buildSystemContent + the twelve section constants); internal/planner/react/react.go (DefaultSystemPrompt sentinel + WithSystemPromptExtra Option); internal/planner/react/testdata/golden_default_prompt.txt (the normative fixture); internal/config/config.go (PlannerConfig.ExtraGuidance).

Decision. Phase 83a replaces the ReAct planner's flat one-string DefaultSystemPrompt (Phase 45/47) with the twelve XML-tagged sections inventoried in brief 13 §2.1 — <identity>, <output_format>, <action_schema>, <finishing>, <tool_usage>, <parallel_execution>, <reasoning>, <tone>, <error_handling>, <available_tools>, <additional_guidance>, <planning_constraints> — assembled in that fixed order, separated by \n\n. Four calls are settled here.

1. The twelve sections are the section anchors the rest of the 83-band builds on. XML tags make each section individually editable (brief 13 §2.1). Phase 83b replaces the <available_tools> body with per-tool args_schema + curated examples; 83c populates <planning_constraints> from RunContext.PlanningHints and merges per-turn repair guidance; 83d injects <read_only_*_memory> UNTRUSTED-framed blocks. Each is a localised edit against an established anchor, not a structural rewrite. The two optional sections (<additional_guidance>, <planning_constraints>) are omitted entirely — never emitted as empty tag pairs — when their content is absent.

2. The action JSON drops the reasoning field; the <tone> CRITICAL clamp is ported verbatim. Per brief 13 §2.6 (2026-05-19 revision), reasoning is captured from the provider's reasoning channel (Phase 83e) and persisted on the trajectory step — never required in the model's structured output. The rendered <action_schema> example is {tool, args} only; the trajectory replay renderer echoes {tool, args} only; <tone> carries the two CRITICAL lines instructing the model not to emit a thought / reasoning field. Phase 83a is the prompt-side alignment; Phase 83e narrows the runtime-side Decision sum.

3. Rich output is dropped from Harbor entirely — not reserved, not deferred. The <finishing> block carries only args.answer (plain text). No confidence / route / requires_followup / warnings finish-args fields; <error_handling> guides clarification via args.answer, not a requires_followup flag. Rich UI is delivered through MCP-Apps tools the planner invokes (brief 13 §5), never through a typed finish-payload.

4. DefaultSystemPrompt becomes a routing sentinel; operators inject guidance without forking the builder. The old single-string constant is removed (not renamed to a dangling legacyDefaultSystemPrompt — the golden fixture is the normative spec, a legacy constant would be dead code per CLAUDE.md §13). DefaultSystemPrompt is now a stable non-empty sentinel string the builder compares against to choose the structured layout vs. honouring a verbatim WithSystemPrompt override. The new WithSystemPromptExtra(s string) Option and the new planner.extra_guidance config key flow operator-supplied domain guidance into <additional_guidance> without writing Go.

Why. The Phase 45 flat prompt gave the LLM no schema discipline, no failure-recovery framing, and no explicit injection points. The twelve-section layout is the load-bearing structure the dynamic-augmentation, tool-schema, and memory-framing phases (83b/c/d) all depend on; landing it first as a content-only refactor de-risks the band.

Findings I'm departing from. None — this phase matches brief 13's 2026-05-19 revised design exactly. The brief's own §9 records the departure from the superseded "rich-output deferred to V2" note; Phase 83a inherits that closed departure rather than re-opening it.

Protocol additions. None — Phase 83a is a planner-internal prompt-content refactor plus one operator-facing config key (planner.extra_guidance) and one constructor Option (WithSystemPromptExtra). No Protocol method, error code, wire type, or CLI subcommand changes.


D-147 — The ReAct action schema is narrowed to {tool, args}; reasoning is captured on the provider channel, not the decision

Date: 2026-05-22 Status: Settled (shipping with Phase 83e)

Where it lives: internal/planner/decision.go (CallTool); internal/llm/llm.go (CompleteResponse.Reasoning); internal/llm/drivers/bifrost/reasoning.go + bifrost.go + translate.go; internal/planner/repair/parser.go + repair.go; internal/planner/trajectory/trajectory.go (Step.ReasoningTrace); internal/planner/react/react.go; internal/planner/events.go (DecisionPayload, ActionExtraFieldDroppedPayload).

Decision. The planner.CallTool decision shape drops its Reasoning field. The model emits {tool, args} only. The provider-side thinking trace — Anthropic extended thinking, OpenAI o-series, DeepSeek native, Gemini thought:true parts — is captured separately: llm.CompleteResponse gains a Reasoning string field, the bifrost driver reads BifrostChatResponse.Choices[0].Message.ReasoningDetails (bifrost's normalised canonical surface) on BOTH the unary and streaming paths, and the captured trace persists on trajectory.Step.ReasoningTrace. This closes two gaps brief 13 §2.6's empirical Bifrost probe pinned: the unary-path gap (OnReasoning was streaming-only) and the Gemini-direct black hole (bifrost populated reasoning_details[] on the message but Harbor dropped it). Phase 44's schema-repair parser tolerates incoming reasoning / thought fields by silently stripping them and emitting a planner.action_extra_field_dropped telemetry event per dropped field — the runtime fails OPEN for backward compatibility with older trained models.

Why. A reasoning string inside the structured decision conflates two concerns: the action the runtime executes, and the model's chain of thought. The conflation cost a schema field the model had to fill on every step, and it never carried the real provider thinking trace — only whatever free text the model echoed. Reading the provider's normalised reasoning channel captures the genuine trace; narrowing the action schema removes the model's expectation of an echo field. The "we need reasoning visible in the trajectory" use case is preserved by D-148's replay knob — by configuration, not by schema.

Findings I'm departing from. This is a binary departure from Phase 45 / D-051, which shipped CallTool{Tool, Args, Reasoning} as the V1 action shape. The departure is recorded here; the deterministic planner's CallToolStep.Reasoning field is dropped in the same change since it has nowhere to land.

Protocol additions. None — CompleteResponse and the planner Decision sum are internal Go types, not Protocol wire types. The planner.decision and planner.action_extra_field_dropped events are internal event-bus types (registered in internal/planner/events.go), surfaced to operators via harbor inspect-runs event replay — not new Protocol methods.

Dated implementation correction (2026-08-02). D-402 supersedes only this decision's capture-source precedence: a streamed raw reasoning channel wins over synthesized ReasoningDetails when it was observed. The action-schema narrowing, provider-side capture boundary, trajectory persistence, and D-148 replay policy remain unchanged.


D-148 — Reasoning replay is a per-agent operator knob; never by default for ALL models, two modes only

Date: 2026-05-22 Status: Settled (shipping with Phase 83e)

Where it lives: internal/planner/planner.go (ReasoningReplayMode, RunContext.ReasoningReplay, EffectiveReasoningReplay); internal/config/config.go (PlannerConfig.ReasoningReplay) + internal/config/validate.go; internal/planner/react/react.go (WithReasoningReplay) + prompt.go; internal/planner/registry.go + react/init.go.

Decision. Whether a prior step's captured reasoning trace is re-injected into the next turn's prompt is an operator-controlled per-agent knob: config.PlannerConfig.ReasoningReplay, a string enum validated to never / text (empty resolves to never). The ReasoningReplayMode Go enum's zero value resolves to never — replay is OFF unless an operator opts in, for ALL models. When the mode is text, the ReAct trajectory renderer prepends each prior step's captured ReasoningTrace as a text block above the prior {tool, args} action JSON. A per-run RunContext.ReasoningReplay *ReasoningReplayMode override wins over the agent-configured value for tenant- or run-specific policy. V1 ships exactly two modes — there is NO provider_native mode.

Why. The predecessor never replayed reasoning; Harbor's stance is the same default (never-replay for every model — thinking-class or not), with a deliberate per-agent opt-in for workloads where chain-of-thought continuity across turns measurably helps. Making it a knob rather than a hardcoded behaviour means the "reasoning visible in the trajectory" use case D-147 removed from the schema is recovered by configuration. The zero-value-resolves-to-never contract is load-bearing: a misconfigured or zero-value enum must NOT silently opt an agent into replay.

Findings I'm departing from. Brief 13 §2.6 noted three candidate modes (never, text, provider_native). V1 ships only the first two. provider_native would round-trip Anthropic's signature-bearing thinking blocks through bifrost as API constructs across turns; Bifrost's docs do not address that round-trip, so Harbor cannot guarantee correctness today. Deferred — revisit when (a) Bifrost documents the signed-thinking-block round-trip or (b) a real workload measurably benefits.

Protocol additions. None — PlannerConfig.ReasoningReplay is a harbor.yaml config key (restart-required; no reload:"live" tag). No Protocol method, error code, wire type, or CLI subcommand change.


D-145 — Repair counters live on RunContext, not on the ReActPlanner struct

Date: 2026-05-22 Status: Settled (shipping with Phase 83c)

Where it lives: internal/planner/planner.go (RepairCounters, PlanningHints, BudgetHints, RunContext.RepairCounters, RunContext.PlanningHints, PlanningNudges); internal/planner/events.go (EventTypePlannerRepairGuidanceInjected, RepairGuidanceInjectedPayload); internal/planner/react/repair_guidance.go + planning_hints.go + prompt.go; internal/planner/repair/repair.go (RunResult.Repair, RepairOutcome).

Decision. Phase 83c's per-run, across-step failure counters — RepairCounters{FinishRepair, ArgsRepair, MultiAction} — live on the per-run planner.RunContext, never on the shared ReActPlanner struct. The runtime constructs one RepairCounters per run and threads the same pointer through every per-step RunContext; the ReAct planner reads the counters in its prompt builder and updates them after each step (updateRepairCounters). A nil pointer means "no augmentation". The richer PlanningHints struct (constraints, preferred order, parallel groups, disallow/preferred tools, budget caps) also lives on RunContext as RunContext.PlanningHints and renders into the <planning_constraints> prompt section; the pre-existing parallel/transport nudge struct was renamed PlanningHints → PlanningNudges to free the name.

Why. The reference design the brief drew from stores failure counters on the planner instance, persisting across runs ("no orchestrator wiring required"). Harbor cannot: the ReActPlanner is a shared compiled artifact (D-025), and a mutable counter field on it would be a §13-forbidden mutable-state-on-a-compiled-artifact bug — two concurrent runs sharing the planner would cross-contaminate each other's counters. Scoping the counters to the per-run RunContext is the only shape that satisfies the concurrent-reuse contract. The cost — the counters must be threaded through RunContext rather than read off this — is the wiring the reference design saved; Harbor pays it deliberately. The cross-run isolation test (TestE2E_React_RepairGuidanceCrossRunIsolation, plus TestUpdateRepairCounters_ConcurrentDisjointRunContexts at N=128) is the proof.

Findings I'm departing from. Brief 13 §2.2's "planner-instance counters, no orchestrator wiring" — departed for the D-025 reason above. The departure is the whole point of this decision; the per-run scope is the chosen contract.

Protocol additions. None — RepairCounters / PlanningHints / RepairOutcome are internal Go types, not Protocol wire types. planner.repair_guidance_injected is an internal event-bus type (registered in internal/planner/events.go), surfaced to operators via the Console / harbor inspect-runs event replay — not a new Protocol method, error code, or CLI subcommand.


D-144 — ReAct tool catalog renders args_schema + side_effects + tag-ranked examples

Date: 2026-05-22 Status: Settled (shipping with Phase 83b)

Where it lives: internal/tools/tools.go (Tool.Examples, ToolExample); internal/tools/example_validation.go (validateExamples, ErrToolExampleInvalid) + internal/tools/catalog.go (Register calls it); internal/planner/react/prompt.go (renderTool, renderAvailableToolsSection, toolRenderConfig, rankedExamples, compactJSON); internal/planner/react/react.go (WithMaxToolExamplesPerTool); internal/planner/react/init.go; internal/config/config.go + internal/config/validate.go (PlannerConfig.MaxToolExamplesPerTool); internal/planner/registry.go; cmd/harbor/cmd_dev.go.

Decision. The ReAct system prompt's <available_tools> section renders each tool with its full args_schema (compact single-line JSON), declared side_effects class, and up to N curated examples — not the Phase 45 / 83a name + description shape. Examples are a new opt-in field Tool.Examples []ToolExample ({Args, Description, Tags}); they are tag-ranked minimal (rank 0) > common (1) > edge-case (2) > untagged (3), stable-sorted by (rank, originalIndex), and the renderer keeps the top MaxToolExamplesPerTool (operator knob, default 3). A tool that ships no examples renders through its side_effects line and omits the examples: line entirely — no registration-site code change is needed for existing tools. Curated examples are validated at catalog registration: an example whose Args names a key not declared in the tool's args_schema.properties fails Register loudly with ErrToolExampleInvalid (a passing example is a working example). args_schema is re-marshalled to compact JSON via encoding/json (deterministic map-key order) so the section stays KV-cache-stable across turns.

Why. The dominant ReAct failure mode the prompt-quality band closes is the args-validation-failure cascade: with only name + description exposed, the LLM guesses argument shapes, the catalog edge rejects the guess, and the planner burns steps recovering. Brief 13 §2.4 pins examples as the most token-efficient way to constrain args — "a single concrete example is worth several lines of schema prose." Surfacing the schema + examples gives the LLM the information to decide correctly the first time (brief 07 §3: runtime owns dispatch, the LLM is the decision-maker). Registration-time example validation closes the secondary risk that examples become performative — an example that contradicts the schema would teach a shape the runtime then rejects.

Findings I'm departing from. None — this matches brief 13 §2.4 exactly. The Tool.Examples field and ToolExample type pre-existed (added speculatively with Phase 26's catalog); Phase 83b gives them their first consumer (the renderer) and their first guard (the validator), satisfying the §13 primitive-with-consumer rule.

Protocol additions. None — Phase 83b is a planner-internal prompt-content change plus one operator-facing config key (planner.max_tool_examples_per_tool) and one constructor Option (WithMaxToolExamplesPerTool). No Protocol method, error code, wire type, or CLI subcommand change.


D-146 — ReAct memory + skills inject as separate UNTRUSTED-framed system messages; serialisation fails loudly

Date: 2026-05-22 Status: Settled (shipping with Phase 83d)

Where it lives: internal/planner/planner.go (MemoryBlocks type, RunContext.MemoryBlocks, RunContext.SkillsContext); internal/planner/errors.go (ErrMemoryBlockUnserializable); internal/planner/react/memory_wrappers.go (wrapper copy + render helpers); internal/planner/react/prompt.go (buildRequest / baseRequest); internal/planner/react/react.go (Next drives the error-returning build path).

Decision. Pre-fetched memory blobs and pre-retrieved skill bodies are injected into the ReAct planner's system prompt as separate llm.ChatMessage system-role entries — never concatenated into the twelve-section base system message. The Runtime populates RunContext.MemoryBlocks ({External any, Conversation any}) and RunContext.SkillsContext []any; the planner renders. Three wrappers, in a fixed order — <read_only_external_memory><read_only_conversation_memory><skills_context> (most-stable → least-stable → operator-curated, so the message-slice prefix stays KV-cache-stable across turns). Each memory wrapper carries the verbatim five-line anti-prompt-injection rule list from brief 13 §2.3; the skills wrapper carries an analogous shorter operator-curated framing. Payloads are compact JSON (sorted keys, no whitespace, HTML-escaping off). A nil tier / nil MemoryBlocks / empty SkillsContext is omitted entirely — no empty wrapper is rendered.

A value json.Marshal rejects (a chan, a function, a cyclic structure) fails the planner step loudly with a typed planner.ErrMemoryBlockUnserializable naming the offending tier / index — never a silently dropped tier or an empty wrapper. The PromptBuilder.Build interface signature is unchanged (it cannot return an error); the planner instead drives the in-package defaultBuilder via the error-returning buildRequest and surfaces the sentinel from Next.

Why. Memory feeds (Phase 23 / 24) can carry user-contributed conversational content susceptible to prompt-injection; the UNTRUSTED framing is the prompt-time mitigation that makes memory safe to inject. Distinct tag names per tier let the model use tier semantics and let Console traces / debugging tools grep one tier. Separate messages (not one mega system prompt) keep each tier independently isolatable. Fail-loud serialisation closes the silent-context-loss failure mode the project explicitly closes (CLAUDE.md §5 + §13): a dropped memory tier is invisible context loss.

Findings I'm departing from. None — brief 13 §2.3 + brief 04 are followed as written. Render-only is deliberate: runtime-side retrieval policy (when to fetch, what query, cardinality) stays on the runtime where it has identity + cost context.

Protocol additions. None — RunContext.MemoryBlocks / SkillsContext and ErrMemoryBlockUnserializable are internal Go types, not Protocol wire types. No new method, error code, config key, or CLI subcommand.


D-149 — Phase 83f: dev RunLoop driver populates the four 83-band primitives + session-scoped memory/skills fetch + fail-loud on store errors

Date: 2026-05-22 Status: Settled (shipping with Phase 83f)

Where it lives: cmd/harbor/cmd_dev_runloop.go (perTaskRunLoopDriver opts + runOne fetch path + projectMemoryBlocks / projectSkillsContext helpers); cmd/harbor/cmd_dev.go (bootDevStack opens skills.SkillStore when configured and threads MemoryStore / SkillStore / SkillsContextMax / projected PlanningHints into the driver; plannerHintsFromConfig is the YAML→Go projector); internal/config/config.go (PlannerConfig.SkillsContextMax + PlannerConfig.PlanningHints of type PlannerPlanningHintsCfg); internal/config/validate.go (validates the new fields); harbortest/devstack/devstack.go (mirror of the production driver per D-094); examples/harbor.yaml.

Decision. Phase 83f closes the §17.5 Wave 15 audit's W3/W4 finding (issue #208). Four calls are settled here.

1. Where the fetch happens — perTaskRunLoopDriver.runOne. After MarkRunning and before building the steering.RunSpec, the driver: (a) calls tasks.Get(taskCtx, taskID) to read the user-facing Query; (b) calls memory.GetLLMContext(taskCtx, sessionQ) when MemoryStore is configured; (c) calls skills.Search(taskCtx, sessionQ, task.Query, skillsContextMax) when SkillStore is configured AND task.Query != ""; (d) allocates &planner.RepairCounters{} per run; (e) projects the operator-supplied *planner.PlanningHints from config. The RunSpec.Base is then built with Query, Goal: Query, MemoryBlocks, SkillsContext, RepairCounters, PlanningHints — every field 83c/83d/83e require. The runtime side of the 83-band is now genuinely on the operator's golden path.

2. Memory + skills are session-scoped — sessionQ := {Identity: q.Identity}. Per RFC §6.6 ("Memory is session-scoped by default") + §6.7 (skills DB schema keys by (tenant, user, session) only), the fetch quadruple zeroes RunID so each run inherits the session's accumulated state rather than seeing only its own (empty) per-run slice. This matches the brief 02 §6 split — runtime owns identity-scoped fetch, planner is render-only. The MemoryStore inmem driver's internal keying currently includes RunID; the driver works around that by always handing RunID="" — a future memory-driver phase should normalise the inmem key to the session triple, but that is out of 83f's scope.

3. Fail-loud on store errors — runtime_fetch_error. Any non-nil error from tasks.Get / memory.GetLLMContext / skills.Search immediately calls tasks.MarkFailed with Code: "runtime_fetch_error" and a Message naming the failing call site, and bails BEFORE the LLM is called. No silent degradation to nil blocks; no provider cost burned on a degraded run. This matches CLAUDE.md §5 fail-loud and the §13 "silent degradation forbidden" rule. The integration test pins this with a forced MemoryStore.GetLLMContext error.

4. YAML surface is intentionally small for V1.1. planner.skills_context_max (int, default 5 via package const, validator rejects negatives) caps the Search result count. planner.planning_hints is a struct with constraints (free-form text) + preferred_tools ([]string); the richer Go-struct fields on planner.PlanningHints (ParallelGroups, DisallowTools, Budget) remain reachable through a custom planner Option but not via harbor.yaml. Empty YAML block ⇒ nil pointer projection ⇒ <planning_constraints> section omitted from the prompt. The richer surface lands in a follow-up when an operator actually needs it.

Why. Wave 15 shipped the four primitives (MemoryBlocks, SkillsContext, RepairCounters, PlanningHints) and the 83e reasoning-trace capture path, but the production dev binary never populated them — only the test code did. That made the wave's value real for library consumers building their own RunContext but invisible to operators running harbor dev. The audit (W3/W4) named this a §13 "test stubs as production defaults on operator-facing seams" failure mode read one level out: the seams exist but the production wiring doesn't fill them. 83f closes the consumer gap so the prompt-quality band's value reaches the operator on the golden path.

Findings I'm departing from. None — 83f is a pure consumer phase against already-shipped primitives. The memory keying observation (point 2) surfaces a divergence between the RFC's session-scope and the inmem driver's run-scope, documented here for a future memory-driver phase to normalise.

Protocol additions. None — 83f is internal wiring plus two operator-facing config keys. No new Protocol method, error code, wire type, or CLI subcommand.


D-150 — Phase 83g: dev binary spawns + registers MCP southbound providers at boot; fail-loud on connect/discover

Date: 2026-05-23 Status: Settled (shipping with Phase 83g)

Where it lives: cmd/harbor/cmd_dev.go (attachDevMCPServer helper + the bootDevStack per-server loop); harbortest/devstack/devstack.go (mirror per D-094); cmd/harbor-mcptest-stdio/ (the integration test's stdio MCP server fixture); test/integration/phase83g_mcp_dev_consumer_test.go.

Decision. Phase 83g closes the second consumer gap surfaced during the Phase 83f operator-validation work. The 83-band's gap (issue #208) was that the dev binary populated only Quadruple on RunContext; the MCP gap is the same shape one layer over: cfg.Tools.MCPServers[] is declared in the config schema, validated at boot, and exposed READ-ONLY by Phase 73h's Console mcp.servers.* Protocol methods, but nothing in bootDevStack calls mcpdrv.New to spawn an MCP server, open a session, discover tools, or register them into the tool catalog. Configuring an mcp_servers[] entry in harbor.yaml was silently ignored. Three calls are settled here.

1. Per-server attachment shape — attachDevMCPServer. For each cfg.Tools.MCPServers[i], the dev boot: (a) constructs mcpdrv.Config with the configured transport / URL / Command / Headers / KeepAlive + the dev token's identity (tenant=dev / user=dev / session=dev) for server-pushed mcp.resource_updated events; (b) calls mcpdrv.New(cfg) then provider.Connect(ctx); (c) calls provider.Discover(ctx) for the tool list; (d) registers each returned ToolDescriptor on the tool catalog via cat.Register(d); (e) registers the live Provider with the boot-time mcp.Registry so the Console MCP-page mount lands with no re-spawn when the surface wiring follows. The Provider's Close is appended to the dev stack's closer chain — stack teardown drains every subprocess; no orphan-process regression.

2. Fail-loud on Connect / Discover / Register errors. Any non-nil error from mcpdrv.New / provider.Connect / provider.Discover / cat.Register is returned wrapped (mcp[<name>]: <stage>: <err>) and bootDevStack calls closeAll(ctx) + returns. The dev binary exits non-zero with the operator-actionable error. No silent-degradation branch that boots without a configured MCP server. Matches the §5 / §13 / 83f convention. The decision to fail-loud rather than degrade (optional: true per-server flag, --skip-mcp-on-error CLI flag) is deliberate for V1.1 — an operator who declared an MCP server should see a clear failure if it cannot reach. Graceful-degradation knobs are a follow-up if pain accrues.

3. Console MCP-page mount is a follow-up, not part of 83g. Wiring the Registry onto the Protocol mux via mcp.NewRegistryAccessor + protocol.NewMCPSurface requires a single *auth.Provider accessor (per mcpconsole.NewOAuthAccessor's signature). The dev binary's OAuth side is a slice of per-tool-entry providers (returned from applyToolCatalogWiring), not a master *auth.Provider. Plumbing that is a small but separate phase. 83g constructs and populates the Registry so the follow-up only adds the surface mount — no re-spawning, no second source of truth. The integration test asserts on the Registry directly (via stack.MCPRegistry); operator visibility through the Console UI lands when the surface mount does.

Why. Without 83g, configuring an mcp_servers[] entry in harbor.yaml was a no-op — the operator's chat-with-MCP-tools story (the headline use case for the v1.1 cut) didn't work out of the box. The §17.5 audits to date didn't trace the MCP path end-to-end with ps-real subprocess spawning, so the gap escaped Wave 15's checkpoint. 83g is the lift-and-cover phase: same shape as 83f (primitive without dev-binary consumer), same fail-loud posture, same D-094 devstack mirror discipline.

Findings I'm departing from. None — 83g is a pure consumer phase. The decision to defer the Console MCP-page mount is documented in the phase plan's risks section, not a departure from any brief.

Protocol additions. None — 83g consumes existing cfg.Tools.MCPServers config and the already-exported mcpdrv API. No new method, error code, wire type, or CLI subcommand.


D-151 — Phase 83h: hot-reload watcher skips DB sidecars; LLM safety wrapper defaults req.Model from cfg.Model

Date: 2026-05-23 Status: Settled (shipping with Phase 83h)

Where it lives: cmd/harbor/cmd_dev_hot_reload.go (dbSidecarSuffixes, isDBSidecar, shouldTrigger); cmd/harbor/cmd_dev_hot_reload_test.go (TestShouldTrigger_SkipsDBSidecars); internal/llm/safety.go (the req.Model = c.cfg.Model default-fill in safetyClient.Complete); internal/llm/safety_test.go (TestSafety_DefaultsModelFromConfigSnapshot).

Decision. Two hard-block bugs surfaced when the v1.1 operator validation booted harbor dev against a real bifrost LLM + the scaffolded sqlite-backed state + skills drivers. Both fixes are tiny; the audit lesson is bigger.

V1 — hot-reload watcher reboot-loops on SQLite WAL/SHM/journal sidecars. Default harbor dev watches the cwd. SQLite (the scaffold-default for state.driver and skills.driver) rewrites its *.sqlite-wal / *.sqlite-shm companions on every commit. fsnotify fires CREATE/WRITE on each rewrite; the watcher triggers a drain+reboot; the rebooted binary opens SQLite, the WAL gets rewritten, repeat. ~700ms loop, dev binary unusable. Fix: extend shouldTrigger with a fixed suffix-deny list — .sqlite-wal, .sqlite-shm, .sqlite-journal, .db-wal, .db-shm, .db-journal, -journal. Operator-supplied glob ignores stay deferred to a follow-up when pain accrues; the fixed list unblocks V1.1 against the scaffold defaults.

V2 — LLM safety wrapper rejects requests with empty Model. The react planner (Phase 45 / 83a) builds llm.CompleteRequest{Messages: ...} without setting Model. The safety wrapper's validateRequest rejects with CompleteRequest.Model is empty. The mock LLM driver used in every existing dev-binary integration test does not invoke the safety wrapper's structural validation path (the mock returns canned responses), so the gap escaped the Wave 13 / 14 / 15 checkpoints. Real-bifrost reaches validateRequest and fails at step 0. Fix: in safetyClient.Complete, before validateRequest, default req.Model = c.cfg.Model when the caller did not pin one. Callers that DO pin Model (multi-model agents, posture sub-clients) keep their pin.

Audit lesson — record explicitly so the §17.5 audits to come catch these earlier. Both V1 and V2 are §13 "test stubs as production defaults on operator-facing seams" failure modes read one layer over: the integration tests used the mock LLM and didn't spawn real subprocesses + write real sqlite + send real prompts, so two real-bifrost+real-sqlite-binding bugs sat untested through Wave 14's V1 cut + Wave 15's prompt-quality band. The post-83h checkpoint audit specifically targets the harbor dev + real-bifrost end-to-end path to find whatever V4/V5/V6 are waiting after the next prompt.

Why now. Without V1 the operator's first harbor dev boot enters an infinite reboot loop after the planner persists any state; without V2 the operator's first prompt is rejected before the LLM call. Together they are the difference between "v1.1 ships a working out-of-the-box framework" and "v1.1 ships only to operators who already know to set --no-hot-reload and pin Model upstream." The fixes are 10 lines each + a unit test apiece.

Findings I'm departing from. None — both fixes follow the project's fail-loud / fill-loud posture (V1: filter inputs that produce noise; V2: fill the documented default at the documented boundary).

Protocol additions. None — both fixes are inside implementation packages and preserve existing function signatures.


D-152 — Phase 83i: runloop ToolExecutor + Catalog/Trajectory/Emit/Memory wiring closes the v1.1 operator-validation blockers

Date: 2026-05-23 Status: Settled (shipping with Phase 83i)

Where it lives: internal/runtime/steering/runloop.go (ToolExecutor interface, ErrDecisionShapeUnsupported, RunSpec.ToolExecutor, the default-case dispatch + trajectory append); cmd/harbor/cmd_dev_executor.go (the dev binary's devToolExecutor); cmd/harbor/cmd_dev_catalog_view.go (the planner-facing runtimeCatalogView); cmd/harbor/cmd_dev_runloop.go::runOne (Catalog + Trajectory + Emit + Executor + MaxSteps wiring + memory.AddTurn writeback + extractAssistantAnswer); harbortest/devstack/devstack.go (D-094 mirror).

Decision. Wave 17's operator validation against harbor dev + real bifrost + mcp-youtube hit the "64 steps, 0 tool calls" failure mode. The §17.5 audit pinned four root causes, all the same shape: primitives shipped without their production runtime consumer. 83i closes all four. Four calls are settled here.

1. steering.ToolExecutor is the runloop's dispatch seam. Phase 53 left the runloop's default: case as dead code with a comment that "a later phase wires the executor." That phase never landed; every CallTool decision was observed and discarded, and the planner was structurally a "decide-without-doing" loop. 83i ships the interface (ExecuteDecision(ctx, rc, decision) (observation, llmObservation, error)) and the runloop's dispatch path. CallParallel / SpawnTask / AwaitTask remain executor-side errors (ErrDecisionShapeUnsupported) for V1.1 — the runloop seam supports them; the dev executor declines and the planner re-plans.

2. Trajectory is appended by the runloop after every dispatched step. Without an append, the planner's prompt was identical on every iteration — the live validation showed 30 LLM calls with byte-for-byte identical (PromptTokens, CompletionTokens). 83i's runloop append uses the per-run pointer on spec.Base.Trajectory (value-copy of spec.Base per step preserves the pointer, so mutations are visible to the next iteration's rc). Each step records Action (the planner's Decision), Observation (raw runtime result), and LLMObservation (the D-026 projection — see point 3).

3. D-026 heavy-content discipline lives in the dev executor. The first successful tool call returned a 1.5 MB JSON observation. Rendered verbatim into the next prompt, the LLM safety wrapper rejected with ErrContextLeak. 83i's executor encodes the raw result with json.Marshal, checks against the configured cfg.Artifacts.HeavyOutputThresholdBytes, and on overflow stores the encoded bytes in the artifact store + returns a small summary map ({tool, size_bytes, truncated:true, preview, artifact_ref}) as llmObservation. Small results pass through as observation == llmObservation. The artifact-store path degrades to a logged-Warn truncation summary when the store is unavailable — silent context loss is §13-forbidden, the operator must see what was elided.

4. RunContext.Emit + MemoryStore.AddTurn close the observability + multi-turn affordances. The runOne builds an Emit closure that stamps the run's identity quadruple and publishes through the bus; without it the planner's planner.decision / planner.finish / planner.repair_guidance_injected events stay in the planner's head and never reach the Console / harbor inspect-runs. On FinishGoal the driver calls memory.AddTurn(taskCtx, sessionQ, ConversationTurn{user, assistant}) so the next session turn sees prior context. Best-effort: a memory.AddTurn failure logs Warn but does NOT downgrade the run's status (the planner reached FinishGoal; the operator should see Complete).

Why. Without 83i, every Wave 13/14/15/83f-h investment in the planner band is invisible to operators — the dev binary boots, accepts a prompt, runs the planner against the LLM, but the planner can never CALL anything because the catalog projection is empty, can never make progress because the trajectory never grows, and can never persist context because memory writeback never fires. The §17.5 operator-validation audit was the surface that pinned this; live validation against mcp-youtube after 83i lands shows a 2-LLM-call end-to-end (decision: CallTool → executor runs the tool → planner sees the observation → decision: Finish).

Findings I'm departing from. None — 83i is a pure consumer phase.

Protocol additions. None — the runloop seam is internal Go; the dev executor + view are package-private; no wire shape changed.


D-153 — Phase 83n: harbor init + tiered yaml + docs/CONFIG.md drift gate + opt-in built-in tools

Date: 2026-05-23 Status: Settled (shipping with Phase 83n)

Where it lives: cmd/harbor/cmd_init.go (cobra wiring + CLIError code mapping); cmd/harbor/init/init.go (harborinit.Init engine + sentinels); cmd/harbor/init/templates/default/ (the four .tmpl files); internal/tools/builtin/ (the new package — builtin.go, clock.go, text.go, builtin_test.go); internal/config/config.go (ToolsConfig.BuiltIn); internal/config/validate.go (allowedBuiltInTools mirror + KnownBuiltInTools() + validation); internal/config/doc_drift_test.go (the CI drift gate); docs/CONFIG.md (the operator-facing reference); cmd/harbor/cmd_dev.go + harbortest/devstack/devstack.go (built-in registration + D-094 mirror).

Decision. V1.1's adoption-first posture demands a real first-clone entry point. Until 83n the operator path was: read the RFC → fork a YAML example → grep godoc → guess. harbor init collapses that into one command. Four settled calls.

1. harbor init ships exactly one template (default) with a tiered yaml. REQUIRED (identity placeholders that pass validation + four commented LLM-provider example blocks for OpenRouter / Anthropic / OpenAI / NVIDIA NIM, all reachable through bifrost), COMMON KNOBS (memory / planner / tools / skills / governance — all commented with sensible defaults shown), ADVANCED (pointer to docs/CONFIG.md). The operator uncomments exactly one provider block, sets the API key env var, runs harbor validate, then harbor scaffold (83o consumes the operator-edited yaml). The choice of "tiered + commented" over "fully populated and operator-trims" is deliberate: a commented block invites editing; a populated block reads as "this is fine as-is, don't touch."

2. The framework is prescriptive about correctness, unopinionated about taste. The yaml hard-codes nothing about provider / model / reasoning_effort / budget; the four examples are equivalent starting points. The init's bias is toward "easy on-ramp" not "ideal config." This is the V1.1 mantra: prescriptive about catalog wiring, trajectory append, fail-loud (D-152's ToolExecutor seam, D-026's heavy-output discipline); unopinionated about provider / model / utility tools.

3. Built-in tools are opt-in by name through tools.built_in []string. V1.1 ships two — clock.now and text.echo. They live at internal/tools/builtin/ and register through inproc.RegisterFunc the same way an operator's custom Go function would. The yaml field is purely additive: an empty list registers nothing. The §4.4 mirror pattern (internal/config carries allowedBuiltInTools; builtin_test.go asserts the mirror) means a typo fails at harbor validate time rather than at boot, while a new built-in addition requires both surfaces to update or the mirror test fails. Phase 83o consumes the same yaml field to materialise per-built-in Go imports in the scaffolded project.

4. docs/CONFIG.md ships with a Go drift gate. Every leaf yaml path on Config{} MUST have a corresponding ### <path> heading in docs/CONFIG.md. The gate (TestConfigDoc_AllFieldsDocumented) walks the struct via reflection — the same shape walkLeaves uses for env-overrides — and fails CI when a new field lands without an entry. The test is deliberately permissive about format (any line starting with ### path satisfies the assertion, trailing text allowed). This pattern is the operator-side companion to the brief-reading rule from §16: documentation lives next to the code it documents, and CI rejects drift.

Why. Without harbor init the V1.1 framework is undiscoverable: a fresh operator clones the repo, runs harbor --help, and sees dev / scaffold / validate / console — but there is no obvious "start here." Adding init as the first surface flips the discovery: harbor init → drops a workflow-explaining README.md → operator follows it. Without docs/CONFIG.md the operator's only path to discovering knobs is to read internal/config/config.go godoc — which is the §6 "DevX is binding" failure mode brief 06 calls out. Without the drift gate, CONFIG.md rots within two phases. Without built-in tools, the smoke-test path for a fresh agent depends on the operator authoring Go code or attaching an MCP server first — neither is a zero-friction first experience.

Findings I'm departing from. None.

Protocol additions. None — harbor init is operator-side; built-in tools are catalog-side; docs/CONFIG.md is documentation.


D-154 — Phase 83o: scaffold reads operator-edited yaml + materialises per-custom-tool Go stubs + --patch preserves operator code

Date: 2026-05-23 Status: Settled (shipping with Phase 83o)

Where it lives: internal/config/config.go (ToolsConfig.Custom, CustomToolConfig); internal/config/validate.go (allowedCustomToolTypes mirror + KnownCustomToolTypes() + validateCustomTools inline in validateTools); cmd/harbor/scaffold/scaffold.go (Options.FromConfigPath, Options.Patch, Result.Skipped, ErrUpstreamConfigInvalid); cmd/harbor/scaffold/render.go (renderProject rewrite + loadUpstreamConfig + renderCustomTools fan-out + copyUpstreamYAML + the projection helpers); cmd/harbor/scaffold/templates/minimal-react/{tool.go.tmpl,tool_test.go.tmpl} (NEW); cmd/harbor/scaffold/templates/minimal-react/agent.go.tmpl (the RegisterTools function); cmd/harbor/cmd_scaffold.go (--from-config / --patch flag wiring + CodeUpstreamConfigInvalid); cmd/harbor/scaffold/scaffold_from_yaml_test.go (the engine-level coverage); cmd/harbor/cmd_scaffold_test.go (the cobra-level coverage); docs/CONFIG.md (tools.custom).

Decision. Phase 83n landed harbor init and made the operator yaml the source of truth, but harbor scaffold still rendered its own self-contained yaml and ignored what the operator just edited. 83o closes the loop. Four settled calls.

1. Scaffold reads the operator yaml by default. Explicit --from-config <path> wins; an empty flag auto-detects ./harbor.yaml in cwd; neither resolved falls through to the template-only path (so the pre-83o "scaffold without init" workflow still works for one-shot quick starts). The yaml is loaded + validated via internal/config.Load — if it doesn't pass the validator, scaffold fails closed with ErrUpstreamConfigInvalid (CLI code upstream_config_invalid). The loaded yaml is then copied VERBATIM into the output project's harbor.yaml (the operator's comments + uncommented LLM block survive; the templated harbor.yaml is the placeholder that the copy overwrites).

2. Custom tools declared in tools.custom materialise as typed Go stubs. New CustomToolConfig shape: name / description / input (map of field: type) / output (same shape). V1.1 type allowlist is intentionally flat — string / integer / number / boolean / []string — operators with complex shapes write Go by hand via inproc.RegisterFunc (the schema deriver already handles arbitrary Go types). The yaml-shorthand cap is a deliberate scope cut: every shape the scaffold supports must round-trip through a deterministic Go type, and nested objects expand the test surface faster than they pay off for V1.1. Each entry produces tools/<name>.go (typed Input/Output structs + stub Handle) + tools/<name>_test.go (round-trip happy path). The validator catches name collisions between tools.custom and tools.built_in so the catalog never sees two registrations under the same name.

3. RegisterTools(cat tools.ToolCatalog) error is the operator's wiring entry point. The generated agent.go includes one function that registers each built-in (calling builtin.Register(cat, [...])) and each custom tool (calling inproc.RegisterFunc[Input, Output] with the operator's typed Handle). The runtime does NOT auto-discover the scaffolded tools — the operator imports the generated tools/ package + calls RegisterTools from their binary's bootstrap. This stays consistent with §1 ("no magic") and §13 ("primitive-with-consumer") — the generated wiring is a consumer the operator chooses to wire, not a runtime that silently scans tools/.

Correction (v1.13.1, 2026-07-13 — reported by an external adopter integrating harbor scaffold --with-server). The "registers each built-in" half of item 3 was wrong and shipped a broken golden path: a scaffolded project whose harbor.yaml declared ANY tools.built_in entry could not boot — open server: tools/builtin: builtin: failed to register built-in tool: "clock.now": tools: duplicate tool name. The generated registrar registered the declared built-ins at the pre-policy catalog seam (with a Catalog-only builtin.RegistryContext), and the assembly THEN registered the same names from cfg.Tools.BuiltIn with the full context (SkillStore, ArtifactStore, Bus, Redactor, GrantedScopes) — the second registration hit ErrToolDuplicateName. The Catalog-only context was a second, latent defect: a stateful built-in registered that way (artifact_fetch, the skill_* set) would have been store-less. Built-ins are config-driven and the RUNTIME owns them. The generated RegisterTools now carries this module's COMPILED tools (tools.custom) and nothing else — the registrar seam's entire purpose; the yaml entry IS the built-in opt-in, with no Go wiring beside it. A built-ins-only project gets a RegisterTools that returns nil (the seam stays emitted so cmd/<name>/main.go keeps a stable shape). The decision's substance — the registrar is an explicit consumer the operator wires, the runtime never scans tools/ — is unchanged; only the false claim about WHAT it registers is corrected. The gate that would have caught it now exists: scripts/smoke/phase-160.sh's probe config declares a built-in alongside the custom tool, so the scaffold → build → BOOT leg exercises the shape (verified to FAIL against the pre-fix template).

4. --patch is the operator-edit-survival invariant. When set: the existing output dir is accepted (no ErrOutputDirExists), existing files are SKIPPED (listed under Result.Skipped), only new files (newly-declared tools, missing scaffolded files) are written. The skipped list surfaces in the human + JSON output so the operator sees what scaffold left alone. The semantics are deliberately conservative: scaffold NEVER merges, NEVER modifies an existing file. Operators who want diff-and-merge use git. The rationale: an in-place merge would invite the silent-degradation failure mode CLAUDE.md §13 forbids — a "smart" scaffold that re-emits agent.go with a new RegisterTools body could overwrite hand-edited registration calls. Refuse to touch existing files; force the operator to delete (or git-rebase) if they want a fresh re-emit.

Why. Without 83o the four-step workflow (init → edit → validate → scaffold → dev) collapses into "rewrite the yaml twice." The operator runs harbor init, edits the yaml, runs harbor scaffold, and the scaffold ships a fresh placeholder yaml that ignores everything the operator just edited. The result: operators distrust the framework and hand-author everything. 83o makes the operator's edit canonical end-to-end.

Findings I'm departing from. None.

Protocol additions. None — the scaffold flags + Options/Result fields are operator-side; the new yaml field (tools.custom) is internal config; no wire shape changed.


D-155 — Phase 83l: real-bifrost integration tests + production bug fix (snapshot drops CustomProviders / NetworkDefaults / Corrections)

Date: 2026-05-23 Status: Settled (shipping with Phase 83l)

Where it lives: test/integration/phase83l_real_bifrost_test.go (the scripted-server helper + two end-to-end tests); cmd/harbor/cmd_dev.go (three new projection helpers copyCustomProviders / copyNetworkDefaults / disableCorrectionsFromConfig + the snapshot wiring); harbortest/devstack/devstack.go (D-094 mirror).

Decision. The 83l integration test was supposed to be a defensive backfill — the audit-lesson hole-plug from D-151. The first run of TestE2E_RealBifrost_PlannerExecutorTrajectory_HappyPath immediately failed with bifrost: invalid provider: "83l-fake" (allowed native: …; declared custom: (none)). The test was correct; the production code was wrong. cmd/harbor/cmd_dev.go::bootDevStack constructed the llm.ConfigSnapshot by hand, copying only Driver / Provider / Model / APIKey / BaseURL / Timeout / ContextWindowReserve / HeavyOutputThreshold / ModelProfiles — silently dropping cfg.LLM.CustomProviders, cfg.LLM.NetworkDefaults, cfg.LLM.Corrections. The config validator accepted the operator's yaml (a custom_providers[] entry is structurally valid); llm.Open then rejected at boot with the misleading "declared custom: (none)" error because the snapshot it received carried no custom providers. Two settled calls.

1. Fix the bug in the same PR that surfaces it (CLAUDE.md §17.6). §17.6 is unambiguous: when an integration test surfaces a bug, fix it in the same PR, even when the root cause is in a previously-shipped phase's code. The fix lands as three new projection helpers (copyCustomProviders, copyNetworkDefaults, disableCorrectionsFromConfig) at the bottom of cmd_dev.go next to the existing copyModelProfiles, wired into the llm.ConfigSnapshot literal at bootDevStack line 490. D-094 requires the devstack mirror — same three helpers + same wiring at the matching tryAssemble call site. The fix is ~80 lines (helpers + mirror); the integration test surfacing it is ~400.

2. The fake-server pattern is scriptedLLMServer, not a stub LLM driver. The 83l tests stand up a real httptest.NewServer that mimics OpenAI's /v1/chat/completions endpoint, records every request, and replays a scripted JSON-response sequence. This exercises the FULL production path — the bifrost driver opens its HTTP client, the safety wrapper validates the request, the correction layer optionally rewrites, the retry layer handles a hypothetical failure, the response parses back through the same chain. A stub LLM driver (the path the mock takes) skips every one of those layers. Wire-level assertions are the value-prop: Model field present (the 83h V2 regression), the second request's prompt contains the first request's observation (the 83i trajectory regression), the request body parses as OpenAI-compat (the snapshot-projection bug surfaced exactly here). The scripted-server pattern scales to any wire-level invariant a future test needs to assert.

Why. The audit lesson from D-151 was specifically: "test stubs as production defaults on operator-facing seams" is the failure mode CLAUDE.md §13 forbids, read one layer over to integration tests — the mock LLM is a stub-as-default for the wire path. Wave 17 closeout cannot ship without at least one real-bifrost integration test, full stop. The bug surfaced here is the proof that the lesson was real: every Wave 13/14/15 audit "passed" against the mock; the moment a real wire-level assertion ran, the bug fell out within seconds.

Findings I'm departing from. None.

Protocol additions. None — the bug fix is internal Go; the integration test is operator-side coverage.


D-156 — Phase 83m: WARN-band cleanup (eight items, two-bucket parallel agent integration)

Date: 2026-05-23 Status: Settled (shipping with Phase 83m)

Where it lives: Bucket A: internal/tools/drivers/mcp/mcp.go (pushIdentity helper), cmd/harbor/cmd_dev.go (closer-chain appends + scopes wiring), cmd/harbor/cmd_dev_hot_reload.go (extended dbSidecarSuffixes), cmd/harbor/cmd_dev_runloop.go (extractSkillKeywords + grantedScopes plumb-through), internal/config/config.go + validate.go (ToolsConfig.GrantedScopes), internal/devdraft/devdraft.go (no-op Close). Bucket B: internal/llm/safety.go (cfg.Timeout-prefer fix), internal/tasks/tasks.go + internal/tasks/drivers/inprocess/inprocess.go + internal/tasks/conformancetest/conformancetest.go + internal/tasks/protocol/registry_projector.go (Task.ToolCount + IncrementToolCount + projector wire + conformance), internal/planner/planner.go + internal/planner/react/react.go (RunContext.OnReasoning callback), internal/runtime/steering/runloop.go (RunSpec.OnToolDispatched hook + per-step reasoning capture + Step.ReasoningTrace copy), cmd/harbor/cmd_dev_runloop.go + harbortest/devstack/devstack.go (wiring + D-094 mirror). Coordinator-owned: docs/CONFIG.md (### tools.granted_scopes), docs/plans/README.md, docs/glossary.md, scripts/smoke/phase-83m.sh.

Decision. Phase 83m closes the eight WARN-tier items the §17.5 audit + Wave 17 operator validation surfaced. The band ships them together because they share the failure mode "the surface works but a hygiene corner is dead." Three settled calls.

1. Two-bucket parallel-agent dispatch is the right scaling shape for ≥5 disjoint hygiene items. Each bucket maps to a package boundary so the agents touch disjoint files. Bucket A (cmd/harbor + tool drivers): items 1, 2, 3, 4, 6. Bucket B (internal/llm + tasks + steering + planner): items 5, 7, 8. Overlap is narrow + additive — cmd_dev_runloop.go and harbortest/devstack/devstack.go are touched by both, but the additions are in disjoint sections (Bucket A adds the keyword extractor + scopes plumb-through; Bucket B adds the tool-dispatched hook + reasoning callback). Cherry-pick integration auto-merges cleanly; the smoke script catches any wiring drift.

2. Item 8's design — RunContext.OnReasoning callback (option b), not a Decision field (option a). The agent picked the side-channel shape for sound reasons: (a) the Decision sum is the planner→runtime instruction contract every future planner concrete (Deterministic, Workflow, Plan-Execute, Supervisor, MultiAgent, HumanApproval) implements — adding a Reasoning field to most variants pollutes every consumer; (b) reasoning is per-step observation, not per-step instruction, and conceptually lives on the run context rather than the action; (c) D-025 holds because the runloop scopes a per-step stepReasoning local on the goroutine stack with a fresh closure each step (no planner-side mutable state, no stale leak from a prior step). The Decision sum stays sealed; the side-channel is opt-in (planners that don't populate reasoning leave Step.ReasoningTrace empty, exactly the pre-83m behavior).

3. Item 7's interface widening (TaskRegistry.IncrementToolCount) is acceptable because the conformance suite catches it. Adding a method to a long-lived registry interface is a contract change. Phase 83m accepts the cost because (a) the V1 inprocess driver is the only consumer today, (b) the internal/tasks/conformancetest suite gains a new subtest (TestIncrementToolCount) that every future driver must pass, and (c) the increment surface closes a wire field (prototypes.Task.ToolCount) that has been dead since Phase 73h — the Console renders 0 forever otherwise. The N=128 D-025 concurrent-reuse test in the conformance suite asserts atomic correctness.

Why. Each item is a small WARN individually but together they form a quality posture: identity reuse across MCP push events (item 1) was a multi-isolation footgun; the sqlite-main-file watcher (item 2) made harbor dev reboot-loop more often than expected; lifecycle closers (item 3) leaked goroutines; the FTS5 ranker (item 4) got bad recall on full-sentence queries; the per-call LLM timeout (item 5) ignored the operator's harbor.yaml choice; the GrantedScopes plumb-through (item 6) was a stubbed nil-pass that the catalog filter silently accepted; ToolCount (item 7) was a dead wire field; the reasoning trace (item 8) made the ReasoningReplay=text operator knob structurally ineffective. None block V1.1 individually; together they form the kind of "thousand-paper-cuts" backdrop that erodes operator trust.

Findings I'm departing from. None. Bucket A agent surfaced one MCP SDK behavior worth recording: the SDK's Client.callResourceUpdatedHandler does not propagate the per-call subscription ctx through to the registered handler today. The pushIdentity helper closes the latent multi-tenant cross-stamp bug at the boundary we control (preferring ctx-identity when present, falling back to cached default); a future SDK release that threads the subscription ctx through would land transparently with the helper unchanged.

Protocol additions. None — every item is internal. The prototypes.Task.ToolCount wire field existed pre-83m; this phase just produces a non-zero value for it.


D-157 — Phase 83k: make build + release pipeline rebuild Console; placeholder copy reframed for go install operators

Date: 2026-05-24 Status: Settled (shipping with Phase 83k)

Where it lives: Makefile (build target gains console-build dependency; new build-fast for iterative dev); scripts/release-build.sh (calls make console-build before go build); scripts/check-console-bundle.sh (NEW — staleness gate); .github/workflows/ci.yml (wires the gate into the frontend-e2e job); cmd/harbor/cmd_console.go (placeholder page copy refreshed); docs/plans/README.md + docs/plans/phase-83k-console-release-embed.md + docs/glossary.md + scripts/smoke/phase-83k.sh.

Decision. The operator-validation surfaced that cmd/harbor/consoledist/* is gitignored except for .gitkeep (committed in Phase 73m to keep //go:embed happy on a bare checkout). A fresh git clone + go build ./cmd/harbor produces a binary that embeds an empty Console — harbor console serves the synthesized placeholder page, not the real UI. Operators must remember to run make console-build before make build to get a working Console. The release pipeline (scripts/release-build.sh + .github/workflows/release.yml) skips the Console build too, so tagged releases would carry an empty bundle if not for a stale local artifact carrying over. Three settled calls.

1. make build rebuilds the Console first; make build-fast preserves the iterative-dev shortcut. The default make build invocation (operators' "I cloned the repo, let's run it" path) MUST produce a working binary. Adding console-build as a prereq of build makes the dev loop slower but correct. make build-fast is the iterative shortcut for changes that don't touch web/console/; documented in the Makefile comment block. The go build ./cmd/harbor invocation (Go's own canonical command) bypasses Make and is documented as "embeds whatever consoledist/ holds on disk — caveat operator." This is the smallest possible footprint for the binding behavior change: operators who type make build always get a working binary; operators who reach for go build directly get the documented caveat.

2. The release pipeline rebuilds Console before go build. scripts/release-build.sh runs make console-build in its new step 2 before the existing build (renumbered to step 3). The release artifact ALWAYS carries a fresh Console — a tagged release shipping an empty bundle is exactly the "test stubs as production defaults" failure mode CLAUDE.md §13 forbids, applied one layer over to deployment artifacts. The fix is identical in shape to D-093's make protocol-ts-gen-check discipline: generated artifacts that drift from source fail the build LOUDLY.

3. The placeholder page copy is reframed for the go install reality. The pre-83k copy said "run make console-build, then make build" — accurate for repo operators, useless for go install operators (who never cd Harbor and can't make-target). The new copy has three sections: "If you cloned the repo" (just make build now), "If you ran go install" (workaround: clone + make build; long-term: wait for tagged release with embedded Console), "Configuration" (pointers to harbor init + docs/CONFIG.md). Better visual hierarchy, dark mode, code blocks, real links. The placeholder is now an operator-onboarding surface, not a "build the thing" stub.

Why. Without 83k, every operator who tries the framework outside a repo checkout sees a broken Console. The Protocol surface still works (Bearer-token RPC against the dev binary) but the in-browser UI — the entire DX bet — is dark. The release pipeline + Makefile fix makes "I want to try Harbor" a one-command experience; the placeholder rewrite makes "I tried it and the UI is missing" a 30-second self-recovery instead of a GitHub-issue file.

Findings I'm departing from. None.

Protocol additions. None — 83k is build pipeline + operator-facing copy.


D-158 — Phase 83p: Settings page two-group layout closes the F1 add-runtime-form regression

Date: 2026-05-24 Status: Settled (shipping with Phase 83p)

Where it lives: web/console/src/lib/settings/state.svelte.ts (SETTINGS_SECTIONS entries gain a group discriminator; two new exported helpers consoleLocalSections() + runtimePostureSections()); web/console/src/routes/(console)/settings/+page.svelte (cards loop splits into two groups — console-local outside <PageState>, runtime-posture inside it); web/console/tests/settings-page.spec.ts (new test asserting the add-form is reachable in the disconnected state).

Decision. The post-83k visual walkthrough surfaced Bug F1: a fresh harbor console operator sees "Not connected to a Harbor Runtime · Attach one in Settings." on every page. Clicking through to Settings shows the SAME placeholder + a circular link. The + Add Runtime form (ConnectedRuntimesCard) existed in the codebase but the page template wrapped the WHOLE cards loop in <PageState status={settings.status}>. When the operator had no Runtime attached, settings.status === 'disconnected' and <PageState> short-circuited the children render — hiding the form behind the same placeholder it was supposed to help the operator escape.

The bug is purely structural — SettingsState.load()'s own docstring already pinned the intended split: "The Console-local sections (Connected Runtimes, Per-Runtime Auth, Appearance, …) do NOT depend on the runtime posture, only the four read-only posture cards do." The state-machine code is correct; the template ignored its docstring.

Two settled calls.

1. The split lives on the section definitions, not in a template branch. Adding group: 'console-local' | 'runtime-posture' to each SETTINGS_SECTIONS entry — plus the two consoleLocalSections() / runtimePostureSections() helpers — pushes the discrimination into the data model. The template iterates each subset once. A future section addition just sets its group field; the template need not change. This is the §4.4 seam-pattern read one layer over: the data shape carries the dependency, not the consumer.

2. The Settings page is the ONE page where <PageState> cannot wrap everything. Every other Console page can degrade to "disconnected — attach in Settings"; Settings itself MUST NOT degrade the Connected Runtimes section because the connection happens there. The template now reflects this: console-local sections render unconditionally; runtime-posture sections still route through <PageState> (preserving the per-page four-state contract D-121 mandates).

Why. Without 83p the Console is unusable for a fresh harbor console operator: the only path to attach a Runtime through the UI is gated by Settings, but Settings hides the gate behind a "not connected" placeholder. Operators must edit console_default.yaml by hand pre-boot — defeating the entire harbor console zero-config DX bet. This is exactly the "two parallel implementations of the same conceptual feature" trap §13 forbids, read one layer over: two state contracts (state-machine docstring vs template wrapper) drifted, and the operator paid the cost.

Findings I'm departing from. None.

Protocol additions. None — the fix is template + state-file shape only.


D-159 — Phase 83q: Playground sidebar entry + breadcrumb derives from the NAV constant

Date: 2026-05-24 Status: Settled (shipping with Phase 83q)

Where it lives: web/console/src/routes/(console)/+layout.svelte (the NAV constant — adds the Playground entry to the EXECUTION cluster); docs/design/console/CONVENTIONS.md §2 (rewrites the Playground bullet that explicitly declared it was NOT a sidebar entry); web/console/tests/harness.spec.ts + web/console/tests/wave13.spec.ts (cardinality bump + Playground entry assertion).

Decision. The post-83k visual walkthrough surfaced Bug F2 + Nit N1 — Playground route exists, but it was unreachable from the sidebar nav, and the page's breadcrumb showed lowercase playground instead of Playground. The root cause for both: the Console's (console)/+layout.svelte defines the NAV constant (cluster → items) and derives the breadcrumb's crumbLabel from the SAME NAV by matching the first URL segment to an item.href. Missing { label: 'Playground', href: '/playground' } in NAV closed both bugs simultaneously — F2 because the entry now renders in the sidebar, N1 because the breadcrumb lookup now returns "Playground" instead of falling through to the lowercase URL segment.

Why this matters. The fix is structurally satisfying — one entry in one constant closes both bugs. The pre-83q failure mode existed because CONVENTIONS.md §2 declared "Playground is NOT a sidebar entry" (a Phase 73n design call) without anyone updating the doc when the page actually shipped as a Console-bound surface. The decision is a 5-line code change + a doc-truth update.

Findings I'm departing from. None.

Protocol additions. None — Console-internal.


D-160 — Phase 83r: Disconnected-state hygiene + isDisconnected() predicate

Date: 2026-05-24 Status: Settled (shipping with Phase 83r)

Where it lives: web/console/src/lib/connection.ts (new isDisconnected() predicate + DISCONNECTED_TOOLTIP constant); web/console/src/lib/components/ui/PageState.svelte (vertical-centring CSS with min-height: 40vh on disconnected/empty/error branches); web/console/src/lib/components/ui/StatusChip.svelte + StateFacetChips.svelte (new desaturated prop that flips data-kind to neutral); web/console/src/lib/components/runtime/CostRollupCard.svelte (no synthetic $0.00 when disconnected); web/console/src/lib/components/live-runtime/run-composer.svelte (disabled textarea + buttons + tooltip when disconnected); 13 page Svelte files standardised on the predicate; web/console/tests/disconnected-state.spec.ts (new Playwright spec covering W1/W2/W3 + N5/N7/N8/N9/N2).

Decision. The post-83k walkthrough surfaced a cluster of disconnected-state failure modes: action buttons enabled with no Runtime (W2/W3), synthetic $0.00 cost data even when disconnected (W1), two stacked empty-state messages on Tools (N5), inconsistent KPI dashes between Agents and Tools (N4), full-state status chip colors when meaningless (N8), "— 0 artifacts" subtitles when no Runtime attached (N9), and empty-state placeholders hugging the top of the viewport instead of centring (N10). The pattern was the same: each page reached for its own disconnected check, sometimes none at all.

Two settled calls.

1. The predicate lives in connection.ts, not a new helper file. connection.ts already exposes resolveConnection() — adding isDisconnected() and DISCONNECTED_TOOLTIP next to it puts the predicate where consumers already look. Pages compose it via $derived(connection === null) locally. The shared tooltip constant prevents the five-different-strings drift the walkthrough pinned.

2. The shared <PageState> component stays the visual contract. 83r adds vertical-centring CSS to its disconnected / empty / error branches (min-height: 40vh) so the placeholder appears in the middle of the viewport, not hugging the top. Loading keeps min-height: auto so skeleton rows don't stretch.

Bundled production-bug fix (§17.6): during the 83r pass the agent surfaced a pre-83r ESLint break in web/console/src/routes/(console)/settings/+page.svelte line 94 — the placeholder const _ = [consoleLocalSections, runtimePostureSections]; I added in Phase 83p to keep the helper imports alive. ESLint flagged it as unused-variable. Fixed inline (void [...] instead of const _ = [...]) so the new svelte-check pass stays clean.

Findings I'm departing from. None.

Protocol additions. None.


Date: 2026-05-24 Status: Settled (shipping with Phase 83s)

Where it lives: Inline across 13 page Svelte files (canonical "Save view" button + "Save current as…" placeholder + removed inline Disconnected · no Runtime attached footers); web/console/src/routes/(console)/playground/[session_id]/+page.svelte (the playground detail page — only walkthrough-surfaced changes here are 83s-shaped); scripts/smoke/phase-83s.sh enumerates the 13 pages + asserts the canonical label + single-footer invariant per route.

Decision. The walkthrough N2 + N7 nits were both shape-consistent: the same concept (a saved-view save gesture, a disconnected indicator) drifted into eight different phrasings + two stacked indicators across pages. The pre-83s drift was real but not load-bearing — operators could still use the surfaces — but it eroded the visual contract CONVENTIONS.md §3 + §6 explicitly mandate.

Two settled calls.

1. The canonical pair is "Save view" (button) + "Save current as…" (input placeholder). Settled by enumerating the eight pre-83s phrasings and picking the shortest one that reads as an action (verb "Save", noun "view"). The eight drifted phrasings — "Save current as…" / "Save view as…" / "Save view" / "Save snapshot" / "Save filter" / "Save" / "Save current as…" / "Bookmark section" — are all derivatives or shortenings of the same gesture; the canonical pair is one form of the dominant shape.

2. The viewport-fixed ConnectionFooter is the single source of truth for the disconnected indicator. Every per-page inline copy of "Disconnected · no Runtime attached" is removed; pages now show ONE indicator per viewport (the fixed footer) instead of two stacked ones. The fixed footer's identity-aware shape (it already handles the disconnected, partial-scope, and full-attach states) is the canonical surface; the inline duplicates were vestigial.

Why now. N2 + N7 are tied to 83r's disconnected-state pass — both touch per-page footers + filter rows. Shipping them together avoids a second per-page edit pass for unrelated nits.

Findings I'm departing from. None.

Protocol additions. None.


D-162 — Phase 83v: Runtime CORS allowlist with default-deny posture + dev-only escape hatch

Date: 2026-05-24 Status: Settled (shipping with Phase 83v)

Where it lives: internal/protocol/transports/cors/ (new package — Wrap() middleware factory + Config shape + tests); internal/config/config.go (ServerConfig.AllowedOrigins []string + ServerConfig.CORSDevAllowAny bool); internal/config/validate.go (origin shape validation + * rejection unless dev flag); cmd/harbor/cmd_dev.go (wraps the protocol mux + SSE handler at bootDevStack); harbortest/devstack/devstack.go (D-094 mirror); test/integration/phase83v_cors_test.go (cross-origin preflight end-to-end); docs/CONFIG.md (server.allowed_origins + server.cors_dev_allow_any sections with production-security note); scripts/smoke/phase-83v.sh.

Decision. The round-2 walkthrough (Phase 83t) pinned F4: cross-origin requests from the Console (:18790) to a remote Runtime (:18080) were blocked at the browser CORS preflight stage. The repo-wide grep grep -rn 'Access-Control\|cors' --include='*.go' returned zero matches. The D-091 multi-process posture was advertised in the docs but structurally broken at the wire. 83v closes the gap with operator-configurable CORS that defaults to deny.

Three settled calls.

1. Default deny. Empty server.allowed_origins (the default) emits no CORS headers — same-origin only, which preserves the existing co-resident harbor console mode. Operators opt in by listing exact origins. No silent broadening of the wire's reachability.

2. Per-origin echo, never *, in production. The middleware echoes the request's Origin header verbatim after an exact-match check against the allowlist. Access-Control-Allow-Credentials: true (required for the Bearer-token + future cookie auth path) is incompatible with * per the CORS spec, which forces the per-origin shape. The validator rejects * in server.allowed_origins unless the operator ALSO sets the dev-only escape hatch.

3. Dev-only wildcard escape hatch is explicit + loud. server.cors_dev_allow_any: true is the single sanctioned * path, intended for iterative harbor dev workflows where the Console origin changes per browser tab. When enabled, every boot prints a stderr banner ([DEV-ONLY CORS WILDCARD — DO NOT USE IN PRODUCTION]). The validator + the banner + the explicit godoc warning on the field together prevent silent prod leakage.

Why now. The pre-83v state is a §13 forbidden-practice tripwire: two parallel postures (advertised "Console can attach any remote Runtime" + actual "blocked at preflight"). 83v makes the documented posture real and ships it in the same wave as the Console DB chicken-and-egg fix (D-163) so the multi-process surface works end-to-end in one cut.

Findings I'm departing from. None.

Protocol additions. None — this is a transport-layer change, not a method addition.


D-163 — Phase 83u: Console DB chicken-and-egg fix — attachConnection() helper writes localStorage first, DB upsert is best-effort

Date: 2026-05-24 Status: Settled (shipping with Phase 83u)

Where it lives: web/console/src/lib/connection.ts (new attachConnection(baseURL, opts) helper + AttachConnectionOptions interface); web/console/src/lib/settings/console_db.svelte.ts (addRuntime rewires through attachConnection() first + adds private #catchUpAddressBook() invoked from load()); web/console/src/lib/components/settings/ConnectedRuntimesCard.svelte (accepts addWarning + onaddsuccess props + renders info banner); web/console/src/routes/(console)/settings/+page.svelte (wires props + reload-on-success); web/console/src/lib/tests/connection.spec.ts (4 new unit tests); web/console/tests/settings-page.spec.ts (new test (h) — disconnected-boot → Add → reload → connected); scripts/smoke/phase-83u.sh.

Decision. The round-2 walkthrough pinned F3: console_db.svelte.ts::addRuntime called this.#db.runtimes.upsert(...) on a Console DB that required an active RuntimeConnection to derive its per-operator AES key. Operator without a Runtime → no connection → DB stays closed → addRuntime threw "Console DB not open — attach to a Runtime first". The form was reachable (Phase 83p) but structurally non-functional: operator could not attach a Runtime through the UI without first attaching a Runtime through the UI.

Two settled calls.

1. localStorage is the source of truth for the active connection; Console DB is the convenience address book. The Connected Runtimes form's two effects split cleanly: (a) "make the Console talk to this Runtime" → write harbor.runtime.* keys to localStorage (no DB dependency); (b) "remember this Runtime for later" → upsert into the Console DB's runtime_registry table (only works after the DB has unlocked via a connected operator). The form does (a) first, then attempts (b) and degrades to a non-fatal warning if the DB is still locked.

2. Page reload after attach; address-book catch-up on next DB load. A connection change requires a reload (every page subscribes to the connection on mount). The form triggers it explicitly. On the reloaded page the Console DB opens via the now-active connection, and #catchUpAddressBook() runs on load() — if the active connection is not yet in the address book, it's inserted with is_default: 1. The operator's first-attach gesture round-trips through to a persisted address-book entry without a second user gesture.

Why now. F3 is the load-bearing showstopper that blocks the multi-process posture from working at all. D-162 (CORS) makes the wire reachable; D-163 makes the form usable. Without both, the documented "Console attach to remote Runtime" flow doesn't work end-to-end. Shipping them in the same wave is the rule (§13 — no primitive without its consumer; here the consumer of the CORS allowlist is the Settings add-form).

Findings I'm departing from. None.

Protocol additions. None — this is a Console-local layering fix; no wire-shape change.


D-164 — Phase 83w: Friendly unknown_method info banner + mcp.servers.list wire surface

Date: 2026-05-24 Status: Settled (shipping with Phase 83w)

Where it lives:

  • F5 (Console side, Agent B): web/console/src/lib/components/ui/PageState.svelte (new 'info' branch added to PageStatus union); web/console/src/lib/protocol/errors.ts (new isUnknownMethod(err) helper); web/console/src/routes/(console)/live-runtime/+page.svelte + web/console/src/routes/(console)/playground/[session_id]/+page.svelte (special-case unknown_method on topology.snapshot → route to PageState info branch with "Topology view not available on this Runtime — planner/RunLoop runtime, not engine-graph" copy).
  • F6 (Go side, Agent A): cmd/harbor/cmd_dev.go::bootDevStack constructs the Phase 73k MCPSurface from the boot-time *mcp.Registry and threads it into transports.NewMux via transports.WithMCPSurface(mcpSurface); harbortest/devstack/devstack.go (D-094 mirror); internal/mcpconsole/mcpconsole.go (new NoOAuthAccessor type — read-only methods work, OAuth-flow methods fail loudly with ErrNoOAuthConfigured per §13 fail-loud); test/integration/phase83w_mcp_servers_list_test.go; scripts/smoke/phase-83w.sh.

Decision. The round-2 walkthrough pinned two wire-surface gaps that surfaced as scary red ERROR PageStates on the operator's most-used debugging surfaces (Live Runtime + Playground + MCP Connections). F5 was a Console-side error-mapping miss; F6 was a missing Go-side method handler. Both fit naturally in one phase because both are wire-surface coherence and both produce identical operator-visible symptoms (red error on a page that should render fine).

Two settled calls.

1. The 'info' branch is a first-class addition to PageState, not a per-page mapping. Agent B chose option (a) of the plan — add 'info' to the PageStatus union, mirroring the existing four states (disconnected/loading/error/empty/ready). Rationale: two existing call sites need the same shape today, and D-164-style "Runtime does not host this surface" cases are anticipated to recur. Adding ~12 LOC to the single async-state contract is cheaper than duplicating per-page mapping and preserves PageState as the canonical contract per CONVENTIONS.md §4. The info branch carries no Retry button — "Retry" makes no sense for a fundamentally-not-applicable surface.

2. The mcp.servers.list handler reuses the existing Phase 73k MCPSurface — no new package. The *mcp.Registry already exists at boot (Phase 83g); the wire-side MCPSurface dispatcher already exists from Phase 73k. F6 is wiring-only: construct MCPSurface from the boot-time registry + thread it into transports.NewMux. For the V1 harbor dev posture (no OAuth providers), mcpconsole.NoOAuthAccessor provides the read-only access pattern; OAuth-flow methods (start/finish/refresh) fail loudly with ErrNoOAuthConfigured rather than returning a stub. Fail-loud per §13.

Why now. Both gaps surface as red errors on operator-visible pages that worked fine in every prior walkthrough except real-data round-2. The cluster of "Runtime is healthy but the Console shows error" is the most damaging UX regression in the post-Phase-83p surface. F5 + F6 together close it.

Findings I'm departing from. None.

Protocol additions. mcp.servers.list — the read-only list shape (existing prototypes.MCPServerRow / prototypes.MCPServersListResponse). Identity-required; no new scope.


D-165 — Phase 83x: Per-page real-data layout polish + cross-stack created_at / session-row fixes

Date: 2026-05-24 Status: Settled (shipping with Phase 83x)

Where it lives:

  • Console side: web/console/src/lib/components/live-runtime/status-counter-strip.svelte (N14 "(now)" suffix + W10 status derivation); web/console/src/lib/components/tasks/KanbanBoard.svelte + web/console/src/lib/protocol/tasks.ts (W7 Complete column); web/console/src/lib/components/tools/ToolOverviewCard.svelte (N12 "In-flight (now)" relabel + N13 --size-col-reliability width token); web/console/src/lib/tokens.css (new column-width token); per-page +page.svelte files for agents (W11), artifacts (W5 grid layout), events (W9 driver-name copy), live-runtime (W10), memory (W4 ellipsis), overview (N11 "(now)" suffixes), tools (N12/N13).
  • Go side: cmd/harbor/cmd_dev_executor.go::projectForLLM (W6 created_at: time.Now().UTC() on heavy-tool artifact promotion); internal/protocol/artifacts.go::handlePut (W6 created_at: s.clock() on artifacts.put upload); cmd/harbor/cmd_dev.go::bootDevStack (W8 idempotent dev-session Open after registry construction — swallows ErrSessionAlreadyOpen).
  • Tests + smoke: web/console/tests/tasks-page.spec.ts (5-column kanban); scripts/smoke/phase-83x.sh (170-line static tripwire across all 12 items).

Decision. The round-2 walkthrough pinned 12 polish items (W4-W11 + N11-N14) — none individually a showstopper, together a "every page has a paper cut" backdrop that erodes operator trust. Two items (W6 + W8) span Console + Go because the symptom is on the Console but the root cause is in the Go-side data source. Per §17.6, fix both sides in the same PR.

Two settled calls.

1. Empty-state copy carries the operator hint, not a "fix it for you" code path. W9 (events) + W11 (agents) both surface "this Runtime is configured for a different posture; that's not a bug." Rather than auto-switching the events driver or auto-registering a synthetic agent row, the empty-state copy names the configuration knob (events.driver: durable) or the posture (synthetic-default agent). Operators learn the model rather than chasing a phantom bug.

2. The W10 status derivation reads the live status-counter strip, not the page-level PageStatus. A topology.snapshot failure pre-83x poisoned the session-detail right-rail with Status: error even though the task itself completed cleanly. 83x derives the session status from the strip's aggregate counts — Complete if any completed task is present, Running if any in-flight, etc. The page's own PageStatus (which reflects topology fetch outcome, not session state) no longer drives the rail's Status field.

Why now. Round-3 walkthrough validates the multi-process posture (D-162 + D-163 + D-164) end-to-end; 83x ensures the per-page surfaces it lands on read honestly under real data. Shipping all four phases (83u + 83v + 83w + 83x) in one wave gives round-3 a clean target.

Findings I'm departing from. None.

Protocol additions. None — W6's created_at field already exists on prototypes.Artifact; the change is to populate it.


D-166 — Round-7 F11: Playground multimodal artifact input — runtime inlines image bytes, per-MIME dispatcher routes the rest

Date: 2026-05-25 Status: Settled (shipping with round-7 F11)

Where it lives:

  • Wire types: internal/protocol/types/control.go::StartRequest.InputArtifactIDs (new []string field, omitempty); round-trip test in internal/protocol/types/types_test.go.
  • Tasks subsystem: internal/tasks/tasks.go::SpawnRequest.InputArtifactIDs + tasks.Task.InputArtifactIDs (persisted on the FSM record); internal/tasks/drivers/inprocess/inprocess.go folds the slice into spawnRequestContentHash + spawnRequestsEqual so idempotency keys still distinguish same-key/different-attachments correctly.
  • Tool descriptor: internal/tools/tools.go::Tool.HandlesMIME (new []string field) + Tool.MatchesMIME(mime string) helper supporting type/* wildcards.
  • Planner materialization layer: internal/planner/multimodal.goMaterializeInputContent(goal, []InputArtifactView, ToolCatalogView) llm.Content, the per-MIME dispatcher. internal/planner/planner.go::RunContext.InputArtifacts carries the pre-resolved views.
  • ReAct integration: internal/planner/react/prompt.go first-turn user message uses the materializer (replaces the unconditional textContent(userContent) wrap).
  • Run loop: internal/runtime/steering/runloop.go clears spec.Base.InputArtifacts after the first step so subsequent steps see an empty slice (no re-inlining of bytes across the run's planner loop).
  • Pre-fetch wiring: cmd/harbor/cmd_dev_runloop.go::perTaskRunLoopDriver.resolveInputArtifacts (reads task.InputArtifactIDs, calls ArtifactStore.GetRef for metadata + Get for image bytes); cmd/harbor/cmd_dev.go::bootDevStack plumbs the shared artStore into the driver; harbortest/devstack/devstack.go D-094 mirror.
  • Console: web/console/src/lib/protocol/client.ts::ControlNamespace.start gains inputArtifactIDs?: string[]; web/console/src/routes/(console)/playground/[session_id]/+page.svelte::buildChatClient.sendMessage plumbs the composer's chat-attach uploads through.
  • Tests: ten unit tests covering the materializer's per-MIME branches (image inline / pdf file-part / audio file-part / catch-all stub-text), nil-catalog defense, mixed-attachment ordering, empty-goal text-elision, and handles_mime Fetch.Tool population (internal/planner/multimodal_test.go); MIME-matcher test (internal/tools/tools_test.go::TestTool_MatchesMIME).

Decision. When the Playground operator uploads a file alongside a chat message, the runtime materializes the multimodal llm.Content BEFORE handing the prompt to the planner. The per-MIME dispatcher routes:

  • image/*llm.ImagePart{DataURL: data:<mime>;base64,<bytes>} — bytes inline so vision-capable providers actually see the image (Path 1 below).
  • application/pdfllm.FilePart{Artifact: &ArtifactStub{...}} — providers with native PDF (Anthropic) translate the ref; providers without get the canonical ArtifactStub-JSON text description.
  • audio/*llm.AudioPart{Artifact: &ArtifactStub{...}} — same graceful-degradation rule.
  • everything else → ArtifactStub text block on the user message — the LLM reads the stub JSON (ref + MIME + size + optional Fetch.Tool pointer) and routes to a matching tool via the catalog.

The Fetch.Tool annotation on every emitted ArtifactStub is populated from the supplied ToolCatalogView: the first tool whose HandlesMIME matches the artifact's MIME wins. Operators register audio.transcribe once with HandlesMIME: ["audio/*"] and the LLM gets an explicit "use this tool for this ref" hint — no LLM-side catalog-discovery guesswork.

Three settled calls.

1. Path 1 (runtime inlines image bytes) over Path 2 (driver-side resolution). Path 1 keeps the LLM driver layer unchanged — bifrost's existing translateImagePart already forwards ImagePart.DataURL to the provider via its native image block. Path 2 would have required every driver to grow an ArtifactStore handle. The trade-off: Path 1 violates D-026 ("no inline bytes") in spirit FOR INPUTS. The carve-out is deliberate: D-026 was written for the heavy-output flood that returning a 50MB tool result inlined as text would cause; operator-uploaded inputs are explicit, single-shot, bounded by the upload size cap, and the bytes have to reach the provider one way or the other. The safety net's materializeRequest STILL fires when an input DataURL crosses the heavy-output threshold (32KB default) — large image inputs get rewritten back to ArtifactStub form, preserving the safety contract. The Path 1 carve-out is therefore: small inputs inline (the common case); large inputs round-trip through the existing materializer pass (graceful degradation to ref-as-text).

2. The per-MIME dispatcher lives in the planner package, not in the run loop or LLM driver. Three reasons. (a) The planner is the unit that owns the prompt-assembly contract; routing a Content sum-type by MIME is a prompt concern. (b) The dispatcher is pure (MaterializeInputContent takes pre-resolved views; no I/O), so it stays inside the planner's synchronous prompt-assembly path. (c) Future planners (PlanExecute, Workflow, ...) reuse the same dispatcher; pushing it into the runloop would force per-planner duplication.

3. HandlesMIME is an opt-in descriptor field, not a registry mechanism. Operators register the tool with the MIME(s) it consumes; the materializer reads the catalog at prompt-build time. No registry / no global map / no init-side hook. Wildcards are bounded (type/* only — no full-*/*, no subtype glob) so an operator typo can't accidentally claim every MIME on the planet. Empty HandlesMIME keeps the legacy V1 behaviour: the LLM finds the binding via the catalog description, the Fetch.Tool annotation stays nil.

The mid-run user_message gap (deliberate V1.1 scope). The Round-6 F10 queue-vs-steer feature lets an operator inject a user_message mid-run. The user_message payload today carries only {message: string} — extending it to carry attachments would (a) require an addition to the steering verb's wire shape (internal/runtime/steering/taxonomy.go), (b) thread the artifact refs through ControlSignals.UserMessages (currently []string), and (c) materialize on the appropriate planner turn (NOT the first turn — the carry-over is later). All three are tractable but they touch the steering inbox semantics which the V1.1 round-7 cut deliberately leaves alone. The Console's sendMessage throws a clear error when the operator selects 'steer' with attachments — no silent degradation.

Why now. F11 was the deferred half of the round-6 Playground walkthrough (the F7 commit left a TODO multimodal marker in sendMessage). The user explicitly asked for it; the F10 queue-vs-steer feature shipped the composer's attach control surface; the architectural blocker (D-026 inline-bytes interpretation for inputs vs outputs) was the only design question, which Path 1 resolves cleanly.

Findings I'm departing from. None.

Pre-existing limitation, not caused by F11. The bifrost+OpenRouter+anthropic/claude-haiku-4.5 vision path returns HTTP 400 from the upstream provider — the existing bifrost/conformance_test.go::TestE2E_Bifrost_LiveSixProviderConformance/multimodal subtest fails identically against this build, with or without F11's planner-side changes. The F11 materialization pipeline is verified correct via the unit-test branches AND by the live CSV round-trip (operator uploads text/csv; LLM sees the ArtifactStub correctly and responds about the attached file). Image-input via OpenRouter for the specific haiku-4.5 model is a separate provider/driver issue worth its own bug filing; image-input via a different vision-capable provider should work because the request shape is provider-canonical.

Protocol additions. StartRequest.InputArtifactIDs []string (json:"input_artifact_ids,omitempty") — opt-in; text-only starts elide the field from the wire body entirely (the omitempty tag honors the V1 wire shape).


D-167 — Phase 107c: native provider tool-calling cutover for the React planner + deferred-loading meta-tools

Date: 2026-05-28 Status: Settled (shipping with Phase 107c)

Where it lives:

  • LLM wire surface: internal/llm/llm.go — new types ToolDeclaration{Name, Description, Schema json.RawMessage} and ToolCallStructured{ID, Name, Args json.RawMessage}; new fields CompleteRequest.Tools []ToolDeclaration + CompleteRequest.ParallelToolCalls bool + CompleteResponse.ToolCalls []ToolCallStructured; new ChatMessage.ToolCallID *string field that round-trips a provider call id back into the next-turn RoleTool message.
  • LLM safety: internal/llm/safety.go extends the heavy-output guard so a ToolCallStructured.Args payload above the heavy threshold trips ErrContextLeak the same way an oversize tool result would; new internal/llm/errors.go sentinels (ErrToolCallArgsTooLarge etc.) keep the failure mode named.
  • bifrost driver: internal/llm/drivers/bifrost/translate.go maps CompleteRequest.Tools + ParallelToolCalls → upstream tool block and assembles CompleteResponse.ToolCalls from the upstream tool_calls array; translate_test.go pins the bidirectional shape against scripted JSON.
  • Tools subsystem: internal/tools/tools.goLoadingMode enum (LoadingAlways default, LoadingDeferred) on Tool.Loading; CatalogFilter.LoadingModes defaults to [LoadingAlways] for the prompt-time view; internal/tools/catalog.go grows a Catalog.Search(ctx, query, tags, limit) method backed by the new SearchCache.
  • Search cache: internal/tools/drivers/searchcache/ — SQLite FTS5-backed driver (regex fallback for non-FTS5 builds) mirroring internal/skills/drivers/localdb/. Schema-migrated, fingerprint-deduped, refreshed on every catalog sync.
  • Built-in meta-tools: internal/tools/builtin/tool_search.go, tool_get.go, skill_search.go, skill_get.go, declarative_action.go (off by default; opt-in escape hatch dispatching through the existing repair.ActionParser), plus the always-loaded artifact_fetch.go for heavy-output recovery. All register through the existing builtin.Register seam (Phase 83n / D-153). Default-enabled four: tool_search, tool_get, skill_search, skill_get. Default-disabled: declarative_action.
  • Planner: internal/planner/planner.go — new per-run RunContext.DiscoveredTools []string + RunContext.PendingToolCalls []ToolCallDeferred + RunContext.OnPendingToolCalls callback (the runloop's stack-local bridge keeping AC-19's serialization fallback alive across steps without leaking onto the shared planner artifact, per D-025).
  • React planner: internal/planner/react/react.go swaps the JSON-from-Content ActionParser for a ToolCallProjector that reads resp.ToolCalls directly; the repair.ActionParser is retained but only fires through the declarative_action meta-tool. internal/planner/react/prompt.go (the 1k-LOC prompt assembler) drops <action_format>, narrows <available_tools> to {name, description} (schemas now live in req.Tools[]), and adds a <tool_discovery> section instructing the LLM about the deferred-loading two-turn cycle; prompt_test.go + testdata/golden_default_prompt.txt re-pin the rewritten shape. The reserved _finish discriminator is RETIRED from the prompt entirely — the model produces a Finish by returning Content with empty ToolCalls[].
  • Trajectory: internal/planner/trajectory/trajectory.goStep.Action (still any) now stores the structured planner.CallTool complete with the provider CallID; internal/planner/react/prompt.go's next-turn message builder projects each captured CallTool into the matched assistant-with-tool-calls + RoleTool message pair the providers expect.
  • Runloop wiring: internal/runtime/steering/runloop.go captures the new OnPendingToolCalls callback per step and writes the queue back into spec.Base so subsequent value-copy steps see the residue.
  • Config: internal/config/config.go adds ToolEntryConfig.LoadingMode (yaml: loading_mode); internal/config/validate.go rejects unknown values pre-boot; docs/CONFIG.md already carries ### tools.search_cache_dsn.
  • Bootstrap: cmd/harbor/cmd_dev.go::bootDevStack constructs the SearchCache + attaches it to the catalog; cmd/harbor/cmd_dev_executor.go wires the OnPendingToolCalls closure through the per-step driver; cmd_dev_executor_preview_test.go is the cross-driver regression gate.
  • Tests: internal/planner/react/projector_test.go (Decision mapping); internal/planner/react/integration_test.go (the AC-26 two-turn discovery cycle); internal/planner/react/concurrent_test.go (N=128 concurrent reuse against one planner under -race); internal/llm/drivers/bifrost/native_toolcall_integration_test.go (AC-28 live provider — SKIP without provider key); internal/llm/drivers/bifrost/translate_test.go; internal/tools/builtin/*_test.go for each new meta-tool.

Decision. Phase 107c is a deliberate cutover from prompt-engineered tool-calling (the brief 07 path — every Decision shape parsed out of resp.Content by repair.ActionParser) to native provider tool-calling for the React planner concrete. The LLM client now carries a structured Tools[] declaration on every turn and a structured ToolCalls[] array on every response; the React planner reads ToolCalls directly via a ToolCallProjector. The prompt sheds its <action_format> JSON-shape instruction, narrows <available_tools> to a quick-reference (the schemas live in the typed req.Tools[]), and grows a <tool_discovery> section the LLM uses to find deferred tools through four built-in meta-tools (tool_search, tool_get, skill_search, skill_get). The compatibility seam for providers without reliable native tool-calling is the optional declarative_action meta-tool — off by default, opt-in, deferred-loaded; when an operator enables it the LLM can fall back to the prompt-engineered {tool, args} shape exactly once per turn through a single deferred surface. Six settled calls.

1. Reverse brief 07's "LLM driver layer never touches tools=" principle — but only for the React planner concrete, and only at the bifrost mapping layer. Brief 07 was load-bearing for V1's uniformity guarantee: the planner emitted {tool, args} JSON, every provider that could speak JSON could be a Harbor target, and parallel tool calling worked uniformly through the runtime's CallParallel mechanism rather than per-provider tool-call wire shapes. The principle's value was a settled mapping layer that nothing else had to know about. After two waves of operator validation against the real bifrost+OpenRouter+Anthropic path, the cost side of the principle finally outweighed the benefit: every provider Harbor cares about has converged on a compatible structured tool-call shape, and the JSON-in-Content parser is brittle in exactly the ways native tool-calling solves by construction (no escape-character confusion, no fence/no-fence drift, no half-streamed-JSON edge cases, no <action_format> instructions to fight against RLHF). The reversal is targeted: only the React planner concrete adopts the native path; the Deterministic planner stays text-only (its Tools[] is nil, and AC-2's nil-short-circuit preserves its pre-107c behavior). The declarative_action escape-hatch tool preserves brief 07's parser path verbatim for the carve-out (local Llama / Mistral / weaker fine-tunes without reliable tool-calling) — the parser stays in tree, lifted into one tool's body instead of the planner's primary input shape. The cost of the reversal — uniformity is now a bifrost-layer concern rather than a planner-layer concern — is ~80 LOC of translate.go mapping plus translate_test.go's round-trip pins, paid once.

2. The CallParallel serialization fallback is the default V1.1.x behavior; the executor's parallel dispatch lands in a follow-up phase. The dev executor's ErrDecisionShapeUnsupported branch for CallParallel decisions is a documented post-V1.1 deferral (cmd/harbor/cmd_dev_executor.go:100); shipping the executor's goroutine-fanout + JoinSpec evaluation + per-branch identity propagation is a separate body of work outside this plan's scope. Phase 107c takes the defensive posture per the plan's "Critical scope constraint" section: when the LLM emits N>1 native ToolCalls in one response, the React planner emits CallTool for the FIRST call and records the rest on the new RunContext.PendingToolCalls. The next Next() step consumes the queue head before consulting the LLM again; the LLM perceives one call per turn, the runtime sees one CallTool per turn, the operator gets correct semantics with sequential dispatch. The plumbing is the OnPendingToolCalls callback — a stack-local closure the runloop captures and writes back into spec.Base (the planner's value-copy step boundary), so per-run state crosses step boundaries without ever touching the shared planner artifact. D-025 holds cleanly. When the executor's CallParallel branch lands (Phase 110z or equivalent), the operator opts in via planner.react.parallel_tool_calls: true and the planner emits the native CallParallel decision instead — single-line opt-out, no client-visible wire change.

3. Deferred loading travels through the catalog filter, not through prompt mode-switching. The plan's tools.Tool.Loading field plus CatalogFilter.LoadingModes (defaulting to [LoadingAlways] for the prompt-time view) were already declared as latent primitives in the tools package. Phase 107c wires them: the React planner builds req.Tools[] from the always-loaded subset + the always-loaded meta-tools + the per-run RunContext.DiscoveredTools; deferred tools are absent from the catalog unless the LLM names them through a tool_search result. The discovered set accumulates within one run and resets at run start (a fresh run rediscovers as needed). The two-turn discovery cycle is structural: turn N the LLM calls tool_search, turn N+1 the planner has appended the discovered tool to Tools[], the LLM calls it. Same-turn race (the LLM emits BOTH a tool_search AND a call to the tool it expects to find — provider rejects because the second tool isn't declared) is naturally guarded by the serialization fallback (only the head of PendingToolCalls dispatches per turn). The <tool_discovery> prompt section names the two-turn cycle explicitly.

4. The declarative_action escape-hatch is the only seam that preserves brief 07's parser, and it's a single deferred tool. Brief 15 sketched a two-planner-concretes carve-out (a react-native package alongside the existing react). Phase 107c collapses this to ONE concrete with one optional deferred meta-tool. The escape-hatch tool accepts a {tool, args} JSON body and dispatches through repair.ActionParser + the runtime tool executor, returning the dispatched tool's observation as if the LLM had called the tool natively. Operators with non-tool-calling providers opt the tool in via tools.built_in: [declarative_action] and the LLM discovers the structured-action shape when needed. The Decision sum (CallTool / CallParallel / SpawnTask / AwaitTask / RequestPause / Finish) is unchanged — brief 15 §6 "Decision-sum invariance" holds verbatim. The reserved discriminator _finish is RETIRED from the prompt and the projector; a model that wants to finish returns Content with empty ToolCalls[], which the projector maps to Finish{Goal, Payload: Content}. The _finish name survives ONLY inside declarative_action's body for the parser's backward compatibility with the brief-07 shape.

5. Tool results round-trip as provider-typed RoleTool messages, not as user-role text. Brief 07's prompt-engineered path rendered each prior tool observation as a user-role text block. Native tool-calling requires the provider's typed shape — an assistant message carrying the original ToolCalls[i] followed by a RoleTool message with matching ToolCallID. The React prompt builder projects each trajectory.Step whose Action is a CallTool AND whose Observation is non-nil into this pair; ChatMessage.ToolCallID *string (the new field) round-trips the id. trajectory.Step.Action (still any) carries the structured CallTool complete with CallID — no new trajectory wire-shape change, just field presence. The bifrost driver's existing translateMessages path handles the typed message shapes; the runtime emits tool.invoked events with the existing {name, args} payload sourced from the new structured CallTool (no wire-shape change to the event surface).

6. artifact_fetch is always-loaded — the LLM-edge heavy-output guard's recovery surface. D-026 plus the LLM-edge ErrContextLeak rewriter materialize heavy tool results to the artifact store and replace the LLM-facing observation with a short head-bytes preview + a positional footer naming the artifact_fetch built-in and the ref. Phase 107c registers artifact_fetch as an always-loaded built-in (LoadingMode: LoadingAlways) so operators who opt it in via tools.built_in: [artifact_fetch] get the recovery path without needing tool_search to find it. The tool takes {ref, max_bytes?} (default 64 KiB, hard cap 1 MiB), reads the artifact under the run's (tenant, user, session) scope, and returns {ref, mime, size_bytes, content, truncated}. Cross-tenant reads are rejected by the artifact store with a soft "not found" — the regression gate is internal/tools/builtin/artifact_fetch_test.go::TestArtifactFetch_CrossIdentity_RejectedByStore.

Why. Two failure modes Phase 107c closes by construction: (1) <action_format> instructions in the prompt fight against modern LLMs' RLHF for native tool-calling, and the JSON-in-Content parser path accumulated a growing pile of repair-loop salvage patterns (multi-action salvage, fence detection, bare-array decoding) that exist solely to handle the LLM's drift away from the prompt-engineered shape. Native tool-calling is one structural fix for all of those drifts. (2) The catalog scales beyond the prompt-budget ceiling. A V1.1.x operator with 50+ tools — common for the dev-loop + a couple of MCP servers + a few skills — was already at the edge where rendering every tool's full schema in every prompt either blows the token budget or forces operators to trim catalog ambition. Deferred loading + meta-tools collapse the catalog to its always-on essentials; everything else is one tool_search away. Brief 15's path B is what this phase implements.

Findings I'm departing from. Brief 07's settled "no tools= at the LLM driver layer" principle is the load-bearing reversal — the rationale is item (1) above and the carve-out is the declarative_action escape hatch (preserves brief 07's parser path for non-tool-calling providers). Brief 15's two-planner-concretes shape is the second departure — collapsed to one concrete with one optional escape-hatch meta-tool per item (4). Both departures are documented in the plan's "Brief findings incorporated" section as Phase 107c's deliberate scope.

Protocol additions. None — the LLM wire shape (Tools[], ToolCalls[], ToolCallID) lives in internal/llm/, not on the Protocol surface. The audit event payload (tool.invoked) carries unchanged {name, args} fields sourced from the new structured CallTool. The CLI / Console / Protocol method surfaces are unchanged.

Known limitation, named here. Same-turn N>1 native ToolCalls serialise through PendingToolCalls; the executor's CallParallel branch lands in a follow-up phase (110z or equivalent). Operator yamls today rely on the runtime's sequential dispatch — the prompt-engineered multi-action salvage Phase 47 surfaced is unreachable after Phase 107c (the parser no longer fires on the native path), but the salvage's downstream CallParallel emission becomes a no-op rather than a regression because the executor was already rejecting the shape. The follow-up phase adds the executor branch + flips the planner's default to native CallParallel emission; the serialization fallback becomes a single-knob opt-out.

Live-test coverage. AC-28 ships internal/llm/drivers/bifrost/native_toolcall_integration_test.go against a real provider — SKIP when no provider key. The integration test elicits one tool-call and asserts resp.ToolCalls is non-empty + resp.Content is the model's preamble (or empty). The two-turn discovery cycle is covered by AC-26's internal/planner/react/integration_test.go::TestReactPlanner_NativeToolCall_DiscoveryCycle (scripted streaming LLM). The N=128 concurrent-reuse test is internal/planner/react/concurrent_test.go::TestReactPlanner_NativeToolCall_NoCrossTalk (D-025 cross-package gate under -race).


D-168 — 85-band re-plan against the MCP 2026-07-28 release candidate: cut sampling / roots / original Tasks, slim logging, defer elicitation + conformance + Apps, add 85m for cross-cutting RC adoption

Date: 2026-05-28 Status: Settled (master plan updated; phase plan files retained as historical context for cut phases)

Trigger. The MCP Foundation published the 2026-07-28 release candidate on 2026-05-21 (final spec drops 2026-07-28; Tier-1 SDKs ship support within a 10-week RC window, ≈ late July–early August 2026). The RC deprecates three capabilities Harbor's 85-band was building operator-facing surface against (sampling, roots, logging), redesigns Tasks from an experimental core feature into a standalone extension with a new method set (tasks/list removed; new lifecycle around tools/call returning a task handle, then tasks/get / tasks/update / tasks/cancel), removes the protocol-level session handshake (initialize / initialized + Mcp-Session-Id), flips the resource-not-found error code (-32002-32602), restructures server-to-client requests (SSE elicitation replaced by InputRequiredResult with inputRequests / requestState + retry with inputResponses), and adds six authorization-hardening SEPs (iss validation per RFC 9207 / SEP-2468; DCR application_type / SEP-837; credential binding to issuer / SEP-2352; OIDC refresh-token docs / SEP-2207; scope accumulation during step-up / SEP-2350; .well-known suffix clarification / SEP-2351). The deprecations are annotation-only (functional for 12+ months) but committing operator surface to features on a 12-month death clock is bad investment; the breaking changes (sessions, headers, errors, schema, cache, trace) need a dedicated adoption phase.

Where it lives.

  • Master plan (docs/plans/README.md): Phase index (lines 152–162) updated — 85a / 85b / 85f marked Ready now (with 85b's scope ↑ for the RC auth SEPs and 85f slimmed to drop logging); 85c / 85e / 85h / 85i marked Cut; 85d / 85m marked Revisit after SDK-RC; 85g / 85j marked Revisit after RC-final. New row 85m added. Per-phase detail block (lines 1055–1072 post-edit) rewritten with the RC re-plan header, per-phase verdict + readiness, and an explicit revisit trigger on each. Cross-cutting references (numbering line 9, V1 critical-path paragraph line 191, V1 conclusion line 1177, post-V1 deferrals block line 1182) updated for consistency.
  • Phase plan files for cut phases retained: docs/plans/phase-85c-mcp-sampling-provider.md, docs/plans/phase-85e-mcp-roots-provider.md, docs/plans/phase-85h-mcp-tasks-wire-types.md, docs/plans/phase-85i-mcp-tasks-client.md are NOT deleted — they remain as historical context. The master plan's Status column is the canonical "do not implement" signal.
  • New phase plan to author: docs/plans/phase-85m-mcp-rc-2026-07-28.md — stub created from _template.md; absorbs the RC's cross-cutting breaking changes.
  • Lettering note: 85l was skipped to avoid l/I/1 ambiguity next to the existing 85i row; the new phase is 85m.

Decision. The 85-band re-shapes against the MCP 2026-07-28 RC as follows. Five settled calls.

1. Cut sampling (85c) and roots (85e) entirely — the RC's replacements are what Harbor already has. The RC deprecates sampling/createMessage; the replacement is "direct LLM provider API integration" — which is what llm.LLMClient already is. Building a CreateMessageHandler, a pause-gated review surface, modelPreferences resolution, multimodal mapping and tool-enabled sampling would ship operator-facing surface (config knobs, Console review UI, audit shapes) for a feature with a 12-month EOL. Servers needing an LLM bring their own provider per the RC's guidance. Similarly, the RC deprecates roots; the replacement is "tool parameters, resource URIs, or server configuration." 85e was scoped to ship a real operator-config-driven roots provider that replaced 85a's honest-empty stopgap — but the honest-empty advertisement is now the permanent posture, not a stopgap. Both plan files stay as historical context; neither phase implements.

2. Cut the original Tasks pair (85h + 85i) — the RC's redesign makes the 2025-11-25 hand-transcription wrong. 85h hand-transcribed the experimental 2025-11-25 Tasks surface into Go types (the same pattern as the A2A wire shapes), and 85i built the requestor's poll loop, tasks/list consumption, and input_required → elicitation composition on top. The RC moves Tasks from an experimental core feature into a standalone extension; tasks/list is removed; the new lifecycle is tools/call returns a task handle that the client drives with tasks/get / tasks/update / tasks/cancel; the input_required state collapses into the new InputRequiredResult mid-call retry pattern rather than a status transition. Hand-transcribing the old shape now locks in code that the extension SEP + Dockyard's Go port + the SDK update will all diverge from. The right move is to wait for the extension SEP to stabilize, then Dockyard's port, then SDK support — and refile Tasks as a NEW band, not as 85h/85i. Both plan files stay as historical context; neither phase implements.

3. Slim 85f to drop the logging slice. 85f bundled four small server-side features Harbor's client driver currently ignores: completions, logging, resource templates, progress. The RC deprecates logging/setLevel + notifications/message and points to stderr (stdio) or OpenTelemetry (structured) as replacements — both of which Harbor already has via slog + the telemetry stack. The other three (completions, templates, progress) are unaffected and ship as planned. The slim 85f stays Ready now against go-sdk v1.6.0.

4. Add 85m as the cross-cutting RC adoption phase. Seven items the RC introduces are cross-cutting and don't fit any existing 85-band phase:

  • Remove initialize / initialized handshake plumbing and Mcp-Session-Id header dependence; client info moves to per-request _meta.
  • Streamable HTTP: set Mcp-Method and Mcp-Name headers on every outbound request; assert server reject-on-mismatch.
  • Error code flip: every -32002 (resource-not-found) callsite → -32602 (Invalid Params).
  • Server-to-client request restructuring: server-initiated requests only issuable while server is actively processing a client request; SSE elicitation polling removed (composes with 85d's rewrite).
  • JSON Schema 2020-12 (SEP-2106): full draft support in tool / resource-template schema validation.
  • Cache directives (SEP-2549): respect ttlMs and cacheScope on list / resource reads.
  • W3C Trace Context propagation (SEP-414): wire Harbor's existing OTel traceparent / tracestate / baggage into MCP _meta.

These need go-sdk RC support; the plan can be authored against the RC SEPs now so implementation can start the day the SDK lands.

5. Defer 85d (elicitation) until SDK-RC; defer 85g (Apps) and 85j (conformance) until RC-final. 85d's form vs URL mode distinction and the secret-rejection rule survive the RC; the wire mechanic does not — SSE-based elicitation is replaced by InputRequiredResult with a requestState echo and retry. The pause/resume primitive integration is still conceptually right but the round-trip flow needs a redesign pass against the RC and SDK support. 85g (MCP Apps host) is SDK-independent (Console-side TS), but the RC's extension-stabilization policy may reshape _meta.ui.resourceUri or move Apps into a versioned extension; verify after RC-final (2026-07-28). 85j (conformance harness + scoped compliance statement) targets the RC, not 2025-11-25 — the statement's wording obligation (never "fully compliant" unqualified) survives, but the enumerated capabilities drop the cut areas and add 85m's items; the harness lands after the dependent phases ship.

Why. Two failure modes the re-plan closes by construction: (1) committing operator-facing surface (config knobs, Console UI, audit shapes, identity-scoping work) to deprecated capabilities is debt that has to be retracted within 12 months — better to skip the investment entirely than to ship-then-deprecate. (2) Hand-transcribing the 2025-11-25 Tasks shape now produces code that the extension SEP, Dockyard's port, and SDK support will all diverge from — three independent moving targets, none stable. Cut, wait, refile. The re-plan also tightens 85b's scope to include the six new auth SEPs in one PR rather than two — the auth-hardening changes compose; splitting them gains nothing.

Findings I'm departing from. Brief 14 §4's "biggest gaps" list cited sampling (#5), roots (#26), and the full Tasks surface as priority closures for the 85-band. The RC's deprecation/redesign of all three reverses brief 14's prioritisation for those specific phases — the gaps remain real today but invest into surface that will be deprecated before any operator can rely on it. Brief 14 §4's compliance-statement wording ("MCP 2025-11-25 core-compliant, with stdio + Streamable HTTP transports, OAuth for remote servers, Roots, Sampling, Elicitation, Tasks, and MCP Apps support") is superseded by an RC-target statement that drops the cut areas and adds 85m's items.

Protocol additions. None at this layer — the re-plan only re-shapes the master plan and adds a new phase stub. 85m, 85b's expanded scope, and 85d's rewrite will each carry their own Protocol implications when authored.

Known limitation, named here. The deprecated capabilities (sampling, roots, logging) remain functional for 12+ months per the RC's lifecycle policy. Harbor still recognises and responds correctly to server-initiated roots/list, sampling/createMessage, and notifications/message traffic through the existing go-sdk wiring — the re-plan's cut is "do not invest more operator-facing surface," not "rip out wire-level recognition." Phase 85a's honest-empty roots capability advertisement is the permanent posture. If a future RC removes the deprecated methods outright, a new phase will retract the recognition; that's beyond this re-plan's horizon.

Cross-references. The MCP RC blog post is at https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/. The re-plan was prompted by the 2026-05-28 review of that post against the in-flight 85-band plans. Authored alongside docs/plans/phase-85m-mcp-rc-2026-07-28.md (the new phase stub).


D-169 — Phase 107d: native parallel tool-calls — executor CallParallel branch, JoinAll-on-native, non-atomic setup, default flip

Date: 2026-05-28 Status: Settled (planned; shipping with Phase 107d)

Where it lives:

  • Dev executor: cmd/harbor/cmd_dev_executor.go — the case planner.CallParallel ErrDecisionShapeUnsupported reject (line ~101) becomes a real dispatch through a shared *internal/runtime/parallel.Executor; the merge layer applies the existing projectForLLM (D-026) per branch and assembles an aggregate observation keyed by each branch's CallID. cmd/harbor/cmd_dev.go::bootDevStack constructs the executor (the catalog already satisfies parallel.Resolver) and plumbs the new config knob.
  • Parallel executor: internal/runtime/parallel/parallel.go — a per-call non-atomic setup mode where a branch's Resolve miss / Validate failure becomes that branch's Result.Err instead of the whole-call ErrParallelBranchInvalidArgs abort. dispatchAll is reused verbatim as the native-path engine.
  • React planner: internal/planner/react/projector.go emits planner.CallParallel{Branches, Join: nil} for N>1 ToolCalls when the knob is on (the default); the 107c serialization fallback (RunContext.PendingToolCalls) is preserved on the off path. internal/planner/react/prompt.go::renderNativeStepPair gains a CallParallel case: one assistant message with N tool_calls + N RoleTool messages keyed by branch CallID. internal/planner/react/react.go adds WithParallelToolCalls(bool).
  • Config: internal/config/config.go adds planner.parallel_tool_calls as a pointer-bool (*bool, yaml parallel_tool_calls) defaulting to true when omitted. The key lives flat under planner: (alongside reasoning_replay, max_steps, max_tool_examples_per_tool), NOT nested under a planner.react: block — the plan's planner.react.parallel_tool_calls dotted notation is shorthand for "the React planner's knob"; the config surface has no react: sub-block and adding one would fragment the React knobs across two places. The *bool flows through the planner.PlannerConfig boundary so the react factory distinguishes "unset" (nil → keep the planner's true default) from an explicit false.
  • Docs: glossary updates (ParallelExecutor, JoinAll, RunContext.PendingToolCalls) + new parallel_tool_calls; skill drift on add-an-in-process-tool + drive-the-playground.

Decision. Phase 107c (D-167) cut the React planner over to native provider tool-calling but deliberately deferred the N>1 case: rather than emit planner.CallParallel, it dispatched the head tool-call and queued the tail on RunContext.PendingToolCalls, draining one per step, because the dev executor still rejected CallParallel. Phase 107d closes that carve-out. Five settled calls.

1. Reuse the existing parallel.Executor — do not build a second dispatcher. internal/runtime/parallel.Executor shipped at Phase 47 (D-056) with goroutine fanout, the AbsoluteMaxParallel cap, identity-from-ctx propagation, the per-branch Result shape, and an N≥128 -race reuse test — but it has carried zero production consumers since (it is referenced only by its own tests). Phase 107d makes the dev ToolExecutor its first consumer, closing the §13 primitive-without-consumer gap the executor has carried for ~60 phases. Building a second fanout in the dev executor would violate §13 "two parallel implementations of one feature"; the dispatcher's engine (dispatchAll) is consumed verbatim.

2. On the native path JoinKind collapses to JoinAll; the other kinds are re-scoped as programmatic-planner surface. The JoinSpec machinery (D-056) was designed for a planner that authors the parallel call and chooses a merge strategy — the pre-107c prompt-engineered React parsed a structured plan out of LLM content. Native provider tool-calling gives the model no channel to request JoinFirstSuccess / JoinN: the provider returns N tool_calls with no join semantics, and the provider wire contract makes "every tool_call_id is answered exactly once before the next assistant turn" a correctness requirement. JoinFirstSuccess and JoinN cancel the losers and return fewer results than branches — which orphans the unanswered tool_call_ids and malforms the next request. So the React projector always emits Join: nil (→ normaliseJoinJoinAll). The other kinds are NOT removed (that would violate the swappable-planner principle — RFC §1 property 3): a future Deterministic / Workflow / Graph planner that authors a CallParallel programmatically, without an LLM round-trip, is a legitimate JoinFirstSuccess consumer. dispatchFirstSuccess / dispatchN / JoinKeyed's ErrParallelInvalidJoin reject are all unchanged.

3. Native dispatch is non-atomic; atomic stays the programmatic default. D-056's atomic setup validation fails the WHOLE call if any one branch's args fail Validate, before any branch dispatches — a side-effect-safety guard for programmatic plans. That posture is wrong-shaped on the native path: the model chose N calls knowing it wanted all of them, and aborting the whole call orphans every tool_call_id. Phase 107d adds a per-call non-atomic mode where a branch's resolve/validate failure becomes that branch's Result.Err (→ one error RoleTool message the model reads and repairs next turn, exactly like a single failed CallTool), while valid branches still fan out. The atomic default is preserved byte-for-byte for existing (today test-only) callers. The branch-count cap and missing-identity reject stay fail-loud in both modes. This also simplifies the failure posture: a native branch failure is not a parallel-specific re-plan concept — it is just an error tool-result, handled identically to a single CallTool failure (superseding the pre-107c planner.go note that parallel-branch failures bypass the re-plan counters).

4. The default flips to native CallParallel; serialization survives as a single-knob opt-out. planner.react.parallel_tool_calls defaults to true (D-167 §2 promised this flip). false reverts to the 107c serialization fallback. The serialization path is NOT dead code at false: it also remains the same-turn-discovery-race guard (D-167 risk #4 — a tool_search plus a call to the not-yet-declared tool in one response must serialise so the second call lands on the turn after the tool is declared).

5. Reserved planner-control names are standalone — co-occurrence with another tool-call is ErrInvalidDecision (carried-over 107c silent tail-drop fix; AC-21). 107c's projectResponse switched on resp.ToolCalls[0].Name and translated a reserved control name (_finish / _spawn_task / _await_task) to its Finish / SpawnTask / AwaitTask Decision before the N>1 tail-queueing block — so a control meta-tool emitted alongside other tool-calls in one response silently honoured only the first and dropped the rest (no error, no event — the §13-forbidden silent-degradation pattern). The symmetric tail case was just as wrong: a reserved name in the tail got queued to PendingToolCalls and later drained as a literal CallTool{Tool:"_spawn_task"} that hit the catalog as an unknown tool. Phase 107d adds a guard in projectResponse that runs BEFORE the head switch: any response where a reserved control name co-occurs with one or more other tool-calls is rejected with a wrapped planner.ErrInvalidDecision naming the offending control tool. Reserved control meta-tools are terminal/standalone — they are not CallParallel branches (Branches is []CallTool, catalog tools) and not serialisable tail entries. The guard fires whether the reserved name is head or tail, and on BOTH the native-parallel path and the serialization opt-out (it is independent of parallel_tool_calls). Single-reserved-call cases are unchanged; all-regular N>1 still flows to CallParallel (on) or the serialization tail (off). This is bundled here, not a separate hotfix, because it is the same projector seam 107d reshapes for the N>1 → CallParallel mapping, and the two changes must agree on what a "batchable" tool-call is. A future one-turn batch-spawn, if wanted, is a dedicated _spawn_tasks meta-tool taking an array — never reserved names as CallParallel branches. (The array-meta-tool direction of this closing note is superseded by D-322: spawns batch as the typed Batch.Spawns half of a dedicated Decision shape — still never CallParallel branches.)

Why. The serialization fallback was always a defensive stop-gap (D-167 §2 named the follow-up "Phase 110z or equivalent"); it makes the LLM's single N-tool-call turn replay to the provider as N separate assistant turns — tolerated by providers but unfaithful, and it serialises genuinely-independent calls that the model wanted run concurrently. Closing the carve-out delivers the concurrency the model asked for and makes the trajectory round-trip faithful to the provider wire shape. The cost is concentrated in the trajectory→prompt round-trip (one assistant message with N tool_calls answered by N RoleTool messages) and per-branch heavy-output projection — not in the dispatch engine, which already exists.

Findings I'm departing from. Two departures from D-056, both native-path-only and both documented in the phase plan's "Findings I'm departing from": (a) atomic-setup-validation → non-atomic per-branch error disposition (call 3); (b) planner-chosen JoinKind → always-JoinAll on the native path (call 2). Neither removes the original behaviour — both re-scope it as programmatic-planner surface. brief 15 §6's "N native ToolCalls → CallParallel" mapping is implemented in full here (107c implemented the 0-and-1 cases and serialised the N case).

Protocol additions. None. CallParallel, JoinSpec, ToolCallStructured, and ChatMessage.ToolCallID all already exist; this phase wires consumers, not new wire types. The tool.invoked audit event payload is unchanged (it already carries {name, args} per branch).

Known limitation, named here. Pause mid-parallel stays fail-loud: parallel.Executor returns ErrParallelPauseUnsupported if a pause request lands mid-dispatch (the Phase 47 placeholder; Phase 50's checkpoint-atomicity was never wired because the executor had no consumer until now). Accepted for V1.1.x — dev-path branches are short tool invokes and the window is tiny. True checkpointed atomic-pause-mid-parallel is a Phase 50-extension follow-up; if an operator workload hits the limit in practice, that is the trigger to pull it forward rather than silently swallow.

Cross-references. Builds directly on D-167 (native tool-calling cutover) and D-056 (the parallel executor + JoinSpec). Phase plan: docs/plans/phase-107d-native-parallel-tool-calls.md. Informed by brief 15 §6 "Decision-sum invariance."


D-170 — Phase 107e: SpawnTask + AwaitTask dev-executor dispatch — background-task execution, synchronous-runloop join, spawn-depth cap

Date: 2026-05-28 Status: Settled (planned; shipping with Phase 107e)

Where it lives:

  • Dev executor: cmd/harbor/cmd_dev_executor.go — the case planner.SpawnTask / case planner.AwaitTask ErrDecisionShapeUnsupported rejects become real dispatch. devToolExecutor gains a tasks.TaskRegistry field + a maxSpawnDepth field (both immutable after construction — D-025). SpawnTask maps the decision into a tasks.SpawnRequest under the run's identity triple (rc.Quadruple.Identity, never a global) and calls Spawn; AwaitTask + a retain-turn SpawnTask poll Get until the task reaches a terminal status. The await/retain observation goes through the existing projectForLLM (D-026) so a heavy awaited result is artifact-stub-shaped before the LLM edge.
  • Per-task driver: cmd/harbor/cmd_dev_runloop.go — the foreground-only kind filter gains an opt-in driveBackground bool (set true by bootDevStack). With it on, the driver runs a planner sub-run for KindBackground tasks too, picked up via the same task.spawned subscription and driven through the identical MarkRunning → Run → MarkComplete/MarkFailed answer-envelope path. Recursion is bounded at the spawn site (call 4), not here.
  • Config: internal/config/config.go adds planner.absolute_max_spawn_depth int (yaml absolute_max_spawn_depth, flat under planner: — matching D-169's "no react: sub-block" call) + a SpawnDepthCap() accessor (non-positive → default 4). validate.go rejects a negative value.
  • Docs: glossary updates (SpawnTask / AwaitTask gain a dev dispatch consumer; new absolute_max_spawn_depth); skill drift on drive-the-playground.

Decision. Phase 47 (D-056) shipped the runtime machinery — the SpawnTask/AwaitTask Decision shapes, the React _spawn_task/_await_task emission (re-confirmed native by 107c/D-167), the tasks.TaskRegistry.Spawn + WatchGroup + GroupCompletion surface — but the only steering.ToolExecutor V1.1.x ships (the dev executor, 83i/D-152) rejected both shapes, and the per-task driver drove foreground tasks only (cmd_dev_runloop.go explicitly deferred background execution to "the runtime dispatch executor — a later phase"). 107e is that phase. Four settled calls.

1. SpawnTask and AwaitTask dispatch land together (§13). CLAUDE.md §13 binds the twin: "a planner that can spawn a background task but cannot join it produces orphan work the runtime cannot recover." That pinned the emission twin at Phase 47; the same logic binds the dispatch twin here. Wiring spawn without join on the dev path would orphan every spawned background task. So 107e ships both, even though the request named only SpawnTask.

2. The join is synchronous, not eager push wake-on-resolution. D-032 / the Phase 45+47 detail describe ReAct's push wake mode as the runtime re-invoking Planner.Next on GroupCompletion without an explicit AwaitTask. The steering RunLoop V1.1.x ships is synchronous — it dispatches a decision through ToolExecutor, appends one trajectory step, and re-enters the planner on the next step; there is no group-watcher re-entry path. Building eager push re-entry would be steering-runloop surgery, out of scope for a dev-executor dispatch phase. So on the dev path the realizable shapes are: a retain-turn SpawnTask blocks in-decision until the spawned task is terminal (synchronous spawn-and-join), and a non-retain-turn SpawnTask returns {task_id} immediately and is joined later by an explicit AwaitTask. Both produce concurrent background execution (background tasks run once spawned). Eager push re-entry is filed as a steering follow-up.

3. The join polls Get, not WatchGroup — because tasks.Task has no GroupID field. WatchGroup(sessionID, groupID) watches a group; AwaitTask carries a single TaskID, and the persisted Task record exposes no group id to resolve it from. The registry's own group docs (internal/tasks/groups.go) bless Get(taskID) polling as a first-class poll wake mode (cheap in-memory map lookup on the dev path). So the dev executor polls Get until the task reaches a terminal status (Complete/Failed/Cancelled), bounded by the per-step ctx, with a spawnAwaitPollInterval cadence. This is identity-safe (Get rejects cross-session/cross-tenant reads — closing the isolation case for free), needs no group-resolution, and adds no second wake mechanism (§13). A §4.3 deviation from the phase plan's original WatchGroup wording, documented in the plan's "Findings I'm departing from."

4. Background-task recursion is bounded by a spawn-depth cap at the spawn site, not by refusing to drive background tasks. The driver's original foreground-only filter existed precisely because "driving a planner against a background task would create a recursive planner loop." 107e drives background tasks (the driver — not the executor — is the right home for running a task's planner: it reuses all of 83f/83i/106's per-run wiring + the FSM bridge) but closes the recursion concern at the executor: SpawnTask reads the parent ParentTaskID-chain depth (walking Get upward, bounded) and rejects loudly — an error observation, never a silent drop (§13) — any spawn whose child would exceed planner.absolute_max_spawn_depth (default 4). The cap bounds depth, not breadth; a per-run total-spawn budget is a noted follow-up if breadth becomes a problem.

Why. Phase 47's SpawnTask/AwaitTask emitters and the background-task machinery have had no shipping binary consumer since they landed — the §13 primitive-without-consumer gap, one layer in from the D-169 parallel-executor gap. 107e closes it on the dev path: a planner that emits _spawn_task now actually spawns and runs a background sub-agent, and _await_task joins it. The synchronous-runloop join + Get-poll + spawn-depth-cap are the minimal correct wiring that fits the V1.1.x runloop without new runtime mechanisms.

Findings I'm departing from. (a) D-032 eager push wake-on-resolution → synchronous retain-turn / explicit-AwaitTask join (call 2); (b) the plan's WatchGroup join → Get-poll join, forced by Task having no GroupID field (call 3); (c) the driver's foreground-only filter → opt-in background driving with a spawn-depth cap (call 4). None removes the runtime surface — WatchGroup stays the group-fan-in mechanism for programmatic planners and a future eager-push runloop.

Protocol additions. None. SpawnTask, AwaitTask, SpawnRequest, TaskHandle, the Task FSM, and the answer-envelope TaskResult shape all already exist; this phase wires consumers, not new wire types.

Known limitations, named here. (1) Eager push wake-on-resolution is deferred (call 2) — a steering-runloop follow-up. (2) A retain-turn spawn / AwaitTask holds a planner step open until the child resolves (bounded by ctx); fine for short V1.1.x dev sub-goals, surfaces as a deadline error observation if a child never terminates — never a hang. (3) Background tasks use the in-mem driver and do not survive a harbor dev restart (Phase 87 owns durability). (4) The spawn-depth cap bounds depth, not breadth.

Cross-references. Builds on D-056 (the spawn/await emission + group surface), D-032 (wake modes), D-152 (the dev ToolExecutor seam), D-097/D-098 (the per-task driver + FSM bridge), D-167/D-169 (the native + parallel cutover this rides behind). Phase plan: docs/plans/phase-107e-spawn-await-dev-executor-dispatch.md. Informed by brief 02 (planner + steering) and brief 05 (tasks).


D-171 — Per-request session model: connection token authenticates (tenant, user); session is chosen per-request via X-Harbor-Session; create-on-first-use; boot is crash-proof

Date: 2026-05-29

Context. harbor dev minted one dev JWT whose session claim was hardcoded to "dev"; control.start folded identity from that token, so EVERY conversation wrote to session "dev". Worse, boot called sessionRegistry.Open("dev", ...): once the persisted "dev" record was Closed (idle-GC'd or operator-closed), the next boot hit ErrReopenAfterClose and crashed — restarting harbor dev against an existing state dir required deleting the state DB. There was effectively one session; you could not create new conversations or reload past ones.

Decision. The connection token is a per-backend credential, like an API key: it authenticates (tenant, user, scopes) and does NOT pin a session. The session is dynamic — chosen per-request via the X-Harbor-Session header, scoped under the token's verified (tenant, user). The token's session claim becomes a back-compat default used only when the header is absent. The multi-isolation triple (tenant, user, session) stays mandatory and enforced (CLAUDE.md §6); only the SOURCE of session changed from JWT-claim to per-request-under-token-scope.

What changed (runtime-only + one contract doc).

  • internal/protocol/auth/middleware.go — after JWT verification, the middleware re-folds the ctx identity: X-Harbor-Session (when present) REPLACES the claim's session; tenant + user stay token-verified (a header can never widen the principal). A verified token with empty tenant/user is a 500 (validator bug); a request that resolves to no session is a 401 identity_required.
  • internal/sessions — new EnsureOpen(ctx, ident) create-on-first-use entry point + a persistent per-(tenant, user) session catalog (session.catalog Kind) so a fresh process re-discovers a prior process's sessions on the read path (the StateStore has no List; the typed wrapper owns enumeration). ListSnapshots / Inspect hydrate from the catalog. EnsureOpen on a CLOSED session id fails loud (ErrReopenAfterClose) — no silent revive (RFC §6.9).
  • internal/protocolSessionEnsurer seam + WithSessionEnsurer option; dispatchStart calls it so a start on a not-yet-existing session materialises its row before spawning the task.
  • cmd/harbor/cmd_dev.goremoved the boot-time Open("dev") (the crash). The registry is constructed alongside the ControlSurface and wired with the ensurer. harbor dev now boots clean against an existing state dir regardless of session state.
  • harbortest/devstack — mirrors the production wiring (D-094): registry + ensurer + sessions.* routes.

Tests. Restart-resilience (boot twice over the same SQLite dir, second boot healthy + sessions re-discovered); multi-session isolation under one token (N concurrent sessions, no cross-talk, -race); create-on-first-use; closed-session rejection; per-request session override in the auth middleware (header overrides claim; header cannot widen tenant/user). Integration: test/integration/session_model_d171_test.go + the updated TestE2E_Phase72_BodyVsTokenIdentityMismatch (now asserts the per-request-session contract).

RFC delta flagged for coordinator audit. RFC §8 / CLAUDE.md §6/§8 describe identity as flowing "via JWT." This decision sources tenant+user+scopes from the JWT (unchanged) but sources session per-request (header), validated under the token's (tenant, user). The isolation triple and fail-closed posture are unchanged; only the session SOURCE moves. Proposed wording: "The connection credential carries (tenant, user, scopes); the session is selected per-request within the credential's (tenant, user) scope and is never client-widenable." Shipped in the dev posture now; flagged here for an RFC-text reconciliation PR.

Known limitation. sessions.list / sessions.inspect survive restart (persistent catalog + StateStore-backed records), but the task registry is in-memory and not rehydrated on boot, so tasks.list for a pre-restart session returns empty after a restart (the session row reloads; its task rows do not). Full task durability is a separate post-D-171 workstream. Documented in docs/notes/session-model-contract.md.

Cross-references. Builds on D-082 (Phase 61 auth middleware + ctx-first identity), D-122 (sessions.* Protocol surface), D-108 (SessionLister), RFC §6.9 (session lifecycle / reopen-after-close), RFC §8 (Protocol auth). Contract doc: docs/notes/session-model-contract.md.


D-172 — Deprecate Phase 85g; ship MCP Apps as the 109a–c wave under V1.1.x, scheduled right after Phase 108

Date: 2026-05-29

Context. Phase 85g ("MCP Apps host") sat in the post-V1 85-band with status "Revisit after RC-final (2026-07-28)" on the premise that MCP Apps was experimental in the 2025-11-25 spec and the RC might reshape _meta.ui.resourceUri or move Apps into a versioned extension. Two facts overturn that premise: (1) MCP Apps is already a stable, independently-versioned extension (io.modelcontextprotocol/ui, the ext-apps repo) — it is NOT gated on the July RC and the RC does not change it; (2) the extension ships an official, framework-agnostic host bridge (@modelcontextprotocol/ext-apps AppBridge), so the single largest risk the 85g plan carried — hand-rolling the postMessage JSON-RPC dialect, the ui/initialize handshake, lifecycle, and message validation — disappears (we consume it, we do not author it). A code audit also found 85g's "Apps is purely Console-side; the runtime driver is unchanged" non-goal to be factually wrong: the MCP driver does not parse _meta.ui.resourceUri (content.go has no _meta slot), tool.completed carries no result content, and the runtime's ReadResource is not exposed on the Protocol — so there is real runtime + Protocol work before any Console renderer can fetch a ui:// resource.

Decision. Deprecate Phase 85g (plan file kept as historical context, marked deprecated) and supersede it with a three-phase "MCP Apps host" wave under V1.1.x, scheduled immediately after Phase 108:

  • 109a — MCP Apps runtime + Protocol surface (internal/tools/drivers/mcp + internal/protocol + cmd/harbor): parse _meta.ui.resourceUri, recognise ui:// resources, project the app reference (resourceUri + negotiated DisplayMode + RawHTMLTrusted) onto the tool-result Protocol surface, add the mcp.servers.read_resource method (identity-scoped, D-026 heavy-content aware), negotiate DisplayModes from the server's io.modelcontextprotocol/ui capability (replacing the static registry.go placeholder), and add an app-initiated-tool-call proxy method that routes through the existing approval/OAuth/identity tool-safety path.
  • 109b — Console MCP Apps host (web/console): the sandboxed-iframe renderer in the shared chat module, the official AppBridge in manual-handler mode (see D-173), and the inline DisplayMode. Consumes 109a's surface — this is the §13 same-wave consumer for 109a's primitives.
  • 109c — MCP Apps DisplayMode layout (web/console): the Playground page-level layout state machine for fullscreen (app replaces chat + composer; multi-tab) and pip (50/50 resizable split, right rail hidden by default + toggle). inline already shipped in 109b.

The wave honours the inline-first incremental cut: 109a+109b prove the bridge + proxy end-to-end with inline rendering; 109c adds the heavier fullscreen/pip layout.

Numbering note. The wave claims integers 109a/b/c as the "MCP Apps host" band, executing right after 108. The 14-round page-by-page visual-polish series that Phase 108 opens continues from the next free integer after this band (it is not yet numbered beyond 108); this band does not displace it, it precedes it in execution order.

Dependency prerequisite (binding). 109b adds @modelcontextprotocol/ext-apps + its peer @modelcontextprotocol/sdk to web/console. Per CLAUDE.md §13 / §16 this is a dependency addition requiring an RFC §10 companion update before/with the phase. These are framework-agnostic TypeScript (core + app-bridge entry points only, never the /react entry), so they are not the forbidden React/Vue surface — but the RFC sign-off is still required and is named as a prerequisite risk in the 109b plan.

Cross-references. Supersedes the 85g detail block + plan file. Builds on D-062 (DisplayMode + Live-Runtime ≠ Sessions), D-091 (shared chat module + Console deployment posture), D-093 (protocol.ts generated), D-026 (context-window safety net), D-120/D-121 (Console renderer registry + design-system conventions). Paired with D-173 (AppBridge manual-handler mode). Plans: docs/plans/phase-109a-mcp-apps-runtime-protocol.md, phase-109b-console-mcp-apps-host.md, phase-109c-mcp-apps-displaymode-layout.md. Briefs: 14 (MCP compliance), 11 (Console/playground), 12 (Console deployment).


D-173 — The MCP Apps host integrates the official AppBridge in manual-handler mode; every app→host call is Protocol-proxied, never a direct MCP connection

Date: 2026-05-29

Context. The official @modelcontextprotocol/ext-apps AppBridge offers two integration modes: (1) auto-forward, where the bridge wraps a live MCP Client and proxies app requests straight to the MCP server; (2) manual-handler, where the host registers handlers (oncalltool, onreadresource, onlistresources, onlisttools, onrequestdisplaymode, …) and wires each itself. Auto-forward is the natural fit for a host that is itself an MCP client. The Harbor Console is not an MCP client — it is a Protocol client of the Harbor Runtime (CLAUDE.md §4.5), and the Runtime owns the MCP southbound connection, the (tenant, user, session) isolation boundary, audit redaction, and the unified pause/approval/OAuth tool-safety gates.

Decision. The Harbor MCP Apps host MUST integrate the AppBridge in manual-handler mode only. Every app→host request — tool call, resource read, resource/prompt list, display-mode change — is wired to the injected Harbor ProtocolClient (the 109a methods) → Runtime → MCP southbound. The Console never opens a direct MCP transport and never lets the AppBridge wrap an MCP Client. Concretely:

  • An app-initiated tools/call is routed to 109a's app-tool-call proxy, which enters the SAME identity + approval-gate (Phase 31) + tool-side-OAuth (Phase 30) path a planner-initiated call uses. An app call to a gated tool parks on the unified pause primitive exactly as a planner call does — no bypass.
  • An app-initiated resources/read is routed to mcp.servers.read_resource, scoped to the request identity triple, D-026 heavy-content aware.
  • postMessage origin validation is mandatory: the host accepts messages only from the expected iframe; a foreign-origin or malformed message is rejected, not executed.
  • The iframe sandbox is set with no allow-same-origin unless the projected RawHTMLTrusted state explicitly permits; strict CSP; no parent-DOM / cookie / localStorage access.

Why. If the AppBridge opened its own MCP connection (auto-forward), an in-iframe app could call tools and read resources outside the runtime's identity scope, audit redaction, and approval/OAuth gates — a direct violation of CLAUDE.md §6 (multi-isolation), §7 (security), and §13 (Console reading runtime internals / bypassing the unified pause primitive). Manual-handler mode makes the Protocol the only path, so the app is structurally confined to what the operator's (tenant, user, session) may already do. The 109b test suite asserts the Console opens no direct MCP transport and that an app call to a gated tool still parks.

Cross-references. Implements the security posture of D-172's 109b. Builds on D-091 (shared chat module — injected ProtocolClient, never a singleton), D-062 (DisplayMode), the unified pause/resume primitive (Phase 50), tool-side OAuth (Phase 30 / D-083), tool-side approval (Phase 31 / D-086). CLAUDE.md §4.5, §6, §7, §13. Plan: docs/plans/phase-109b-console-mcp-apps-host.md.


D-174 — Durable memory strategies: the SQL memory drivers delegate to the shared strategy executors; Summarizer threads through memory.Open

Date: 2026-05-30

Context. Phases 24 (memory strategies) and 25 (SQLite/Postgres memory drivers) both shipped, but their intersection did not: the truncation and rolling_summary strategies were only ever implemented in the inmem driver. The SQLite + Postgres memory drivers implement strategy=none only and return ErrStrategyNotImplemented for the rest. And the registry factory memory.Open has no Summarizer in its Deps, so harbor dev special-cases rolling_summary with a direct inmem.New(...) call and rejects every non-inmem driver at boot — the "hardwiring for devs" an operator hit when asking for durable rolling-summary memory. Net effect: durable memory with real recall did not exist; only inmem had the strategies.

Key architectural finding. The strategy algorithms are NOT in the inmem driver — they live in a driver-agnostic internal/memory/strategy/ executor package that persists through an injected state.StateStore. The inmem driver is a thin shell delegating to the executor. So the fix is NOT to reimplement truncation/rolling_summary in SQL — it is to make the SQL drivers delegate to the SAME executors with their injected state.StateStore, exactly like inmem. Durability then rides on the StateStore writes (a SQL StateStore → durable across restart, proven by a reopen-rehydration test).

Decision (Phase 25a).

  • Add Summarizer memory.Summarizer to memory.Deps; memory.Open validates it (required for rolling_summary, on every driver) and routes it to the driver factory → the executor Deps.
  • The SQLite + Postgres memory drivers delegate to strategy.StrategyExecutor (using their state.StateStore dep), gaining all three strategies; the ErrStrategyNotImplemented rejections and the Rejects*Strategy guard tests are removed.
  • cmd/harbor/cmd_dev.go collapses to a single memory.Open(ctx, cfg, Deps{State, Bus, Summarizer}) call; the rolling_summary-only-inmem error is deleted. The summariser still defaults to the agent's configured LLM (llmsummarizer.New(llmClient)) — no separate summariser model, no special-case.
  • Fail-loud preserved (CLAUDE.md §13): rolling_summary without a Summarizer errors at memory.Open on all drivers; the registry default is NEVER a stub summariser.
  • The memory conformance suite runs {none, truncation, rolling_summary} × {inmem, sqlite, postgres}. Rolling-summary snapshots keep the summary because Snapshot/Restore go through the executor's own Summary-bearing record (strategy.memoryStateRecord{Strategy, Turns, Summary}) on every driver; the exported memory.Record (turns-only, no Summary) is unchanged and remains a Console-facing turns projection — it is no longer on the persistence path, so nothing drops the summary.

Cross-references. Completes Phase 24 (strategies) × Phase 25 (SQL drivers). Builds on Phases 15/16 (SQLite/Postgres StateStore — the durable backing), the §13 primitive-with-consumer rule (Deps.Summarizer + its cmd_dev consumer land together), brief 02 (fail-loud memory), brief 13 (memory injection / recall). Plan: docs/plans/phase-25a-durable-memory-strategies.md. The dev binary's prior special-case it replaces is the one referenced by docs/plans/phase-25-memory-drivers.md.


D-175 — Per-MCP-server + per-tool tool-policy config (policy: / tool_policies: in YAML); projection via a cycle-free ProjectedToolPolicy

Date: 2026-05-30

Context. Tool retry/timeout was the hardcoded tools.DefaultPolicy() (30 s per-attempt deadline, 4 total attempts) with NO operator knob — MCPServerConfig had no policy field. A slow/throttled tool (a YouTube metadata call over uvx mcp-youtube → yt-dlp) burned ~4×30 s = ~128 s before failing, and the operator could not tune it. The MCP driver already carried an unused per-server DefaultPolicy slot (mcp.go) but it was never wired from config.

Decision. Phase 26b exposes the policy as operator YAML on each MCP server: a policy: { max_attempts, timeout_ms, retry_on, backoff_* } block (per-server default) plus a tool_policies: { <tool-name>: { … } } map (per-tool overrides). The YAML uses max_attempts = TOTAL attempts including the first (projected to tools.ToolPolicy.MaxRetries = max_attempts - 1), because operators think in total attempts, not retries. Per-field zero-value fall-through is preserved (a policy: that sets only timeout_ms keeps the default attempt count) — the projection never substitutes a default itself; tools.ToolPolicy.resolved() does at dispatch.

Two implementation subtleties (settled).

  • Import cycle → ProjectedToolPolicy. internal/config cannot import internal/tools (tools → events → config cycle). So the single config→policy interpretation seam, config.ToolPolicyConfig.ToToolPolicy(), returns a cycle-free primitive image config.ProjectedToolPolicy; the binary entry point (cmd/harbor) does the trivial primitive→tools.ToolPolicy copy. The tools.ToolPolicy struct stays the single definition (CLAUDE.md §13); there is exactly ONE interpreter of the operator fields. This is a §4.3 deviation from the plan (which placed the projection's return type as tools.ToolPolicy); justified by the hard cycle.
  • max_attempts: 1 needs an explicit-empty RetryOn. Because resolved() treats a zero MaxRetries on an otherwise-set policy as "inherit the default 3 retries", MaxRetries: 0 alone does NOT pin a single attempt. The policy shell reads an EXPLICIT empty (non-nil) RetryOn as "retry on nothing". So when the operator asks for max_attempts: 1 and names no retry_on, the projection sets a RetryOnEmpty flag and cmd/harbor materialises an empty non-nil RetryOn — making one attempt mean one attempt.

Tests. Projection off-by-one + per-field fall-through + retry_on mapping + unknown-class rejection; MCP integration: a per-tool max_attempts: 1 override makes exactly one attempt while a sibling tool uses the server default of four; concurrent-reuse (100 concurrent calls across two differently-policied tools, no cross-bleed, goroutine baseline); a "no policy → DefaultPolicy" regression. go test -race ./internal/config/... ./internal/tools/drivers/mcp/... green.

Cross-references. Builds on D-024 (tools.ToolPolicy + RunWithPolicy), the §4.4 MCP seam (Phase 28). Validation in internal/config/validate.go; example in examples/harbor.yaml; mcp/tools operator skills updated (§18). Plan: docs/plans/phase-26b-per-source-tool-policy-config.md.


D-176 — Session artifact manifest: the run loop injects a read-only <session_artifacts> block each turn so the planner stays aware of artifacts across turns

Date: 2026-05-30

Context. A user upload, or a tool result materialised above the heavy-output threshold (D-026), becomes a session-scoped artifact the model can read via the artifact_fetch builtin. But the model learns the artifact's ref ONLY on the turn it is created (from the input ArtifactStub or the heavy-result summary). On the next turn the ref is gone from context, so the model cannot iterate on an uploaded file or a prior tool output even though artifact_fetch already resolves session-scoped (the artifact is still readable — the model just doesn't know it exists). A second, latent issue: artifact provenance is keyed inconsistently — uploads set Source["source"]="user_upload", but tool/flow artifacts record the producer under Source["tool"]/Source["producer"] and leave the canonical "source" blank, so artifacts.list (and the Console Artifacts page) show blank source for them.

Decision (Phase 107f). Each planner turn, the run loop lists the session's artifacts (ArtifactStore.List scoped to (tenant, user, session) — already in scope) and pre-resolves a metadata-only manifest onto a new planner.RunContext.SessionArtifacts []ArtifactManifestEntry field (the D-166 pattern — the planner does no I/O, reads rc only). The ReAct planner renders a read-only <session_artifacts> system block (one line per artifact: ref · filename (mime, size) · provenance) with the same UNTRUSTED-metadata anti-injection framing the memory blocks use, instructing the model it may artifact_fetch <ref> to read/iterate on any of them. Empty session → NO block (no fabricated rows). Capped at 20 newest-first with an explicit +K more (use artifact_fetch by ref) line — never a silent truncation. A List error fails soft: a logged Warn and NO manifest that turn — never a fabricated or partial one (CLAUDE.md §5).

Provenance canonicalisation (the latent-bug fix). The dev tool-executor stamps Source["source"]="tool" and the flow catalog Source["source"]="flow", in addition to the producer/tool name. internal/protocol/artifacts.go::projectRow resolves the source discriminator from an else-chain ("source""tool""producer"/"flow") so even artifacts created before this phase project a correct non-blank source. The closed types.ArtifactSource enum is NOT extended: "tool" is an existing member; "flow" maps to ArtifactSourceSystem (a flow run is runtime-produced); the richer "flow: <name>" string surfaces only in the manifest provenance, never on the wire enum.

Shared builder + parity. planner.BuildArtifactManifest + planner.ResolveProvenance are the single source the run loop AND harbortest/devstack both call, so production and the harness cannot diverge (§17.6). BuildArtifactManifest imposes a deterministic newest-first (created_at desc, ID tiebreak) order because ArtifactStore.List order is interface-unspecified — keeping the prompt prefix stable across turns.

Tests. Provenance resolver table; manifest ordering + cap + empty; the read-only render framing + the artifact_fetch instruction; the Protocol source projection (a tool artifact no longer projects blank); the run-loop build with a prior-turn artifact + a user upload; identity scoping (session A's artifacts never appear for session B); the fail-soft List-error path. go test -race ./internal/planner/... ./internal/protocol/... ./cmd/harbor/... ./harbortest/... green.

Cross-references. Builds on Phases 17–19 (Artifacts + ArtifactStore.List), 33 (multimodal upload), 107c (the artifact_fetch meta-tool + the heavy-result ArtifactStub), D-026 (heavy-content routing — the manifest is metadata-only, never inlines content), D-166 (run-loop pre-resolution of RunContext inputs), brief 13 (read-only injected prompt blocks + anti-injection framing). The reserved memory.ConversationTurn.ArtifactsShown/ArtifactsHiddenRefs fields (brief 04) are NOT wired — a future "new-since-last-turn" delta optimisation. Plan: docs/plans/phase-107f-session-artifact-manifest.md.


D-177 — Live Runtime reframed to a single-runtime capability-adaptive cockpit (supersedes the topology-first composition of D-126)

Date: 2026-06-01

Context. D-126 composed the Live Runtime page topology-first: the engine graph is the hero, everything else is trim. But the topology.snapshot surface exists only on engine-graph runtimes; the dominant V1 shape is planner/RunLoop (the harbor dev dev posture, most scaffolded agents), which returns unknown_method (D-164). So on the common runtime the page's hero is the honest "topology not available" banner and the remaining surface (steer-a-run + an event table) duplicates — and underperforms — the Playground. After 108d shipped, the operator (2026-06-01) judged the page low-value and Playground-overlapping, and its negative space / lack of viewport discipline confirmed the framing was wrong, not just the polish.

Decision (Phase 108e). Reframe Live Runtime as the single-runtime operations cockpit — the Overview(fleet) → runtime drill-down, one runtime selected at a time — whose composition is a pure function of the runtime's advertised runtime.info capabilities (Phase 84a). A declarative capability→panel registry (web/console/src/lib/live-runtime/panels.ts::resolvePanels) yields an always-present spine (runtime posture · activity counters · needs-attention pauses/approvals · live event stream · active sessions) plus capability-gated panels (cost/governance, health, topology, and future multi-agent / workflow / distributed shapes — additive, no page rebuild). Topology becomes ONE gated panel, not the spine; on a planner runtime it is absent or collapsed, never an empty hero. The free-floating Start/Redirect/Inject/User-message composer is REMOVED (it duplicated the Playground, D-062); run-level steering is a drill into a session → Playground.

Supersedes. The composition half of D-126 (the topology-first spine + the page-local steering composer). D-126's per-datum data-source map and the "no Console shadow store" rule (D-061) stay intact and are reused. D-164 (honest unknown_method info state) is preserved and generalised — from "hide topology" to "gate every capability-conditional panel." D-062 (Live Runtime ≠ Sessions; chat is one panel) is reinforced, not changed.

No fabrication (CLAUDE.md §13). A gated-absent panel renders an honest "this runtime does not advertise <X>" state, never synthetic data. Node run-state (the topology legend counts + failed-node styling) stays Console-derived from the live event stream — the Protocol projection carries no per-node state — so on a runtime that emits none the legend reads zeros and nothing is styled failed.

Layout bar. The reframe carries an explicit layout-fidelity acceptance gate the 108d build missed: viewport-locked (no full-page scroll; only inner regions scroll), shared baseline grid, full-bleed, deliberate negative space — validated in the EMPTY/info state (what dev runtimes render), not only the populated one.

Tests. panels.test.ts (the pure resolver across planner / engine+posture / unknown-capability inputs); the carried-forward 108d topology-adapter.test.ts (structural graph render incl. a failed node); a rebuilt live-runtime-page.spec.ts (cockpit hydration with zero console errors, the capability matrix, scope-gated intervention verbs, disconnected→/settings). scripts/smoke/phase-108e.sh (static) guards capability-driven composition, topology-gating, and the composer/chat removal.

Cross-references. Supersedes the composition of D-126; preserves D-164, D-062, D-061, D-066. Builds on Phase 84a (runtime.info capabilities) and Phase 108d (the reused components + topology adapter + capability probe). Consumes-as-available 72f (runtime health), 72g (governance/llm posture), the 73-cluster (sessions.list/sessions.inspect, tasks.get, artifacts.list), 74 (topology.snapshot) — each honest-gated when absent. RFC §7, §7.1, §6.3, §6.13. Plan: docs/plans/phase-108e-live-runtime-capability-cockpit.md.


D-178 — Console Settings reframed to a calm sub-nav + single-section layout (supersedes the Phase 73m / D-129 paginated-cards + saved-views + detail-rail composition)

Date: 2026-06-01

Context. D-129 (Phase 73m) shipped the Settings page with three navigation models running at once — a left sub-nav rail AND scroll-to-anchor AND a 6-per-page paginator over the 12 section cards — plus a top FilterBar with a section-search input, saved-view chips, a "Bookmark section" button, a right detail rail ("Active section / Runtime / LLM mode"), and a page-level runtimes DataTable duplicating the Connected Runtimes card. The result was busy and over-engineered: the page's own spec (page-settings.md §4) had always prescribed the calm "section-nav rail + one section's content at a time" model, and brief 11 §"Settings view" describes exactly that. After 108c (Overview) and 108d/108e (Live Runtime) landed the carded .panel.card vocabulary, the operator (2026-06-01) judged the Settings page the last over-built surface and approved the simplification.

Decision (Phase 108f). Rebuild the page to a two-pane flex: the <SubNavRail> on the left (lightly grouped — Console-local sections, a hairline divider + a "Runtime" sub-heading, then the read-only runtime-posture sections) and a .section-pane on the right that renders ONLY the active section (default connected-runtimes). Each active section is a carded <section class="panel card"> with an <h2 class="panel-title"> reading the section label, copying the Overview page's (108c) vocabulary — tokens only, Svelte 5 runes (D-092). All of the 73m cruft is removed: FilterBar, saved-view chips, the Bookmark-section button, the detail rail, the paginator, the duplicate runtimes DataTable, and scroll-to-anchor. A single section is always in view, so the page is calm and viewport-friendly (the rail is sticky; the right pane scrolls internally for a long section like Keybindings — the chrome never full-page-scrolls).

Supersedes. The composition half of D-129 — the paginated-cards layout, the FilterBar + saved-view chips + Bookmark-section button, the detail rail, and the page-level DataTable. The section CARD components, the state module (state.svelte.ts), and the Console DB controller (console_db.svelte.ts) are unchanged; the auth.rotate_token method and its D-066 admin gating are untouched.

Preserved. D-158 (the console-local / runtime-posture split) is kept per active section — a console-local section renders DIRECTLY inside a settings-cards-console-local wrapper (works disconnected, the operator's only path to attach a runtime), while a runtime-posture section renders inside <PageState> inside a settings-cards-runtime-posture wrapper. D-061 (Console DB local-only, never a shadow store for runtime entities) and D-066 (rotate-token admin gating) are reinforced, not changed. $lib/settings/saved_views.svelte.ts is left in place (merely no longer imported by the page) to avoid breaking other refs.

No fabrication (CLAUDE.md §13). The single-section model removes parallel navigation affordances rather than adding any; nothing synthetic is introduced. Posture sections still render each card's honest "unavailable" state from a null when a posture read fails, and the disconnected branch shows the standard <PageState> placeholder.

Tests. A rebuilt web/console/tests/settings-page.spec.ts (hydration; sub-nav → active-section heading reads "About"; the default-section add-runtime round-trip; rotate-token disabled-without-admin / enabled-with-admin after clicking the per-runtime-auth entry; the conditional mock-mode banner after clicking the llm-posture entry; the disconnected shell; the D-158 disconnected attach path — console-local renders + the add form is reachable, then a posture section shows page-state-disconnected; the 83u disconnected-boot localStorage write). scripts/smoke/phase-108f.sh (static) guards the removal of the cruft and the preservation of the D-158 split + the load-bearing testids. npm run check 0/0, npm run lint clean, npm run test green.

Cross-references. Supersedes the composition of D-129; preserves D-158, D-061, D-066. Builds on Phase 73m (the page it simplifies), Phase 83p (the D-158 split), Phase 105 (first-attach — AttachToLocalCard), and Phase 108c (the carded vocabulary). RFC §7, §7.1; brief 11 §"Settings view"; brief 12 §"auth-storage threat model"; CONVENTIONS.md (D-121) + PAGE-POLISH-PROCEDURE.md. Plan: docs/plans/phase-108f-console-settings-page.md.


D-179 — Console Sessions page rebuilt + fully wired (supersedes the Phase 73c / D-122 placeholder bottom-dock + disabled-bulk composition)

Date: 2026-06-01

Context. D-122 (Phase 73c) shipped the Sessions list + detail routes on the design-system foundation, but predated the 108b app-shell chrome and the 108c carded retheme, and it left two placeholders the PAGE-POLISH bar forbids: the detail-view BottomDockTabs rendered five static descriptive blurbs (Trajectory / Events / Cost History / Control History / Interventions) instead of real data, and the list's bulk Cancel / Pause buttons were permanently disabled with a "wired with Phase 73b" tooltip. A wire investigation (2026-06-01) confirmed the shipped Protocol surface can feed every one of those affordances for real — the bulk control methods (cancel / pause) are shipped (D-047), and each dock tab is a session-filtered projection of the shipped events.subscribe SSE (the taxonomy ships planner.decision/finish/error, tool.*, task.*, llm.cost.recorded, control.received/applied/rejected, pause.*, tool.approval_*, tool.auth_*). The operator approved rebuilding both routes to the carded vocabulary the four done pages (Overview, Live Runtime, Settings, Playground) set, with zero placeholders.

Decision (Phase 108g). Rebuild /sessions + /sessions/[id] to the carded .panel.card + .panel-title vocabulary (Overview 108c), tokens only, Svelte 5 runes (D-092), HarborClient + connection.ts only. The list keeps sessions.list (cursor-paged) under a calm carded toolbar (free-text search → the sessions.list query / search.sessions; Status facet; admin-only Tenant facet per D-079; Sort; Refresh), lean registry-owned columns (Session / Status / Agent / Identity / Started / Last activity / Duration) plus an Events count enriched per visible row via events.aggregate. Bulk Cancel / Pause are wired for real: when rows are selected and the operator holds the control scope, each iterates the shipped cancel / pause method per selected session's active run (D-066 gates the affordance; absent the scope it is disabled with a tooltip naming the claim). The detail view replaces the placeholder BottomDockTabs with five real tabs, each a session-filtered projection of events.subscribe: Trajectory (the planner/tool/task lifecycle timeline), Events (the raw filtered log, reusing the events/ lib), Cost History (llm.cost.recorded summed client-side, reusing overview/cost.ts), Control History (control.*), and Interventions (pause.* / tool.approval_* / tool.auth_* + pause.list backfill, with a real Resume action invoking resume / approve / reject). The detail action set — Continue in Live Runtime (nav), Clone (start with the cloned query), Cancel session (cancel per active task), Export events (JSONL via the shipped events/export.ts) — is wired against the live Runtime.

Supersedes. The composition half of D-122 — the placeholder blurb BottomDockTabs, the permanently-disabled bulk Cancel / Pause buttons, and the pre-chrome PageHeader-led list layout. The sessions.list / sessions.inspect wire shapes, the typed SessionsProtocol, the sessions/format.ts helpers, SessionFacetChips, and IdentityCell are unchanged; D-122's "registry projection is pure, the Console enriches" stance is reinforced, not changed.

Cost column departure. brief 11 §"Sessions view" sketched a per-row cost / token column on the list. This phase departs: the Phase 08 Session registry does not model per-session cost / tokens (D-122 — the row is a pure lifecycle projection), and no shipped aggregate wire sums llm.cost.recorded per session (events.aggregate counts events by type only). Rather than fabricate a value or ship an always-empty column, the list omits Cost / Tokens; the detail's Cost History tab computes cost from the live event stream where it can be done honestly. A dedicated cost.aggregate wire is the V1.3 evolution that would restore a per-row list cost.

No fabrication (CLAUDE.md §13, PAGE-POLISH §1). Every datum the rebuilt page renders is traced to a shipped Protocol method or event and verified against live Runtime data. The genuine V1 gaps render honest states, never invented values: the scrubbing replay player and the Markdown full-transcript export need the Phase 73 state.history / state.list_trajectories surface (still Pending) and stay deferred (the static Trajectory timeline from events still ships; "Export events (JSONL)" ships in their place); Convert-to-Evaluation stays disabled with a D-064 tooltip; a session whose events have aged out of an in-memory event bus shows an honest empty dock, not a fabricated history.

Tests. New Vitest specs for the event→step Trajectory projection, the client-side cost sum, the per-row Events enrichment, and the dock tab filtering — each against a captured real wire frame (the SSE PascalCase payload gotcha). A rebuilt web/console/tests/sessions-page.spec.ts (Playwright) covers hydration on both routes, list → detail navigation, the four PageState branches, the bulk-action scope gating, the dock tab switching, and the disconnected shell. scripts/smoke/phase-108g.sh (static) guards the removal of the placeholder blurbs + disabled-bulk and the presence of the real per-tab components + the events subscription. npm run check 0/0, npm run lint clean, npm run test green.

Cross-references. Supersedes the composition of D-122; preserves D-061 (Console DB local-only), D-066 (control-scope gating), D-079 (admin tenant facet), D-107 (impersonation triplet), D-064 (Evaluations post-V1). Builds on Phase 73c (the page it rebuilds), Phase 60 / 72 / 72a (events.subscribe + events.aggregate), Phase 54 / 72e (pause.list + approve / reject), Phase 42 / D-047 (cancel / pause / resume / start), Phase 108b (chrome) + Phase 108c (the carded vocabulary). RFC §7, §7.1; brief 11 §"Sessions view", §CC-2, §CC-4; brief 12 §"The two-surface model"; CONVENTIONS.md (D-121) + PAGE-POLISH-PROCEDURE.md. Plan: docs/plans/phase-108g-console-sessions-page.md.


D-180 — Console Events page rethemed to the carded, viewport-locked composition (refines the Phase 73g / D-125 pre-chrome layout)

Date: 2026-06-01

Context. D-125 (Phase 73g) shipped the Events page fully wired to the shipped Protocol surface — the live events.subscribe SSE table feed, the events.aggregate per-type rate sparkline, the Console-local saved views / export / pause-stream, the admin cross-tenant fan-in, and the bus.dropped strip. It is genuinely well-wired and is NOT over-engineered (Events is the power-user event-bus investigative surface and legitimately needs its filter + sparkline + table + detail-rail composition). But it predates the 108b app-shell chrome and the 108c carded retheme: it renders a per-page PageHeader (duplicating the breadcrumb chrome) and is not viewport-locked, so the page full-page-scrolls and the table grows unbounded. A wire investigation (2026-06-01) also surfaced three honest gaps: the empty-state copy mis-describes the live SSE table feed as a persistent-buffer read; events.aggregate defaults to the caller's own session when the filter elides it, so the rate sparkline renders empty on the default view; and the right rail is blank when no row is selected (page-events.md §4 calls for the live subscription status there).

Decision (Phase 108h). Retheme the page to the carded .panel.card + .panel-title vocabulary the five done pages set (Overview, Live Runtime, Settings, Playground, Sessions), drop the per-page PageHeader (the breadcrumb is chrome), and viewport-lock it (PAGE-POLISH §6 — the Playground / Sessions pattern): the faceted filter strip and the rate sparkline are fixed-height; the events table scrolls internally behind a sticky header; the right rail scrolls internally; the document never full-page-scrolls. Keep the rich query composition unchanged in behaviour — every Runtime read stays on the unified HarborClient via the EventsPageState controller; the phase ships NO new Protocol method. Fill the three audited gaps honestly (PAGE-POLISH §1): reframe the empty copy to the live-stream reality, scope the sparkline aggregate to the active facet set so it reflects what the table shows, and render the live subscription status (cursor sequence, dropped count, stream state) in the idle right rail.

Supersedes. The composition/layout half of D-125 — the PageHeader-led, non-viewport-locked layout and the misleading empty copy. The data layer (EventsPageState, the events lib — subscription / aggregate / filters / taxonomy / export / sparkline / saved-views), the events.subscribe / events.aggregate wires, and the Console-local D-061 saved-views / export / pause semantics are unchanged.

No fabrication (CLAUDE.md §13, PAGE-POLISH §1). Every datum the page renders is traced to events.subscribe / events.aggregate and verified against live Runtime data. The genuine V1 gaps render honest states: runtime-side search.events (Phase 72c / [wave-13-extends]) is absent, so the search box stays a Console-local substring match over the loaded page with honest copy; the trace deep-link (D-073 traceparent) stays disabled-with-tooltip (post-V1); a quiet window on an in-memory event driver shows an honest empty table (the SSE streams live events forward; no historical backfill), never fabricated rows.

Latent production bugs the live verification surfaced (§17.6). The PAGE-POLISH §3 live-wire pass found the Events table was effectively non-functional in production — it never showed live events (the always-empty behaviour previously attributed to the inmem driver). Three reactivity/wiring bugs, masked by the unit/e2e harness (whose mock EventSource factory dispatches synchronously inside open(), before the first render), were fixed: (1) the EventsPageState.subscription / .aggregator fields were plain, not $state, so the async load() assignment (in onMount, after first render) never triggered the reactive re-read that surfaces streamed events — the table stayed bound to the initial null; (2) the subscription opened with the empty default eventTypes, but the SSE transport needs a NAMED addEventListener per subscribed type (the runtime emits event: <type> frames), so an empty list registered no listeners and ingested nothing — defaulting to the full taxonomy when no type facet is set fixes it; (3) the status field was set once at load (0 events → empty) and never recomputed, hiding the table behind the empty-state while events streamed — a derived displayStatus now flips empty↔ready on the live count. Additionally the Session facet only re-scoped the aggregate, not the table feed; a backward-compatible subscribeURL session override now re-scopes the live table to the pinned session, and the rate sparkline is re-fetched (throttled) as the cursor advances so it tracks the stream. Verified live against the YouTube validation agent: the table fills with real planner.decision / tool.* / task.* / llm.cost.recorded events, the sparkline renders, the Event Details rail shows the typed payload + identity + quick actions, and the idle rail shows the live subscription status (Stream open / cursor / dropped / loaded), all with zero console errors.

Packing pass (operator review). A follow-up review flagged that the multi-card Event Details rail overflowed into a page-level scroll, the Event Rate read as a plain stacked bar, and the page carried dead vertical space. Reworked to the mock's denser composition: the Event Details is now ONE packed .panel.card (severity header + close ✕ + Identity / Source / Payload-with-Copy-JSON / Quick Actions) that fills the right column and scrolls INTERNALLY — fixing the page-level scroll; the Event Rate is now a per-category multi-line chart (tool.* / task.* / llm.* / planner.* / …) with a Type / Rate / Total legend on the right of the same card; and the page padding / gaps are tightened so the whole surface packs into one viewport with no full-page scroll even with the detail open (verified scrollHeight == innerHeight).

Tests. An updated web/console/tests/events-page.spec.ts (Playwright) covers hydration, the carded regions, the four PageState branches, row-select → detail rail, the idle rail subscription status, the pause toggle, and the disconnected shell; the events lib vitest suites stay green (extended for the sparkline-facet-scoping change). scripts/smoke/phase-108h.sh (static) guards the PageHeader removal + the carded vocabulary + the load-bearing testids. npm run check 0/0, npm run lint clean, npm run test green.

Cross-references. Refines the composition of D-125; preserves D-061 (Console DB local-only), D-079 (admin fan-in gate), D-026 (heavy-payload by-reference), D-073 (traceparent post-V1), D-074 (durable event log). Builds on Phase 73g (the page it rethemes), Phase 60 / 72 / 72a (events.subscribe + events.aggregate), Phase 108b (chrome) + Phase 108c (the carded vocabulary) + Phase 108g (the DataTable alignment + viewport-lock pattern reused). RFC §7, §7.1; brief 11 §"Events view"/§LR-5/§CC-2/§CC-4; brief 12 §"two-surface model"; CONVENTIONS.md (D-121) + PAGE-POLISH-PROCEDURE.md. Plan: docs/plans/phase-108h-console-events-page.md.


D-181 — Console Tasks page rebuilt to a carded, viewport-locked single-page mode-switch + the per-task dock wired to the live run-scoped event stream (supersedes the Phase 73d / D-123 pre-chrome layout + the placeholder detail tabs)

Date: 2026-06-01

Context. D-123 (Phase 73d) shipped the Tasks page as the task-granularity counterpart to Sessions — a kanban board (Pending / Running / Paused / Complete / Failed) over tasks.list, a list-mode DataTable toggle, a per-task detail with a bottom-dock tab strip, and the bulk + per-task control verbs (Phase 54, control-scope gated per D-066). The board, the list, the filters, the saved views, and the control wiring are genuinely real. But the page predates the 108b app-shell chrome and the 108c carded retheme: it renders a per-page PageHeader (duplicating the breadcrumb chrome) and is not viewport-locked, so the board + the selected-task detail bar + the bottom dock stack into a document that full-page-scrolls. More importantly, the per-task detail tabs (TaskDetailTabs) are SHALLOW — the Events / Logs / Control History / Interventions tabs render placeholder prose ("Live task.* event deltas surface on the kanban board") instead of the live event-bus data the mock (docs/rfc/assets/console-tasks-page.png) and spec §3 / §5 / §12 call for. A live-wire investigation (2026-06-01, against the YouTube validation agent) also pinned three honest gaps: tasks.get.cost comes back all-zero (cost is only real from the llm.cost.recorded event stream); tasks.get.parent_session comes back sparse from the registry (empty agent/status/started); and tasks.list rows carry no agent_name, so the mock's per-card "Agent: Research Agent" line has no wire source.

Decision (Phase 108i). Rebuild the page to the carded .panel.card + .panel-title vocabulary the six done pages set (Overview, Live Runtime, Settings, Playground, Sessions, Events), drop the per-page PageHeader (the breadcrumb is chrome), and compose it as a single viewport-locked mode-switch (PAGE-POLISH §6 — the Playground / Sessions pattern). The mock crams board + selected-task detail bar + bottom dock + right rail simultaneously, which cannot fit one viewport without a page scroll; the operator-signed composition (STEP-0 AskUserQuestion, 2026-06-01) is therefore: board/list is the default mode (the faceted filter strip + the board columns / the list table filling the viewport + a right-rail live board summary); clicking a card swaps the SAME page's main region to detail mode (a compact task header + the real per-task action bar + the bottom-dock tab strip in one internally-scrolling card) and swaps the rail to Summary / Parent Session / Cost; a ← Board affordance returns. No route navigation — it stays one page. Wire the per-task bottom dock to the live events.subscribe SSE (the Sessions 108g BottomDockTabs pattern), RUN-scoped: the dock opens ONE subscription scoped to the task's parent session and filters to the task's run. The phase ships NO new Protocol method — the page stays a pure consumer of tasks.list / tasks.get / events.subscribe / pause.list + the shipped Phase 54 control verbs + the Console DB (CLAUDE.md §13).

Run-match is payload.TaskID, not just e.run (live-wire finding, §17.6). The PAGE-POLISH §3 live-wire pass found that the top-level run field is populated on llm.cost.recorded and planner.decision events but is NULL on the task.* lifecycle events (task.spawned / task.started / task.completed), which carry the id in the PascalCase payload.TaskID instead. A naive e.run === taskID per-task filter (the obvious first cut) would therefore silently drop every lifecycle event from the Events tab and mis-render the task's timeline. The dock's per-task predicate is e.run === taskID || payload.TaskID === taskID || payload.Identity.RunID === taskID, locked by a run-events vitest against a captured real SSE frame. This is the Tasks-page instance of the recurring SSE casing/shape gotcha (a decoder reading the wrong shape silently drops every value) that bit the Events page (D-180).

Cost is from the event stream, by token type (operator sign-off). tasks.get.cost is all-zero on the validation runtime, so the Summary cost/tokens figures and the right-rail Cost Breakdown are aggregated CLIENT-SIDE from the run-scoped llm.cost.recorded events (payload.Cost.TotalCost, payload.Usage.TotalTokens, payload.Cost.InputTokensCost / OutputTokensCost / ReasoningTokensCost — all PascalCase, verified live), the same projection Sessions / Overview cost uses. The mock's Cost Breakdown card shows LLM / Tools / Embeddings / Overhead rows, but no such category split exists on the wire; per the operator's STEP-0 sign-off the card renders by TOKEN TYPE (Input / Output / Reasoning / Total — the real payload.Cost fields), which keeps the mock's four-row visual while staying 100% wire-real, never inventing a Tools/Embeddings/Overhead split.

Supersedes. The composition/layout half of D-123 — the PageHeader-led, non-viewport-locked board+detail stack — and the placeholder TaskDetailTabs (Events / Logs / Control History / Interventions prose blurbs), which is deleted in favour of the live TaskBottomDock. The data-read layer of D-123 (the tasks.list / tasks.get wire types, the cursor pagination, the kanban-column model, the Console-local D-061 saved filters) and the Phase 54 control wiring are unchanged.

No fabrication (CLAUDE.md §13, PAGE-POLISH §1). Every datum is traced to a live wire and verified against real Runtime data. The genuine V1 gaps render honest states, never fabricated values: the Logs tab needs the Phase 73 state.history surface (still Pending) and renders an honest empty state pointing at it (and the Events tab for the live log); runtime-side search.tasks (brief 11 §CC-4 / [wave-13-extends]) is absent, so the search box stays a Console-local substring match over the loaded page with honest copy; the per-card agent line is replaced by the parent session id + the query snippet (no agent_name on the row); the Parent Session card shows the real session_id + link and for the sparse registry fields; the board drag-to-transition gesture maps only where a real control verb exists (running→paused = pause, paused→running = resume, running→failed = cancel) and is otherwise a no-op toast — the board is primarily click-to-select (D-065 / spec §10 keep priority on the explicit prioritize verb, not a drag).

Tests. A new web/console/src/lib/tasks/run-events.test.ts (Vitest) covers the eventBelongsToRun predicate against a captured real frame (a task.completed whose run is null but payload.TaskID matches IS included; a foreign-run event is excluded) and the trajectory / control / interventions / cost projections. An updated web/console/tests/tasks-page.spec.ts (Playwright) covers hydration, the carded board, the four PageState branches, the board→detail mode-switch, the dock tab strip, the action-bar control gating, and the disconnected shell. scripts/smoke/phase-108i.sh (static) guards the PageHeader removal, the carded vocabulary, the TaskBottomDock import, the load-bearing testids, and the TaskDetailTabs deletion. npm run check 0/0, npm run lint clean, npm run test green.

Cross-references. Refines the composition of D-123; preserves D-061 (Console DB local-only), D-066 (control claim), D-065 (no session-level priority; task-level Prioritize stays), D-047 (TaskRegistry state machine the kanban columns mirror), D-026 (heavy-payload by-reference), D-079 (admin scope gate), D-171 (session header / blank-session default). Builds on Phase 73d (the page it rebuilds), Phase 54 (the control verbs), Phase 60 / 72 / 72e (events.subscribe + pause.list), Phase 108b (chrome) + Phase 108c (the carded vocabulary) + Phase 108g (the Sessions BottomDockTabs run-scoped pattern + the DataTable sticky-header / clickable-row fix) + Phase 108h (the viewport-locked carded Events pattern). RFC §7, §7.1; brief 11 §"Tasks view" / §"Per-task detail pane" / §CC-4; brief 12 §"two-surface model"; CONVENTIONS.md (D-121) + PAGE-POLISH-PROCEDURE.md. Plan: docs/plans/phase-108i-console-tasks-page.md.


D-182 — Console Background Jobs page rethemed to the carded, viewport-locked composition + the right rail deepened to full mock fidelity + the king file refactored to a controller (supersedes the Phase 73h / D-128 pre-chrome layout)

Date: 2026-06-02

Context. D-128 (Phase 73h) shipped the Background Jobs page as the focused queue projection of tasks.list with kinds: ['background'] — the queue table, the Console-side AwaitTask orphan detector, the planner-progress mini-bar, the Console-DB saved filters, and the bulk Phase-54 control verbs are genuinely real and wired. But the page predates the 108b app-shell chrome and the 108c carded retheme: it renders a per-page PageHeader (duplicating the breadcrumb chrome), is not viewport-locked, and the right rail is SHALLOW — its Details / Progress / Logs / Approvals / Artifacts / Related tabs render placeholder prose ("events for this job stream on the Events page…", "Artifacts… are listed via artifacts.list?task_id=…") instead of the live data the mock (docs/rfc/assets/console-background-jobs-page.png) and spec §12 call for. The +page.svelte had also grown to an ~801-line king file mixing the controller, the loaders, the bulk verbs, the saved-view CRUD, and the rail wiring in one monolith.

Decision (Phase 108j). Retheme the page to the carded .panel.card + .panel-title vocabulary the seven done pages set (Overview, Live Runtime, Settings, Playground, Sessions, Events, Tasks), drop the per-page PageHeader (the breadcrumb / ⌘K / footer are 108b chrome), and compose it as the viewport-locked Events-108h shape (operator STEP-0 sign-off, 2026-06-02): TABLE-primary on the left (the queue fills the viewport and scrolls internally behind a sticky <thead>) + a right-rail detail on the right (the table stays visible; the rail shows the selected job or an idle "select a job" hint) — NOT a Tasks-style mode-switch. Deepen the right rail to full mock fidelity: a header (short hash + kind + status + copy + close), the tabs Details | Progress | Events | Control History, and the sections Artifacts-for-this-Job / Parent task / Related Sessions, all packed into ONE internally-scrolling rail card. Wire Events / Control History to a RUN-scoped events.subscribe projection by REUSING the Tasks-108i lib/tasks/run-stream.svelte.ts (TaskRunStream) + run-events.ts (eventBelongsToRun / filterControlEvents / …) — never a fork (CONVENTIONS.md §3). The phase ships NO new Protocol method — the page stays a pure consumer of tasks.list / tasks.get / artifacts.list / events.subscribe + the shipped Phase 54 control verbs + the Console DB (CLAUDE.md §13).

King-file refactor. The ~801-line +page.svelte is decomposed to today's standard (the Tasks-108i pattern): the controller / async-state logic moves into a BackgroundJobsPageState class in lib/background-jobs/state.svelte.ts (mirrors EventsPageState, with $state fields for everything an async load assigns after first render — the D-180 lesson); the pure projections live in unit-testable .ts modules (derive.ts for ETA / type / state-timeline + the kept orphan-detector.ts); the right rail splits into focused components (JobDetailRail.svelte + JobProgressTab.svelte). The pre-chrome RightRail.svelte is deleted.

ETA, type, and timeline are derived honestly (operator sign-off, PAGE-POLISH §1). There is no dedicated ETA / type wire field, so each is a Console-local derivation that NEVER fabricates: the ETA is the planner's own task.progress hint projected over elapsed wall time (remaining = elapsed × (1−progress)/progress), labelled an estimate, and reads "Unknown" when no hint was emitted; the type badge is the first planner-emitted tag, else a keyword match over the spawn description (Indexer / Report / Long Poll), else the generic "Job"; the Progress state-transition timeline is the run's REAL task.* lifecycle events from the run-scoped SSE. The run-scoped SSE is live-only (no backlog on a fresh connect), so the Events / Control History tabs + the timeline render honest-empty states for a quiet or already-finished run — the same constraint the Sessions / Tasks docks carry (D-181). Artifacts-for-this-Job is artifacts.list filtered by scope.task = the job's run id (verified live: rows are {ref:{id,mime_type,size_bytes,filename}, tags, source}), honest-empty when the job produced none; spawned-by shows the parent_task_id or "—" (no fabricated agent); Related Sessions is tasks.list?group_id siblings, honest-empty when not grouped.

Live-wire verification (PAGE-POLISH §3). The empty / filtered-empty states were verified against the live YouTube validation harbor dev runtime (it spawns no background work, so tasks.list {kinds:['background']} is honestly empty). The populated queue + the rail were verified against a HARBOR_DEV_SEED_FIXTURES=1 runtime (two seeded background jobs — "Background index rebuild" / "Background summary job"), confirming the queue rows, the Details tab, the honest Progress (no seeded progress hint → "Unknown" + indeterminate bar), and the honest-empty Artifacts / Parent / Related / Events / Control sections. Zero browser console errors; scrollHeight == innerHeight (no full-page scroll) and zero horizontal table overflow at the supported width.

Supersedes. The composition/layout half of D-128 — the PageHeader-led, non-viewport-locked layout + the shallow placeholder right rail (RightRail.svelte, deleted). The data-read layer of D-128 (the kinds: ['background'] queue projection, the Console-side orphan detector, the planner-progress mini-bar, the Console-local D-061 saved filters) and the Phase 54 bulk-control wiring are unchanged.

Tests. A new web/console/src/lib/background-jobs/derive.test.ts (Vitest) locks the ETA / type / timeline projections against their honest states (no-progress → "Unknown"; no-signal → "Job"; newest-first events → oldest-first timeline; group events excluded). The existing orphan-detector.test.ts is unchanged. A rewritten web/console/tests/background-jobs-page.spec.ts (Playwright) covers hydration, the carded filter strip, the background-kind queue, bulk-select → toolbar, row → rail tab navigation, the orphan dialog, control-scope gating, the viewport-lock (no full-page scroll), and the disconnected redirect. scripts/smoke/phase-108j.sh (static) guards the PageHeader removal, the carded vocabulary, the BackgroundJobsPageState / derive.ts / JobDetailRail / JobProgressTab files, the RightRail.svelte deletion, the load-bearing testids, and the preserved Save-view contract (phase-83s / disconnected-state N7). npm run check 0/0, npm run lint clean, npm run test green.

Cross-references. Refines the composition of D-128; preserves D-061 (Console DB local-only), D-066 (control claim), D-065 (no session-level priority; task-level Prioritize stays), D-047 (SpawnTask / AwaitTask — the orphan detector surfaces the pairing), D-026 (heavy-payload by-reference — artifacts by ref, never inline bytes), D-079 (admin scope gate), D-171 (session header / blank-session default), D-128 (the orphan detector / progress-bar / saved-filter data layer). Builds on Phase 73h (the page it rebuilds), Phase 73d (tasks.list / tasks.get), Phase 54 (the control verbs), Phase 73l (artifacts.list), Phase 60 / 72 (events.subscribe), Phase 108b (chrome) + Phase 108c (the carded vocabulary) + Phase 108h (the viewport-locked carded Events pattern) + Phase 108i (the reused TaskRunStream / run-events data layer + the DataTable sticky-header / clickable-row fix). RFC §6.8, §7; brief 11 §"Background Jobs view" / §CC-4; brief 12 §"The two-surface model"; CONVENTIONS.md (D-121) + PAGE-POLISH-PROCEDURE.md. Plan: docs/plans/phase-108j-console-background-jobs-page.md.


D-183 — Console Tools page rethemed to the carded, viewport-locked composition + the right rail deepened to full mock fidelity + the king file refactored to a controller (supersedes the Phase 73f / pre-chrome layout)

Date: 2026-06-02

Context. D-116 (Phase 73f) shipped the Tools page as the registered-tool-catalog browser — the seven tools.* Protocol methods (tools.list / tools.get / tools.describe / tools.metrics / tools.content_stats + the admin tools.set_approval_policy / tools.revoke_oauth), the Console-DB saved filters, the faceted catalog table, the per-tool descriptor tabs, and the right-rail stat cards are genuinely real and wired. But the page predates the 108b app-shell chrome and the 108c carded retheme: it renders a per-page PageHeader (duplicating the breadcrumb chrome), is not viewport-locked (the table → detail tabs → rail cards stack vertically and full-page-scroll), and the +page.svelte had grown to an ~847-line king file mixing the controller, the loaders, the admin writes, the saved-view CRUD, the export, and the rail wiring in one monolith.

Decision (Phase 108k). Retheme the page to the carded .panel.card + .panel-title vocabulary the eight done pages set (Overview, Live Runtime, Settings, Playground, Sessions, Events, Tasks, Background Jobs), drop the per-page PageHeader (the breadcrumb / ⌘K / footer are 108b chrome), and compose it as the viewport-locked Events-108h / Background-Jobs-108j shape (operator STEP-0 sign-off, 2026-06-02): a filter card + a layout of TABLE-primary on the left (the catalog fills the viewport and scrolls internally behind a sticky <thead>) + a right-rail detail on the right (the table stays visible; the rail shows the selected tool's full detail or — with nothing selected — the catalog overview idle state) — NOT a Tasks-style mode-switch. Deepen the right rail to full mock fidelity in ONE packed internally-scrolling card: a descriptor header (name + transport/scope + side-effect / OAuth / approval badges + copy + close), a tab strip (Manifest | Inputs | Outputs | Recent invocations | Approval), then a Statistics card (tools.metrics error-rate gauge + status pill + window toggle), a Content size & display-mode card (tools.content_stats), a Source-provenance card, and a Run-history strip. The phase ships NO new Protocol method — the page stays a pure consumer of the shipped seven tools.* methods + the Console DB (CLAUDE.md §13).

King-file refactor. The ~847-line +page.svelte is decomposed to today's standard (the Background-Jobs-108j pattern): the controller / async-state logic moves into a ToolsPageState class in lib/tools/state.svelte.ts (mirrors BackgroundJobsPageState, with $state fields for everything an async load assigns after first render — the D-180 lesson); the pure projections live in a unit-testable lib/tools/derive.ts (lastUsed / oauthKind / approvalKind / statusKind / toPageError / displayStatus) with a derive.test.ts; the catalog table and the right rail split into focused components/tools/ToolCatalogTable.svelte + ToolDetailRail.svelte. The pre-chrome ToolDetailTabs.svelte is deleted (superseded by the rail). The existing body-only stat cards (StatusErrorRateCard / ContentSizeCard / RunHistoryStrip), the ToolOverviewCard (now the idle-rail content), the ToolFacetChips, and lib/tools/export.ts are reused, not rewritten.

"Try this tool" is omitted (operator sign-off, 2026-06-02). The mock gestures at a developer "Try this tool" form that depends on a tools.invoke Protocol method. A live probe of the validation runtime confirmed only the seven canonical tools.* methods exist — tools.invoke is NOT shipped at V1 (D-132, unchanged). The Phase 73f page surfaced a disabled-with-tooltip tools-try-tool affordance naming the deferral; per operator STEP-0 decision the 108k rebuild omits the affordance entirely rather than carrying a disabled stub. The tools.invoke deferral itself is unchanged; the affordance simply does not render on the rebuilt page. This supersedes the tools-try-tool half of D-132's Console treatment (the method-level deferral stands); docs/design/console/page-tools.md §3/§13 is updated to record the omission.

No fabrication (CLAUDE.md §13, PAGE-POLISH §1). Every datum is traced to a live wire and verified against real Runtime data. The genuine V1 gaps render honest states, never fabricated values: tools.metrics / tools.content_stats are all-zero / empty for a never-invoked tool (the live probe confirmed every catalog tool in the validation agent starts so), and the stat cards render their own honest "no invocations / no recent invocations recorded" copy, never a fabricated rate/latency; the OAuth / Approval badges are real from the descriptor ("n/a" when neither); the last_used_at Go zero time renders an honest "never"; recent invocations stream from the tool.* Events surface (no durable read-back here — the rail points at it); free-text search is a Console-local tools.list search facet (brief 11 §CC-4 — no runtime search.tools); the Approve / Reject + bulk Revoke OAuth call the REAL admin methods and render disabled-with-tooltip without the admin claim (D-079), never a fabricated success.

Live-wire verification (PAGE-POLISH §3). Every method was probed against the live YouTube validation harbor dev runtime, whose catalog is genuinely populated (7 real tools: artifact_fetch + the MCP youtube_* family). Pinned shapes: tools.list returns the rows + {total:7, active:0, pending_approval:0, awaiting_oauth:0} aggregates; tools.describe returns the real args_schema / out_schema JSON + side_effect:external + loading_mode:always; tools.metrics is all-zero with status:Healthy; tools.content_stats is {histogram:[], heavy_threshold_bytes:0, heavy_count:0}; tools.set_approval_policy round-trips 200 {id,policy}; an unknown id → 404 not_found; a no-identity request → 401 identity_required. The loaded / selected / admin-round-trip states were verified populated; empty / filtered-empty via a tight facet; error via the unknown-id path; disconnected via no connection (the idle overview renders placeholders, not fabricated zeros). Zero browser console errors; scrollHeight == innerHeight (no full-page scroll) and zero horizontal table overflow at the supported width.

Supersedes. The composition/layout half of D-116 — the PageHeader-led, non-viewport-locked table+tabs+rail stack + the standalone ToolDetailTabs.svelte (deleted) — and the tools-try-tool disabled-affordance half of D-132's Console treatment (the tools.invoke method deferral stands). The data-read layer of D-116 (the seven tools.* wire types + the page-based pagination + the Console-local D-061 saved filters + the admin-write wiring) is unchanged.

Tests. A new web/console/src/lib/tools/derive.test.ts (Vitest) locks the pure projections against the real wire shapes (the Go zero time → honest "never"; the StatusChip mappings exhaustive over the wire enums; toPageError keeps the Protocol code; displayStatus derives ready/empty live from the loaded-row count). A rewritten web/console/tests/tools-page.spec.ts (Playwright) covers hydration, the carded catalog table + mockup columns, a facet toggle re-render, the row → rail tab navigation, the real-admin-or-disabled Approve control, and the disconnected redirect. scripts/smoke/phase-108k.sh (static) guards the PageHeader removal, the carded vocabulary, the ToolsPageState / derive.ts / ToolCatalogTable / ToolDetailRail files, the ToolDetailTabs.svelte deletion, the load-bearing testids, the scoped DataTable override, and the preserved Save-view contract (phase-83s / disconnected-state N7). scripts/smoke/phase-83x.sh N13 grep is retargeted from +page.svelte to ToolCatalogTable.svelte where the Reliability column width token now lives (§17.6). npm run check 0/0, npm run lint clean, npm run test green.

Cross-references. Refines the composition of D-116; supersedes the tools-try-tool affordance half of D-132 (method deferral stands). Preserves D-061 (Console DB local-only — saved views + export), D-066 (control claim), D-024 (ToolPolicy reliability shell — the Manifest tab), D-026 (heavy-content threshold — the Content-size card), D-062 (MCP-Apps DisplayMode via the canonical registry), D-079 (admin scope gate for the writes), D-083 (auth.BindingScope — the OAuth badge), D-086 (tool-side approval gates), D-132 (tools.invoke deferral), D-171 (session header / blank-session default), D-180 (derive display-state live). Builds on Phase 73f (the page it rebuilds), Phase 26–31 / 64a (the tool catalog + transports + OAuth + approval the descriptors project), Phase 108b (chrome) + Phase 108c (the carded vocabulary) + Phase 108h (the viewport-locked carded Events pattern) + Phase 108j (the table-left + right-rail-detail twin + the scoped DataTable override + the controller refactor). RFC §6.4, §6.5, §7; brief 11 §"Tools view" / §CC-4 / §PG-3; brief 12 §"The two-surface model"; CONVENTIONS.md (D-121) + PAGE-POLISH-PROCEDURE.md. Plan: docs/plans/phase-108k-console-tools-page.md.


D-184 — Agents fleet-control Protocol surface landed + the Console Agents page rebuilt to the carded, viewport-locked three-column canvas with live control + activity (supersedes the D-132/F4 control-verb deferral + the Phase 73e / D-124 pre-chrome layout)

Decision (Phase 108l). Land the five agent fleet-control verbs as real Protocol methods AND retheme both Agents routes to the page-polish standard, in one PR (the primitive + its consumer in the same wave — CLAUDE.md §13). Runtime half: agents.{pause,drain,restart,force_stop,deregister} mount at POST /v1/agents/{verb} (the same one-shot shape as the eight read methods), each wrapping the shipped in-process registry.* control verb (D-066) through a Controller seam on the agents Protocol service. They are admin-gated: the handler computes controlScoped from the verified auth.ScopeAdmin claim and the service fails closed (ErrControlScopeRequired403 identity_scope_required) before attaching registry.WithControlScope(ctx) and invoking the registry. Request/response shapes are AgentControlRequest{identity, id, reason} / AgentControlResponse{agent_id, command, status}. Console half: the detail route's ControlButtons call the real methods (control-scope gated — disabled-with-tooltip without admin, never a fabricated success), the previously-placeholder AgentActivityFeed projects a live agent.* events.subscribe stream filtered to the agent, both routes are rebuilt as carded .panel.card viewport-locked pages (the detail as the three-column main canvas — tabbed detail / topology / activity+tools+memory — NOT a right rail, per page-agents.md §4), and both ~500-line king files are refactored into AgentsListPageState / AgentDetailPageState controllers + a pure, unit-tested lib/agents/derive.ts.

Honest re-read status (CLAUDE.md §13 — no fabrication). The V1 registry Health enum is {unknown, healthy, degraded, draining, stopped} — there is NO "paused". So a control response re-reads the agent's ACTUAL post-command status rather than echoing the command's intent: pause and restart emit their agent.paused / agent.restart_requested event but leave the observable status active; only drain→drained, force_stop→force_stopped, and deregister→deregistered (record removed) transition it. The Console surfaces the returned status truthfully and treats the emitted event (observed live on the activity feed) as the observation of pause/restart — it never claims a "paused" state the registry did not produce.

Live-wire verification (PAGE-POLISH §3). The five verbs were probed against a live harbor dev runtime: each returns admin-gated 404 not_found on an unknown id (route mounted + admin-gated), a no-identity request → 401, and a non-admin token → 403 identity_scope_required. Populated page states were verified against a HARBOR_DEV_SEED_FIXTURES=1 runtime (the plain validation runtime's registry is empty — the synthetic default agent is not a registered row): the list hero rollup + cards, the detail three-column canvas + each of the six tabs, a control round-trip (drain → status flips to drained + agent.drained on the activity feed; pause → status stays active + agent.paused event shown), and the disabled-without-admin control surface. Empty via the plain runtime, error via a bad token, disconnected via no connection. Zero browser console errors; scrollHeight == innerHeight (no full-page scroll) and zero horizontal overflow at the supported width.

Supersedes. The D-132/F4 agent fleet-control deferral (the previous ControlButtons rendered disabled-with-tooltip "no registry.* Protocol surface exists" regardless of scope) — the surface now exists and the buttons are live, admin-gated. AND the composition/layout half of the Phase 73e / D-124 Agents page (the PageHeader-led, non-viewport-locked rollup + cards + the DetailRail-based detail). The data-read layer of D-124 (the eight agents.* read wire types + the page-based pagination + the Console-local D-061 saved filters) is unchanged.

Tests. Go: internal/runtime/registry/protocol/control_test.go (gating, honest re-read, sentinels) + internal/protocol/transports/stream/agents_handler_test.go (control gating + routing through the real registry controller, identity propagation, 404/403/401 failure modes, under -race); the single-source / conformance count assertions bump 71→76. Console: web/console/src/lib/agents/derive.test.ts (Vitest — StatusChip mappings exhaustive over the wire enums, displayStatus derives ready/empty live from the row count (D-180), projectActivity filters by the payload AgentID and stays empty when quiet, controlResultMessage reports the honest re-read status); web/console/tests/agents-page.spec.ts is updated from the D-132/F4 disabled-stub assertion to the control-scope degradation contract. scripts/smoke/phase-73e.sh adds the five control-verb live assertions; scripts/smoke/phase-108l.sh (static) guards the carded vocabulary, the controllers + derive.ts, the preserved testids (incl. the disconnected-state N7 Save-view contract), and the no-hand-rolled-fetch rule. npm run check 0/0, npm run lint clean, npm run test green.

Cross-references. Supersedes D-132/F4 (control deferral) + the layout half of D-124. Preserves D-059 (agent_id is a registration identity, NOT an isolation principal — control scopes by the tuple, never by agent_id), D-060 (Agent Registry in-process per-runtime), D-061 (Console DB local-only), D-062 (Agents ≠ chatbots), D-066 (control-scope claim), D-079 (admin scope gate), D-083 (tool-side OAuth — the binding rows deep-link, no parallel flow), D-171 (session header default), D-180 (derive display-state live). Builds on Phase 53a (Agent Registry), Phase 73e (the Agents read surface it rebuilds), Phase 108b (chrome) + Phase 108h/108j/108k (the carded viewport-locked page-polish pattern + the controller refactor). RFC §6.16, §6.4, §7.2; brief 11 §"Agents view" / §CC-4; brief 12 §"Open architectural questions … resolved here"; CONVENTIONS.md (D-121) + PAGE-POLISH-PROCEDURE.md. Plan: docs/plans/phase-108l-console-agents-page.md.


D-185 — Console MCP Connections page rethemed to the carded, viewport-locked master-detail composition + the right rail deepened to full mock fidelity + the king file refactored to controllers (supersedes the Phase 73k / D-119 pre-chrome layout)

Decision (Phase 108m). Retheme the Console MCP Connections page to the Phase 108 page-polish standard. It is a PURE Protocol consumer — the mcp.servers.* surface shipped in Phase 73k / D-119; this phase builds NO new Protocol method. The page becomes the carded (.panel.card), viewport-locked master-detail composition modelled on Tools-108k (D-183): a filter card (saved-view chips + state facets + Console-side search) + the servers TABLE on the left (scrolling internally behind a sticky <thead>) + a right-rail server detail on the right (or the catalog-overview idle state when nothing is selected). The separate tabbed-detail route (/mcp-connections/[server]) is REMOVED — the rail is the single detail surface (CLAUDE.md §13 — no two parallel implementations of one feature). The rail is deepened to full mock fidelity: header (name + state badge + transport + endpoint + last-discovery + tool/resource/prompt/OAuth counts + Refresh discovery / Test connection / raw-HTML toggle), a five-tab strip (Tools | Resources | Prompts | OAuth bindings | Policy), a LIVE Recent-events card, and a binding-scope summary. The king file is refactored into McpListState + McpDetailState controllers + a pure, unit-tested derive.ts (folding in the former status.ts) + the focused ServersTable / McpDetailRail / McpOverviewCard / McpRecentEvents components.

Honest wiring (CLAUDE.md §13 — no fabrication). Refresh discovery calls the real mcp.servers.refresh_discovery and re-loads the detail so the rendered counts + last-connect reflect the runtime; Test connection calls the real mcp.servers.probe and surfaces the ACTUAL outcome (Reachable — round-trip N ms, or the transport error) — never a faked OK. The raw-HTML trust toggle calls the real mcp.servers.set_raw_html_trust and is admin-gated (D-079, D-066): disabled-with-tooltip without the admin claim, and a real re-read reflects the new trust (a wiring gap is closed here — the pre-chrome McpDetailState.isAdmin was never set, so the toggle was permanently disabled regardless of scope; it now derives from hasScope(connection, 'admin')). OAuth Connect/Reconnect/Revoke deep-link to the Tools binding surface + wire refresh_binding / revoke_binding directly (no parallel OAuth path, §13 / D-083). The Recent-events card is a LIVE-only events.subscribe projection (mcp.resource_updated + tool.auth_required + the transport-error subset of tool.failed), honestly empty until events stream in (no durable read-back); mcp.resource_updated / tool.auth_required are attributed to the server by the payload Source id, and tool.failed (which carries no server field) by membership in the server's owned tool-name set (tools.list owner === name) — an event that cannot be attributed is dropped, never mislabelled. The youtube validation server advertises 0 resources / 0 prompts / 0 bindings, so those tabs + the summary render their HONEST empty copy.

Live-wire verification (PAGE-POLISH §3). Verified against a live harbor dev runtime configured with a REAL MCP server (youtube via stdio, 6 tools — mcp.servers.list returns a populated catalog with no fixture seeding). Each mcp.servers.* method was curl-probed and the real payload captured (protocol_version 0.1.0, url_or_command uvx mcp-youtube, tool_policy {30000, 3, 0}, empty resources/prompts/bindings/health, probe {ok:true, latency_ms:2}). The recent-event decoders are unit-pinned against the REAL PascalCase SSE payload fields (Source, ToolName, URI, ErrorClass) — the GET /v1/events projection marshals exported Go fields UNTAGGED, so a snake_case decoder silently drops every value (§3 casing gotcha). Browser-truth: every state was screenshotted at 1512×945 — the list + idle overview, the detail rail (each tab), a Test-connection round-trip (Reachable — round-trip 6 ms), a Refresh-discovery re-read (header flips to "last connect just now"), the admin raw-HTML toggle (flips to "trusted" + a header badge, then back), the honest-empty OAuth/Resources/Prompts tabs + binding-scope summary, filtered-empty (non-matching search), error (bad token → auth_rejected: jwt rejected: token_malformed + Retry), and the disconnected → /settings redirect (Phase 105). Zero browser console errors on a clean load; scrollHeight == innerHeight (no full-page scroll) and zero horizontal overflow.

Supersedes. The Phase 73k / D-119 pre-chrome MCP Connections layout: the PageHeader-led, non-viewport-locked list + the DetailRail-summary + the separate /mcp-connections/[server] tabbed-detail route. The data-read layer of D-119 (the mcp.servers.* wire types + the MCPServersNamespace client + the Console-local D-061 saved filters) is unchanged. The six-tab detail (with a Health tab) collapses to the five-tab rail (Tools | Resources | Prompts | OAuth bindings | Policy) per the page-polish scope; mcp.servers.health stays a shipped method, unconsumed by this page.

Tests. Console only (no Go change). web/console/src/lib/mcp-connections/tests/derive.test.ts (Vitest — the mcpStatusKind / mcpStateLabel mappings exhaustive over the wire enum; relativeTime renders the Go zero time as never; serverStateCounts; displayStatus derives ready/empty live from the row count (D-180); extractEventSource / extractEventToolName read the real PascalCase payload; summarizeMcpEvent + projectServerEvents attribute by Source / owned-tool-name and drop unattributable events). The existing web/console/src/lib/mcp-connections/tests/state.svelte.spec.ts (four-state contract + control-surface routing) stays green against the extended controllers. web/console/tests/mcp-connections-page.spec.ts is rewritten for the rail-based master-detail (the row-select → rail, the five tabs paint in place, the Tools deep-link, the admin-disabled raw-HTML toggle, the disconnected redirect). scripts/smoke/phase-108m.sh (static) guards the removed [server] route + the removed PageHeader + the carded vocabulary + the controllers + derive.ts (status.ts folded in) + the four components + the five tabs + the real action wiring + the isAdmin gate + the live EventsSubscription + the Save-view N7 contract + the no-hand-rolled-fetch rule. npm run check 0/0, npm run lint clean, npm run test green.

Cross-references. Supersedes the layout half of D-119 (Phase 73k MCP Connections). Preserves D-061 (Console DB local-only — saved-view chips), D-062 (MCP-Apps renderer registry — the Resources tab inventories read-only, no bespoke renderer), D-065 (no session priority — none rendered), D-066 (control-scope claim — Refresh / Test / admin verbs), D-079 (admin scope gate — raw-HTML + OAuth admin verbs), D-083 (tool-side OAuth — the bindings deep-link, no parallel flow), D-119 (the mcp.servers.* surface), D-121 (CONVENTIONS.md foundation), D-171 (session header default), D-180 (derive display-state live). Builds on Phase 73k (the MCP read surface it rebuilds), Phase 73f (tools.list), Phase 73g (events.subscribe), Phase 105 (the disconnected redirect), Phase 108b (chrome) + Phase 108k / D-183 (the carded viewport-locked master-detail pattern + the controller refactor it mirrors). RFC §6.4, §7; brief 11 §"MCP Connections view" / §PG-3 / §"Open architectural questions" #8; brief 12 §"The two-surface model"; CONVENTIONS.md (D-121) + PAGE-POLISH-PROCEDURE.md. Plan: docs/plans/phase-108m-console-mcp-connections-page.md.


D-186 — Console Memory page rethemed to the carded, viewport-locked master-detail composition + the king file refactored to a controller, AND the real memory.strategy_trace read + admin-gated memory.put / memory.delete mutation pair landed (supersedes the Phase 73j / D-118 read-only layout + the deferred mutation/trace surfaces)

Decision (Phase 108n). Bring the Console Memory page to the Phase 108 page-polish bar AND land the Protocol surface it needed to stop deferring features — the primitive + its consumer in one wave (CLAUDE.md §13, the D-184 pattern). Three new methods route through the existing memory stream handler: memory.strategy_trace (read, no admin), memory.put (admin-gated "add a turn"), memory.delete (admin-gated "evict a turn by key"). All three compose the ALREADY-SHIPPED MemoryStore interface (Phases 23–25): strategy_trace = GetLLMContext + Health; put = AddTurn; delete = Snapshot → drop-the-keyed-turn → Restore. NO driver-seam change — all three V1 drivers (inmem / sqlite / postgres) back them with zero per-driver work, so no conformance-parity churn. Console half: a carded .panel.card viewport-locked master-detail rebuild (filter card + records table-left + a stacked right rail of health / strategy-trace / live-events / add-memory / selected-item), the ~728-line king file refactored into a MemoryPageState controller + a pure unit-tested derive.ts + the focused MemoryTable / MemoryEventsCard / StrategyTraceCard / AddMemoryComposer components, the per-page PageHeader dropped.

Honest scope — strategy_trace is a real projection, promotions stays deferred (§13 / PAGE-POLISH §1). The mock's "strategy debugger" asks for a per-step selection trace with rejected items. The rolling_summary strategy SUMMARISES (it does not select-and-reject candidates), so memory.strategy_trace ships the honest, real form: the strategy's LIVE GetLLMContext output — the rolling-summary text (the compaction OUTPUT), the verbatim-turn count, the token estimate — plus Health. It is real runtime state, never a fabricated rejection list; an empty session projects an empty trace. The memory.promotions viewer is NOT shipped: cross-session memory promotion is UNIMPLEMENTED in the runtime (only a doc comment in types/memory.go; a code audit found zero implementation), so a method would be a hollow always-empty stub — forbidden. It stays an honest finding until the promotion subsystem lands (an RFC-level addition the user explicitly deferred).

Wire-shape bug fixed (PAGE-POLISH §3) + lossless delete. memory.get's Value is a Go []byte, which encoding/json marshals as BASE64; the pre-chrome value viewer JSON.parsed the raw base64 and rendered gibberish. Fixed in derive.decodeMemoryValue (base64 → UTF-8 → pretty JSON; multibyte-safe). The memory.delete read-modify-write decodes the snapshot Record, drops the keyed turn, and re-marshals — so memory.Record gained a Summary field (additive, matching the strategy's persisted memoryStateRecord json tag) to round-trip the rolling summary LOSSLESSLY rather than silently dropping it. The mutations are admin-gated at the handler edge (auth.ScopeAdmin strictly — console:fleet is a cross-runtime OBSERVATION claim, never a write entitlement) and emit memory.item_put / memory.item_deleted audit events (SafePayload — the hashed key + operation only, NEVER the turn text). The deferred event-feed card (D-132/W5) is upgraded to a LIVE events.subscribe projection (memory.identity_rejected + memory.health_changed + memory.recovery_dropped), honest-empty when quiet.

Live-wire verification (PAGE-POLISH §3). Verified against a live harbor dev runtime with real sqlite / rolling_summary memory (no fixture seeding — the validation agent persists turns across runs). memory.strategy_trace returned the REAL rolling summary (the actual compacted conversation), 4 verbatim turns, 643 estimated tokens, health healthy. memory.put appended a turn and returned its resolvable key; memory.get round-tripped it (base64 value decoded to the JSON). memory.delete evicted by key (remaining_turns decremented); an unknown key → not_found; an empty key → 400; a no-bearer request → 401. Browser-truth at 1512×945: the carded list + the stacked rail (health + the live strategy summary + live events), a real UI evict (selected-item "Evict turn" → memory.delete → the table dropped 3→2 + an honest "Evicted — 2 turn(s) remain" result line), and the selected-item value viewer rendering DECODED pretty JSON (not base64). Zero browser console errors; scrollHeight == innerHeight; no horizontal overflow.

Supersedes. The Phase 73j / D-118 read-only Memory layout (the PageHeader-led list + the __HARBOR_PROTOCOL_CLIENT__ global-injection seam + the disabled-with-tooltip bulk bar + the deferred event-feed placeholder + the base64-rendering value viewer). The data-read layer of D-118 (the memory.{list,get,health} wire types + the Console-local D-061 saved filters) is unchanged; the closed-set IsMemoryMethod predicate + the methods count grow from three to six.

Tests. Go: internal/memory/protocol/mutate_test.go (StrategyTrace projection; Put appends + returns a resolvable key + emits the audit event; Delete evicts by key + emits the audit event + the Record.Summary lossless round-trip; not-found; identity-required) + internal/protocol/transports/stream/memory_handler_test.go (the 3 routes through the real handler over a real MemoryStore — 200 happy paths, 401 no-bearer, 403 non-admin mutation, 400 empty-key — under -race); the single-source / conformance count assertions bump 76→79. Console: web/console/src/lib/memory/tests/derive.test.ts (the base64 decodeMemoryValue incl. a multibyte UTF-8 value, the PascalCase summarizeMemoryEvent / projectMemoryEvents, the Go-zero-time honest rendering); web/console/tests/memory-page.spec.ts rewritten for the carded structure + the live event feed + the admin-gated mutation surface. scripts/smoke/phase-108n.sh (live-server) exercises the 3 new methods + the static Console guard. npm run check 0/0, npm run lint clean, npm run test green.

Cross-references. Supersedes the layout + read-only half of D-118 (Phase 73j Memory). Preserves D-001 (identity-mandatory — the new methods validate the triple, fail closed), D-026 (heavy-value-by-reference — memory.get unchanged), D-033 (memory.identity_rejected — the live feed surfaces it), D-035 (memory.recovery_dropped), D-061 (Console DB local-only — saved views), D-065 (no priority/pin dimension — no fabricated bulk buttons), D-066 / D-079 (admin scope gates the mutations), D-118 (the read surface), D-121 (CONVENTIONS.md), D-171 (session header default), D-180 (derive display-state live), D-184 (the primitive+consumer-in-one-wave pattern this follows). Builds on Phase 23–25 (MemoryStore), Phase 73j (the read surface it rebuilds), Phase 73g (events), Phase 105 (disconnected redirect), Phase 108b / 108k / 108m (the carded master-detail pattern + controller refactor). RFC §6.6, §7; brief 11 §"Memory view"; brief 12 §"The two-surface model"; CONVENTIONS.md (D-121) + PAGE-POLISH-PROCEDURE.md. Plan: docs/plans/phase-108n-console-memory-page.md.


D-187 — Console Artifacts page rethemed to the carded, viewport-locked master-detail composition + the king file refactored to a controller, AND the real admin-gated artifacts.delete mutation landed (supersedes the Phase 73l / D-120 read-only layout + the deferred delete surface)

Decision (Phase 108o). Bring the Console Artifacts page to the Phase 108 page-polish bar AND land the admin mutation it had been deferring — the primitive + its consumer in one wave (CLAUDE.md §13, the D-184/D-186 pattern). The new artifacts.delete method routes through the existing ArtifactsSurface control dispatcher and composes the ALREADY-SHIPPED ArtifactStore.Delete (Phases 17–19) — NO driver-seam change, so all V1 drivers (inmem / fs / sqlite / postgres / s3) back it with zero per-driver work and no conformance-parity churn. Console half: a carded .panel.card viewport-locked master-detail rebuild (filter card + records table-left + a stacked right rail of preview / actions / metadata / tags), the ~916-line king file refactored into an ArtifactsPageState controller + a pure unit-tested derive.ts + the focused ArtifactsTable component, the per-page PageHeader dropped, and the page's prominent Delete affordances (the row action, the bulk bar, and the rail) turned from disabled placeholders into the REAL admin mutation.

Honest gating + scope (§13 / PAGE-POLISH §1). artifacts.delete gates STRICTLY on the verified admin scope claim (D-079 / page-artifacts §9 — Delete is a mutating verb, strictly more than the read scope; unlike the cross-tenant READ gate, a mutation does NOT admit console:fleet, which is an observation claim). It is identity-mandatory (full triple), idempotent (deleting an absent id returns deleted=false with no error — matching the store contract, never a fabricated CodeNotFound), and emits an artifacts.deleted audit event ONLY on an actual eviction (SafePayload — the content-addressed artifact id only, never any bytes — D-026). The Console gates the row/bulk/rail Delete disabled-with-tooltip without the admin claim. artifacts.usages (the "Where used" cross-reference) and the Set retention bulk action stay deferred: usages needs a state-store join that is not a cheap pure-consumer read, and retention is the immutable-V1 carve-out (§10) — honest findings, not stubs. The preview pane is unchanged and still dispatches through the canonical renderer registry ($lib/chat/renderers, brief 12) — no bespoke per-mime renderer.

Heavy bytes by reference (D-026), preserved. The catalog rows stay metadata-only; preview + download route through the artifacts.get_ref presigned URL; the CSV export is metadata-only. artifacts.delete carries only the scope + the content-addressed id — no bytes cross the wire on the mutation either.

Live-wire verification (PAGE-POLISH §3). Verified against a live harbor dev runtime booted with HARBOR_DEV_SEED_FIXTURES=1 (the seeder writes three text artifacts — research-notes.txt / triage-report.txt / thread-summary.txt — under the dev triple; the inmem artifact store is otherwise empty on a fresh boot). The catalog rendered the three seeded rows; selecting one resolved a preview through the ArtifactPreview registry path (the inmem driver does not presign, so the preview legitimately resolves the presign-unsupported branch — proving the registry dispatch is wired); an artifacts.put upload appeared in the catalog; and a real admin artifacts.delete evicted a row (the table count dropped + an honest result line). artifacts.delete of an unknown id returned {deleted:false}; a no-bearer request → 401. Browser-truth at 1512×945: zero browser console errors; scrollHeight == innerHeight; no horizontal overflow. The validation sqlite state was backed up before seeding and restored afterward (the seed writes memory turns to it; the artifact store is ephemeral inmem).

Supersedes. The Phase 73l / D-120 read-only Artifacts layout (the PageHeader-led list + the globalThis-injection seam removed in W6 + the disabled-with-tooltip Delete / Set-retention bulk placeholders + the row Delete deferred stub). The data-read layer of D-120 (the artifacts.{list,put,get_ref} wire types + the surface + the Console-local D-061 saved filters) is unchanged; the closed-set IsArtifactsMethod predicate + the methods count grow from three to four.

Tests. Go: internal/protocol/artifacts_delete_test.go (admin evicts + emits the artifacts.deleted audit event + the store no longer holds the id; non-admin → scope_mismatch; idempotent on an absent id; missing-identity / empty-id failure modes; under -race); the single-source / conformance count assertions bump 79→80. Console: web/console/src/lib/artifacts/tests/derive.test.ts (fmtSize / sourceKind / displayStatus / the Go-zero-time relative label / previewFamily); web/console/tests/artifacts-page.spec.ts updated from the PageHeader + disabled-Delete assertions to the carded structure + the admin-gated Delete contract. scripts/smoke/phase-108o.sh (live-server) exercises artifacts.delete + the static Console guard. npm run check 0/0, npm run lint clean, npm run test green.

Cross-references. Supersedes the layout + read-only half of D-120 (Phase 73l Artifacts). Preserves D-001 (identity-mandatory — the delete validates the triple, fails closed), D-021 (multimodality — upload V1 input unchanged), D-022 / D-026 (ArtifactRef canonical + heavy bytes by reference — the delete carries only the id), D-061 (Console DB local-only — saved views + CSV export), D-065 (no priority dimension), D-066 / D-079 (admin scope gates the mutation), D-120 (the read surface), D-121 (CONVENTIONS.md), D-171 (session header default), D-180 (derive display-state live), D-184/D-186 (the primitive+consumer-in-one-wave pattern this follows). Builds on Phase 17–19 (ArtifactStore), Phase 73l (the read surface it rebuilds), Phase 105 (disconnected redirect), Phase 108b / 108k / 108n (the carded master-detail pattern + controller refactor). RFC §6.10, §7; brief 11 §"Artifacts view" / §PG-4; brief 12 §"The shared chat / playground library"; CONVENTIONS.md (D-121) + PAGE-POLISH-PROCEDURE.md. Plan: docs/plans/phase-108o-console-artifacts-page.md.


D-188 — Console Flows page rethemed to the carded, viewport-locked composition + the king files refactored to controllers; the detail route is deliberately retained against the rail-as-detail pattern; the empty-state copy is corrected to the flows-as-tools truth (supersedes the Phase 73i / D-117 pre-chrome layout)

Decision (Phase 108p). Bring the Console Flows page to the Phase 108 page-polish bar (PAGE-POLISH-PROCEDURE.md). This is a Console-ONLY pass — the six flows.* Protocol methods (flows.list / flows.describe / flows.runs.list / flows.runs.describe / flows.run / flows.metrics) already shipped in Phase 73i, so the page is already fully real-wired; 108p closes the foundation gap. Both routes — the /flows catalog and the /flows/[flow_id] detail — adopt the carded .panel.card, viewport-locked composition (a non-scrolling filter card / action row; only the table / graph / rail scroll internally — PAGE-POLISH §6); the per-page PageHeader is dropped on both (the breadcrumb / ⌘K / footer are app-shell chrome, 108b); and the king files are refactored into FlowsListState ($lib/flows/state.svelte.ts) + FlowDetailState ($lib/flows/detail.svelte.ts) controllers + a pure unit-tested $lib/flows/derive.ts (re-exports the shipped format.ts projections, adds toPageError / displayStatus / successKind / health) + a focused FlowsTable component — the Tools-108k / Memory-108n / MCP-108m / Artifacts-108o pattern. No new Protocol method; no Go change.

Deviation 1 — the /flows/[flow_id] detail route is RETAINED, against the 108m/n/o rail-as-detail pattern. The other Phase 108 pages collapse detail into a right rail; Flows does not. The flow detail surface is a read-only engine-graph DAG canvas (EngineGraphCanvas, the Live-Runtime-topology renderer family) plus a run-history table and a per-run summary — it needs full width and does not fit a --size-rail rail. The mock (page-flows.md §4) itself specifies a distinct Detail Mode with a full-bleed graph; the rail pattern is the wrong shape here. Both routes still adopt the carded + viewport-locked shell, so the divergence is in topology only, not in the design vocabulary. The list-mode interaction stays mock-faithful (page-flows.md §6): a row-click opens the detail route; a per-row Metrics affordance loads flows.metrics into the list rail; Run flow ▶ opens the inline runner — the artifacts-style row-click-selects model was considered and rejected because it contradicts the mock.

Deviation 2 — the empty-state copy is CORRECTED, departing from the mock (§17.6 fix-what-you-find). The pre-chrome catalog empty-state (and page-flows.md §7) read "No flows registered — flows are defined in agents whose planner is Graph / Workflow / Deterministic." That is factually wrong. A flow is a composable engine-graph DAG (internal/runtime/flow) that is registered as a tool (flow.RegisterAsTool, Transport: TransportFlow — D-023) and is invocable DIRECTLY via flows.run with no planner in the loop (the run Trigger is one of user / planner / system). PlannerFamily ("graph" / "workflow" / "deterministic") is CATALOG METADATA, never a runtime gate, and the flow engine is a general primitive (a future runtime could embed it). Rendering the planner-family claim would teach the operator a false mental model. The copy is rewritten to the flows-as-tools, future-open truth; page-flows.md §7 is reconciled in the same PR. The mock prose is the lower authority (PAGE-POLISH §0 authority chain); the runtime truth wins.

Honest scope, preserved. Run flow stays the page's ONLY mutating action, scope-gated on auth.ScopeAdmin (D-079) and degrading to disabled-with-tooltip, never vanishing (D-066) — the runtime is the authoritative gate, the UI gate is advisory. The graph canvas is view-only (D-063): Add node / Delete edge / Save graph / New flow do not render (absent, not disabled). Saved-view chips + the snapshot / compare-versions affordances are Console-local (D-061) — never a shadow source of truth for flow entities. Heavy run outputs surface by reference (D-026) — RunSummaryPanel renders an Open artifact link, never inline bytes.

Live-wire verification (PAGE-POLISH §3). Verified two ways against a live harbor dev runtime. (1) The EMPTY path against the real validation agent (no registered flows): flows.list with a bearer returned {"flows":[],...} and the catalog rendered the CORRECTED flows-as-tools empty-state copy (not the old planner-family wording); no-bearer → 401 (fail closed). (2) The LOADED path against an ISOLATED seeded runtime (HARBOR_DEV_SEED_FIXTURES=1 on a throwaway /tmp/harbor-flowseed data dir + --no-hot-reload, so the validation agent's durable sqlite was never touched — confirmed byte-identical to a pre-run backup afterward; the seeder logged flows=2). Every datum traced end-to-end: the catalog rendered the two seeded flows ← flows.list; the per-row Metrics affordance loaded the rail (2 runs, 24 sparkline buckets) ← flows.metrics; a row-click opened the detail route rendering the read-only graph (1 node), the Healthy health pill, the real Source: cmd/harbor/devseed.go, and the 2-row run history ← flows.describe + flows.runs.list; selecting a run loaded the per-node timeline + honest "This run produced no output." ← flows.runs.describe; the admin token enabled Run this flow ▶ with the correct tooltip. All four PageState branches seen: Loaded, Empty (corrected copy), Loading (skeleton), and disconnected (clearing the connection redirected /flows/settings, Phase 105). Browser-truth at 1512×945 on BOTH routes: scrollHeight == innerHeight (945 == 945), no horizontal overflow, and zero console errors on a clean hard-reload with a valid token (a direct-URL load of the detail route re-rendered the graph + runs from the runtime — hydration confirmed).

Supersedes. The Phase 73i / D-117 pre-chrome Flows layout (the PageHeader-led list + detail, the inline page state, the format.ts-only projection split, and the inaccurate planner-family empty-state). The data layer of D-117 (the flows.* wire types + the surface + the Console-local D-061 saved filters) is unchanged.

Tests. Console: web/console/src/lib/flows/tests/derive.test.ts (displayStatus ready/empty derived live — D-180; toPageError ProtocolError vs unknown; successKind / health thresholds; the re-exported format.ts projections). web/console/tests/flows-page.spec.ts updated for the carded structure (testids preserved). scripts/smoke/phase-108p.sh carries the static Console guard (PageHeader gone, carded vocabulary, the controllers + derive.ts + FlowsTable exist, no hand-rolled fetch, the corrected empty-state copy) + an optional live flows.list route probe (404/405/501 → SKIP). npm run check 0/0, npm run lint clean, npm run test green.

Cross-references. Supersedes the layout half of D-117 (Phase 73i Flows). Preserves D-001 (identity-mandatory — the consumed flows.* surface validates the triple), D-023 (Flow-as-Tool — the corrected empty-state names it), D-026 (heavy outputs by reference), D-061 (Console DB local-only — saved views + snapshot/compare), D-063 (view-only — no authoring affordances), D-065 (no priority dimension), D-066 / D-079 (admin scope gates flows.run), D-117 (the data surface), D-121 (CONVENTIONS.md), D-171 (session header default), D-180 (derive display-state live), D-184/D-186/D-187 (the controller / derive.ts king-file refactor pattern). Builds on Phase 73i (the flows.* surface it rebuilds), Phase 75a (the flow fixtures), Phase 105 (disconnected redirect), Phase 108b / 108k / 108m / 108n / 108o (the carded composition + controller refactor). RFC §6.1, §6.2, §7; brief 11 §"Flows view"; brief 12 §"The two-surface model"; CONVENTIONS.md (D-121) + PAGE-POLISH-PROCEDURE.md. Plan: docs/plans/phase-108p-console-flows-page.md.


D-189 — Multimodal attachment handling is split into disposition-policy (84b) + provider-native mechanism (84c) + embedding client & semantic retrieval (84d); disposition is policy, not mechanism; embeddings target semantic memory / skill retrieval

Decision (planning). The superseded phase-84b-bifrost-multimodal-v13.md ("Bifrost extended multimodal") conflated three separable concerns and, in doing so, forced behavior: it hardcoded a per-MIME disposition map in internal/planner/multimodal.go::materializeOne and auto-routed attachments (e.g. a PDF) to provider-native understanding, with no way for a developer — least of all a Protocol or third-party client — to say "don't send this to the provider; I'll process it myself with a tool / retrieval." The work is split into three phases:

  • 84b — Multimodal attachment disposition policy. The mechanism becomes declared policy. An AttachmentDisposition enum (ref / inline / provider_native / tool:<name>) is resolved per-attachment caller hint (carried by the Protocol input-artifact disposition field, or set directly on InputArtifactView by a headless library consumer) > per-agent policy map (carried by harbor.yaml, or constructed programmatically as planner.DispositionPolicy) > runtime default. The policy core — the enum, the policy type, and the pure precedence resolver — lives in internal/planner; the dev run loop (and its devstack mirror) is a thin caller, never the home of the precedence logic. The default is ref — byte-for-byte the behaviour shipped today (the ArtifactStub + Fetch.Tool hint the planner already drives via native tool-calling, 107c). The materializer becomes a policy consumer, not the policy author. 84b ships no provider mechanism and no embeddings.
  • 84c — Provider-native multimodal mechanism. Implements the provider_native disposition (opt-in via 84b, never the default): the bifrost driver uploads an over-threshold attachment via Bifrost.FileUploadRequest (already on core@v1.5.15) and rewrites the content part to a file_id reference, performed inside Complete so LLMClient stays one method (RFC §6.5). The driver is the only seam — the run loop never pre-uploads, the part-level ProviderNative flag is settable by any CompleteRequest builder (headless consumers included), and the file_id cache + lifecycle (TTL/evict + Close-time cleanup) are driver-owned with identity read from ctx; observability is the llm.provider_file.uploaded event, not a task field. Priority order is deliberate — image / audio / video first (the perception modalities; regain capability the stub path loses), PDF / documents last (the ref/tool + 84d path is the preferred document route). ArtifactStub stays the universal degradation. Also completes the streaming-with-multimodal residual Phase 107's row forward-referenced (107 shipped text streaming; 84c proves multimodal inputs combine with it, in 107's req.Stream + llm.completion.chunk vocabulary — NOT the non-existent CompleteStreaming the old plan named).
  • 84d — Embedding client + semantic retrieval. Adds Harbor's first embeddings capability — an Embedder §4.4 seam wired to bifrost's EmbeddingRequest — required for the "process it myself" path (a developer keeping a doc as a ref and retrieving over it needs embeddings). Per the §13 primitive-with-consumer rule, its in-wave consumers are semantic memory retrieval and semantic skill retrieval — the direction set by the project owner, NOT a standalone RAG tool. Both are opt-in modes composing with (not replacing) rolling_summary memory and token-savvy skill retrieval. The Embedder is a standalone, factory-constructible primitive usable à la carte (memory/skills are its first consumers, not its gatekeepers); injection into both consumers is via explicit Deps with fail-loud guards (mirroring memory.Deps.Summarizer), so the modes are constructible in Go with no config file, and identity is mandatory at the Embed edge. Requires a §6.5 RFC addendum (the Embedder seam) landed in the same PR.

Why policy, not mechanism (the load-bearing principle). Disposition is a choice that belongs to the developer / operator / planner, not a fact the runtime hardcodes. Harbor already has the "process it myself" seam — every ArtifactStub can carry a Fetch.Tool hint (internal/llm/llm.go::StubFetch, populated at multimodal.go:176), and with native tool-calling (107c) the planner elects the tool path turn-by-turn. The bug in the old plan was preempting that seam by auto-uploading. Making provider_native an opt-in disposition behind a policy (default ref) preserves the developer's control across Playground, Protocol, and third-party clients, and is strictly less code than the auto-routing it replaces.

RFC alignment. Adding optional ProviderFileID / DocumentType fields preserves §6.5's "exactly one of URL/DataURL/Artifact" invariant (additive, backward-compatible). The Embedder is a separate interface, not a method on the one-method chat LLMClient — bifrost itself separates Embedding / Speech / Transcription, and §6.5's "one method" rule is about not bolting tools= onto chat, not about other capabilities; the §6.5 addendum sanctions the seam. The ErrContextLeak LLM-edge guard (D-026) gains a precise exemption: a file_id-only part (no inline bytes) is legal over-threshold.

Numbering. This entry (D-189) records the split + the principles. D-190 is reserved for Phase 84c (provider-native mechanism) and D-191 for Phase 84d (Embedder + semantic retrieval); each is logged in full when its phase ships.

Cross-references. Supersedes the scope of the old phase-84b-bifrost-multimodal-v13.md (renamed to phase-84b-multimodal-disposition-policy.md; provider-native content moved to 84c). Builds on D-026 (context-window safety net), D-166 (F11 multimodal happy path), D-167 (native tool-calling, 107c). Preserves D-001 (identity-mandatory — disposition + file_id cache + embeddings are all identity-scoped). RFC §6.4, §6.5, §6.6, §6.7, §6.10, §11 Q-3; brief 03 (tools-and-llm), brief 04 (memory-and-skills), brief 08 (llm-client-validation), brief 11 (console-feature-surface). Plans: docs/plans/phase-84b-multimodal-disposition-policy.md, phase-84c-provider-native-multimodal.md, phase-84d-embedder-semantic-retrieval.md.


D-192 — RunLoop dispatches decision execution on a per-step goroutine and drains APPROVE/REJECT mid-step, closing the approval-gated CallTool deadlock

Date: 2026-06-09 Status: Settled (shipping with this PR)

Where it lives: internal/runtime/steering/dispatch.go (the new RunLoop.dispatchDecision — the per-step dispatch goroutine + mid-step inbox drain); internal/runtime/steering/apply.go::applier.routeApprovalControl (the mid-step entry into the D-097 bridge); internal/runtime/steering/runloop.go (the default: decision-execution case now calls dispatchDecision; the new carryEvents per-run local merges mid-step-deferred controls ahead of the next boundary's drain); internal/runtime/steering/dispatch_test.go (mid-step approve / reject / defer-once / cancel unit tests against the REAL gate + Coordinator); test/integration/approval_midstep_test.go (the end-to-end choreography: real deterministic planner → real catalog + Phase 64a builder WrapWithApproval → real approval.ApprovalGate → real pauseresume.New Coordinator → real in-mem bus → steering.RunLoop, with APPROVE / REJECT enqueued on the steering Inbox — the Protocol edge's path).

The bug (verified by the SDK-friction audit, every line confirmed). Approval-gated CallTool deadlocked the run loop. The chain: the run-loop goroutine called spec.ToolExecutor.ExecuteDecision SYNCHRONOUSLY (runloop.go, the default: case) → the executor invoked the descriptor inline (cmd_dev_executor.go::callTool) → the approval wrapper called gate.RunGuarded inside Invoke (catalog.go::WrapWithApproval) → RunGuarded blocked on the per-pause resolve channel until ResolveApproval or ctx cancel (gate.go) → but the steering Inbox was drained ONLY at the step boundary (runloop.go), so an APPROVE / REJECT control enqueued on the inbox could never reach the D-097 bridge (apply.go::advancePauserouteThroughGate) while the step was blocked. A planner-dispatched gated tool hung the run until ctx cancellation — in EVERY deployment shape (headless and harbor dev + Console; the Protocol edge's approve method enqueues onto the same inbox). HITL approval is one of the four canonical reasons the unified pause/resume primitive exists (RFC §6.3; pauseresume.go reason set), yet no working choreography existed anywhere in the tree for the canonical shape. The D-097 entry's "common shape (planner idle, gate's pause is independent)" assumption was only ever satisfied by test choreography that invoked the gated tool on a separate test goroutine.

Decision. RunLoop.Run's decision-execution case dispatches ExecuteDecision on a per-step goroutine (under a stepCtx derived from the run ctx) and, while the execution is in flight, keeps draining the steering inbox — routing ONLY the approval-bridge-eligible controls mid-step: an APPROVE / REJECT whose wire payload carries a gate-minted token that one of the configured gates owns (exactly what the D-097 bridge handles). The bridge implementation is NOT duplicated: the mid-step path enters through the new applier.routeApprovalControl, a thin eligibility pre-check (type + gates + wire-token extraction) in front of the SAME routeThroughGate the step-boundary advancePause uses (scope elevation, gate iteration, ResolveApproval — one implementation, two entry points).

ALL other controls (PAUSE / RESUME / CANCEL / REDIRECT / INJECT_CONTEXT / USER_MESSAGE / PRIORITIZE — and any APPROVE / REJECT no gate owns) keep their existing step-boundary semantics: drained mid-step, they are deferred verbatim into a per-run carryEvents local and merged AHEAD of the next boundary's fresh drain (FIFO preserved), where they get the full applyEvent treatment exactly once — including their control.received / control.applied lifecycle emits and control-history record (emitted at apply time, never duplicated). This is identical to when they would have acted under the synchronous dispatch: the step was in flight; the boundary was the first point they could ever apply. A control consumed mid-step is consumed — it records the same lifecycle + history footprint the boundary path produces and is NOT re-applied (a re-apply would fail loud with ErrNoOutstandingPause).

Invariants preserved:

  • Join-before-return. The per-step goroutine is joined on every path — happy, run-ctx-cancelled, retired-inbox, and mid-step-bridge-error (where stepCtx is cancelled first so a parked RunGuarded waiter unblocks before the join). The per-iteration WaitForEvent waiter goroutine is likewise always joined (1-buffered result channel, received on every path). No goroutine outlives the step; the goroutine-baseline tests stay green.
  • Cancellation semantics unchanged. The execution runs under a child of the run ctx; cancelling the run still aborts an in-flight gated decision (RunGuarded honours ctx) and the next step boundary surfaces ctx.Err() exactly as the pre-D-192 synchronous path did.
  • D-025. Nothing lands on the RunLoop struct: carryEvents, the done / waitRes channels, and stepCtx all live on the run's own goroutine stack. The RunLoop stays a compiled artifact; the existing N=120 concurrent-reuse test passes unchanged.
  • Fail-loud. A substantive mid-step gate error (scope mismatch, gate closed, coordinator error) records the failure in the history + lifecycle events and fails the run — the same posture as a step-boundary apply failure. No silent degradation.

Alternative considered — an exported ApprovalResolver component subscribed per-run (rejected). A standalone resolver (subscribing to the bus or owning a side-channel the Protocol edge dispatches APPROVE / REJECT into, calling ResolveApproval off-loop) would also unblock the gate. Rejected because: (a) it is D-097's already-rejected shape 3 (ApprovalDispatcher) re-litigated — it duplicates the inbox-drain and creates a SECOND consumption path for the same control vocabulary, violating §13's "two parallel implementations of the same conceptual feature"; (b) it bifurcates ordering and audit: controls would race the boundary drain, the control-history record, and the scope checks the inbox already performed at Enqueue; (c) it widens the public surface (a new exported component every embedder must wire) where the mid-step drain fixes the one goroutine that already owns the inbox, the gates map, and the history — the smallest semantic delta. No hard blocker for the mid-step drain was found.

Tests. Unit (in-package, real gate + real Coordinator + real in-mem bus via the bridge fixtures): TestRun_MidStepApprove_UnblocksGatedDecision (the canonical resume + consumed-once history assert), TestRun_MidStepReject_SurfacesRejectionObservation (tool body never runs; error-shaped observation), TestRun_MidStepDrain_DefersNonApprovalControls_AppliedOnceAtNextBoundary (deferred controls applied exactly once — no drop, no double-apply), TestRun_CancelWhileGatedMidStep_AbortsCleanly (ctx cancel aborts the parked dispatch; goroutine baseline restored). Integration (test/integration/approval_midstep_test.go, §17.3 real-drivers-on-every-seam): TestE2E_ApprovalGatedCallTool_ApproveUnblocksRun (real deterministic planner + real catalog/builder/gate/coordinator/bus; identity propagation asserted through every layer; original args round-trip), TestE2E_ApprovalGatedCallTool_RejectSurfacesRejection (the failure mode; tool.rejected on the bus), TestE2E_ApprovalGatedCallTool_CancelWhileGated_AbortsCleanly (no orphaned goroutine). The one stand-in is the test-local ToolExecutor shim: the production executor is unexported in cmd/harbor (package main) — a known audit finding (Pattern 1 / P1); the shim mirrors devToolExecutor.callTool's resolve-then-Invoke shape so the REAL approval wrapper + gate sit in the invocation path, and the test carries a top-of-file note naming the production gap per §17.6. Promoting the executor into an importable runtime package is a separate wave.

Cross-references. Fixes the deadlock latent in D-097 (the bridge itself is unchanged — this entry gives it its first reachable production trigger for the planner-dispatched shape); preserves D-090 (AppliedGates is still the one gates map), D-096 (typed resume Decision), D-098 (the per-task driver's FSM bridge is untouched), D-025 (concurrent-reuse contract), D-067 / D-070 (the pause/resume + steering primitives), brief 02 §6 (step-boundary semantics retained for all non-bridge controls). RFC §6.3 (steering + the unified pause primitive), §6.4 (approval gates). CLAUDE.md §5 (concurrency), §11 (testing), §13 (fail-loud, no parallel implementations), §17.3/§17.6.


D-193 — The SDK re-homing program: Harbor must hold as a headless Go SDK; production semantics move out of cmd/harbor; half-shipped primitives get first consumers or formal deferral

Date: 2026-06-09 Status: Settled (planning — the program structure; each phase logs its own entry when it ships)

Where it lives: docs/notes/sdk-friction-audit.md (the §17.5-pattern ad-hoc audit this program answers: 10 seam investigators + adversarial verification, 62 findings, 0 refuted); docs/plans/phase-110{a,b,c,d}-*.md (Wave B — re-homing) and docs/plans/phase-111{a..f}-*.md (Wave C — finish the primitives); docs/plans/README.md 110-band + 111-band sections.

Decision. RFC §1's "ships as a Go module" is a product property, not an aspiration: a Go consumer who embeds the Runtime headless — never running harbor dev, never serving the Protocol, never opening the Console — must reach every runtime capability through exported, constructible seams. The audit found the capability layer SDK-clean but a stratum of production semantics living only in package main (with an already-diverged D-094 devstack mirror) and a band of primitives whose only consumers are tests. The program:

  • Wave A (shipped as PR #277 / D-192 + PR #278): the correctness bugs + honesty fixes — the approval-gated CallTool deadlock, the unwired session-GC RunningProbe, devstack parity drifts, fail-loud on dead config knobs, lying godocs.
  • Wave B (Phases 110a–110d; D-194–D-197 reserved): mechanical promotion of cmd-only production semantics into reusable internal/ packages — the ToolExecutor (110a), the RunContext population + event closures (110b), the config→snapshot projections + defaults + the blank-import aggregator (110c), the config→stack assembly fan-out + MCP/OAuth attach helpers (110d). Each promotion's §13 consumer is cmd/harbor AND harbortest/devstack converting to thin callers in the same phase, collapsing the D-094 hand-maintained mirror. Staging: 110a∥110c, then 110b∥110d.
  • Wave C (Phases 111a–111f; D-198–D-203 reserved): every shipped-but-consumerless primitive gets its first production consumer or a recorded deferral — governance enforcement (111a), tool-OAuth completion (111b), durable pauses + pause lifecycle (111c), the skills canonical surface + ingestion verb (111d), trajectory compression (111e), telemetry assembly + the approval-gate authorizer seam (111f). Mutually independent; parallelize after Wave B Stage 1.
  • Wave D (not yet planned): the external-module facade — promoting the verified inventory (identity, events, tools, llm, stores, planner/tasks/steering, assembly) out of internal/ so external teams can import what the scaffold templates and recipes already pretend is public. This is an RFC-level decision (the Phase 71 harbortest/ precedent) and is deliberately gated on Wave B (you cannot facade what lives in a binary); it is NOT covered by the 110/111 plans.

The two principles the program enforces. (1) Re-homing over mirroring: logic needed by more than one assembly (cmd, devstack, a consumer's loop) lives in an exported internal/ package; D-094-style verbatim mirrors are a deprecated pattern — every Wave B phase deletes one. (2) The §13 primitive-with-consumer rule is re-read against the current tree: a primitive whose only consumer is a test is in violation TODAY regardless of what wave shipped it; Wave C is the repayment schedule, and the honest-godoc rule (a seam's docs state its real consumption status) guards against recurrence.

Direction rule (recorded for future phases). Runtime packages may import internal/protocol/types (pure-data projection vocabulary — the Protocol is the canonical contract) but never protocol auth / methods / transports (behavior). The one standing violation (internal/tools/approval importing internal/protocol/auth) is repaid by 111f.

Numbering. D-194–D-197 reserved for 110a–110d; D-198–D-203 reserved for 111a–111f; each logged in full when its phase ships.

Cross-references. Builds on D-189 (the 84-band split — the SDK-consumer lens's first application), D-192 (the deadlock fix), D-149/Phase 83f (the seam-without-wiring class the audit generalized), D-155 (config-projection drift), D-094 (the devstack mirror this program collapses), D-044 (governance latent default — preserved by 111a), D-025/D-026 (the contracts the promoted code carries with it). CLAUDE.md §13 (primitive-with-consumer, two-implementations, silent degradation), §17.5 (the audit pattern), §17.6 (fix-both-sides), §17.7 (the wave cadence the staging follows). RFC §1, §6.3, §6.4, §6.5, §6.15. Plans: docs/plans/phase-110a…110d, phase-111a…111f; findings: docs/notes/sdk-friction-audit.md.


D-194 — The production ToolExecutor is promoted to internal/runtime/dispatch; the answer envelope + terminal error codes are exported in internal/planner; the catalog→planner view is tools.NewPlannerView; the devstack degraded executor is deleted

Date: 2026-06-09 Status: Settled (shipping with Phase 110a)

Where it lives: internal/runtime/dispatch/dispatch.go (the promoted executor — dispatch.NewToolExecutor(cat, store, taskReg, opts...) steering.ToolExecutor with WithHeavyThreshold / WithMaxSpawnDepth / WithLogger functional options, plus the exported dispatch.HeavyTruncationSummary shape contract); internal/planner/answer_envelope.go (planner.AnswerEnvelope, planner.TaskErrorCodeRunLoopError / planner.TaskErrorCodeCancelled, planner.TaskErrorCodeForFinish); internal/tools/planner_view.go (tools.PlannerView + tools.NewPlannerView); thin callers at cmd/harbor/cmd_dev.go / cmd_dev_runloop.go and harbortest/devstack/devstack.go; the converted D-192 E2E at test/integration/approval_midstep_test.go.

Decision. The only production steering.ToolExecutor lived unexported in package main (cmd/harbor/cmd_dev_executor.go, ~660 lines) — the SDK friction audit's Pattern 1 / P1 finding (D-193 Wave B). Phase 110a promotes it verbatim into internal/runtime/dispatch behind an exported constructor; the int constructor parameters (heavyThreshold, maxSpawnDepth) become functional options with the SAME normalization (non-positive → 32 KiB floor / depth 4), so config-value passthrough behaviour is unchanged. Three companion exports close the adjacent audit findings in the same stroke:

  • planner.AnswerEnvelope + terminal error codes (P3). The Phase-106 {answer, finish_reason, tool_calls_seen} shape and the Finish.ReasonTaskError.Code mapping were an implicit cmd↔cmd wire contract (one cmd file marshalled what another parsed). Now one named type + constants, homed in internal/planner on import-direction grounds: the envelope is the projection of planner.Finish, and homing it in internal/tasks would force a new tasks→planner edge (tasks is planner-free). A golden test pins the encoding byte-for-byte against the Phase-106 map-literal shape. The dispatch executor's taskOutcomeObservation keeps its parse generic (json.Unmarshal into any) — a TaskResult.Value is not guaranteed to be an envelope, and a typed parse would silently drop unknown fields (§13) — with the godoc naming planner.AnswerEnvelope as the documented shape and a round-trip test consuming the typed struct.
  • tools.NewPlannerView (P5). The catalog→planner view adapter moves to internal/tools as an exported concrete satisfying planner.ToolCatalogView STRUCTURALLY — internal/tools cannot name the interface because internal/planner imports internal/tools; the compile-time assertion lives in internal/planner's tests. The per-run, never-cached construction discipline and its cross-tenant warning move from a package-main comment into the exported godoc; the constructor copies GrantedScopes so callers cannot mutate a constructed view.
  • dispatch.HeavyTruncationSummary (the "savoring" finding). internal/planner/react/prompt.go pattern-matched the heavy-truncation map shape while citing cmd_dev_executor.go::heavyTruncationSummary as its source of truth — an internal/ package documenting package main as its contract. The shape builder is now the exported identifier the prompt renderer cites; the dependency arrow points the right way (the citation is comment-level — react cannot import internal/runtime/... per the planner import-graph lint, and dispatch imports react for the reserved tool names).

§13 consumers in the same phase. cmd/harbor AND harbortest/devstack convert to thin callers of the same constructors; cmd_dev_executor.go + cmd_dev_catalog_view.go are deleted; the devstack degraded devStackToolExecutor (CallTool-only, no D-026 promotion by its own admission — the brief-03 "two parallel modes" smell) and devStackCatalogView are DELETED, giving devstack D-026 heavy-result promotion, CallParallel, and SpawnTask/AwaitTask parity in one stroke. No second executor implementation survives anywhere (§13 two-implementations rule); the D-094 mirror shrinks.

§17.6 fix bundled. The D-192 HITL E2E shipped with a test-local executor shim and a top-of-file KNOWN PRODUCTION GAP note (the production executor was unreachable from outside package main). The E2E now drives the REAL promoted dispatch.NewToolExecutor (over a real inmem ArtifactStore + real inprocess TaskRegistry); the shim and the gap note are deleted.

Behaviour preservation (the promotion bar). Dispatch semantics, D-026 thresholds and provenance stamps (source: tool, created_at, and the verbatim-preserved producer: dev-tool-executor value pre-110a consumers may key on), preview heuristics, error strings, spawn-depth defaults, poll cadence, and the envelope's JSON byte-shape are unchanged — pinned by the moved parity tests plus the new golden/degradation tests. The executor's D-025 posture carries over: immutable after construction, per-run state on ctx/RunContext, with the mandatory N≥100 concurrent-reuse tests (CallTool + CallParallel + spawn/await) and a new parallel-cancel cross-talk test.

Cross-references. Implements Wave B item 1 of D-193 (the re-homing program); builds on D-152 (Phase 83i — the original executor), D-169 (107d CallParallel), D-170 (107e SpawnTask/AwaitTask), D-026 (heavy-content safety net), D-025 (concurrent reuse), D-176 (provenance discriminator), D-156 (granted-scopes filter), D-192 (the E2E this phase converts), D-094 (the mirror this phase shrinks). CLAUDE.md §13 (primitive-with-consumer, two-implementations), §17.6 (fix-both-sides), §4.3 (deviations: none — behaviour moved verbatim). RFC §6.2, §6.4, §6.5. Plan: docs/plans/phase-110a-tool-executor-promotion.md; findings: docs/notes/sdk-friction-audit.md §2 (P1, P3, P5).


D-195 — Phase 110b: RunContext population promoted to internal/runtime/runctx; per-run event closures become events.IdentityStampingEmitter + llm.NewChunkPublisher; devstack gains Emit/OnChunk + answer-envelope parity

Date: 2026-06-09 Status: Settled (shipping with Phase 110b)

Where it lives: internal/runtime/runctx/runctx.go (the five promoted helpers + golden parity tests in runctx_test.go); internal/events/emitter.go (IdentityStampingEmitter); internal/llm/chunk_publisher.go (NewChunkPublisher + the envelope-identity regression gate in chunk_publisher_test.go); thin callers at cmd/harbor/cmd_dev_runloop.go and harbortest/devstack/devstack.go (every duplicate deleted); test/integration/phase110b_runctx_parity_test.go (the devstack-parity E2E); scripts/smoke/phase-110b.sh.

Decision. The five RunContext-population helpers — the runtime's half of the planner contract per brief 02 ("the planner never imports runtime internals; everything it sees arrives through RunContext") — lived as unexported package main functions, hand-duplicated in devstack (the D-094 mirror tax; the SDK friction audit's verifier found a THIRD drifting copy of the keyword shaper). Phase 110b promotes them verbatim into the direction-safe package internal/runtime/runctx (runtime/* may import planner/memory/skills/artifacts; internal/planner gains NO new imports — its import list stays memory-free): ProjectMemoryBlocks (D-149 llm_context projection), ProjectSkillsContext (D-149), ExtractSkillKeywords + its stopword set and 10-term cap (the D-156 FTS5/BM25 query shaping), ExtractAssistantAnswer (D-152), and ResolveInputArtifacts (the D-166 identity-scoped GetRef + image-byte-inlining + ref-only-fallback policy, now a function over its explicit dependencies). The two per-run event-emission closures become ~20-line constructors on their owning packages: events.IdentityStampingEmitter(bus, q, logger) func(Event) (stamps the run quadruple on identity-less events, Warns loudly on publish failure) and llm.NewChunkPublisher(bus, q, taskID, logger) func(delta, done, kind string) (identity on the Event ENVELOPE — encoding the trap that once produced 280+ bus-rejected chunks per task; the kind parameter is string because planner imports llm, with a one-line planner.ChunkKind adapter at the call site). Settled calls:

1. §13 consumers in the same phase. cmd/harbor AND harbortest/devstack convert to thin callers; the five cmd-local helpers, the two cmd-local closures, and ALL devstack duplicates (devStackProjectMemoryBlocks, devStackProjectSkillsContext, devStackExtractSkillKeywords + stopwords, devStackExtractAssistantAnswer, the devstack resolveInputArtifacts method) are deleted — grep-asserted in the smoke. No second copy of any projection survives (§13 two-implementations rule); the D-094 mirror shrinks again.

2. Devstack parity closure (§17.6 — the mirror gains what it was MISSING, not just what it duplicated). The kit's RunSpec wired neither Emit nor OnChunk, so planner telemetry (planner.decision/planner.finish) and token streaming (llm.completion.chunk) were silently dead on the official test surface — devstack validated weaker semantics than production ships. Both are now wired via the SAME promoted constructors production calls. Its MarkComplete carried an empty tasks.TaskResult{} (the audit's "empty result" drift); it now marshals the 110a-exported planner.AnswerEnvelope byte-identically to production. The integration test pins all three: a devstack-run task produces decision + chunk events on the bus under the run's quadruple (N=10 concurrent runs, no cross-run bleed), the completed task's result parses as a non-empty envelope, and a bus closed mid-run produces loud Warns (never silent drops). Note (2026-06-09, Wave B checkpoint audit): one parity gap was recorded rather than closed — cmd registers per-run trajectories and wires the tasks-protocol Enricher (cmd_dev.go's devEnricher over runLoopDriver.TrajectoryByTaskID), while devstack discarded the trajectory and wired no enricher, so devstack tasks.get reads carried no trajectory enrichment. RESOLVED (2026-06-10, program follow-ups chore): DevStackRunLoopDriver gains the mutex-guarded per-task trajectory map + TrajectoryByTaskID accessor and devstack's tasks projector wires the mirrored devStackEnricher (harbortest/devstack/enricher.go) when a run-loop driver exists — parity, not promotion: the driver shell stays per-caller per D-197 call 4. Pinned by test/integration/devstack_trajectory_enrichment_test.go (a devstack tasks.get wire read carries the trajectory enrichment).

3. ExtractSkillKeywords promoted WITH a deletion notice (owner decision 2026-06-09, the amended 110b scope). The 111d Directory wiring (D-201) replaces the raw-Search injection path and deletes this helper + its call sites. It is promoted anyway — the mirror collapse must not wait on 111d and landing order is not guaranteed — but its godoc carries "scheduled for deletion by Phase 111d (D-201); add no new consumers", and the deletion rides 111d regardless of which phase lands first.

4. Publish-context bridge. The constructors' returned closures publish under context.Background() with a documented rationale: planner.RunContext.Emit/OnChunk are ctx-less by contract (the CLAUDE.md §5 "documented bridge across an unmanaged async boundary" case), bus drivers detect their own closure internally (ErrBusClosed), and the failure path is the loud Warn — behaviour against a closed bus is unchanged from the pre-110b d.subCtx shape (the inmem driver reads the ctx only for redaction). Correction (2026-06-09, Wave B checkpoint audit): the recorded justification is complete only for the inmem driver — the DURABLE driver drives store.Save with the publish ctx, so under events.driver: durable the old d.subCtx cancellation-on-Close semantics changed: persistence is now bounded only by the driver's closed.Load() check + bus Close, no longer by caller-ctx cancellation. Threading a real caller ctx through the emit constructors is a recorded Wave-C-candidate follow-up. Resolved (2026-06-10, D-207): events.IdentityStampingEmitterContext + llm.NewChunkPublisherContext carry a caller-supplied base ctx; both run-loop drivers (cmd + devstack) pass their driver-lifetime d.subCtx, restoring the pre-110b cancellation-on-Close semantics on the durable bus (whose Publish now also honours ctx.Err() up front, so the bound holds even over the ctx-blind inmem StateStore). The ctx-less constructors remain as the documented Background bridge for callers with no lifetime ctx.

5. The D-196 call-4 handoff one-liner rides this phase. internal/runtime/dispatch's spawn-depth clamp now references config.DefaultSpawnDepthCap (the duplicated defaultMaxSpawnDepth = 4 literal is deleted) — the single-line cross-region unification deferred from the Stage-1 merge because 110a/110c built in parallel worktrees.

Behaviour preservation (the promotion bar). Keyword shaping (stopwords, 1-char drop, dedupe-preserving-order, 10-term cap, all-stopwords→caller-falls-back-to-raw-query), memory/skills projection shapes, assistant-answer fallbacks, and the input-artifact policy (nil-store Warn, GetRef-miss skip, image-bytes ref-only fallback, identity-scoped reads) move verbatim — pinned by golden tables (including the keyword table moved verbatim from the cmd test). D-025: the five projections are pure functions; the two constructors allocate no shared mutable state (per-run closures over one concurrent-safe bus), gated by N≥128 concurrent-run stress tests under -race asserting no cross-run identity bleed and goroutine-baseline restoration.

Findings I'm departing from. None.

Protocol additions. None — pure Go re-homing; no new event types, no chunk-payload shape change, no wire changes.

Cross-references. Implements Wave B item 2 of D-193 (the re-homing program; Stage 2, parallel with 110d); builds on D-194 (110a — the exported planner.AnswerEnvelope devstack's parity consumes), D-196 (110c — the config.DefaultSpawnDepthCap single source this phase wires into dispatch), D-149 (83f population), D-152 (83i emit closure + answer extraction), D-156 (83m keyword shaping), D-166 (input-artifact policy), D-176 (the BuildArtifactManifest promotion precedent), D-094 (the mirror tax shrunk), D-025/D-026 (the contracts carried over), D-201 (the 111d deletion the keyword helper's godoc names). CLAUDE.md §13 (primitive-with-consumer, two-implementations), §17.6 (fix-both-sides — the parity closure), §6 (envelope identity). RFC §6.2, §6.5, §6.13. Plan: docs/plans/phase-110b-runcontext-population-promotion.md; findings: docs/notes/sdk-friction-audit.md §2 (P2, P4).


D-196 — Phase 110c: exported FromConfig projections + config.Defaults()/ValidateCore + the internal/drivers/prod aggregator close the config-duality seam

Date: 2026-06-09 Status: Settled (shipping with Phase 110c)

Where it lives: internal/llm/from_config.go (SnapshotFromConfig + the absorbed copy helpers + the reflection field-parity gates in from_config_test.go); internal/memory/from_config.go (SnapshotFromConfig); internal/skills/from_config.go (SnapshotFromConfig); internal/planner/from_config.go (ConfigFromOperator + HintsFromConfig + the B3-closing reflection parity test); internal/governance/from_config.go (ConfigFromOperator); internal/config/loader.go (defaults() → exported Defaults()); internal/config/validate.go (ValidateCore — the headless profile that skips only the validateIdentity JWT ceremony; Validate semantics unchanged); internal/config/config.go (SkillsContextMaxResolved() + exported DefaultSkillsContextMax / DefaultSpawnDepthCap — the deduped knob defaults); internal/drivers/prod/prod.go (the production blank-import aggregator); cmd/harbor/main.go + cmd/harbor/cmd_dev.go + cmd/harbor/cmd_dev_runloop.go (thin-caller conversion; every local duplicate helper deleted); harbortest/devstack/devstack.go (ALL mirror duplicates deleted; aggregator imported; cfg-driven skills open added; AssembleOpts overrides now fall back to the cfg-projected values); test/integration/phase110c_config_projections_test.go (the B3 wiring regression gate + wrapper-chain seating + fail-loud unknown driver + identity-isolation stress); scripts/smoke/phase-110c.sh; AGENTS.md/CLAUDE.md §3 + §4.4 + §13 (the aggregator named as the single sanctioned blank-import home); docs/glossary.md ("FromConfig projection", "production driver aggregator").

Decision. Five subsystems (llm, memory, skills, planner, governance) deliberately decouple from internal/config via snapshot/config types — but every config→snapshot projection was an unexported package main helper, hand-duplicated in devstack. That mechanism shipped D-155 (the snapshot dropped CustomProviders/NetworkDefaults/Corrections) and carried the audit's live B3 drift (devstack's planner projection dropped ExtraGuidance/ReasoningReplay/MaxToolExamplesPerTool/ParallelToolCalls despite its own "MUST track production field-for-field" comment). Phase 110c exports ONE projection per owning package — llm.SnapshotFromConfig(cfg, art), memory.SnapshotFromConfig(cfg), skills.SnapshotFromConfig(cfg), planner.ConfigFromOperator(cfg) + planner.HintsFromConfig(cfg), governance.ConfigFromOperator(cfg) — converts cmd + devstack to callers, deletes every duplicate, and pins each projection with a reflection field-parity test (every config field is projected or carries an explicit exclusion naming its real consumer; a new field without either fails the build). Settled calls:

1. Import direction: the subsystem imports internal/config additively; config stays a leaf. internal/config has zero internal imports (verified); many subsystems (state, artifacts, audit, tasks…) already take config.XConfig at Open. The snapshot decoupling is preserved because FromConfig is optional sugar on the side — Open(ctx, snapshot, deps) signatures are unchanged and snapshot-first construction remains the headless golden path.

2. The parity gate found a THIRD live field-drop, fixed in the same PR (§17.6). Both copyModelProfiles copies (cmd + devstack) silently dropped LLMModelProfileConfig.CostOverrides and .Corrections: an operator's per-model cost_overrides: / corrections: yaml validated cleanly and then did nothing (the Phase 34 corrections layer read a zero-valued CorrectionsProfile; the Phase 36a cost accumulator never saw the override table). llm.SnapshotFromConfig maps both; the sub-struct parity tests pin them. Three field-drop instances from one mechanism (D-155 shipped, B3 live, this one latent) is the full proof the mechanism — not the discipline — was the bug.

3. config.Defaults() exported; ValidateCore is subtractive-minimal. The loader-private defaults() meant a hand-built config got a different baseline than a YAML-loaded one. Defaults() is the one documented baseline; Load still calls it; security-relevant fields stay intentionally absent. ValidateCore() runs EVERY section validator except validateIdentity (the Protocol-server JWT ceremony a headless embedder never serves); anything ambiguous stays in core (fail-closed bias). Full Validate() order and semantics are byte-identical (shared runValidators walk). The documented headless recipe: config.Defaults() → set LLM.Provider/Model/APIKeyValidateCore()<pkg>.SnapshotFromConfig(...)Open(...).

4. Planner-adjacent knob defaults are single-sourced on internal/config. skills_context_max's zero→5 lived as TWO run-loop literals (cmd + devstack); now config.PlannerConfig.SkillsContextMaxResolved() + exported config.DefaultSkillsContextMax are the one source (the driver constructors keep a defensive clamp referencing the same constant). The spawn-depth default deduped to exported config.DefaultSpawnDepthCap (referenced by SpawnDepthCap()); the executor-side clamp (promoted to internal/runtime/dispatch by the parallel Phase 110a) references this constant — the coordinator wires that one-line reference at Stage 1 merge since 110a/110c built in parallel worktrees. planner.HintsFromConfig re-homes the YAML→PlanningHints projection out of the run loop.

5. internal/drivers/prod is the single sanctioned blank-import home (§4.4/§13 amended). The aggregator's doc-commented blank imports register everything main.go's ~30-line block registered (drivers + the corrections/downgrade/retry/governance LLM wrapper hooks + the notifications event-type registration). main.go and devstack both collapse to one import — closing the audit's §7 trap (devstack composed the LLM client without the full wrapper chain because its hand-curated list drifted; Wave A patched three wrappers in, 110c makes the class structurally impossible). The aggregator concentrates — not widens — the blank-import privilege; the mock LLM driver stays OUT (D-089's gated boundary). New drivers add their import to the aggregator, never to main.go.

6. The Wave A devstack parity test is superseded by a strictly stronger gate. TestPlannerConfigFromConfig_FieldParityWithProduction (and its export_test.go alias) guarded the devstack duplicate; with the duplicate deleted, parity holds by construction. The replacement gates: the reflection field-parity test at the owning package (internal/planner/from_config_test.go) + the integration-level B3 wiring gate (test/integration/phase110c_config_projections_test.go registers a capture planner driver and proves the config that reaches planner.Resolve through a devstack assembly carries every operator-set field and equals planner.ConfigFromOperator's output).

7. Devstack consumes every projection, with AssembleOpts overrides falling back to cfg. Assemble now opens skills from cfg.Skills via skills.SnapshotFromConfig (production mirror; AssembleOpts.SkillStore still wins), and the per-task driver's memory / skills-cap / planning-hints wiring falls back to the cfg-projected values when the opts overrides are unset — D-094 parity by construction instead of by comment.

Why. RFC §1's "ships as a Go module" requires a Go consumer with a *config.Config to reach subsystem snapshots without transcribing five unexported package-main helpers — and both existing transcriptions had shipped silent field-drop bugs. After 110c the config-duality seam moves from "partial" to "yes" on the audit's scorecard, and it is the substrate 110d's Assemble composes.

Findings I'm departing from. None.

Protocol additions. None — pure Go re-homing; zero schema or wire changes (no config field changed semantics; the examples load byte-identically).

Cross-references. D-193 (the re-homing program; Wave B Stage 1 = 110a ∥ 110c), D-155 (the recurrence class closed), D-094 (the mirror tax this deletes), D-149 (the planner-adjacent knobs), D-169/D-170 (the planner fields + spawn-depth default in scope), D-089 (the mock stays gated), D-044 (governance latent default unchanged). CLAUDE.md §4.4 + §13 (amended), §10, §17.6 (the copyModelProfiles cross-fix). RFC §6.5, §6.6, §6.7, §9, §10. Plan: docs/plans/phase-110c-config-projection-exporters.md; findings: docs/notes/sdk-friction-audit.md §1 (B3) + §6 + §7.


D-197 — Phase 110d: the assembly entry point (assemble.Assemble) — the D-094 subsystem-wiring mirror collapses to thin callers; MCP attach / OAuth assembly / deps-aware events factory promoted; the headless recipe ships acceptance-gated

Date: 2026-06-09 Status: Settled (shipping with Phase 110d)

Where it lives: internal/runtime/assemble/assemble.go (Assemble / Stack / Options, the closer chain, the partial-failure contract) + assemble_test.go (golden boot, forced-failure table, Skip knobs); internal/tools/drivers/mcp/attach.go (mcpdrv.Attach + the exported ProjectToolPolicies config→tools.ToolPolicy projection) + attach_test.go; internal/tools/auth/build_providers.go (auth.BuildProviders + the KEK resolver) + build_providers_test.go; internal/events/openwith.go (events.OpenWith / Deps / RegisterWithDeps) + openwith_test.go; internal/events/drivers/durable/durable.go (the deps-aware durable registration) + openwith_test.go; cmd/harbor/cmd_dev.go (bootDevStack thin-wrapper conversion; applyToolCatalogWiring / resolveOAuthTokenKEK / attachDevMCPServer / projectMCPToolPolicies / toolPolicyFromProjected / cloneStringMap deleted); harbortest/devstack/devstack.go (tryAssemble and its hand-mirrored fan-out deleted; assembleWith is the thin core; attachDevStackMCPServer deleted); docs/recipes/embed-harbor-headless.md (+ index entry); test/integration/phase110d_assemble_test.go (the recipe-path E2E + concurrency stress); test/integration/phase83g_mcp_dev_consumer_test.go (the devstack policy-projection regression gate, §17.6); scripts/smoke/phase-110d.sh (+ re-pointed assertions in phases 83g/83i/83l/83m/83n/107c smokes); docs/glossary.md ("assembly entry point").

Decision. The config→stack fan-out existed in exactly two places — cmd/harbor/cmd_dev.go::bootDevStack (package main) and devstack's unexported, *testing.T-gated tryAssemble — and the copies had drifted (SDK friction audit §2 P6–P8, §5). Phase 110d promotes ONE exported, error-returning assemble.Assemble(ctx, cfg, opts) (*Stack, error); both callers are thin wrappers; the official external surface stops being "a test fixture wearing the assembly-entry-point's clothes". Settled calls and reconciliations (production wins where the copies disagreed; each difference recorded here):

1. Drift reconciliations the conversion closed (§17.6 fix-both-sides). (a) Devstack's MCP attach silently DROPPED the Phase 26b ToolPolicy projection — the promoted mcpdrv.Attach carries it; the regression is pinned twice (the attach SSE-server E2E asserts the projected policy lands on the Registry; the phase83g integration test now declares a policy: block against the real stdio fixture and asserts the registry view). (b) Devstack never constructed cfg-declared OAuth providers (cfg.Tools.OAuthProviders was ignored); the assembly runs auth.BuildProviders for every caller, with Options.OAuthProviders entries overriding same-named cfg-built ones (caller owns injected lifecycles). (c) Devstack's posture Counters passed a nil SessionLister with a stale "devstack assembles no session registry" comment (false since D-171) — now stack.Sessions. (d) Devstack gains the Agent Registry (production had it; the kit did not).

2. One deliberate ordering change: State opens BEFORE the event bus. Pre-110d production opened bus→state; the durable event-log driver could therefore only share the runtime's StateStore via cmd-side direct construction (it never did — it opened a PRIVATE store from events.state_driver). The assembly opens state first and calls events.OpenWith(ctx, cfg.Events, red, Deps{State}), so events.driver: durable with no state_driver now shares the runtime's store (the store outlives the bus; closers run in reverse). Precedence in the deps-aware durable factory: explicit events.state_driver wins (dedicated, owned store — operator intent); else a non-nil Deps.State is shared (not owned); else fail loud naming both ways out (PR #91's §13 posture carried forward). events.OpenWith is a PARALLEL entry point — the registered Factory signature, Open, and deps-ignorant drivers are byte-identical; a third deps-aware driver post-V1 reopens the Factory shape as an RFC follow-up, not this seam.

3. §4.3 signature refinements from the plan. auth.BuildProviders(ctx, config.ToolsConfig, BuildDeps) (map[string]OAuthProvider, error) returns the provider map ONLY — approval gates were never built by applyToolCatalogWiring itself but by the catalog Builder's AppliedGates output, which the assembly invokes; returning gates from BuildProviders would have required auth → catalog (an import cycle: catalog already imports auth). applyToolCatalogWiring is deleted outright rather than "reduced to a thin call" — its body IS the assembly's catalog band. mcpdrv.Attach(ctx, ms, AttachDeps{Catalog, Registry, Bus, Logger, DefaultIdentity, Closers}) uses a deps struct over the plan's 7-arg positional sketch (godoc'd per-field; DefaultIdentity is the Phase 83m transport-event fallback both callers previously hardcoded).

4. Scope boundary: Assemble ends where the network surface begins. Protocol surfaces (ControlSurface, posture, search, per-page services), transports/mux/CORS, dev auth, draft store, devseed, and listeners stay in cmd/harbor; the test-kit conveniences (signer, httptest-able Handler, draft temp-dir) stay in devstack. The per-task run-loop DRIVER (the task.spawned subscriber) deliberately stays per-caller — it is Phase 110b's seam (the population helpers are shared; the subscriber shell is not), and a headless embedder drives Stack.RunLoop.Run directly (the recipe's shape). (The residual driver-shell divergence this leaves — devstack's missing trajectory registration + tasks-protocol Enricher — is recorded in D-195's 2026-06-09 dated note.) Options is the union of what the two real callers need today (Logger, LLMSnapshot, PlannerOverride, SkillStore, OAuthProviders, PreRegisterTools, MCPDefaultIdentity, MetricsOptions, the three Skip knobs) — no speculative embedder wishlist (§13 options-creep guard). One widening: a PlannerOverride now yields a RunLoop even without an LLM client (pre-110d devstack also required LLMClient != nil); the override IS the planner, so the LLM gate was incidental.

5. Lifecycle contract. On error Assemble returns the PARTIAL *Stack (devstack's tryAssemble contract, kept — the caller's deferred Close drains whatever opened); Stack.Close(ctx) error runs the closers in reverse, joins errors, and is idempotent via sync.Once. cmd seeds its rollback list with stack.Close so cmd-only legs close first, the assembled core last — the same effective order as the pre-110d flat list. The governance identity-tiers honesty warning and the notification.* subscriber moved INTO the assembly (one home; both callers previously duplicated them).

Why. Brief 01 §5: two hand-ordered copies of the fan-out are two modes of the same feature, and they had already diverged on a security-adjacent surface (tool policy). Brief 06 §5 one layer down: when the only assembly prior art is package main + a *testing.T fixture, every embedder re-implements boot. After 110d the audit's "reachable headless: no" flips to yes for in-module consumers — docs/recipes/embed-harbor-headless.md is honestly writable because test/integration/phase110d_assemble_test.go executes it (Defaults → ValidateCore → prod import → Assemble → one goal through planner/runloop/executor → AnswerEnvelope → Close), plus durable-store sharing, identity propagation, two failure modes, N=10 concurrent Assemble/Close cycles and N=100 concurrent runs against one stack under -race. Wave D (the external facade RFC) inherits a promotable entry point instead of a binary.

Findings I'm departing from. None.

Protocol additions. None — pure Go re-homing; zero schema or wire changes.

Cross-references. D-193 (the re-homing program; this is Wave B's capstone), D-194 (110a executor — Stack.Executor), D-196 (110c projections/Defaults/ValidateCore/aggregator — the substrate Assemble composes), D-094 (the mirror this collapses), D-150 (Phase 83g MCP attach), D-090/D-095 (catalog wiring + OAuth assembly), D-074 (durable-log degradation posture), D-089 (mock stays gated; validateLLMProvider remains cmd-side policy), D-171 (sessions create-on-first-use), D-025 (the Stack as compiled artifact), D-026 (heavy-content threshold threaded into the executor). CLAUDE.md §4.3 (recorded deviations), §4.4, §13 (primitive-with-consumer: both callers convert in the same phase), §17.3/§17.6/§17.7 (wave-end E2E + fix-both-sides). RFC §6.4, §6.13, §9, §10. Plan: docs/plans/phase-110d-assembly-promotion.md; findings: docs/notes/sdk-friction-audit.md §2 (P6–P8) + §5.


D-198 — Phase 111a: governance enforcement assembled from config — NewSubsystemFromConfig + SetFactory's first production caller; the SetFactory-vs-per-Open decision; Wrap documented as the multi-runtime escape

Date: 2026-06-10 Status: Settled (shipping with Phase 111a)

Where it lives: internal/governance/assembly.go (NewSubsystemFromConfig) + assembly_test.go (D-044 latent pin, nil-deps fail-loud, behavioural compose-order pin, reject-short-circuit zero-provider-calls pin, shared-store persistence, N=128 concurrent reuse); internal/runtime/assemble/assemble.go (the delimited Phase 111a band: eager build → SetFactory BEFORE llm.Open, ClearFactory on empty tiers and on Stack.Close); internal/governance/registry.go (SetFactory godoc: the process-global multi-runtime limitation + the Wrap escape; the wrapper hook's factory-error path now warns loudly instead of silently passing through); internal/governance/wrap.go (the headless composition godoc); test/integration/phase111a_governance_test.go (the three-enforcer E2E + latent golden + cross-session isolation + missing-identity + N=100 concurrent reuse); docs/recipes/embed-harbor-headless.md ("Enforce governance headless") + docs/recipes/run-harbor-dev.md (tiers-now-enforce note); internal/config/config.go / examples/harbor.yaml / docs/CONFIG.md / docs/skills/define-the-agent-yaml / docs/skills/validate-and-package (posture-only phrasing flipped to enforcement, §18 same-PR rule); scripts/smoke/phase-111a.sh; docs/glossary.md ("Governance enforcement assembly").

Decision. Governance enforcement was a fully-built primitive with zero production consumers: SetFactory's only caller was a test, and a populated governance.identity_tiers map drove ONLY the read-only posture surface — clean validation, silent no-op (SDK friction audit §1+§3; a §13 primitive-without-consumer standing violation). Phase 111a ships the assembly and settles three calls:

1. The exported assembly entry. governance.NewSubsystemFromConfig(cfg, store, bus) (Subsystem, error) composes NewCompound(NewMaxTokensEnforcer, NewRateLimiter, NewCostAccumulator) in the documented cheapest-reject-first order. Empty IdentityTiers(nil, nil), preserving the D-044 latent default exactly (the wrapper hook treats a nil Subsystem as pass-through; the one sanctioned "no enforcement" state, visible in posture). Non-empty tiers with a nil store or nil bus → wrapped ErrInvalidConfig — enforcement without persistence or observability is a misconfiguration, not a degraded mode.

2. The production consumer (§13) + the eager-build shape. assemble.Assemble (D-197) calls NewSubsystemFromConfig EAGERLY at boot whenever cfg.Governance.IdentityTiers is non-empty — a construction failure fails the boot loud — and installs the already-built Subsystem via SetFactory BEFORE llm.Open composes the wrapper chain, so the in-wrapper factory-error fallback stays unreachable in production (it now logs a loud Warn for the only callers that can reach it: test-installed factories). Empty tiers call ClearFactory, and the stack registers ClearFactory as a closer: the factory's state always reflects the LAST Assemble's governance config, and a stale factory from a prior stack in the same process can never wrap a stack that declared no tiers (the cross-stack-bleed shape the eager closure would otherwise allow). The Wave A posture-only boot warning (PR #278) is deleted — its condition can no longer occur — and every operator-facing surface that said "enforcement not yet wired" (config godoc, example yaml, CONFIG.md, two skills, the prod aggregator comment, cmd/devstack comments) flips to enforcement phrasing in the same PR.

3. SetFactory-vs-per-Open — evaluated, decided: keep SetFactory global. The seam is process-global; two stacks with different tier maps in one process collide (second SetFactory wins). The binary assembles exactly one stack, llm.Open already consults the seam, and D-044 settled the shape — so no new per-llm.Open option is minted for a consumer that doesn't exist. The multi-runtime escape ALREADY exists and is now documented as the SDK path: governance.Wrap(client, sub) with a per-stack NewSubsystemFromConfig (governance stays outermost per D-043) — godoc'd on SetFactory + Wrap and recipe'd in embed-harbor-headless.md.

Why. Brief 03's two-parallel-modes smell one layer up: the config knob shipped two behaviours (display vs. enforce) and silently delivered only one — populated tiers now mean enforcement, full stop. Brief 08 pinned that bifrost reports real USD cost (Usage.Cost.TotalCost), so there was no missing-data excuse for latency. The E2E proves a configured tier actually gates a real assembled stack: cost ceiling (ErrBudgetExceeded + governance.budget_exceeded), a 1-call rate bucket (ErrRateLimited + governance.rate_limited), and an over-cap MaxTokens request (ErrMaxTokensExceeded + governance.maxtokens_exceeded), each with identity propagation asserted on the emitted event; a governance reject emits NO provider-side request (the wrapped inner client's counter is pinned); one session exhausting its budget never gates a sibling (§6 rule 10); the latent default is golden-tested (marker-bounded zero-governance.* assertion); and the wrapped client + Compound carry D-025 (N=100 integration + N=128 unit concurrency under -race, goroutine baseline restored after Close).

Findings I'm departing from. None. One §4.3 plan correction recorded in the plan file: the Wave A posture-only warning lived in assemble.go (one home post-110d), not internal/config/validate.govalidateGovernance never carried a warning, so the removal lands in the assembly.

Protocol additions. None — no new event types (the three governance.* rejection events shipped with 36a/36b), no wire changes; governance.posture is unchanged.

Cross-references. Implements Wave C item 1 of D-193 (the 111 band); builds on D-044 (the latent default this preserves), D-043 (governance outermost), D-196 (110c ConfigFromOperator consumed), D-197 (110d assemble.Assemble — the wiring site; one home, cmd + devstack thin callers), D-081 (the tier-only config surface), D-025 (concurrent reuse), D-089 (mock-LLM E2E driver, explicitly gated). CLAUDE.md §13 (primitive-with-consumer; no silent degradation), §6 rule 10 (cross-session isolation), §17.3 (real drivers at the seam), §18 (skill same-PR updates). RFC §6.15, §6.5, §6.11. Plan: docs/plans/phase-111a-governance-enforcement-assembly.md; findings: docs/notes/sdk-friction-audit.md §1 + §3.


D-199 — Phase 111b: the tool-OAuth completion leg — auth.CallbackHandler is CompleteFlow's production caller; the flow record is the callback's identity source; denied authorizations resume-with-rejection (DenyFlow); one steer-and-resume recipe

Date: 2026-06-10 Status: Settled (shipping with Phase 111b)

Where it lives: internal/tools/auth/callback.go (CallbackHandler / CallbackOption / WithCallbackLogger / WithSuccessPage / CallbackPath = /v1/tools/oauth/callback / CallbackRoutePattern) + callback_test.go (mappings, no-secret assertions, replay, the D-025 N=128 concurrent pin); internal/tools/auth/auth.go (PendingFlowInfo; the OAuthProvider interface gains PendingFlow + DenyFlow; the RedirectURI godoc re-pointed at the now-real handler); internal/tools/auth/provider.go (Provider.PendingFlow returns the info projection; Provider.DenyFlow; the CompleteFlow godoc honesty edit superseded by truth); internal/tools/auth/drivers/oauth2/oauth2.go (passthroughs + the ErrMissingRedirectURL message naming the mount); cmd/harbor/cmd_dev.go (the unauthenticated GET /v1/tools/oauth/callback mount over assemble.Stack.OAuthProviders); harbortest/devstack/devstack.go (the mirrored mount — thin-caller parity per D-197); test/integration/phase111b_oauth_completion_test.go (the full choreography E2E + expired-flow + replay legs); docs/recipes/steer-and-resume-a-run.md (+ index entry); scripts/smoke/phase-111b.sh; docs/glossary.md ("OAuth callback handler").

Decision. Provider.CompleteFlow — the resume half of the tool-OAuth pause — had ZERO production callers; no route anywhere exchanged (state, code), and the godocs referenced a callback handler that did not exist (SDK friction audit §3). Phase 111b ships the real thing and closes the §13 primitive-without-consumer pair (InitiateFlow/CompleteFlow are the SpawnTask/AwaitTask of OAuth). Settled calls:

1. The handler shape. auth.CallbackHandler(providers map[string]OAuthProvider, opts ...CallbackOption) http.Handler — a plain handler with no Protocol-server / dev-server / cmd dependency. State→owner lookup across the provider map via PendingFlow; CompleteFlow on the owner; sentinel→status mapping ErrFlowNotFound→404, ErrFlowExpired→410, ErrStateMismatch→400, upstream exchange/discovery/registration failure→502, ErrProviderClosed→503, missing params / upstream error→400; success → a static HTML page. No token / code material in any response or log line (§7; pinned by test). harbor dev mounts it at GET /v1/tools/oauth/callback (the documented default RedirectURI shape) BEFORE the /v1/ catch-all; devstack mirrors; both read the same assemble.Stack.OAuthProviders (D-197) so the flow records the catalog wrapper parks are the records the route completes.

2. The flow record is the callback's identity source. The provider redirect carries no Harbor JWT — it CANNOT. The handler rebuilds the completing ctx from the provider's OWN flow record (PendingFlowInfo.Identity, pinned at initiation) and, for ScopeAgent flows, restores the control scope whose admin gate already fired at InitiateFlow. The unguessable one-time 256-bit state nonce is the bearer capability (standard OAuth state semantics). The handler adds zero identity logic of its own — CompleteFlow's identity cross-check and the Coordinator's resume-scope check verify against the same record (brief 09). The route is therefore mounted WITHOUT auth middleware, deliberately and documentedly.

3. §4.3 interface refinements. Provider.PendingFlow(state) bool (zero non-test callers) becomes PendingFlow(state) (PendingFlowInfo, bool) — the bool alone could not locate an owner or rebuild identity; PendingFlowInfo exposes Source / BindingScope / Identity / ExpiresAt and deliberately NOT the PKCE verifier or the pause Token (a bare Coordinator.Resume without CompleteFlow re-parks the run immediately — the trap the plan names). Both methods land ON the OAuthProvider interface (no Supports* ceremony — §4.4: every implementation implements everything; the oauth2 driver passes through).

4. Denied authorizations resume-with-rejection. Upstream error=access_denied → the handler answers 400 with the audit-safe reason AND consumes the flow via the new Provider.DenyFlow(ctx, state, reason), which resumes the pause with the typed DecisionReject marker (D-096). The run fails loud instead of hanging to flow-TTL; the denial is observable as pause.resumed{Decision: reject} (no new event type — the coordinator's emission is the signal). Composes with 111c's sweeper (independent, mutually reinforcing).

5. Run re-entry: one automatic leg, one steered leg. The OAuth pause's resolution is fully automatic (callback → CompleteFlowCoordinator.Resume, Decision: resume). The RUN-level re-entry — a planner that parked the run with RequestPause{ExternalEvent} — rides the EXISTING steering surface: a RESUME control on the run's inbox (the Protocol resume method / Console intervention queue / an in-process bus watcher), the same path HITL approval already uses. The E2E proves the observable contract the plan pins (run re-enters; the re-dispatched tool succeeds USING the freshly-minted token — the tool body fetches the bearer via provider.Token exactly as the HTTP/MCP drivers do) without inventing a parallel resume path (§13). An automatic completion→run-resume bridge is a recorded candidate follow-up; the recipe documents the honesty note.

6. One recipe, not a per-reason recipe. The OAuth completion choreography ships as a section of docs/recipes/steer-and-resume-a-run.md alongside the HITL-approval trigger — HITL and tool OAuth ride the SAME primitive (RFC §3.3); fragmenting per-reason would re-teach the four-parallel-implementations mistake.

Why. A flow you can start but never complete is indistinguishable from a hang (brief 09 — the reference machinery is an explicit initiate/complete PAIR). The pause producer had been live since Phase 30; every operator hit a wall at the redirect. With the handler mounted by default, harbor dev operators get working tool-OAuth with zero ceremony, and headless embedders mount one http.Handler.

Findings I'm departing from. None.

Protocol additions. None — the callback is an OAuth wire endpoint (RFC 6749 redirect target), not a Protocol method; no wire types, no method names, no error codes added.

Cross-references. D-083 (Phase 30 — the provider pair this completes), D-067 (the one Coordinator), D-096 (typed Decision markers — resume on completion, reject on denial), D-097 (the gate bridge whose direct-Resume path the steered re-entry leg preserves), D-192 (mid-step drain — re-entry reachable while a dispatch is in flight), D-193 (the re-homing program; 111b is Wave C), D-197 (Stack.OAuthProviders — the one provider assembly both mounts read), D-025 (the handler as compiled artifact). CLAUDE.md §7 (secrets), §13 (primitive-with-consumer — closed in-phase), §4.3 (the recorded signature refinements), §4.4 (no optional-capability ceremony). RFC §6.4, §3.3, §6.3. Plan: docs/plans/phase-111b-tool-oauth-completion.md; findings: docs/notes/sdk-friction-audit.md §3.


D-200 — Phase 111c: durable pauses + the pause lifecycle — trajectory threaded into the production pause path, WithCheckpointStore wired in the assembly, the max-park sweeper as DecisionTimeout's first producer, timeout-is-terminal

Date: 2026-06-10 Status: Settled (shipping with Phase 111c)

Where it lives: internal/runtime/steering/runloop.go (requestPause trajectory threading; awaitResumeSignal — the parked run's timeout wake — + pauseTimedOut / timeoutFinish and the ErrAlreadyResumed-lost-to-timeout race carve-out) + runloop_timeout_test.go; internal/runtime/pauseresume/coordinator.go (WithMaxParkDuration; derived expiresAt stamping at Request + re-stamp on rehydrate; Status.Decision); internal/runtime/pauseresume/sweeper.go (newRunSweeper / WithSweepInterval / WithSweeperLogger, sweepOnce, ErrSweeperMisconfigured) + sweeper_test.go; internal/runtime/assemble/assemble.go (the ONE Coordinator now constructed WithBus + WithCheckpointStore(stack.State) + WithMaxParkDuration; the config-gated sweeper goroutine on the closer chain) + assemble_pauseresume_test.go; internal/config (PauseResumeConfigpauseresume.max_park_duration / pauseresume.sweep_interval — Defaults + validatePauseResume); examples/harbor.yaml + examples/dev.yaml + docs/CONFIG.md; test/integration/phase111c_durable_pause_test.go; docs/recipes/steer-and-resume-a-run.md (durability + expiry section; file created here — sibling 111b owns the steering-control half); scripts/smoke/phase-111c.sh; docs/glossary.md ("Max park duration", "Pause sweeper").

Decision. The pause/resume primitive shipped durability machinery nothing turned on and a lifecycle with no end (SDK friction audit §3; D-193 Wave C item 3). Phase 111c closes all three gaps:

1. Trajectory threading. steering.RunLoop.requestPause hands the run's LIVE trajectory (RunSpec.Base.Trajectory; planner.Trajectory = trajectory.Trajectory) into the PauseRequest — the Trajectory: nil + "later-phase concern" comment are gone. A non-serialisable trajectory leaf fails the run loud at Request time with trajectory.ErrUnserializable (§11 mandatory test on the production path); nothing is half-persisted.

2. Checkpoint-store wiring (§13 primitive-with-consumer). The ONE Coordinator construction in assemble.Assemble (the merged D-197 assembly site) now passes WithCheckpointStore(stack.State) — cmd + devstack inherit as thin callers, so the D-094 hand-mirror failure mode is closed by construction. Every pause checkpoints through the runtime's own StateStore (D-067 — no parallel persistence seam); the durability E2E proves the restart shape end-to-end (real RunLoop pause → byte-stable trajectory in the format_version: 1 envelope → NEW Coordinator over the SAME store → Resume → run continues) and re-asserts the destructive-Resume contract (resumed ⇒ checkpoint deleted ⇒ ErrPauseNotFound).

3. Pause lifecycle — the sweeper. WithMaxParkDuration(d) stamps a DERIVED expiry (PausedAt + d; zero = never, the default) — deliberately never persisted, so the format_version: 1 envelope is untouched and a restarted Runtime applies its OWN ceiling to rehydrated pauses. pauseresume.RunSweeper(ctx, coord, opts...) reaps expired pauses by calling the public Coordinator.Resume(token, DecisionTimeout, auditFacts) under each pause's OWN identity — DecisionTimeout's first producer (D-096's reserved value goes live; its "no producer yet" godoc is corrected). Reaping deletes the checkpoint; cancel-while-paused stops orphaning records (the sweeper-at-deadline backstop is the shipped floor; no eager cancel-time release — the cancel path stays untouched). The assembly starts the sweeper config-gated (max_park_duration > 0), cancellable + joined on Close (§5; goroutine-baseline test green). Per-record reap failures (e.g. ErrToolContextLost) log loud and do NOT halt the pass; losing the race to a legitimate Resume (ErrAlreadyResumed / ErrPauseNotFound) is the documented benign loser outcome (exactly-once pinned under -race, N=100).

4. Timeout is terminal (the plan's settled semantic). A timed-out pause finishes the waiting run with Finish{ConstraintsConflict} (metadata steering_reason: pause_timeout) — the D-071 REJECT posture applied to deadlines; never a silent unpark-and-continue (the planner is not re-entered), never a park-forever. The parked RunLoop observes the out-of-band reap through two channels: the canonical pause.resumed bus event (primary; identity-scoped subscription while parked) and a coarse Coordinator.Status re-check (the delivery-independent backstop and the only channel on a bus-less RunLoop) — Status gains an additive Decision field so the observer can distinguish timeout from a legitimate out-of-band resume without parsing payloads. A legitimate RESUME control that loses the race (its Coordinator.Resume surfaces ErrAlreadyResumed because the sweeper won) yields the honest timeout-terminal Finish, not a run error; a non-timeout ErrAlreadyResumed still fails loud (the carve-out never widens into silent swallowing). Non-timeout out-of-band resumes (e.g. OAuth completion) deliberately do NOT wake the park — those flows re-enter via steering controls exactly as before (no collision with 111b).

5. §4.3 deviation — the sweeper scan is registry-internal, not Coordinator.List. The plan sketched the sweeper "over the existing List surface"; its Risks section anticipated the conflict and it materialised: List is §6-identity-scoped by design (empty TenantIDs = caller's own tenant; cross-tenant filters must NAME tenants under AdminScoped) — there is no "all tenants" wildcard, and a maintenance actor cannot enumerate tenants it has never seen. Rather than widening §6 with a wildcard or minting an elevated List shape, the sweeper lives in the pauseresume package and snapshots the registry directly (value copies under the mutex — the same discipline List itself uses), while every MUTATION goes through the public Resume under the pause's own triple: scope check, handle re-attach, checkpoint delete, and event emit run unmodified. No storage-level identity filter is bypassed. Consequence (recorded limitation): the V1 sweeper reaps pauses live in the process registry; checkpoints orphaned by a PROCESS CRASH are rehydrated on demand (Status/Resume) but not proactively scanned — state.StateStore has no scan-by-kind surface, and adding one is a §9 RFC conversation, not a quiet widening. Filed as the known V1 boundary in the plan's deviations note. Resolved (2026-06-10, D-207): the §9 conversation happened — RFC §6.11 gained the ONE explicitly-elevated maintenance scan (StateStore.ListKind(ctx, ListScope{MaintenanceScoped: true}, kindPrefix), all three drivers + conformance suite), and every sweep pass now rescues crash-orphaned pauseresume.checkpoint: rows into the registry (rescanCrashOrphans) so the unchanged expired-scan + public-Resume path reaps them at deadline.

Why. Brief 02's whole durability premise ("the planner can pause … get serialised to a state store, and be resumed in a different process") was false on every production path — both assemblies constructed the Coordinator storeless with the store in scope, and even with a store the pause carried no trajectory. And a lifecycle with only an entry edge (Resume was the ONLY checkpoint-deletion path) leaks by construction. Closing both in one phase keeps the §13 pairing honest: the durability machinery gets its production consumer, and the reserved timeout Decision gets its producer, in the same wave.

Findings I'm departing from. None (the List-vs-registry-scan resolution follows the plan's own Risks instruction; recorded above and in the plan).

Protocol additions. None — pause.resumed with decision: timeout was already typed on the wire (D-096); it now occurs in production. The runtime-internal pauseresume.Status gains the additive Decision field (not a wire type).

Cross-references. D-193 (Wave C item 3), D-197 (the one assembly site this wires), D-067 (StateStore as the checkpoint seam), D-069 (format_version: 1 + fail-loud serialise), D-096 (the typed Decision marker; first producer delivered), D-071 (REJECT-is-terminal posture mirrored for deadlines), D-110 (the §6-scoped List the sweeper deliberately does not widen), D-025 (Coordinator/RunLoop stay compiled artifacts; store-backed concurrent-reuse extension), D-192 (the re-entry path the E2E's "run continues" leg rides). CLAUDE.md §5 (fail-loud, ErrUnserializable), §6 (identity-scoped reaping), §11 (pause-serialization + goroutine-leak + concurrent-reuse tests), §13 (primitive-with-consumer ×2). RFC §3.3, §6.3, §6.11. Plan: docs/plans/phase-111c-durable-pause-lifecycle.md; findings: docs/notes/sdk-friction-audit.md §3.


D-201 — Phase 111d: the canonical skills surface — builtin skill_* delegate to the Phase-38/41 handlers; harbor skill import/rm ship over importer.ImportAndStore; the Directory is wired as the <skills_context> producer (owner decision, 2026-06-09)

Date: 2026-06-10 Status: Settled (shipping with Phase 111d)

Where it lives: internal/tools/builtin/skill_search.go / skill_get.go / skill_list.go / skill_propose.go / skill_capability.go (the delegations + the server-computed capability envelope) + skill_delegation_test.go; internal/tools/builtin/builtin.go (RegistryContext gains Bus / Redactor / GrantedScopes; registry entries for skill_list / skill_propose); internal/skills/tools/tools.go (exported SearchHandler / GetHandler / ListHandler seam; the §17.6 nil-Skills-slice fix in GetHandler); internal/tools/visible_names.go (tools.VisibleNames — the ONE allowed-tools producer); internal/skills/importer/importandstore.go (ImportAndStore / ImportReport / ErrDuplicateSkillName / WithOverwrite) + tests; cmd/harbor/cmd_skill.go (+ root.go bind, cmd_skill_test.go); internal/config/config.go + validate.go (skills.directory.{pinned,max_entries,selection}; skill_list/skill_propose in the built-in allowlist); internal/skills/from_config.go (DirectoryFromConfig); cmd/harbor/cmd_dev.go + cmd_dev_runloop.go and the harbortest/devstack mirror (Directory construction + the <skills_context> swap); internal/runtime/runctx/runctx.go (ExtractSkillKeywords DELETED per its D-195 deprecation notice; ProjectSkillsDirectory added); internal/runtime/assemble/assemble.go (RegistryContext wiring); internal/drivers/prod/prod.go (honesty notes replaced with the truth); internal/planner/react/prompt.go (+ golden) — the discovery-section arg shapes; test/integration/phase111d_skills_surface_test.go; scripts/smoke/phase-111d.sh (+ re-pointed assertions in phases 38/83f/83m/110b smokes); docs/skills/configure-memory-and-skills/SKILL.md + define-the-agent-yaml/SKILL.md (§18 same-PR); docs/recipes/use-memory-and-skills-from-go.md; docs/CONFIG.md; docs/glossary.md.

Decision. The skills subsystem shipped deep (Phases 37–41) and production routed around it (SDK friction audit §3): the rich Phase-38 planner tools and the Phase-41 generator were registered NOWHERE while the boot path registered thinner parallel builtin bodies (the §13 two-implementations smell, live); the Phase-40 importer had no shipped invocation path; the Phase-39 Directory had only test consumers. Phase 111d converges all three onto ONE canonical surface. Settled calls:

1. The Phase-38/41 handlers are the single implementation home; the builtin registry stays the single registration carrier. internal/tools/builtin's skill_search / skill_get become thin delegations to the exported skilltools.SearchHandler / GetHandler; the duplicate query/projection bodies (including the 107c client-side tag filter) are DELETED, not toggled. skill_list (Phase-38's third tool) and skill_propose (the Phase-41 generator, D-054 semantics untouched — conflict policy + audit-mandatory emit + rollback) gain their first production registrations through the same carrier. Net effect: capability default-deny filtering, tool-name redaction, and the skill_get token budgeter run on the production path for the first time.

2. The capability envelope is SERVER-computed, never LLM-supplied. The builtin arg shapes deliberately omit the rich handlers' capability field — a model must not widen its own allowed-tool set. CapabilityContext.AllowedTools is derived per call from tools.VisibleNames(catalog, CatalogFilter{triple, GrantedScopes}) over BOTH loading modes (the run's full reachable set). AllowedNamespaces / AllowedTags stay EMPTY — Harbor has no runtime source of namespace/tag grants, so skills requiring them are default-deny filtered (the plan's "surface it rather than pass allow-all" risk resolved on the deny side); when a grants surface lands, runCapability + the run-loop call site are the two places to thread it.

3. skill_propose opt-in rides the existing tools.built_in names list — no second enablement mechanism. The plan sketched a tools.builtin.skill_propose.enabled key; implementing it would have added a parallel enablement shape next to the 107c names-list carrier (§13). Default-disabled = absent from every recommended set in examples/ and the init template; the explicit tools.built_in: [skill_propose] listing IS the yaml opt-in (§4.3 deviation, recorded in the plan).

4. Ingestion ships: importer.ImportAndStore + the harbor skill import / harbor skill rm verbs. ImportAndStore(ctx, id, store, deps, path, opts...) composes the Phase-40 pipeline (frontmatter scan, validation, path-safe attachment resolution rooted at the file's directory) with the store upsert. Conflict policy: duplicate names reject LOUD with ErrDuplicateSkillName unless WithOverwrite(); under overwrite the store's own pack-protection still gates (the verb writes Origin=pack, so pack→pack and pack-over-generated replace; the generator can still never overwrite pack). The CLI verbs are THIN callers (never a second implementation), resolve the store from harbor.yaml's skills: block (the same projection harbor dev boots), print the resolved driver + userinfo-redacted DSN, default to the dev identity triple with --tenant/--user/--session overrides, honour --json, and exit non-zero on rejection with stable codes (skill_config_invalid / skill_import_rejected / skill_rm_failed / skill_internal_error).

5. Directory disposition — RESOLVED: wire it (owner, 2026-06-09; recorded in the plan pre-implementation, logged here in full at ship). Directory.View is the producer of the run loop's <skills_context> prompt block — pinned-then-recent, identity-scoped, capability-filtered (same capfilter source as Phase 38, D-052), redacted — replacing the raw SkillStore.Search + runctx.ExtractSkillKeywords path in both cmd/harbor/cmd_dev_runloop.go and the devstack mirror. The owner adopted the plan's recommendation with the D-176 manifest-pattern + KV-cache framing: a stable pinned-then-recent browse window mirrors the session-artifact manifest, and a stable prompt prefix beats a per-turn query-churned block; per-query RELEVANCE retrieval is the LLM's job via skill_search (107c); the raw-Search path bypassed the capability filter + redaction (a real injection-hygiene gap); operator pinning (DirectoryConfig.Pinned) becomes functional for the first time. The supersede alternative (delete the Directory, keep the keyword heuristic) was presented and declined. Consequences: ExtractSkillKeywords is DELETED per its D-195 deprecation notice (golden tables removed; the 83m item-4 smoke assertions re-pointed at the supersession); runctx.ProjectSkillsDirectory projects the compact SkillView shape (name/title/trigger/task_type/pinned — full bodies stay behind skill_get); the new skills.directory.{pinned,max_entries,selection} config block feeds skills.DirectoryFromConfig, with unset max_entries falling back to the resolved planner.skills_context_max so the pre-111d injection-budget knob keeps its meaning; the Directory is constructed once per stack at the two driver sites (the run-loop driver shell is per-caller per D-197 call 4).

6. §17.6 fix surfaced by the new tests. Invoking the rich skill_get through a catalog with zero surviving skills returned "skills": null and failed the inproc output-schema validation — a latent Phase-38 bug (the handlers were never production-registered, so no catalog-path caller had hit it). Fixed in GetHandler (non-nil empty slice), in the same PR per the fix-both-sides rule.

Addendum (2026-06-10, Wave C checkpoint audit). ImportAndStore's duplicate-name gate is check-then-act (store.Get then store.Upsert, no store-level conditional insert on the V1 SkillStore interface): two CONCURRENT same-name imports without WithOverwrite can both observe ErrSkillNotFound and last-write-win silently. The checkpoint fix serialises the gate+upsert window behind a process-local mutex inside ImportAndStore itself — acceptable for the surface's actual callers (the one-shot CLI verb and single-process headless embedders), and recorded honestly in the godoc. A store-level conditional create (the real cross-process fix) is the noted follow-up if a multi-process ingestion path ever lands; same-name concurrent imports of IDENTICAL content converge regardless.

Why. Brief 04's designed surface (rich tools §4.5, importer §4.7, directory §4.6, persistence-capable generator §5) existed in full and was unreachable — "a Harbor-defining feature that is unreachable is not shipped." After 111d a headless consumer gets ONE answer to "how do I do skills in Go" (ImportAndStore → the exported handlers → Directory.View; docs/recipes/use-memory-and-skills-from-go.md), and the operator gets the verbs the SKILL.md had documented fictionally. The audit's "a headless consumer cannot tell which retrieval surface Harbor stands behind" is closed by there being exactly one.

Findings I'm departing from. None — the phase is the act of stopping a silent departure from brief 04.

Protocol additions. None — CLI + Go surface only; zero schema or wire changes. (The <skills_context> block's per-entry shape changes from search-ranked full bodies to compact directory views; the planner wrapper contract — section name, UNTRUSTED framing — is unchanged.)

Cross-references. D-193 (the re-homing program; this closes audit §3), D-195 (the deprecation notice executed + runctx home), D-196 (config projections precedent; SkillsContextMaxResolved fallback), D-197 (assemble carries the builtin RegistryContext wiring; driver shell stays per-caller), D-052 (Phase 39 directory + the shared capfilter), D-054 (generator semantics, untouched), D-053 (importer round-trip), D-167 (the 107c meta-tool carrier + LoadingMode shape), D-156 (granted-scopes input to the capability envelope), D-176 (the manifest-pattern framing the owner adopted), D-149 (the RunContext skills seam), D-025/D-026 (contracts re-proven through the new registration path). CLAUDE.md §13 (two-implementations closed by deletion; primitive-with-consumer: Phase-38/41 Register surfaces gain production consumers), §17.6 (the nil-slice fix), §18 (SKILL.md same-PR), §4.2 rule 8 (smoke degradation path), §6 (identity-scoped throughout). RFC §6.7, §8. Plan: docs/plans/phase-111d-skills-canonical-surface.md; findings: docs/notes/sdk-friction-audit.md §3.


D-202 — Phase 111e: trajectory compression ships — the TrajectorySummariser home, the RunLoop MaybeCompress call site, planner.token_budget wiring, and the single-compression scope fence

Date: 2026-06-10 Status: Settled (shipping with Phase 111e)

Where it lives: internal/llm/summarizer/trajectory.go (TrajectorySummariser + NewTrajectorySummariser + options; the two-interface disambiguation in the package godoc) and trajectory_test.go (unit + D-025 concurrent-reuse, N=120 shared runner + summariser under -race); internal/runtime/steering/runloop.go (RunSpec.Compression + the step-boundary MaybeCompress gate) and runloop_compression_test.go (golden no-op + fires-once + fail-loud); internal/runtime/assemble/assemble.go (Stack.Compression construction from planner.token_budget); cmd/harbor/cmd_dev_runloop.go + harbortest/devstack/devstack.go (the per-caller driver-shell Budget/Compression projection — both sides in the same PR, §17.6); internal/config (PlannerConfig.TokenBudget + validation); test/integration/phase111e_compression_test.go (the long-trajectory E2E over the 83l scripted-wire server); scripts/smoke/phase-111e.sh.

Decision. The Phase 46 trajectory-compression seam gets its production half end-to-end — the SDK friction audit's "dead on every production path" finding (§3) resolves as SHIP, not defer. Settled calls:

1. The summariser home is internal/llm/summarizer, as a DISTINCT type. NewTrajectorySummariser(client llm.LLMClient, opts ...TrajectoryOption) lands beside the Phase 64/D-089 memory Summarizer — same "LLM client + versioned compaction prompt" composition, same package precedent — but the two interfaces are never conflated: memory.Summarizer is conversation-window → summary text; planner.Summariser is trajectory → five-field TrajectorySummary. The package godoc carries the disambiguation; the import direction is clean (llm/summarizerplannerllm; no cycle). Options: WithTrajectoryModel (route compaction to a cheaper/stronger model than the planner's), WithTrajectorySystemPrompt, WithTrajectoryMaxSummaryTokens. The prompt is versioned (TrajectoryPromptVersion); the response rides the Phase 35 structured-output path (FormatJSONSchema + the existing downgrade ladder); the parse tolerates exactly one markdown fence and otherwise fails loud — garbage is a loud parse error, a vacuous-but-valid object is planner.ErrEmptySummary, an LLM error propagates wrapped.

2. The compaction payload is the planner-facing projection, not the raw serialize (recorded §4.3 deviation). The plan's sketch said "composes a compaction prompt over Trajectory's serialized state"; the implementation renders Step.LLMObservation (the D-026 heavy-content-disciplined projection — what the planner itself saw) over the raw Step.Observation, with a per-fragment byte cap (trajectoryFragmentCap, 4 KB) and a raw-observation fallback for pre-projection-split steps. Reason: the raw observation may legally carry heavy content that MUST NOT reach the LLMClient edge (§13 / ErrContextLeak); a compaction call that trips the safety net on exactly the over-budget trajectories it exists to rescue would be self-defeating. The estimator side is untouched — DefaultTokenEstimator still measures the full Serialize bytes (the budget meters what the trajectory CARRIES; the prompt renders what the planner SEES).

3. The RunLoop is the cadence owner: one MaybeCompress per step boundary, gated spec.Compression != nil && rc.Budget.TokenBudget > 0. The call sits after the control drain/projection and before Planner.Next, so the firing step's own prompt build already renders the Summary != nil path (the consumer half Phase 46 left pre-wired in the React prompt builder). Nil runner / zero budget is byte-identical to the pre-111e loop (golden no-op tests). A MaybeCompress error fails the run LOUDLY — the runner emitted trajectory.compression_failed, the loop returns the wrapped error, the task marks Failed. Never a silent fall-through that pretends compression happened.

4. Single compression per run — the V1.1.x scope fence. The runner's existing Summary != nil idempotence IS the fence: the always-over-budget E2E observes exactly one summariser invocation across the remaining steps. No auto-cascade; a trajectory that re-exceeds budget post-compression grows until the D-026 context-window safety net backstops it. Recorded follow-up: re-compaction cadence (clear-and-recompress policy, owned by the cadence layer) is deliberately out of scope; whoever picks it up clears Trajectory.Summary before re-invoking and revisits the fence here.

5. Production wiring through the merged 110d assembly; the budget is a run option, never planner state. assemble.Assemble constructs planner.NewCompressionRunner(NewTrajectorySummariser(stack.LLM)) onto Stack.Compression when planner.token_budget > 0 — and fails loud at assembly when the budget is set with no LLM configured (§13: no silently-inert knob). The per-task run-loop driver shells (cmd + devstack, the seam D-197 deliberately left per-caller) project cfg.Planner.TokenBudget onto RunSpec.Base.Budget.TokenBudget and Stack.Compression onto RunSpec.Compression — per brief 02 §planner-knobs the budget rides the per-run RunContext, never the planner struct (D-025). Headless reachability: NewTrajectorySummariser(client)NewCompressionRunner(s)RunSpec.Compression + Base.Budget.TokenBudget, no config file required; the recipe section in docs/recipes/configure-a-planner.md shows the snippet.

6. Godoc honesty, reversed. The Wave A dormant-seam markers on planner.Summariser ("Production consumer pending…") and Budget.TokenBudget ("CURRENTLY INERT…") are removed — both godocs now state the true wiring, and the smoke greps assert the markers stay gone.

Why. "Durable long-running agents" is hollow while compression is dead: the consumer half (the React prompt's Summary != nil branch) had been live since Phase 46 with no producer — a standing §13 primitive-without-consumer violation two RFC sections promise away (§6.2, §6.5). The E2E pins the value: a ~2.7 KB tool observation inflates the trajectory past an 800-token budget; the summariser fires once (one extra wire round-trip, latency logged); the next prompt grows by ~320 B instead of the ≥5.4 KB raw-history counterfactual while still carrying the load-bearing fact the final answer depends on; trajectory.compressed lands on the bus under the run's full quadruple.

Findings I'm departing from. None (the payload-projection refinement in call 2 is a §4.3 deviation from the plan's sketch, recorded above; it follows brief 02's intent — the planner-visible view is what gets compacted).

Protocol additions. None — trajectory.compressed / trajectory.compression_failed were already canonical event types (Phase 46); they now occur in production.

Cross-references. D-055 (the five-field summary + estimator mirror), D-025 (compiled-artifact reuse — both new artifacts tested at N≥100), D-026 (heavy-content discipline shaping call 2), D-089 (the summarizer-package precedent; mock stays gated), D-192 (the post-fix step loop this lands in), D-195/D-196/D-197 (driver shells / config projection / assembly bands this threads through), D-094 (cmd↔devstack mirror moved in the same PR). CLAUDE.md §13 (primitive-with-consumer; no silent degradation), §4.3, §17.6. RFC §6.2, §6.5. Plan: docs/plans/phase-111e-trajectory-compression-consumer.md; findings: docs/notes/sdk-friction-audit.md §3.


D-203 — Phase 111f: telemetry assembled in production (telemetry.New + RunErrorHandler + BridgeBusToTracer); the approval gate de-protocolized via the injected resolve authorizer; the Protocol import direction rule recorded

Date: 2026-06-10 Status: Settled (shipping with Phase 111f)

Where it lives: internal/runtime/assemble/assemble.go (telemetry.New construction with the eventbus.New(bus) emitter; Stack.Telemetry / Stack.Tracer / Stack.RunErrorHandler; NewTracer + BridgeBusToTracer started alongside the metrics bridge, all on the closer chain; Options.TelemetryOptions / Options.TracerOptions / Options.ApprovalAuthorizer); internal/telemetry/tracebridge.go (BridgeBusToTracer, DefaultTraceBridgeFilter, ErrTraceBridgeMisconfigured) + tracebridge_test.go; internal/runtime/flow/flow.go (flow.WithRunErrorHandler pass-through to engine.WithRunErrorHandler); internal/tools/approval/authorizer.go (ResolveAuthorizer / PendingInfo / IdentityAuthorizer / ErrAuthorizerRequired / ErrResolveForbidden) + authorizer_test.go; internal/tools/approval/gate.go (GateDeps.Authorizer mandatory; the internal/protocol/auth import DELETED); internal/server/approval_authorizer.go (ProtocolScopeAuthorizer — the wire-side adapter) + tests; internal/tools/catalog/catalog.go (Deps.Authorizer threaded into every built gate); internal/runtime/steering/apply.go (the D-192-era protocolauth.WithScopes self-elevation DELETED; resolve_forbidden apply-error class); cmd/harbor/cmd_dev.go + harbortest/devstack/devstack.go (both inject server.NewProtocolScopeAuthorizer(approval.NewIdentityAuthorizer())); docs/recipes/observe-an-embedded-runtime.md (+ index entry); test/integration/phase111f_telemetry_test.go + phase111f_approval_seam_test.go; scripts/smoke/phase-111f.sh; docs/glossary.md (three entries).

Decision (telemetry half). RFC §6.14's load-bearing claims were false on every production path: telemetry.New (the redactor-mandatory, identity-attributed, bus-paired Logger) had ZERO production callers, engine.WithRunErrorHandler described "production wiring" that did not exist, and NewTracer was never constructed despite main.go blank-importing its span exporters (metrics got BridgeBusToMetrics in PR #91; traces got nothing — the brief 06 "no OTel in the runtime" anti-pattern, half-closed). Phase 111f wires all three ONCE, in assemble.Assemble (the D-197 single fan-out; cmd + devstack inherit as thin callers): the Logger is constructed the moment the redactor + bus exist; the tracer is constructed unconditionally (noop exporter without a collector — spans still exist for in-process propagation); the new BridgeBusToTracer starts alongside the metrics bridge and both join the closer chain. Span model: canonical lifecycle pairs open/end spans, openers nest under the quadruple's most recent open span (tool under task), failure-suffixed closers set span status Error, non-lifecycle events attach as span events (standalone instantaneous spans when no span encloses — nothing silently dropped), and stop ends still-open spans. The brief 06 cardinality split is enforced by construction: metrics keep Type/Producer/Node labels only; identity + run IDs ride on spans. DefaultTraceBridgeFilter() (Admin + lifecycle types only) is the production volume guard — chunk-grade events never become span traffic. The run-error handler is Stack.RunErrorHandler (RunError → Telemetry.Error → paired runtime.error); flow.WithRunErrorHandler is the compose-time pass-through, exercised end-to-end by the flow-as-tool failure E2E. The boot-window posture stands: Options.Logger (bare slog) remains the bootstrap/wiring logger for the pre-redactor window; nothing identity- or payload-shaped is logged there.

Decision (approval half). ApprovalGate.ResolveApproval hard-required internal/protocol/auth scopes, which forced the runtime's own steering bridge to SELF-ELEVATE with protocol scopes to call its own gate (apply.go) — wire vocabulary inside an in-process control path, the audit-§4 tell that the check sat one layer too low. The privilege decision becomes the injected GateDeps.Authorizer seam (interface form, AuthorizeResolve(ctx, PendingInfo) error — PendingInfo carries Tool/Token/Identity/Tags, never arg bytes). Settled calls:

  1. The package default speaks runtime vocabulary. IdentityAuthorizer: the resolving ctx carries the pause's ORIGINATING identity tuple, or the elevated control-scope claim. The control-scope claim REUSES internal/runtime/registry.WithControlScope (evaluated per the plan and preferred over minting a new claim shape: one trust-based-in-V1 elevation vocabulary, same audit posture as the Agent Registry's fleet-control commands). The originating-identity arm is the steering bridge's shape — the run resolving its own gate after the Phase 54 edge already vetted the wire caller's RFC §6.3 steering scope (CheckScope: APPROVE/REJECT need owner-user-or-admin) — so the bridge's self-elevation block is DELETED outright, not relocated. Deliberate, documented permissiveness delta: a DIRECT in-process caller presenting the originating identity (no scopes) could not resolve pre-seam and now can; that is the SDK-consumer story (a headless embedder resolves its own approvals in the identity vocabulary it already has) and it is not weaker on any wire-reachable path — the Protocol edge's scope checks are untouched, and the Coordinator's Resume-side identity equality check still applies after the authorizer (defence in depth, asserted by the integration matrix).
  2. The protocolauth check moves OUT, one-way. server.ProtocolScopeAuthorizer (the Runtime's network-surface package) preserves the pre-seam admin / console:fleet acceptance byte-for-byte and falls through to Next (production: the identity default); Next == nil is the strict wire-only posture. cmd/harbor and harbortest/devstack inject server.NewProtocolScopeAuthorizer(approval.NewIdentityAuthorizer()) at gate assembly (assemble.Options.ApprovalAuthorizer; nil defaults to the identity authorizer for headless embedders). internal/tools/approval no longer imports internal/protocol/auth; ErrApprovalScopeRequired is replaced by ErrResolveForbidden (same fail-closed posture, runtime vocabulary).
  3. Nil authorizer fails loud (ErrAuthorizerRequired at gate construction; catalog.ErrAuthorizerRequired at Builder validation) — an approval gate with no resolve privilege check is a misconfiguration, not a permissive mode.
  4. Ordering note: ResolveApproval now locates the pending entry BEFORE authorizing (the authorizer needs PendingInfo) and reserves it only after authorization succeeds, so a rejected resolver never mutates pause state; tokens are unguessable Coordinator-minted handles, so the lookup-first not-found answer leaks nothing actionable.

The direction rule (recorded). Runtime packages may import internal/protocol/types (pure data projection); they must never import protocol auth / methods / transports (behaviour). The gate's import was the one standing violation of the otherwise-clean direction check (audit §4); this phase repays it. Mechanical tripwire: the phase-111f smoke greps internal/tools/approval for protocol/auth and apply.go for protocolauth.WithScopes. A depguard rule encoding the full direction rule is the noted follow-up (deliberately out of this phase's scope).

Addendum (2026-06-10, Wave C checkpoint audit). The "otherwise-clean / one standing violation" sentence above was too strong: the repo-wide grep finds two further internal/protocol/{auth,methods} imports outside internal/protocol + internal/server + cmd. (a) internal/runtime/flow/protocol/catalog.go imports internal/protocol/methods — this is the <area>/protocol adapter shape the SDK friction audit (docs/notes/sdk-friction-audit.md §7) blessed as an honest one-way adapter; that carve-out is now RECORDED as part of this rule (it previously lived only in the audit notes): a dedicated <area>/protocol subpackage whose whole purpose is the protocol projection may import internal/protocol/methods. (b) internal/search/scope.go imports internal/protocol/auth (Phase 72c's AdminScopeFromAuth) — the area package itself, NOT an adapter subpackage: the exact pre-seam approval-gate shape this phase relocated. RESOLVED (2026-06-10, program follow-ups chore): the predicate relocated to internal/server/search_scope.go as server.SearchAdminScopeFromAuth (the ProtocolScopeAuthorizer precedent); internal/search/scope.go is deleted, cmd/harbor injects the server-owned checker at both Phase 72c construction sites, and the phase-111f smoke's direction-rule tripwire now greps internal/search too. No standing violation remains ahead of the depguard follow-up. The glossary entry mirrors both clarifications.

§4.3 deviations. (a) Options gains TelemetryOptions / TracerOptions (the MetricsOptions precedent: the integration tests are real consumers needing the writer / in-memory-exporter seams) and ApprovalAuthorizer — still the union of real-caller needs, no speculative surface. (b) The plan's "Protocol-side adapter injected at server-side gate assembly" lands as the assemble.Options injection point because gate assembly lives in the ONE fan-out (D-197) — the adapter is still owned by internal/server and only the serving callers inject it. (c) engine/options.go's godoc is updated (not merely "now true"): the Wave A honesty text explicitly said "no production assembly installs one today", which became false.

Why. D-193's program is "the layer below is right, the assembly never happened" — this phase is its observability + layering capstone: three shipped-but-consumerless primitives (telemetry.New, WithRunErrorHandler, NewTracer) gain their production consumers in the same phase as the new bridge (§13 primitive-with-consumer), and the one runtime→protocol-behaviour import is repaid with a seam that makes the gate cheaper to construct headless, not just cleaner. docs/recipes/observe-an-embedded-runtime.md stops being unwritable (audit §7) because phase111f_telemetry_test.go executes its path.

Findings I'm departing from. None.

Protocol additions. None — wire behaviour for APPROVE/REJECT is unchanged (the Phase 31/54 suites pass with only the construction-site mechanical updates); zero schema changes.

Cross-references. D-193 (the re-homing program; Wave C item 6), D-197 (the assembly site), D-192 (the bridge dispatch whose self-elevation this deletes), D-097 (the steering→gate bridge, option A unchanged), D-096 (the typed pause.resumed Decision the E2Es assert), D-082 (the metrics bridge — the symmetry target), D-020 (redaction fail-loud), D-025 (bridges + gate concurrent-reuse), D-090 (catalog Builder — Deps.Authorizer), D-059/D-124 (the Agent Registry whose control-scope claim is reused). CLAUDE.md §5 (logging canon), §6 (identity), §13 (primitive-with-consumer; fail-loud; two-implementations), §17.3/§17.6. RFC §6.14, §6.4, §5.1, §6.3. Plan: docs/plans/phase-111f-telemetry-assembly-approval-seam.md; findings: docs/notes/sdk-friction-audit.md §3, §4, §7.


D-204 — Wave D: the public SDK facade — a top-level sdk/ tree of alias-based re-exports makes RFC §1's "Go module" claim true for external teams; scaffold output must compile externally, gated by a standing smoke

Date: 2026-06-10 Status: Settled (planning — implemented by Phases 112a/112b; D-205/D-206 reserved)

Where it lives: RFC §3.6 (the settled design, added in this PR); docs/plans/phase-112a-sdk-facade.md + phase-112b-external-consumers.md; the SDK friction audit's external-surface findings (docs/notes/sdk-friction-audit.md §5 — scaffold-with-tools cannot compile, harbortest's vocabulary is externally unconstructible, README presents the test kit as the runtime library).

Decision. A new top-level sdk/ package tree (the harbortest/ Phase 71 precedent for escaping internal/; the pkg/ convention was already rejected there) re-exports the curated public surface via type aliases, re-exported constants/sentinels, and thin forwards. internal/ remains the implementation home — an alias IS the internal type, so no mechanism is duplicated, no types fork, and interface satisfiability crosses the boundary for free. The facade is the API-stability contract: re-exported = supported; omitted = deliberately private. The V1.2 inventory is RFC §3.6's list — exactly the audited set that templates, recipes, and devstack already assumed public. Phase 112a ships the tree + an in-module facade-integrity test (every re-export resolves; the curated surface compiles); Phase 112b converts the external consumers — scaffold templates emit sdk/ imports, harbortest's parameter vocabulary becomes externally satisfiable through the aliases, consumer-facing recipes/README flip to the public paths — and lands the standing external-module compile gate (scaffold a tool-declaring agent into a temp module, go build it) so the audit's headline external break cannot silently return.

Why aliases, not moves. Physically relocating packages out of internal/ would churn every import in the repo, break the §3 layout's contract that internal/ is production code's home, and turn every future internal refactor into a public API event. Aliases give a curation point instead: the public surface is chosen line-by-line, internal packages keep full freedom behind it, and the facade's godocs become the external documentation surface.

Numbering. D-205 reserved for 112a; D-206 for 112b.

Cross-references. Builds on D-193 (the program; Wave D was explicitly gated on Wave B's re-homing — "you cannot facade what lives in a binary"), D-197 (the assembly the facade exposes), D-085/Phase 71 (the top-level-package precedent), the §13 primitive-with-consumer rule (112a's facade ships with 112b's consumers in the same wave). RFC §1, §3.6 (new), §5.3 (deprecation posture). CLAUDE.md §3 gains sdk/ with 112a (the implementation PR carries the AGENTS/CLAUDE amendment, mirror-gated).


D-205 — Phase 112a: the public SDK facade shipped — the sdk/ alias tree per RFC §3.6, forwards-only with one documented generic-wrapper carve-out; parity-by-construction aggregator; the integrity test runs the headless recipe through the facade with zero internal/ imports

Date: 2026-06-10 Status: Settled (shipping with Phase 112a)

Where it lives: sdk/doc.go (the tree-level contract statement) + the twenty inventory packages sdk/{identity,events,config,tools,tools/inproc,tools/builtin,llm,memory,state,artifacts,skills,planner,planner/react,planner/deterministic,tasks,steering,dispatch,runctx,assemble,drivers/prod}; test/integration/phase112a_sdk_facade_test.go (the facade-integrity test); scripts/smoke/phase-112a.sh; AGENTS.md/CLAUDE.md §3 (the sdk/ layout entry, mirror-gated); docs/glossary.md ("SDK facade").

Decision. D-204's design lands exactly as settled: every sdk/<area> package is alias-based re-exports of its internal/<area> counterpart — type X = internal.X aliases, const X = internal.X re-exports, and var Open = internal.Open function/variable forwards — with package godoc naming the internal home and the curation contract (re-exported = supported; omitted = deliberately private). Calls made while shipping, recorded so 112b inherits them:

  1. Forwards-only, one carve-out. The facade declares exactly ONE func body: sdk/tools/inproc.RegisterFunc, a thin generic wrapper — Go has no generic function values, so a var forward cannot express it. The wrapper's signature uses the sdk/tools aliases (identical types), and the smoke's no-behavior guard greps that no other func exists anywhere under sdk/. (Amended by D-273 — the gate is now an enumerated two-func allow-list including sdk/assemble.RunTyped, enforced func-level: exactly one declaration per allow-listed file, matched by name.)
  2. Curation posture. The demand signal was the headless recipe + the consumer-facing recipes + harbortest's vocabulary: driver Open/OpenDriver/RegisteredDrivers + ctx helpers + sentinel errors + the operator-buildable config/section types are IN; driver Register* factories (except events.RegisterEventType, which an embedder publishing custom event types genuinely needs, and planner.Register/MustRegister, the external-planner-author seam the swappable-planner property promises), wire/Protocol adapters, event payload structs, wrapper-hook registration, and the conformance kits are OUT. steering.NewRunLoop and the RunLoopOption set stay internal: the constructor's signature names pauseresume.Coordinator, a type the facade deliberately does not export — the production RunLoop reaches embedders as assemble.Stack.RunLoop. (Amended by D-291 — the curation posture gains ONE deliberate serving exception: sdk/server, a curated facade over the promoted serve constructor. The Protocol-server omission is superseded as a decided contract, RFC §5.6; the rest of the "wire/Protocol adapters are OUT" posture is unchanged.)
  3. sdk/drivers/prod parity is by construction. The public aggregator's ONLY content is _ "github.com/hurtener/Harbor/internal/drivers/prod" — init() transitivity seats the identical registration set, so the two aggregators cannot drift (the smoke pins the single-import shape; the integrity test additionally asserts the expected production driver names through each facade's RegisteredDrivers). The mock LLM stays out, same as the internal aggregator (D-089).
  4. The integrity test is the facade's first consumer (§13). It re-expresses the headless recipe exclusively through sdk/ imports (grep-gated: zero internal/ imports): Defaults → ValidateCore → sdk/drivers/prod → Assemble → an in-proc tool registered via the facade → RunLoop → AnswerEnvelope → Close, with two-identity isolation at the bus, the fail-closed incomplete-identity gate, the missing-LLM-config fail-loud failure mode, and a concurrent slice against the one shared stack. The LLM block uses a custom-provider entry (loopback BaseURL, env-var dummy key) so the REAL bifrost driver constructs offline, and assemble.Options.PlannerOverride injects a deterministic planner (a shipped production concrete, not a stub) — the recipe path runs in CI with no network and no mock-driver import. A compile-coverage block references every exported facade name (575 aliases/consts/forwards at ship), so a re-export that stops resolving breaks the build — the plan's ≥95% integrity-completeness bar, satisfied at 100%.

Cross-references. D-204 (the wave decision this implements), D-196 (the internal aggregator), D-197 (the assembly the facade exposes), D-103 (the planner driver registry now externally reachable), D-089 (the mock exclusion), D-085/Phase 71 (the top-level-package precedent). RFC §3.6, §1. Phase 112b consumes this surface (scaffold templates, harbortest vocabulary, recipes/README, the external-module compile gate).


D-206 — Phase 112b: external consumers on the sdk/ facade + the standing external-module compile gate — Wave D complete; the SDK friction audit's §5 external findings are closed

Date: 2026-06-10 Status: Settled (shipping with Phase 112b)

Where it lives: cmd/harbor/scaffold/templates/minimal-react/ (the sdk/-importing templates); scripts/smoke/phase-112b.sh (the standing gate); harbortest/doc.go (the external-usage contract); the five consumer-facing recipes + README.md + docs/recipes/README.md; the 112b facade additions sdk/{audit,telemetry,telemetry/eventbus,governance,tools/auth,skills/importer,skills/tools,skills/generator} + sdk/tools.ErrorClass; RFC §3.6 item 3 (inventory amended); docs/plans/phase-112b-external-consumers.md.

Decision. Everything that pretends to be external now IS external, and a standing preflight gate keeps it true:

  1. Scaffold templates emit sdk/ imports. agent.go.tmpl imports sdk/tools + sdk/tools/builtin + sdk/tools/inproc; the one behavioral substitution is builtin.Register (deprecated, not re-exported by 112a's curation) → builtin.RegisterWith(builtin.RegistryContext{Catalog: cat}, ...), semantics identical for the catalog-only shape. A tool-declaring scaffold (--from-config, ≥1 built-in + ≥1 custom tool) compiles and TESTS green as an external module — the audit's headline external break (§5: "the product's own golden path is broken for its advertised audience") is closed.
  2. The standing external compile gate (scripts/smoke/phase-112b.sh, preflight unit-tests class): scaffolds the tool-declaring shape into a temp dir, appends a replace directive, go mod tidy && go build ./... — FAIL on compile error; bounded (240s default, ~1–2s warm) and self-tested (a deliberately-broken injected file must fail the same step). Phase-67's smoke keeps the TOOLLESS build-check; the tool-declaring shape is owned here, not duplicated (§4.3 call recorded in both scripts and the plan).
  3. harbortest vocabulary is externally satisfiable through aliases — signatures unchanged, zero kit constructors. The audit's three type-poisoned surfaces (Deps.{Bus,Redactor,Identity}, AssertSequence's []events.EventType, NewFaultInjector's tools.ToolCatalog) resolve via sdk/events + sdk/audit + sdk/identity + sdk/tools; an external Agent emits events and reads identity via the same aliases (the "EventLog is structurally empty" finding is dead). The smoke's second external module proves it by RUNNING go test, not just compiling.
  4. Facade additions, flushed out by the conversions (RFC §3.6 item 2 makes additions cheap; item 3 amended): sdk/audit (events.Open and harbortest.Deps both demand a Redactor — the facade's one genuinely-unconstructible mandatory parameter), sdk/telemetry + sdk/telemetry/eventbus (the observe recipe's manual chain), sdk/governance (the D-198 multi-stack path in the headless recipe), sdk/tools/auth (the headless OAuth callback mount — the only externally-unreachable half of a shipped choreography), sdk/skills/{importer,tools,generator} (D-201's "one skills surface" read externally), and sdk/tools.ErrorClass (+ the four class constants, for SimulateFailure). All forwards-only; the phase-112a no-behavior guard still holds.
  5. What was deliberately NOT added: sdk/pauseresume. D-205 settled the Coordinator as facade-private; the steer-and-resume recipe was reworked to the config-driven shape (cfg.PauseResume.* + assemble.Assemble, which wires bus/checkpoint-store/max-park and starts the sweeper) instead of re-litigating the curation.
  6. Docs flipped truthfully. The five consumer recipes + the README's "runtime library" section import sdk/ only (grep-gated by the smoke: zero hurtener/Harbor/internal in any of the six); the in-module-only scope notes are gone where no longer true. §18 sweep: add-an-in-process-tool rewritten to the real RegisterFunc surface (its worked example predated it), scaffold-a-harbor-agent's output tree corrected to the real scaffold shape.

Program closeout. With this phase, Wave D — and the SDK re-homing program (D-193, D-204) — is complete. The friction audit's §5 external-surface findings (scaffold-with-tools cannot compile; harbortest type-poisoned externally; README presenting the test kit as the runtime library; recipes teaching internal/ imports to external readers) are all closed, and scripts/smoke/phase-112b.sh is the standing guarantee that the class of breakage cannot silently return: every preflight compiles a scaffolded external module and runs an external harbortest probe against the live tree.

Cross-references. D-204 (the wave decision), D-205 (the facade + its curation calls, inherited here), D-201 (one skills surface), D-198 (multi-stack governance), D-199 (OAuth completion), D-196/D-197 (aggregator + assembly), D-089 (the mock stays out — the headless recipe now documents the loopback-custom-provider + PlannerOverride offline shape instead). RFC §3.6 items 2–5, §8. docs/notes/sdk-friction-audit.md §5.


D-207 — Program follow-ups: the StateStore maintenance scan + crash-orphan checkpoint sweep, per-Open :memory: databases, emit-constructor base-ctx threading

Date: 2026-06-10 Status: Settled (shipped)

Where it lives: internal/state/state.go (StateStore.ListKind + ListScope + ErrMaintenanceScopeRequired + ValidateListKind) with all three drivers (drivers/{inmem,sqlite,postgres} — the SQL drivers escape LIKE metacharacters) + six new conformancetest cases; internal/runtime/pauseresume/sweeper.go (rescanCrashOrphans — the scan's first consumer) + coordinator.go (Resume skips the tool-context re-attach for DecisionTimeout only) + sweeper_test.go; the four sqlite-family drivers' uniqueMemoryDSN (internal/state/drivers/sqlite, internal/artifacts/drivers/sqlite, internal/memory/drivers/sqlite, internal/skills/drivers/localdb); internal/events/emitter.go (IdentityStampingEmitterContext) + internal/llm/chunk_publisher.go (NewChunkPublisherContext) + internal/events/drivers/durable/durable.go (Publish honours ctx.Err()) with both run-loop drivers (cmd/harbor/cmd_dev_runloop.go, harbortest/devstack) passing d.subCtx; sdk/state (ListScope + sentinel aliases); test/integration/d207_program_followups_test.go + the durable driver's cancellation tests; RFC §6.11; D-195/D-200 dated notes flipped to resolved; docs/plans/phase-111c-durable-pause-lifecycle.md deviations note; docs/glossary.md. Findings: docs/notes/sdk-friction-audit.md (the program this closes out).

Decision. The SDK re-homing program (D-193..D-206) deferred three design-shaped follow-ups rather than quietly widening settled seams mid-wave. This change closes all three:

1. The StateStore maintenance scan + the crash-orphan checkpoint sweep (D-200's recorded V1 boundary). state.StateStore gains its ONE cross-identity surface: ListKind(ctx, scope, kindPrefix) returns every record whose Kind starts with the literal prefix, across all identities. The elevation is explicit and fail-closed — ListScope{MaintenanceScoped: true} is mandatory (ErrMaintenanceScopeRequired otherwise; the §13 "no cross-session queries without an explicit elevated scope claim" rule applied to the persistence floor), an empty prefix is rejected (ErrInvalidRecord — a whole-store dump is never a valid scan), and callers MUST act on each returned record under that record's own identity. There is deliberately NO identity-scoped ListKind mode: identity-scoped reads stay on Load/LoadByEventID, and a second mode would ship without a consumer (§13). All three drivers implement it (no Supports* ceremony, §4.4/§9); the conformance suite gains six cases including the LIKE-metacharacter-literalness trap for the SQL drivers. The first consumer ships in the same change (§13 primitive-with-consumer): every pause-sweeper pass first rescues pauseresume.checkpoint: rows with no live registry entry into the registry (rescanCrashOrphans), re-stamping the expiry from the running Coordinator's own maxPark (the D-200 derived-deadline discipline) — so a crash-orphaned pause is reaped by the unchanged expired-scan + public-Resume path once its deadline passes, and a not-yet-expired orphan becomes legitimately resumable until then. Corrupt checkpoints are loud-skipped and left in the store for the operator, never silently deleted. One consequence on Resume: a DecisionTimeout resume skips the tool-context handle re-attach — timeout is terminal (D-200 call 4: the run finishes with Finish{ConstraintsConflict}; the planner is never re-entered), so the non-serialisable tool half is never needed, and requiring it would wedge crash-orphan reaping forever (a crashed process's handle registry is empty by definition). Every run-continuing decision keeps the fail-loud ErrToolContextLost re-attach unchanged.

2. Per-Open :memory: databases (the cross-subsystem collision named in PR #301 / the 112b workaround comment). Every sqlite-family driver translated the bare :memory: DSN to the PROCESS-WIDE file::memory:?cache=shared database, so two subsystems' parallel :memory: stores collided on one shared schema_migrations table (observed: the artifacts driver-parity test losing its artifacts_blobs migration to a skills store's migration run). Fixed driver-side in all four (state, artifacts, memory, skills/localdb): :memory: now mints a per-Open uniquely named memory URI (file:harbor_<subsystem>_mem_<crypto-entropy>?mode=memory&cache=shared) — shared across the one store's pool (every driver pins SetMaxOpenConns(1), so the pinned connection's lifetime bounds the data's), fully isolated across Opens and subsystems. Same-DSN-reopen sharing was never a documented :memory: contract (verified: no test depended on it — every consumer opens once and passes the handle); the full suite runs green. The 112b integration test's file-backed-DSN workaround is reverted to :memory: with the comment re-pointed at this fix, and a new cross-subsystem isolation test opens state + artifacts + skills :memory: stores concurrently (N=8 each).

3. Emit-constructor base-ctx threading (D-195's dated correction). events.IdentityStampingEmitterContext + llm.NewChunkPublisherContext are the ctx-first variants of the Phase-110b promoted constructors (stdlib CommandContext shape — one implementation, the ctx-less originals delegate with context.Background() and stay as the documented unmanaged-async-boundary bridge for callers with no lifetime ctx). Both run-loop drivers pass their driver-lifetime d.subCtx, restoring the pre-110b cancellation-on-Close semantics the Wave B audit found lost on the durable bus. §17.6 production-side fix found while pinning it: the durable driver's ctx-boundedness silently depended on the configured StateStore driver reading ctx (the inmem store does no I/O and ignores it) — durable.Publish now honours ctx.Err() up front (§5), so the caller-ctx bound holds deterministically across all stores. Tests pin both closures against the real durable driver: live driver ctx persists, cancelled driver ctx stops persistence and Warns loudly.

Why one entry. The three items are one program-closeout unit: each was a deferral the program's decisions recorded with a named boundary (D-200 call 5, D-195 call 4, the 112b test comment), and each lands with its boundary note flipped to resolved in the same change — no orphaned "follow-up" markers survive.

Findings I'm departing from. None. The D-200 deviation note's own instruction ("adding one is a §9 RFC conversation, not a quiet widening") is followed: RFC §6.11 is amended in this change.

Protocol additions. None — all three items are runtime-internal; no Protocol method, endpoint, or wire shape changed (hence no smoke-script delta; the §4.2 rule 2 trigger never fires).

Cross-references. D-200 (the boundary this closes; the derived-deadline + timeout-is-terminal semantics reused verbatim), D-195 (the correction this resolves; the promoted-constructor seam), D-206 (the program closeout this follows), D-027 (the generic StateStore surface ListKind extends), D-067 (StateStore as the checkpoint seam), D-096 (DecisionTimeout), D-110 (the §6-scoped List deliberately NOT widened — ListKind is a different, store-level surface with its own explicit claim), D-025 (drivers + Coordinator stay compiled artifacts; conformance concurrency suite unchanged and green), D-020 (redaction stays upstream of Save — ListKind returns opaque bytes). CLAUDE.md §4.4, §5, §6 rule 5, §9, §11, §13 (primitive-with-consumer; elevated scope claim), §17.6. RFC §6.11, §3.3, §6.3.


Date: 2026-06-10 Status: Settled (shipped)

Where it lives: docs/site/ (the VitePress project: package.json + committed lockfile, .vitepress/config.ts, the landing page, and one include-stub page per operator skill / recipe / reference doc); .github/workflows/docs.yml (build on every PR + push, deploy to GitHub Pages on main only, permissions-minimal per the official deploy-pages template); Makefile (docs / docs-install); scripts/smoke/phase-103.sh; CLAUDE.md/AGENTS.md §18 (the navigation-manifest drift rule); docs/glossary.md ("docs site"); docs/plans/phase-103-github-pages-docs-site.md.

Decision. Harbor's published docs site is the sibling Dockyard project's mechanism, ported: VitePress under docs/site/, DOCS_BASE read from the environment so CI pins the Pages path without hard-coding the repo name, make docs as the local equivalent of the CI build, and the VitePress build doubling as the link-check gate on every PR. Three calls worth recording:

  1. The site renders FROM the repo; it never forks it. Every published page is either the landing page or a one-line <!--@include: …--> stub over a canonical in-repo file (skills, recipes, CONFIG, glossary, decisions, RFC, master plan, productionization playbook, changelog). Drift between source and site is impossible by construction; the cost is a navigation manifest (config.ts + the stub tree) that must move when a skill or recipe moves — pinned by the §18 extension and by scripts/smoke/phase-103.sh, which fails preflight when a repo skill/recipe has no site page.
  2. The dead-link gate is scoped, not blanket. Canonical docs legitimately link into the repo tree (Go packages, scripts, phase plans, examples) — paths that exist on GitHub but are not site pages. ignoreDeadLinks carries one carve-out function for repo-tree pointers; everything else stays fatal, including cross-skill links (../<slug>/SKILL.md), so a renamed skill referenced by a sibling fails the build. A blanket ignoreDeadLinks: true is rejected by the phase smoke.
  3. Vue-template hazards are neutralised config-side, not by editing canonical docs. The canonical docs quote Go text/template syntax ({{ .Args.city }}) and angle-bracket placeholders (<slug>) in prose; VitePress compiles markdown as Vue SFCs and fails on both. The site config sets markdown.html: false (no included doc uses raw inline HTML) and adds v-pre to every inline code span. The canonical files are untouched.

Findings I'm departing from. The phase plan's dependency ordering ("Land Phase 102 first so the site renders the cleaned godoc cross-links"). Phase 103 ships ahead of 102 for the v1.3.0 cut: the site's pkg.go.dev links work regardless — 102's godoc-jargon cleanup improves what pkg.go.dev RENDERS, not whether the site's links resolve — and the published site is the adoption surface the release needs now. When 102 lands, no docs-site change is required. Also narrowed from the plan: no dedicated "Releases"/"Contributing" site pages (the changelog page + GitHub serve both); no theme customisation beyond VitePress defaults (Dockyard's theme is also stock; "matching posture" is satisfied by the shared mechanism).

One manual step. The repository's Pages settings must be flipped to "Source: GitHub Actions" once (Settings → Pages → Build and deployment) before the first deploy succeeds.

Cross-references. RFC §1, §7, §12; brief 13 (operator UX — the published-site adoption signal), brief 06 (devx); D-137-equivalent posture at Dockyard (VitePress, docs/site/, DOCS_BASE); CLAUDE.md §18 (the same-PR drift rule the navigation manifest joins).


D-209 — Phase 113a: the Protocol adoption track opens — generated contract reference (cmd/harbor-gen-protocol-docs + the protocol-docs-gen-check gate), the executed quickstart, choreographies 1–3

Date: 2026-06-10

Status: Settled (shipping with Phase 113a)

Where it lives: cmd/harbor-gen-protocol-docs/ (the generator: method/route/wire-type join table, the eventPayloadIndex, the typeInstanceIndex, the error-guidance table — each pinned by a lockstep test); docs/site/protocol/ (the four generated pages methods.md / events.md / errors.md / types.md + the hand-written index.md / quickstart.md / auth-and-identity.md / streaming-semantics.md / task-control.md); Makefile (protocol-docs-gen / protocol-docs-gen-check); .github/workflows/docs.yml (the gen-check before the VitePress build; the job now carries the Go toolchain); docs/site/.vitepress/config.ts (the Protocol nav section); scripts/smoke/phase-113a.sh (site trip-wires + the executed quickstart); internal/protocol/transports/control/status.go (HTTPStatus, exported); AGENTS.md/CLAUDE.md §18 (the Protocol-docs regeneration clause, mirror-gated); README.md (the Docs-table Protocol row); docs/skills/use-the-harbor-protocol/SKILL.md (the D-093/D-132 claim correction); docs/glossary.md (three terms).

Decision. The Protocol's adopter-facing documentation is generated from the same canonical sources the Runtime compiles from, and gated so it cannot drift — the house single-source discipline (D-072/D-082) applied to published docs, using the gate SHAPE D-093 specified for the TS client generator (git diff --exit-code after regeneration) but built for a generator that actually exists. The D-093 TS generator stays deferred (D-132 / issue #179); nothing here blocks on it, and its future implementation can reuse this phase's reflection plumbing. The proposal's four open questions land per the owner's resolutions recorded in the phase plan: Q1 the event catalog is registry-read at gen time — the generator blank-imports internal/drivers/prod and reads events.EventTypes(), with payload shapes joined by a generator-side eventPayloadIndex pinned in lockstep; Q2 OpenAPI emission deferred (nothing in the generator precludes it); Q3 conformance sdk-export waits for a real third-party ask (113b documents the in-repo path); Q4 versioned docs deferred to the first breaking Protocol change.

Calls made while shipping, recorded so 113b inherits them:

  1. The lockstep mechanism is four tables, each pinned by a test. methodTable() (route derived from the transports' exported *RoutePattern constants — never hand-typed paths; the nine steering controls' scope column rendered via steering.RequiredScope so the docs publish the binding the inbox enforces), eventPayloadIndex (event type → payload reflect.Type(s) or an explicit no-typed-payload note; audit.admin_scope_used legitimately carries TWO shapes — the bus admin-filter emit and the Protocol-edge impersonation emit), typeInstanceIndex (CanonicalWireTypes name → live reflect.Type, since singlesource records names only), and errorTable (when-it-fires + retry guidance; the HTTP column reads the newly-exported control.HTTPStatus so the docs and the wire transport share one binding). A registry gaining an entry any table lacks fails go test — the TestSingleSource_CanonicalMethodsInLockstep mechanism, four times over.
  2. The registry-read blind spot is closed by a source scan. A subsystem registering an event type from a package the generator does not import would be invisible to events.EventTypes() in the generator binary. TestGen_EventConstantsAllRegistered walks internal/ for typed EventType = "..." constant declarations and asserts each is registered AND indexed in the generator binary — so the import list in cmd/harbor-gen-protocol-docs/events.go cannot silently rot. Two payload-home imports reach into driver packages (distributed/drivers/loopback, tools/drivers/mcp) to read exported payload types those drivers declare; each carries a one-line comment naming why (the §4.4 carve-out hygiene; prod already seats loopback's registration, and the import adds no second registration path).
  3. control.HTTPStatus exported (§4.3 deviation, plan did not name it). The error page's HTTP column must come from the binding the transport serves, and the previous httpStatus was unexported. Renamed-with-export + the one call site updated; no behavior change, no Protocol-surface change.
  4. The executed quickstart's steering step accepts both documented outcomes. Against the preflight mock-LLM dev server a demo run reaches a terminal state in milliseconds, so a post-hoc cancel deterministically returns the canonical 404 not_found envelope (steering targets live inboxes; the inbox is registered and torn down with the run). The page teaches exactly this — acknowledgement-vs-effect, controls-target-live-runs — and the smoke accepts 200 {"accepted":true} (run still live; the real-provider path) OR 404 {"code":"not_found"} (terminal; the mock path), each with its shape asserted. The deterministic not_found leg doubles as the §17.3 failure-mode requirement.
  5. The smoke executes the page, not a copy. scripts/smoke/phase-113a.sh extracts the quickstart's five <!-- qs-step: ... -->-tagged bash blocks and sources them in order in one shell (variables flow block-to-block exactly as for a reader), asserting status + JSON shape per step against HARBOR_BASE_URL. The tag count is load-bearing (≠ 5 fails loudly). The recipe-cannot-lie pattern from embed-harbor-headless (D-197), applied to curl.
  6. Generated markdown is markdownlint-clean and deterministic. Sorted iteration everywhere; two consecutive runs are byte-identical (pinned by test) — the git diff --exit-code gate depends on byte-stability. The pages live directly under docs/site/protocol/ (not include stubs — there is no canonical prose to mirror; the generator IS the source) and are committed.

Findings I'm departing from. None beyond call 3 (recorded §4.3 deviation).

Cross-references. D-093 (the gate shape), D-132 (the TS-generator deferral this phase corrects the skill's claim about), D-072/D-082 (the single-source registries read), D-171 (the session model choreography 1 documents), D-105/D-106/D-079 (the streaming + scope semantics choreography 2 documents), D-070/D-066 (the steering taxonomy choreography 3 documents), D-196 (the prod aggregator the Q1 registry-read imports), D-197 (the recipe-cannot-lie precedent), D-208 (the docs site + §18 manifest rule this extends). RFC §5.1–§5.5, §3.6. CLAUDE.md §4.2, §13 (primitive-with-consumer: the generator's first consumers — the gate, the smoke, the choreography lockstep greps — ship in this phase), §18. Phase 113b consumes this surface (choreographies 4–5, build-a-client, certification).

Addendum (2026-06-11, Protocol-track §17.5 checkpoint audit). The Auth column was the one join the four lockstep tables did NOT pin, and it had drifted: a hand-typed shared note claimed admin-or-console:fleet cross-tenant fan-in on ~10 rows whose handlers consult ScopeAdmin only (tasks.list, pause.list, topology.snapshot, flows.list / flows.runs.list) or overlay the verified identity with no fan-in at all (tools.list, the eight agents.* reads, memory.get / memory.health), while the seven posture rows omitted the note where it actually applies (internal/protocol/posture.go admits both scopes). The checkpoint fix pins the cell so it cannot drift like this again: each row now carries a machine-readable crossTenantPolicy (none / admin-only / admin-or-fleet / admin-widens — the last for the flows describe/metrics reads whose only elevation is visibility widening), the rendered note derives from that single value, and TestGen_AuthColumnMatchesHandlerGates drives baseline / admin-only / fleet-only tokens with cross-tenant request shapes against a devstack-assembled wire for every row claiming a rejecting gate, asserting the observed accept/reject matches the policy (the probe map is itself lockstep-checked both directions). Two ride-alongs: harbortest/devstack gained the production search.* + artifacts-surface mux mounts it had silently omitted (tests-track-production, §17.6 — the probe surfaced the gap), and auth-and-identity.md's scope table now states console:fleet's full real grant set with the admin-only fan-ins called out. The events.md notes for runtime.warning and task.paused / task.resumed were tightened to name their no-production-emit reality, and the 113a quickstart-smoke criterion wording was amended (§4.3, recorded in the plan) to match the shipped inline-jq shape-assertion mechanism.


D-210 — Phase 113b: the Protocol adoption track closes — pause + versioning choreographies (captured wire truth), the SDK-free worked event-viewer (compile-gated), the in-repo conformance-certification path (Q3 honored)

Date: 2026-06-11

Status: Settled (shipping with Phase 113b)

Where it lives: docs/site/protocol/ (the four hand-written closers: pause-model.md / versioning-and-compatibility.md / build-a-client.md / conformance-certification.md, plus the completed-track updates to index.md / quickstart.md / task-control.md); examples/protocol-clients/event-viewer/ (the worked client — package main, stdlib-only); docs/site/.vitepress/config.ts (choreographies 4–5 + the "Adopt" nav group); scripts/smoke/phase-113b.sh (page/nav trip-wires, the lockstep greps, the Q3 guard, the worked-client compile gate); docs/skills/use-the-harbor-protocol/SKILL.md (the §18 sweep — pause + versioning/handshake sections reconciled to the real wire); docs/glossary.md (two terms).

Decision. The track's second half documents shipped mechanics only — zero Protocol surface added — and holds the 113a honesty bar: prose that demonstrates the wire must be pinned to the wire. Calls made while shipping:

  1. The pause guide quotes captured traffic, not freehand prose. The HITL approve / reject / DecisionTimeout legs (SSE frames, pause.list snapshots, control request/response pairs) were captured from a runtime assembled with the production drivers (harbortest/devstack — the same assembly harbor dev boots) running a deny-all-gated tool driven through the production dispatch path (the D-192 mid-step drain). The OAuth-callback leg's shapes are transcribed from the handler + its tests (internal/tools/auth) — a live capture needs a real authorization server — and the page says so explicitly. The capture harness was a throwaway (the standing gates are the 111b/111c E2Es plus this phase's lockstep greps; a committed capture test would be a second harness for an already-gated surface).
  2. The lockstep mechanism extends to events, Decision values, and the callback route. The 113a "> Methods demonstrated:" grep convention continues on the three new method-demonstrating pages; the smoke additionally pins every pause event the guide narrates to its catalog section heading in the generated events.md, the four taught Decision branches to the literal Decision = "<value>" declarations in internal/runtime/pauseresume/decision.go, and the quoted OAuth callback route to the exported auth.CallbackPath constant. One §4.3 deviation from the plan's smoke sketch: the callback route canNOT lockstep against the generated reference — it is a provider-redirect mount, deliberately not a canonical Protocol method, so it has no methods.md row; the source-constant grep is the honest equivalent trip-wire, and the guide states the route's non-method status.
  3. A documentation-honesty fix shipped with the pause guide (§17.6 posture). task-control.md claimed a pause control's effect surfaces as task.paused / task.resumed; in the shipped runtime nothing calls MarkPaused/MarkResumed on the live pause path — a parked run's task status stays running, and the park/wake narration is pause.requested / pause.resumed. The line is corrected and the pause guide documents the real semantics (pause.list, not a task-status filter, is the "what awaits a human" read).
  4. The worked client is in-module and stdlib-only. examples/protocol-clients/event-viewer/ compiles as part of the repo module (examples/ is in-module by layout convention), so the compile gate is a direct bounded go build — the 112b external-module ceremony is unnecessary AND would be dishonest here: the client's premise is zero Harbor imports (the smoke asserts grep-absence of hurtener/Harbor in the source). The guide quotes the gated file and walks its three moves (token → runtime.info handshake with major-pin + capability check + unknown-field tolerance → generic SSE tail).
  5. Q3 honored verbatim. The certification page documents internal/protocol/conformance as run-from-a-clone (go test -race ./internal/protocol/conformance/), the Factory seam as the certify-your-own-assembly path, and the precise pass-claim (wire-level compatibility with the pinned Protocol version across both consumer profiles; nothing about behavioral quality, vendor extensions, or operational properties). It explicitly states no importable package / standalone runner exists and routes third-party demand to the issue tracker — the demand signal the sdk-export decision (proposal Q3) waits on. The smoke's Q3 guard asserts grep-absence of sdk/ phrasing on the page.
  6. The §18 sweep went one honest step past cross-links. use-the-harbor-protocol's pause section (tasks.pause / tasks.resume JSON-RPC shapes that never shipped) and handshake section (a fabricated capability-map response) are exactly the surfaces this phase's pages now document authoritatively; leaving them stale while publishing the truth one click away is the §18 failure mode verbatim. Both sections are reconciled to the real wire (POST /v1/control/{pause,resume,approve,reject}, the real RuntimeInfo shape, the 404/405/501-degrade posture). Sections tied to other surfaces (start/events/artifacts/topology recipes, which predate the track and carry their own drift) were left for a dedicated skill-reconciliation pass — noted here so the gap is on the record, not silent.

Findings I'm departing from. None beyond call 2's recorded smoke-sketch deviation.

Protocol additions. None — no method, error code, event type, wire type, or capability changed; make protocol-docs-gen-check is untouched and green.

Cross-references. D-209 (the track's first half; the lockstep conventions continued), D-080 (the conformance suite documented), D-200/D-096 (durable pauses + the typed Decision the guide teaches), D-199 (the OAuth completion leg), D-192/D-097/D-071 (the approve/reject mechanics the captures exercise), D-171 (the session model the worked client rides), D-132 (the hand-maintained protocol.ts described accurately, again), D-206 (the compile-gate precedent, miniaturized). RFC §5.1–§5.5, §3.3, §6.3, §6.4. CLAUDE.md §4.2, §13 (the worked client is the build-a-client guide's same-wave consumer; the §18 clause), §17.6, §18.

Addendum (2026-06-11, Protocol-track §17.5 checkpoint audit). Three honesty tightenings landed at the checkpoint. (1) pause-model.md's provenance header overclaimed — "every request/response and SSE frame on this page is real wire traffic, captured" was false for the tool.auth_required frame's placeholder values; the header now scopes itself ("except for the OAuth intervention section…") and the OAuth transcription disclaimer explicitly covers the whole leg, frame included (call 1 above already recorded the transcription; the page's blanket claim just hadn't been scoped to match). (2) The "~100-line" event-viewer figure was quoted on six reader-facing surfaces while the shipped file is 158 lines; every quote now says ~150 (or "under 160") — the kind of checkable number the track stakes its credibility on. (3) The §17.6 sweep this phase's own common.sh fix promised is now complete: the || echo "000"-after--w '%{http_code}' shape (curl already prints "000" on connection failure, so the fallback produced "000000" and dodged dead-server SKIP arms) survived at ~45 inline call sites across 13 sibling smokes; all are now || true, the three divergent case-arm postures (000), 000|000000), 000*)) are normalized to plain 000), the 404|405|501) arms across the touched scripts uniformly accept 000 as the SKIP leg, and the affected set was verified server-less (SKIP-not-FAIL everywhere except phase-64.sh's two /healthz/readyz assert_status 200 checks, which stay FAIL on a dead server by the helper's documented design — that smoke is live-server-classed and the preflight guarantees its server). The smoke-sketch line "113a's pages still assert green" is recorded as a §4.3 deviation in the plan (the regression gate lives in phase-113a.sh, which runs in the same preflight fleet).


D-190 — Phase 84c: provider-native multimodal lands as a driver-internal upload — ProviderNative part flag, file_id rewrite inside Complete, identity-scoped cache with a driver-owned lifecycle, llm.provider_file.uploaded observability

Date: 2026-06-11 Status: Settled (shipping with this PR)

Where it lives: internal/llm/llm.go (the ProviderNative bool + ProviderFileID string fields on ImagePart / AudioPart / FilePart, plus FilePart.DocumentType); internal/llm/events.go (EventTypeProviderFileUploaded + ProviderFileUploadedPayload); internal/llm/drivers/bifrost/providerfiles.go (the upload pass applyProviderNative, the providerFileCache LRU+TTL cache with per-key fill coalescing, the Close-time sweep); internal/llm/drivers/bifrost/translate.go::providerFileBlock (the wire-side file_id reference block); internal/planner/disposition.go (EffectiveDisposition now honours provider_native; the provider_native_unavailable degradation vocabulary is retired); internal/planner/multimodal.go::providerNativePart (the per-modality typed-part rendering); docs/recipes/provider-native-attachments.md (the headless recipe).

Decision. The provider_native attachment disposition (84b / D-189) gains its mechanism, and the mechanism is one seam: the LLM driver. When Complete encounters a ProviderNative-flagged part without a ProviderFileID, the bifrost driver resolves the bytes (artifact-store fetch for Artifact-backed parts — fail-loud on a missing store, a missing ref, or a cross-scope ref — or an inline sub-threshold DataURL decode), uploads them via Bifrost.FileUploadRequest, and rewrites the part (copy-on-write; the caller's request value is never mutated) to carry the returned opaque file_id, which the translator emits as bifrost's file-reference content block — the ONE bifrost chat shape that carries an uploaded file_id, so every modality (image / audio / video / document) routes through it and bifrost's per-provider converters re-shape it (e.g. Anthropic source.file_id). LLMClient stays one method (RFC §6.5); the run loop never pre-uploads; planner.InputArtifactView gains NO ProviderFileID. Because the seam is the driver, a library consumer calling llm.Open + Complete with the flag set gets provider-native handling with zero planner, run loop, config file, or Protocol — the headless reachability guarantee the SDK-lens review (C2) demanded.

Priority order (deliberate, D-189). Image first — over-threshold images regain VISION (pre-84c they degraded to a text stub whose artifact_fetch returned raw bytes the model still could not see); then audio and video (perception modalities); application/pdf + documents last, with FilePart.DocumentType disambiguating structured docs — the ref/tool:<name> + retrieval route stays the preferred document path.

The file_id lifecycle is driver-owned, end to end (SDK-lens C3). The cache is keyed (tenant, user, session, content) — the identity TRIPLE is the isolation boundary (a file_id never crosses sessions; the cross-session test pins it) and the content key is the content-addressed artifact ref or a sha256 of inline bytes. TTL expiry (default 1h) and LRU eviction (default capacity 128) best-effort delete the remote file (FileDeleteRequest); Close drains and sweeps everything left, so a headless consumer who never runs a dev loop does not leak provider-side files. Same-key fills are coalesced (a per-key lock) — the concurrent-reuse test surfaced that two simultaneous first-attaches of the same content otherwise double-upload and orphan one remote file. D-025 holds: the cache is internally synchronized and the N=128 concurrent test runs under -race asserting no bleed, bounded uploads, and goroutine-baseline restoration.

Observability is an event, not a task field (SDK-lens C1). The driver emits llm.provider_file.uploaded (identity, provider, model, artifact ref, MIME, modality, file_id, size) on the bus it already holds; cache hits do not re-emit. The Protocol/Console read it from the event stream like everything else; the generated Protocol events reference carries the row (make protocol-docs-gen).

Degradation stays loud and ArtifactStub stays universal. A provider whose file surface returns bifrost's unsupported_operation keeps the part's canonical ArtifactStub rendering and the driver logs a Warn naming provider + modality; any OTHER upload failure fails the call (no silent degradation — CLAUDE.md §13). EffectiveDisposition no longer degrades provider_native — the 84b-era DegradationProviderNativeUnavailable constant and its provider_native_unavailable event vocabulary are retired (the dependent planner / runctx / integration tests were updated in this PR per §17.6).

Edge-guard precision (D-026). A file_id-only part is legal over-threshold — it carries no inline bytes, so findContextLeak has nothing to flag (pinned by test rather than new guard code: the existing guard checks DataURL/text payloads only, which is exactly the right shape). An oversize DataURL riding the same part is still auto-materialized by the safety pass, and a raw heavy sibling text part still trips ErrContextLeak.

Streaming residual closed (RFC §11 Q-3). A provider_native multimodal request with req.Stream=true uploads first, then streams deltas through the Phase 107 path (req.Stream + llm.completion.chunk); pinned by a driver test and a live conformance row.

Conformance. The live matrix gains TestE2E_Bifrost_LiveProviderNativeMultimodal — per-modality rows (image / audio / video / pdf, each against one capable provider, gated by HARBOR_LIVE_LLM + the per-provider key) plus the streaming-with-multimodal row. The video row requires an operator-supplied fixture (HARBOR_LIVE_VIDEO_FIXTURE) — a valid container cannot be synthesized inline.

Deviation from the plan (§4.3, recorded in the plan). The plan's optional run-loop cancel hook ("the cancel path MAY trigger early cleanup through the driver") is NOT shipped: the run loop holds the wrapped LLMClient (governance→retry→downgrade→corrections→safety→driver), so reaching a driver-exported purge method would require threading a forwarding method through five wrapper layers or widening LLMClient beyond one method — exactly the ceremony the SDK-lens review (C3) said to avoid ("Close-time + TTL cleanup needs no new interface method"). The driver-owned lifecycle is the authority and is covered by tests that never touch the run loop, satisfying the acceptance criterion's substance.

Cross-references. D-189 (the split + the policy-not-mechanism principle), D-025 (concurrent reuse — the cache), D-026 (the edge-guard exemption), D-166 (F11 multimodal happy path), D-167 (native tool-calling / 107c streaming vocabulary), D-204 (the sdk facade the recipe imports). RFC §6.5, §6.10, §11 Q-3; brief 03 (provider correction / per-provider shapes), brief 08 (the conformance matrix). Plan: docs/plans/phase-84c-provider-native-multimodal.md; review record: docs/notes/phase-84bcd-sdk-lens-review.md (C1–C5 all addressed).


D-191 — Phase 84d: the Embedder seam + semantic memory & skill retrieval — embeddings land as a standalone §4.4 primitive whose first consumers are opt-in retrieval modes, never a standalone RAG tool

Date: 2026-06-11

Status: Settled (shipping with Phase 84d)

Where it lives: internal/embeddings/ (the Embedder interface + sentinel errors + Cosine + the registry/factory Open(ctx, cfg, deps) with the identity-mandatory guard wrapper + SnapshotFromConfig); internal/embeddings/drivers/bifrost/ (the production driver over the gateway's embedding surface, with the HARBOR_LIVE_LLM-gated conformance probe); internal/embeddings/embeddingstest/ (the deterministic test-grade embedder — never registered, never a default); internal/memory/ (RetrievalMode + Deps.Embedder + the registry guard + MemoryStore.SearchTurns + ErrSemanticDisabled; internal/memory/strategy/semantic.go — the wrapper executor + the memory.vectors StateStore record; the conformance suite's semantic cases, passed by all three drivers); internal/skills/ (RetrievalMode + Deps.Embedder + the registry guard + PathSemantic; internal/skills/drivers/localdb/search_semantic.go); internal/config/ (the embeddings block + memory.retrieval/retrieval_top_k + skills.retrieval + validateEmbeddings's cross-block rule); internal/runtime/assemble/ (Stack.Embedder + Options.Embedder + the Deps threading); internal/drivers/prod (the driver's blank import); sdk/embeddings + the sdk/memory/sdk/skills additions; docs/recipes/embed-and-retrieve.md (+ the docs-site stub/nav); test/integration/phase84d_semantic_retrieval_test.go; RFC §6.5 (the D-191 contract sentence), §6.6, §6.7.

Decision. Harbor's first embeddings capability ships as its own seam, with both §13 consumers in the same PR. The calls that shape it:

  1. Own package, dependency-light Deps. The Embedder lives in internal/embeddings, NOT under internal/llm — an embeddings-only consumer must not inherit the chat client's Deps (artifact store + bus). Deps is empty at this revision (reserved for future governance metering); the factory signature Open(ctx, cfg ConfigSnapshot, deps Deps) mirrors llm.Open so the seam stays shape-compatible. The interface carries Embed(ctx, []string) ([][]float32, error) plus a lifecycle Close — a §4.3 addition over the plan's one-method sketch, mirroring LLMClient: the production driver owns gateway worker pools that must join on teardown (the goroutine-baseline gate).
  2. Identity is mandatory at the Embed edge, enforced by construction. Open wraps every driver in a guard that fails closed on missing-identity ctx (ErrIdentityMissing), rejects empty input before provider traffic, and checks the returned shape; the driver re-checks identity defensively for direct constructors — the chat edge's posture, replicated. The seam stays Protocol-free: identity.With/WithRun is the library path. Consumers that carry identity as an explicit argument (memory, skills) bridge it onto the embed ctx so billable embedding traffic is attributed to the identity the derived vectors are scoped under.
  3. Consumer 1 — semantic memory retrieval composes around the strategy, not inside it. retrieval: semantic wraps the strategy executor (semanticExec): AddTurn embeds the turn (embed-first, so a failure surfaces before state mutates) and appends to an identity-scoped memory.vectors StateStore record (bounded at 256 entries, drop-oldest); SearchTurns ranks by cosine; GetLLMContext delegates untouched — rolling_summary keeps its exact semantics. Persisting through the StateStore floor (the D-027 typed-wrapper pattern, a sibling Kind to memory.state) is what makes §9 conformance parity FREE: no per-driver migrations, all three memory drivers inherit vector persistence, and the suite's new semantic cases (Semantic_SearchTurns_RanksBySimilarity, cross-session/cross-tenant vector isolation, Semantic_Flush_DropsVectors, SearchTurns_DisabledFailsLoudly) run against in-mem, SQLite, and Postgres. SearchTurns joins the MemoryStore interface (every driver implements it; the non-semantic answer is the loud ErrSemanticDisabled, never an empty success — the §4.4 no-optional-capability rule, not a Supports* probe). Vectors are derived data: snapshots/restores carry strategy state only, and a dimension mismatch at search time (embedding-model drift) fails loudly with re-embedding named as the migration.
  4. Consumer 2 — semantic skill retrieval is a ranking mode of the store's Search, not a second tool. skills.retrieval: semantic makes localdb's Search rank the identity-scoped catalog (SQL-side WHERE; candidates capped at 256 newest-first) by one batched Embed call + cosine, result path "semantic", scores mapped onto the canonical 0–1 scale. skill_search, the builtin carrier, capability filtering, redaction, and the budgeter are all untouched — they sit downstream of ranking. An embed failure fails the search loudly; the store never silently degrades to the lexical ladder (§13).
  5. Fail-loud injection, three layers deep. The memory and skills registries reject a semantic config without Deps.Embedder (mirroring the Deps.Summarizer rule verbatim — "no stub fallback"); the driver constructors re-check; and validateEmbeddings enforces the cross-block rule at config time — memory.retrieval: semantic or skills.retrieval: semantic without a configured embeddings block fails validation naming the missing keys and pointing at examples/harbor.yaml. There is deliberately NO mock/stub embeddings driver: the deterministic embeddingstest embedder exists for suites only and is never registered.
  6. À la carte is first-class. embeddings.Open + Embed + the shared Cosine (the ONE ranking primitive — a second cosine implementation anywhere is a bug) work with no memory subsystem, no config file, no Protocol; docs/recipes/embed-and-retrieve.md walks it, and assemble.Assemble exposes Stack.Embedder (+ Options.Embedder for caller-owned injection). The future document.search-style tool is a consumer of this same primitive, never a parallel implementation.

§4.3 deviations from the plan. (a) The plan's "blank-import at cmd/harbor" wording predates D-196 — the driver registers via the internal/drivers/prod aggregator, which the binary/devstack/embedders import; the plan file is amended. (b) Close on the interface (call 1 above). (c) The plan's "injected at the skills directory / skill_search constructor" resolved to the store seam (skills.Deps.Embedder + the localdb Search mode): the directory is a recency-ordered browse window where similarity doesn't apply, and ranking at the store keeps one implementation under skill_search, Search, and any future caller. (d) The RFC §6.5 Embedder-seam paragraph itself pre-landed with the D-189 plans PR; this PR's RFC delta is the D-191 contract sentence in §6.5 plus the §6.6/§6.7 consumer-side settled text.

Protocol additions. None — no method, error code, event type, or wire type changed. Semantic retrieval is a runtime/SDK surface at this phase; a Protocol read over SearchTurns is future work that rides the existing memory-protocol pattern when a Console page demands it.

Cross-references. D-189 (the 84b/c/d split + the direction: embeddings serve semantic memory/skill retrieval, not a standalone RAG tool), D-174 (the registry-threaded Deps.Summarizer fail-loud pattern this mirrors), D-027 (typed-wrapper StateStore persistence the vector record rides), D-196 (the prod aggregator home), D-204 (the sdk/embeddings facade), D-025 (concurrent-reuse: the driver + semantic executors are compiled artifacts; N≥100 gates ship in-package), D-001 (identity-mandatory). RFC §6.5, §6.6, §6.7, §9. CLAUDE.md §4.4, §9, §13 (primitive-with-consumer: both retrieval modes ship in this PR, each exercised end-to-end; no stub defaults), §17.1–§17.3, §18. Briefs 04 §retrieval, 08 §driver seam.


D-211 — Phase 84e: the run loop consumes semantic memory — FetchMemoryBlocks populates the External tier; retrieval_min_score floor; D-094 mirror collapsed

Date: 2026-06-12

Status: Settled (shipping with Phase 84e)

Where it lives: internal/runtime/runctx/memory_fetch.go (FetchMemoryBlocks + capText); internal/memory/from_config.go (RecallSettings + RecallFromConfig); internal/config/config.go (RetrievalMinScore field on MemoryConfig); internal/config/validate.go (validateMemory range check); cmd/harbor/cmd_dev_runloop.go (collapsed to thin FetchMemoryBlocks call + memoryRecall field); cmd/harbor/cmd_dev.go (RecallFromConfig projection into opts); harbortest/devstack/devstack.go (D-094 mirror collapsed, same pattern); internal/runtime/runctx/memory_fetch_test.go (unit + concurrent-reuse + fail-loud suite); test/integration/phase84e_semantic_recall_test.go (E2E acceptance); scripts/smoke/phase-84e.sh (real assertions); docs/CONFIG.md / examples/harbor.yaml / cmd/harbor/init/templates/default/harbor.yaml.tmpl (new field documented); docs/glossary.md (Semantic recall term); docs/skills/configure-memory-and-skills/SKILL.md (§18 sweep).

Decision. MemoryStore.SearchTurns shipped in 84d with store/SDK consumers only; the run loop never called it, so the agent never semantically recalled earlier conversation turns. 84e closes this gap. The calls that shape it:

  1. One home: runctx.FetchMemoryBlocks. The fetch+recall step lives in exactly one function — the 110b promoted-helper pattern (BuildArtifactManifest, ProjectMemoryBlocks, BuildSkillsContext are the siblings). Both cmd/harbor/cmd_dev_runloop.go and harbortest/devstack/devstack.go previously held identical ~20-line inline blocks (GetLLMContextProjectMemoryBlocks); both collapse to a thin call. The D-094 "mirror discipline" is honoured by construction: one implementation, two callers, parity enforced structurally rather than by hand.

  2. Composition, not replacement. When recall fires, GetLLMContext is called first (unchanged) and its patch feeds ProjectMemoryBlocks for the Conversation tier; SearchTurns populates the External tier only. The Conversation tier is byte-untouched. Mode off → FetchMemoryBlocks is byte-for-byte identical to the prior inline block — the 84b golden default-parity posture applied to the recall gate. The ONLY enable switch is memory.retrieval: semantic (the 84d seam); no second knob is introduced.

  3. Three filters before injection. SearchTurns results pass through: (a) retrieval_min_score cosine-similarity floor — turns below the configured threshold (default 0.0, range [-1,1], validated at boot) are dropped silently; (b) recent-turn dedup — a turn whose UserMessage is already in GetLLMContext's RecentTurns window is skipped (injecting duplicates wastes tokens and confuses temporal ordering); (c) 2 KiB per-side text cap (capText, valid UTF-8 boundary truncation with a …[truncated] marker) — this is a D-026 first-line guard; the LLM-edge safety pass stays the authoritative backstop.

  4. RecallSettings + RecallFromConfig follow the 110c field-parity pattern. memory.RecallSettings{Enabled, TopK, MinScore} is the typed holder; memory.RecallFromConfig(cfg.MemoryConfig) is the single exporter — the same structure SnapshotFromConfig, GovernanceFromConfig, HintsFromConfig established in the 110c band. A reflection-based field-parity test gates the exporter (per D-155/B3): every MemoryConfig field is either projected by RecallFromConfig or explicitly excluded in the test with a one-line reason comment.

  5. Fail-loud is the ONLY posture. A SearchTurns error (network outage, embedder down, driver closed) is returned to the caller and propagates as MarkFailed(runtime_fetch_error) — the LLM is never called. There is no silent fall-back to rolling-summary-only. A GetLLMContext error likewise propagates. The "no silent degradation" rule (CLAUDE.md §13) is structurally enforced: FetchMemoryBlocks has no catch-and-ignore path.

  6. Deferred: memory.search Protocol method. The run-loop recall surface is a runtime-internal call to SearchTurns; there is no Protocol method for it. A memory.search method is the prerequisite for any Console memory-search page (D-062 ordering rule). It is not introduced here and is parked for a post-109 planning round.


D-214 — Phase 109c: the Playground DisplayMode layout is a pure page-level state machine; pip / fullscreen are mutually-exclusive regions; the host grants modes the page can apply

Date: 2026-06-13

Status: Accepted

Context. Phase 109b shipped the MCP-Apps iframe host + AppBridge (manual-handler, D-173) + the inline renderer registered on the shared chat-renderer registry. The DisplayMode contract (D-062) also defines fullscreen (the app replaces the chat + composer region, addressable via a tab strip — multiple fullscreen apps yield multiple tabs) and pip (a resizable 50/50 chat-beside-app split, right rail hidden by default with a toggle). 109c delivers the Playground page-level layout that honours those two modes, driven at runtime by the AppBridge onrequestdisplaymode request and by operator affordances, without reloading the session.

Decision. The calls that shape it:

  1. The layout is a pure, DOM-free state machine. web/console/src/lib/components/playground/layout.ts exports reduceLayout(model, action) (the reducer) and computeRegion(model) (a total (LayoutModel) → RegionLayout projection), plus clampRatio. The Playground page is the only stateful holder; the machine is unit-tested without a DOM (layout.spec.ts, 19 cases). This keeps the region routing, the split-ratio clamp, and the tab add/remove/activate logic verifiable independent of Svelte/browser.

  2. pip and fullscreen are mutually-exclusive page regions; pip is ONE app. The reducer maintains the invariant that apps holds EITHER N fullscreen apps OR exactly one pip app — never both. Requesting pip replaces the whole set with the single app and hides the rail by default; requesting fullscreen drops any pip app and adds/focuses a tab. This makes computeRegion total and pins the explicit distinction from PG-6's two-agent comparison (D-064): pip is one app beside chat, not a comparison surface. No comparison affordances are added.

  3. The renderer is REUSED, not forked. The fullscreen / pip AppPanel pulls the registered 109b renderer out of the shared registry (dispatchRenderer(MCP_APP_INLINE_MIME).component) — the public §4.5#11 seam — rather than deep-importing or duplicating mcp-app.svelte. The chat module stays self-contained (the layout components live OUTSIDE it, under components/playground/, and the module gains no page/route import). inline is unchanged — 109b behaviour, guarded by a regression test.

  4. The host grants the modes the page can apply (backward-compatible seam extension). createAppHandlers / AppBridgeHost gain an optional availableDisplayModes (default ['inline'], preserving 109b's inline-only grant + its pinned test). The Playground App panel passes ['inline','fullscreen','pip'] so a ui/request-display-mode for fullscreen/pip is granted and routed to the layout machine; an unsupported mode falls back to the always-available inline. The renderer forwards an optional onDisplayModeRequest to the host — the consumption seam the page reduces on.

  5. Split ratio + rail toggle are Console-local view state (D-061). The split ratio is clamped to [0.2, 0.8] on drag and persists across teardown; the rail toggle reopens the rail in pip WITHOUT resetting the ratio. Layout itself (which apps are open, the active region) is conversation-scoped, not persisted across sessions.

§4.3 deviations from the plan. Minor, in-scope: the plan's file list did not name mcp-app.svelte / the registry index.ts / app-bridge-host.ts, but consuming 109b's onrequestdisplaymode requires threading an optional onDisplayModeRequest + availableDisplayModes through the renderer to the host — an additive, backward-compatible seam extension (the 109b inline-only default + its test are untouched). Documented in the PR.

Known upstream gap (named, not masked — §17.6). Inline MCP-app discovery in the chat bubble (a tool result's _meta.ui.resourceUri → a chat message that mounts the inline renderer) is NOT wired in the Console today — MessageBubble does not dispatch the MCP-app MIME and ChatMessage carries no app ref. That is a 109a/109b integration gap (it needs runtime event surface that carries the app ref), out of 109c's file list. 109c delivers the complete page-level layout subsystem (machine + components + region routing + the onrequestdisplaymode grant seam + operator affordances), which activates the moment that discovery path lands; until then the page region stays chat in production. Tracked as a follow-up.

Protocol additions. None — no method, error code, event type, or wire type changed. DisplayMode rides 109a's projection (MCPAppRef.display_mode), surfaced by 109b.

Cross-references. D-062 (DisplayMode semantics — honoured exactly, no new modes), D-064 (PG-6 two-agent comparison — explicitly NOT this), D-061 (Console-local view state — ratio/rail toggle may persist; layout is conversation-scoped), D-091 (shared chat module encapsulation — layout lives outside it; renderer reused via the registry), D-121 (Console design-system foundation), D-173 (manual-handler AppBridge — app→host stays Protocol-proxied), D-172 (the 109a–c wave). RFC §6.4, §7. CLAUDE.md §4.5, §13, §17.6, §18. Plan: docs/plans/phase-109c-mcp-apps-displaymode-layout.md.

§4.3 deviations from the plan. None — the plan's design matched the implementation.

Protocol additions. None — no method, error code, event type, or wire type changed.

Cross-references. D-191 (the Embedder seam + SearchTurns — 84e is its run-loop consumer, closing the §13 primitive-with-consumer cycle), D-094 (the D-094 mirror discipline; the collapsed duplication), D-026 (the D-026 heavy-content guard; capText is the first-line guard at the injection seam), D-025 (concurrent-reuse contract; the N=100 test in memory_fetch_test.go), D-155 / D-196 / D-197 (the 110c/110b patterns this follows: field-parity test, promoted helper, thin callers), D-062 (the deferred memory.search Protocol method gate). RFC §6.2, §6.5, §6.6. CLAUDE.md §4.2, §4.4, §13 (no second knob; no silent degradation; fail-loud), §17.1–§17.3, §18. Plan: docs/plans/phase-84e-semantic-memory-runloop.md.


D-215 — Phase 109d: inline MCP-app discovery — the mcp.app_available event closes the planner-path → renderer-mount seam; MCPAppRef gains server_id

Date: 2026-06-13

Status: Accepted

Context. The 109 wave shipped the MCP Apps runtime/Protocol projection (109a), the sandboxed iframe renderer + AppBridge (109b), and the fullscreen/pip DisplayMode layout (109c). The wave-end §17.5 audit pinned that the chain "a planner-initiated MCP tool result carrying _meta.ui.resourceUri → a chat message that mounts the 109b renderer → 109c's layout activates" was DEAD: the renderer + entire layout were unreachable in production. Three breaks: (1) the MCP driver parsed the app reference (content.go::parseAppRefMCPToolValue.AppRef, which is json:"-") but projected it onto exactly ONE surface — the mcp.apps.call_tool proxy response — and a planner-initiated call never enters the proxy, so its app reference was dropped; (2) the wire MCPAppRef carried no server id, but the renderer needs one to fetch the ui:// document via mcp.servers.read_resource(serverID, resourceUri); (3) the Console had no ChatMessage app field, no MessageBubble dispatch under MCP_APP_INLINE_MIME, and no population site. D-214 named this as a known upstream gap; 109d closes it.

Decision. The calls that shape it:

  1. A new canonical SafePayload event mcp.app_available, emitted at the MCP provider's invoke site. When a tool result declares a ui:// app, Provider.callTool publishes mcp.app_available (internal/tools/drivers/mcp/events.go, registered alongside mcp.resource_offloaded). The transport-honest choice: the Playground already consumes the SSE event stream, so the discovery rides the same wire as tool.invoked / mcp.resource_updated. The payload (AppAvailablePayload, SafePayload — no caller-controlled bytes) carries the server source id, the ui:// resource URI, the per-result display-mode hint, the default-deny raw-HTML trust posture, and the actor identity quadruple (its RunID correlates the discovery to the turn). The emit is best-effort observability — a missing identity or a publish failure logs and returns rather than failing the tool call (the tool result is the source of truth).

  2. The wire MCPAppRef gains server_id (single-sourced). internal/protocol/types/mcp_apps.go adds ServerID string json:"server_id,omitempty"; MCPAppRefRow carries it and the app-tool-call proxy projection populates it from the tool's catalog source id; web/console/src/lib/protocol/mcp.ts hand-syncs the field (the TS generator is unbuilt — CLAUDE.md §4.5 rule 5). This makes both the proxy-response app ref and the discovery event self-describing about which server hosts the document.

  3. The Console wires discovery at the page level + the message model — the chat module stays encapsulated (D-091). ChatMessage gains app?: MCPAppRefView + serverID?; MessageBubble mounts the 109b renderer via the registry dispatch (dispatchRenderer(MCP_APP_INLINE_MIME)) when an app ref is present, with the injected appHostClient + availableDisplayModes + an onAppDisplayModeRequest callback threaded through ChatPanel; the Playground page decodes mcp.app_available (wire-events.ts::decodeAppAvailable) and attaches the app to the run's agent bubble (applyAppAvailable). An inline app's onrequestdisplaymode — granted fullscreen/pip by the page's full available-mode set — opens it through 109c's already-shipped layout reducer. The chat module gains ZERO imports from Console internals; the discovery is wired outside it (the page + the model), so the future web/shared/chat extraction stays mechanical.

  4. The W3 weak synthetic-DOM Playwright test is replaced by a real-component guard. The audit flagged tests/mcp-app-displaymode.spec.ts for hand-building document.createElement fixtures and re-implementing the split-ratio clamp. The deterministic, always-on regression guard is now a Vitest component suite (web/console/src/routes/(console)/playground/[session_id]/mcp-app-discovery.spec.ts) that mount()s the REAL MessageBubbleMcpAppRenderer and drives the real reduceLayout/computeRegion into the real AppPanel — it fails if the discovery→render wiring is reverted (verified). The Playwright spec is rewritten to drive the real built Playground route under the standard CONSOLE_AVAILABLE skip.

§4.3 deviations from the plan. (a) The Playwright spec cannot trigger a runtime-emitted mcp.app_available without a real MCP app, so the literal "drives the real route" assertion is satisfied for the bundle-level surface while the deterministic real-component guard moves to the always-on Vitest suite — recorded in the plan's Risks + the PR. (b) web/console/vite.config.ts gains a VITEST-gated resolve.conditions: ['browser'] so component specs can mount() real .svelte components in jsdom; the production vite build is byte-unchanged.

Protocol additions. One new canonical event (mcp.app_available) + one new field on an existing wire type (MCPAppRef.server_id). No new method or error code. make protocol-docs-gen regenerated docs/site/protocol/{events,types}.md in the same PR.

Cross-references. D-214 (the 109c layout this activates + the known-gap it named), D-172 / D-173 (the 109a–c wave + the manual-handler AppBridge invariant — app→host stays Protocol-proxied), D-062 (DisplayMode semantics; the no-primitive-without-consumer ordering rule), D-091 (shared chat-module encapsulation), D-026 (heavy-content discipline — the renderer fails loud on a heavy ui:// document), D-209 (the generated Protocol docs regenerate in the same PR). RFC §6.4, §6.5, §7. CLAUDE.md §4.5, §6, §8, §13, §17.6, §18. Plan: docs/plans/phase-109d-inline-mcp-app-discovery.md.


D-216 — Phase 109e: MCP App discovery reads the tool-DEFINITION _meta.ui, not the tool result — discovery now fires against real ext-apps servers

Date: 2026-06-13

Status: Accepted

Context. A live test against a real io.modelcontextprotocol/ui ext-apps server (go-study-mcp) found the 109 wave's MCP App discovery inert: the renderer + the entire DisplayMode layout never activated against a real server. Root cause: the 109 wave (a–d) parsed the app reference from the WRONG place. content.go::lowerCallToolResult did AppRef: parseAppRef(res.Meta) — it parsed _meta.ui.resourceUri from the tool RESULT (CallToolResult._meta), and mcp.go::callTool fired mcp.app_available (D-215) from that. But the canonical spec — the official io.modelcontextprotocol/ui dialect (SEP-1865, rev 2026-01-26; vendored McpUiToolMetaSchema: "UI-related metadata for tools", resourceUri = "URI of the UI resource to display for this tool") — places _meta.ui.resourceUri on the tool DEFINITION. A stdio probe of go-study-mcp confirms it: every tool's tools/list entry carries _meta = {"ui":{"resourceUri":"ui://go-study-mcp/studio/index.html"}}, while a tool call returns an empty/null result _meta. So Harbor's result-parse found nothing and never fired discovery. Every 109a–d test put _meta.ui on the RESULT — a self-consistent but non-conformant fixture that matched the buggy code, so four phases of green tests hid the bug (the §17.8 failure mode, added to CLAUDE.md/AGENTS.md in this PR).

Decision. The calls that shape the fix:

  1. Capture the tool-definition _meta.ui at discovery, bound to the tool. buildToolDescriptor parses parseAppRef(t.Meta) (the MCP SDK Tool embeds Meta, populated from the server's tools/list) and captures the resulting binding by value into the descriptor's Invoke closure (immutable after discovery — no shared mutable per-run state on the Provider, D-025). Preferring closure capture over a per-Provider map[toolName]AppRef keeps the binding strictly immutable: no mutex, no re-Discover write hazard, no shared state to race. This is the spec-conformant source of the ui:// resource URI.

  2. Fire mcp.app_available on invocation of a UI-bound tool. callTool takes the captured toolApp *AppRef; after lowering the result it sets value.AppRef = reconcileAppRef(toolApp, resultHint, uiDisplayModeHint(res.Meta)) and publishes the event when the reconciled ref is non-nil. This REPLACES the old "fire when res.Meta.ui present" trigger. The reconciled value.AppRef (which is json:"-") feeds BOTH the discovery event AND the app-tool-call proxy projection (mcpconsole/apps.go::appRefFromValue), so the SAME §17.6 bug shape in the proxy path — it also read the result-only ref and also broke against a conformant server — is fixed in the same change, no separate edit.

  3. The result _meta.ui is a SECONDARY merge, never required. reconcileAppRef takes the tool binding as the source of the resource URI; a per-result display-mode hint (preferredFrame / displayMode on CallToolResult._meta.ui) wins over the binding's mode for THAT result. A server that (non-conformantly) declares the full app only on the result still surfaces via the resultHint fallback. Conformant servers leave the result _meta empty, so the binding stands alone on the golden path.

  4. DisplayMode default is inline. go-study-mcp advertises capabilities {logging, resources, tools} — NOT io.modelcontextprotocol/ui — so 109a's negotiation yields empty modes and the tool-def _meta.ui carries only a resourceUri (no mode). A UI-bound tool with no negotiated/declared mode still surfaces as renderable: the event's DisplayMode is empty and the Console renderer (mcp-app.svelte: data-display-mode={app?.displayMode || 'inline'}) defaults to inline. No Console change was needed — the renderer already mounts on a bare {resourceUri, serverID}.

The fixture mandate (§17.8, why the bug shipped). The 109d Go test put _meta.ui on the RESULT. This PR corrects it: the fake MCP server declares _meta.ui.resourceUri on the tool DEFINITION and returns an EMPTY result _meta — exactly matching go-study-mcp and the canonical schema — and asserts mcp.app_available STILL fires (now from the binding). A HARBOR_LIVE_MCP-gated probe drives the real go-study-mcp binary over stdio and asserts a UI-bound tool call fires mcp.app_available from the tool-definition ui:// (CI skips it; verified green in dev — the discovery fires even when the TTS call returns IsError, because the binding is captured at discovery, not parsed from the result).

§4.3 deviations from the plan. This phase had no pre-existing plan; it was authored from the live-test finding per §16. One design call worth naming: the binding is captured in the Invoke closure rather than a per-Provider map (the scope's suggested shape) — closure capture is strictly more D-025-clean (no shared mutable state at all) and is documented in mcp.go.

Protocol additions. None — no method, error code, event type, or wire type changed. mcp.app_available + MCPAppRef are unchanged from D-215; only the SOURCE the runtime reads them from changed. make protocol-docs-gen-check is clean.

Cross-references. D-215 (the mcp.app_available event + MCPAppRef.server_id this corrects the source of), D-172 / D-173 (the 109a–c MCP Apps wave + the manual-handler AppBridge invariant), D-025 (concurrent-reuse — the binding is immutable, captured by value in the closure), D-026 (heavy-content discipline on the ui:// read — unchanged), D-062 (DisplayMode semantics; the renderer's inline default). RFC §6.4, §6.5, §7. CLAUDE.md §5 (fail-loud, D-025), §6 (identity on the emit), §13 (no second knob — single reconcile, not parallel triggers), §17.6 (fix the proxy bug-twin in the same PR), §17.8 (real-spec fixtures — new), §18. Plan: docs/plans/phase-109e-mcp-app-tool-def-discovery.md.


D-217 — Phase 109f: heavy MCP App documents render by FETCHING the offloaded artifact (the D-026 by-reference form is consumed, not refused); operator "pop to side-by-side" affordance reuses the injected display-mode seam

Date: 2026-06-13

Status: Accepted

Context. With 109e fixing discovery against real ext-apps servers, a live test drove the real go-study-mcp stdio server in the Console Playground. Two gaps surfaced. Gap A (the primary bug): go-study-mcp's ui://go-study-mcp/studio/index.html is 86.4 KB. The default artifacts.heavy_output_threshold_bytes is 32 KiB, so 109a's mcp.servers.read_resource correctly applies the D-026 heavy-content safety net — it offloads the document to the ArtifactStore by reference and returns an MCPResourceArtifactRef instead of inline content. But the 109b renderer (web/console/src/lib/chat/renderers/mcp-app.svelte) treated any artifactRef as a FATAL error: it threw "app document … exceeds the inline heavy-content threshold" and a code comment wrongly called it "a server bug." It is not — real Svelte/React App bundles are almost always larger than 32 KiB, so this refused nearly every real App; the studio App only rendered earlier behind a threshold-raising config workaround. Gap B: inline→fullscreen/pip was app-initiated only (the app calls AppBridge requestDisplayModeonrequestdisplaymode → the page layout). The owner asked for a HOST-side operator affordance to pop the app to the 109c side-by-side (pip) without the app having to ask.

Decision. The calls that shape it:

  1. The offload is correct; only the renderer's content SOURCE changes. The heavy ui:// document stays offloaded by reference (D-026 — heavy bytes never inline through the context/LLM plane). When readResource returns an artifactRef, the renderer resolves it to a presigned URL and fetches the bytes at the iframe edge, then loads them into the SAME sandboxed srcdoc via the SAME wrapAppDocument + buildAppCSP + appIframeSandbox (no allow-same-origin) + postMessage origin guard the inline path uses. The inline (content) and heavy (artifact-fetch) paths differ ONLY in where the HTML comes from; the security envelope is byte-identical. The wrong "server bug" comment is corrected.

  2. The artifact-fetch capability is a new method on the INJECTED MCPAppHostClient, not a chat-module reach for $lib/protocol (D-091). app-bridge-host.ts adds resolveArtifact(artifactID: string): Promise<string> to the injected interface; the renderer calls it and fetches the returned presigned URL (the same fetch-from-presigned-URL pattern every other MIME renderer uses for its src). The REAL implementation lives OUTSIDE the chat module, in the Console adapter makeMCPAppHostClient (web/console/src/lib/mcp-app-host-client.ts), which delegates to client.artifacts.getRef and returns res.presigned_url. The chat module keeps ZERO $lib/ imports — the future web/shared/chat extraction stays mechanical.

  3. §17.6 bug-twin: the playground ChatProtocolClient.resolveArtifact read the absent resp.url. The Go wire field is presigned_url (internal/protocol/types/artifacts.go::ArtifactsGetRefResponse), but the page-level adapter read resp.urlundefined — silently breaking every chat-bubble artifact preview. Same bug shape as Gap A (reading the wrong get_ref field); fixed in the same PR.

  4. The operator "pop to side-by-side" affordance reuses the EXISTING display-mode dispatch path (no parallel mechanism, §13). An "expand ⤢" button (plus an optional fullscreen button) overlays the inline app frame and dispatches onDisplayModeRequest({ requested: mode, granted: mode }) — the SAME injected callback the renderer already receives, which MessageBubble forwards as onAppDisplayModeRequest(req, app, serverID)ChatPanel+page.svelte::onInlineAppDisplayModeRequest, reducing into the 109c request-display-mode layout action. The host grants the mode directly because it advertised it can apply it (availableDisplayModes). The affordance shows ONLY while the app is inline (the page-level fullscreen/pip panels carry their own mode bar) and ONLY for advertised non-inline modes; it never reaches into the page or imports page/route/store code. Tokens-only; accessible buttons with aria-label. Teardown reuses 109c's return-to-inline.

The fixture mandate (§17.8). The Gap-A guard mounts the real MessageBubbleMcpAppRenderer with an injected client whose readResource returns an artifactRef (NOT inline content) and an artifact-fetch stub returning a REALISTIC >32 KiB App document — modelled on a Vite single-file build (doctype + inlined CSS + a large inlined ES-module bundle), the shape go-study-mcp's 86.4 KB studio/index.html ships, asserted > 32 KiB so the offload path is genuinely exercised. The test asserts the iframe srcdoc is populated from the FETCHED bytes and the "App failed to load" error is gone; it FAILS (times out on the error path) if the artifactRef branch reverts to throwing (verified). An inline-path regression test and a Gap-B affordance→reducer test stay.

§4.3 deviations from the plan. This phase had no pre-existing plan; authored from the live-test finding per §16. The artifact-fetch seam is exposed as resolveArtifact (a presigned-URL resolver the renderer fetches) rather than a bytes-returning method, to match the established renderer pattern (every MIME renderer fetches its own presigned src) and keep the adapter thin.

Protocol additions. None — Console-only. artifacts.get_ref, mcp.servers.read_resource, and the MCPResourceArtifactRef shape all already ship; this phase consumes them. No Go/Protocol source changed, so make protocol-docs-gen-check is clean and no protocol.ts hand-sync is needed.

Cross-references. D-026 (the heavy-content safety net whose by-reference form this CONSUMES — not weakened), D-062 (DisplayMode semantics; the no-primitive-without-consumer ordering), D-091 (shared chat-module encapsulation — the injected MCPAppHostClient), D-172 / D-173 (the 109a–c MCP Apps wave + the manual-handler AppBridge invariant), D-214 (the 109c layout the affordance reuses), D-215 (109d discovery → renderer mount), D-216 (109e tool-def discovery). RFC §6.4, §6.5, §7. CLAUDE.md §4.5 (Console conventions; rule 3 tokens, rule 11 chat encapsulation), §5 (fail-loud on a non-2xx fetch), §13 (no parallel display-mode path; no chat-module reach into the page), §17.6 (the resp.url bug-twin fixed same-PR), §17.8 (real-spec >32 KiB fixture), §18 (drive-the-playground skill sweep). Plan: docs/plans/phase-109f-heavy-app-doc-render.md.


D-218 — Phase 109g: read_resource scopes the LLM-context heavy threshold OUT of ui:// MCP App documents — they render inline on every artifact driver

Date: 2026-06-13

Status: Accepted

Context. A live test against the real go-study-mcp ext-apps server found MCP App documents fail to render on every non-S3 artifact driver. The 109 MCP Apps host gated a ui:// App document on the D-026 LLM-context heavy-output threshold (32 KiB, config.DefaultHeavyOutputThresholdBytes) in internal/mcpconsole/apps.go::AppsAccessor.ReadResource. go-study-mcp's studio App HTML is ~86 KB — above that threshold — so mcp.servers.read_resource offloaded it to the ArtifactStore by reference and returned an artifactRef. The Console can only fetch a by-reference resource via artifacts.get_ref → a presigned URL, and ArtifactsSurface.handleGetRef fails loud with CodePresignUnsupported on every non-S3 driver (D-022 fail-loud posture, the only Presigner is the S3 driver). So on the inmem / fs / sqlite / postgres stores the App never rendered — live error: "the inmem artifact-store driver does not support presigned URLs." The root cause is a category error: the heavy-output threshold exists to keep bulky bytes OUT of the LLM context window (RFC §6.5 / D-026), but a ui:// App document NEVER enters the LLM context — the tool result carries only the tiny _meta.ui.resourceUri reference string; the actual HTML is fetched ONLY by the Console (via mcp.servers.read_resource) and rendered in a sandboxed iframe. Gating an App document on the LLM-context threshold is wrong in principle and breaks rendering on non-S3 stores.

Decision. The calls that shape the fix:

  1. A ui:// App document is a Console-render payload, not heavy LLM output — it rides inline up to a dedicated App-document cap. ReadResource checks mcp.IsUIResourceURI(resourceURI) (the existing driver predicate) and, for a ui:// document, uses appDocumentInlineCap (2 MiB) as the inline ceiling instead of the LLM-context heavy threshold. Below the cap (every real app — a studio App's HTML runs 80–100 KiB) the document rides inline as Content, so 109b's inline renderer path works on EVERY driver with no artifact fetch, no presigning, no S3. The cap is a named const with godoc explaining WHY it differs from the heavy-output threshold (App docs are rendered, not context-injected).

  2. The >cap fallback is preserved, not removed. Above the 2 MiB App-document cap, the existing D-026 offload→artifactRef path stands — the loud mcp.resource_offloaded bypass event fires, the bytes route to the ArtifactStore, and a pathologically large App is never inlined unbounded and never silently truncated (§13). For such an App the Console's presigned fetch (S3-only) is the acceptable degradation; this phase merely raises the boundary at which that path is hit for ui:// documents.

  3. The change is scoped to ui:// App documents specifically. An ordinary (non-ui://) resource read keeps the LLM-context heavy threshold unchanged — the App-document cap is not a blanket widening of the heavy threshold for unrelated content. The resource URI is available at the threshold-decision point (the request carries it), so the scoping is a one-line predicate.

  4. The tests use a REAL inmem ArtifactStore on the seam — no stub (§17.8). 109f's artifact-fetch test stubbed the artifact resolver, so it never hit the real presign-unsupported driver — the same fixture-vs-reality failure mode §17.8 names. The below-cap revert-guard reads an 86 KiB ui:// document against a real artifacts/drivers/inmem store under the identity triple and asserts it returns INLINE with NO mcp.resource_offloaded event; it FAILS if the gate is reverted to the 32 KiB heavy threshold (verified — a reverted build offloads the 86 KiB doc to an artifactRef). The above-cap test asserts a >2 MiB ui:// doc still offloads + fires the event. A HARBOR_LIVE_MCP-gated probe drives the real go-study-mcp studio doc through ReadResource and asserts it returns inline (CI-skipped). The AppsAccessor stays immutable-after-construction (D-025); the N=128 concurrent-reuse test is retained.

§4.3 deviations from the plan. This phase had no pre-existing plan; it was authored from the live-test finding per §16. One design call worth naming: the ui:// document always uses appDocumentInlineCap regardless of the operator-configured heavy threshold — the App-document cap is the App-document ceiling, independent of the LLM-context threshold (an operator who tightens the LLM threshold does not thereby cripple App rendering).

Protocol additions. None — no method, error code, event type, or wire type changed. ReadMCPResourceResponse.Content already carries inline bytes; this phase only populates it for App documents under the cap. make protocol-docs-gen-check is clean.

Cross-references. D-026 (the context-window safety net — this NARROWS its application: the heavy-output threshold never governed a render-only payload in spirit; the LLM-edge net is untouched and still governs every byte that can reach the LLMClient), D-172 / D-173 (the 109a–c MCP Apps wave + the manual-handler AppBridge invariant — app→host stays Protocol-proxied), D-214 / D-215 / D-216 (the wave; 109g is the read-side counterpart to 109e's discovery-side spec-correctness fix), D-022 (the fail-loud CodePresignUnsupported posture that surfaced the bug), D-025 (concurrent-reuse — the AppsAccessor is immutable, per-call identity rides ctx), D-062 (DisplayMode semantics; the renderer's inline default). RFC §6.5 (context-window safety net — the threshold this re-scopes), §7 (the Console as a Protocol client that renders the document). CLAUDE.md §4.4, §5 (fail-loud, D-025), §6 (identity on the read), §13 (no silent degradation — the >cap path fails loud), §17.6, §17.8 (real-spec fixtures on the seam — a real inmem store, not a stub), §18. Plan: docs/plans/phase-109g-app-doc-inline-read.md.


D-219 — Phase 114: the steering control surface derives caller authority from the VERIFIED context identity, never from the request body

Date: 2026-06-14

Status: Accepted

Context. A planning + adversarial review of the Protocol surface found a privilege escalation on the steering control plane. internal/protocol/control.go::dispatchControl discarded the verified request-context identity (_ = ctx) and built BOTH the caller's privilege tier and tenant from the request BODY: the steering scope came from cr.Identity.Scope (a caller-supplied string), and the steering event's CallerTenant was set to the body's target-run tenant (q.TenantID). Two consequences. (1) A caller could assert scope:"admin" in the request body and the per-event check (steering.CheckScope, run inside Inbox.Enqueue) would rubber-stamp it — any authenticated caller could submit any control, including admin-only PRIORITIZE, against any run they could name. (2) Because CallerTenant was always equal to the target run's tenant, CheckScope's cross-tenant-requires-admin gate (callerTenant != runIdentity.TenantID) could never fire — cross-tenant steering was undetectable. The design intent was always the opposite: steering/scope.go documents "The Protocol edge derives the Scope from the caller's JWT scope claim before calling CheckScope" — the edge simply did not do it. The bug was latent (not yet exploitable) only because the dev bootstrap mints admin-scoped tokens exclusively; it becomes live the moment a lesser-privileged token exists. Every sibling Protocol surface (artifacts artifacts.go:261/380, search search.go:73, topology control) already derives identity/scope from the verified ctx — steering was the lone exception.

Decision. The calls that shape the fix:

  1. Authority comes from the verified ctx; the body names only the TARGET run. dispatchControl reads the caller via identity.From(ctx) (the identity the auth middleware places on ctx) and fails closed with CodeIdentityRequired when it is absent — NO fallback to the body. The request body's IdentityScope is treated as a routing key (which run to steer), never as an authority claim. The body's Scope field is no longer read for any purpose; the wire field is retained for compatibility and documented as ignored.

  2. A pure derivation maps the verified caller onto a steering tier (deriveSteeringScope). It reads ONLY the verified ctx identity + JWT scope claims and compares them against the target run: auth.HasScope(ctx, auth.ScopeAdmin)steering.ScopeAdmin (sufficient for every control, cross-tenant included); verified (tenant, user) == the run's → steering.ScopeOwnerUser (which by rank satisfies the session_user-minimum controls INJECT_CONTEXT / USER_MESSAGE); otherwise no authority (the control is rejected CodeScopeMismatch before it reaches the inbox). auth.ScopeConsoleFleet is deliberately NOT honoured — fleet is a read/observe entitlement, steering is a write control; only admin confers cross-tenant write.

  3. ScopeSessionUser is NOT derived from a bare session-id match. Session ids are not globally unique across users, so a same-tenant session-id collision must not confer authority over another user's run. Owner-tier covers every same-user control by rank; the session-scoped tier becomes safe to grant only once a non-admin token carries a verified session principal — owned by the follow-on lesser-privileged-token phase. Until then only the owning user and the administrator can steer.

  4. CallerTenant is the VERIFIED caller tenant, so steering.CheckScope's cross-tenant-requires-admin gate is live: a control whose target run lives in a different tenant passes only for an admin caller. CheckScope stays as the defence-in-depth second gate (per-event minimum + cross-tenant), NOT a substitute for the edge derivation.

The fixture mandate. New unit tests assert the security contract directly: TestDispatch_BodyScopeClaimIsIgnored_NoEscalation (a non-admin owner submitting PRIORITIZE with body Scope:"admin" is rejected CodeScopeMismatch and never reaches the inbox), TestDispatch_NoVerifiedIdentity_FailsClosed (a fully-populated body incl. Scope:"admin" with no ctx identity → CodeIdentityRequired), TestDispatch_CrossTenantNonAdmin_Rejected / TestDispatch_CrossTenantAdmin_Allowed. The round-trip, conformance, concurrent-reuse (N=150), and test/integration/wave9 control scenarios were migrated to authenticate via ctx (a shared authCtx / callerCtx / wave9CallerCtx helper) — the authority source the surface actually reads. The two obsolete body-scope tests (UnknownScope, the Lookup-miss-based CrossTenantNonAdmin) were removed; the cross-tenant contract is now tested through ctx, the source of truth.

§4.3 deviations from the plan. Authored from the adversarial finding per §16; no pre-existing plan. One call worth naming: this phase does NOT mint or accept a lesser-privileged token — it is the prerequisite hardening that MUST precede any phase that does (a non-admin token landing first would open the exact escalation window this closes). The §13 "primitive with a consumer" rule is satisfied by the existing steering controls, which are the derivation's consumers; the lesser-privileged-token contract is its co-requisite follow-on, not a missing consumer.

Protocol additions. None — no method, error code, event type, or wire type changed. types.IdentityScope.Scope is retained (now ignored for steering). No Go/Protocol wire-shape change, so make protocol-docs-gen-check is clean and no protocol.ts hand-sync is needed.

Cross-references. D-025 (concurrent-reuse — ControlSurface stays immutable-after-construction; per-call authority rides ctx, never the surface), D-059 (agent_id is not an isolation principal — the steering authority tuple is (tenant, user) + the run, never agent_id). RFC §5.5 (the Protocol rejects any request without an identity scope), §6.3 (the per-event steering scope mapping + cross-tenant-requires-admin this enforces at the edge — resolves brief 02 Q-3), §7 (the Console / third-party clients that drive the control plane). CLAUDE.md §6 (identity is mandatory; fail closed; no package-level identity), §7 (security — JWT-derived identity, no privilege from request bodies), §13 (no silent degradation; identity is mandatory), §17.6 (the test migration fixes every caller the change surfaced — protocol unit + conformance + transports + wave9 — in the same PR). Plan: docs/plans/phase-114-steering-verified-identity-authority.md.


D-220 — Phase 115: production JWT verification (JWKS-backed KeySet) + harbor serve

Date: 2026-06-15

Status: Accepted

Context. The Protocol's auth surface shipped a production-grade Validator (asymmetric-only allowlist enforced at the parser via jwt.WithValidMethods, the KeySet seam, the eight typed sentinels) but the ONLY KeySet wired into a running binary was the harbor dev ephemeral dev signer — an in-memory ES256 keypair minted at boot. The identity.jwks_url / identity.jwks_file config fields existed and were validated (the full-binary Validate() profile requires asymmetric jwt_algorithms + issuer + audience + one of the two JWKS sources), but had NO consumer: no code path turned a JWK Set into a KeySet, and there was no production-shaped subcommand to boot the headless Runtime behind an operator's own IdP. An operator could not run Harbor against their identity provider; the dev signer was the only path, which is the §13 "test stubs as production defaults" failure mode one layer up — the seam existed but the binary defaulted to the dev surface.

Decision. The calls that shape the fix:

  1. A JWKS-backed KeySet behind the EXISTING interface — additive, no reshape. internal/protocol/auth/jwks.go ships JWKSKeySet, which implements KeyByID(kid) (crypto.PublicKey, alg, error) exactly as the static dev KeySet does. The production Validator is the unchanged NewValidator(keys, …); the asymmetric allowlist gate (HS*/none rejected at the parser before the keyfunc runs) is inherited unchanged. The dev signer and the JWKS keyset are the two concrete KeySet implementations of one interface.

  2. The JWK Set is parsed with the standard library only — no new dependency. RSA (kty:"RSA", base64url n/ersa.PublicKey) and ECDSA (kty:"EC", crv P-256/384/521, base64url x/yecdsa.PublicKey, point validated via the non-deprecated (*ecdsa.PublicKey).ECDH() on-curve check) are supported. kty:"oct" and any symmetric / unsupported material is rejected per-key; a set that yields ZERO usable asymmetric signing keys fails closed (ErrJWKSNoUsableKeys). Each key's alg must fall in the operator's allowlist ∩ the asymmetric AllowedAlgorithms; a key outside it is dropped. Adding a JWKS library would have required an RFC change (§13 — no new heavy deps), so the stdlib parse is the deliberate choice.

  3. Cache + TTL refresh + a BOUNDED on-miss refresh. A KeyByID lookup serves from an RWMutex-guarded snapshot while the cache is within its TTL (default 5m). A kid miss OR a stale cache triggers a re-fetch, but the re-fetch is rate-limited by a minimum interval (default 1m) under a single-flight refresh mutex: a flapping/hostile IdP or kid-spray cannot drive an unbounded fetch storm — at most one fetch per window, and a miss inside the window resolves against the current cache and returns ErrUnknownKey without touching the network. The URL fetch is bounded (client timeout + a 1 MiB response-size LimitReader). No background goroutine is started — refresh is on-demand — so there is nothing to leak.

  4. Fail loud at construction. NewJWKSKeySet performs the initial fetch+parse synchronously and returns the error if it fails: a Runtime serving the Protocol edge must not boot with an unverifiable identity surface. The auth.NewJWKSValidator(ctx, cfg.Identity, deps) projection (mirrors the *.FromConfig convention; auth imports internal/config additively, config stays a leaf) wires issuer/audience/redactor/logger/bus and is the single public entry the serve command consumes.

  5. harbor serve is the production sibling of harbor dev, NOT a parallel boot stack (§13). Rather than duplicate cmd_dev's surface wiring, bootDevStack gained an authValidatorFactory injection point: when non-nil (the serve path) it builds the JWKS-backed validator from the loaded config and marks the boot production, so the dev-only surfaces stay un-mounted — NO bootstrap-token endpoint, NO dev-token mint/print, NO draft scaffolding, NO dev-signer token-rotation surface, NO Console embedding (D-091 — only harbor console serves the Console). When nil, the dev signer path is unchanged. Production binds server.bind_addr (may be non-loopback; --bind overrides) and logs JSON (§5). The mock LLM escape hatch is NOT honoured — harbor serve demands a real provider and the full Validate() profile + the existing LLM-provider gate fail the boot loud (naming the missing field) when the JWKS source, the provider, or the API key is absent.

The fixture mandate (§17.8). The JWKS parse tests exercise a REAL committed JWK Set fixture (internal/protocol/auth/testdata/jwks.json) generated independently from the committed RSA + EC test PEM public keys (a standalone generator using math/big + base64.RawURLEncoding, not the parser under test), so a self-consistent hand fixture cannot rubber-stamp a wrong-field parse. Coverage: RSA + EC resolution, kid hit, kid miss → bounded refresh → still-miss → ErrUnknownKey, oct/symmetric rejected, malformed JWK rejected, alg-outside-allowlist dropped, TTL-refresh picks up a rotated key, and a fetch-counting transport proves the on-miss refresh is rate-limited. End-to-end: a token signed by the fixture's private key verifies through NewValidator(jwksKeySet, …); a foreign kid and HS256/none are rejected. The mandatory concurrent-reuse test (D-025) runs N=150 concurrent KeyByID/Validate against one shared keyset+validator under -race with a goroutine-baseline assertion. A HARBOR_LIVE_JWKS_URL-gated probe fetches a real endpoint (CI-skipped). The integration test (test/integration/jwks_serve_test.go) drives a JWKS-verified request through auth.Middleware over a real httptest server (real keyset + real audit/drivers/patterns redactor + real inmem event bus) and asserts the verified identity + scopes reach the downstream surface on ctx, with three failure modes (foreign kid, HS256, no token) and an N=32 no-identity-bleed concurrency run.

Adversarial-review hardening (same PR). An adversarial security pass over the implementation found and this PR fixed: (F1) the runtime-fixture seeder (HARBOR_DEV_SEED_FIXTURES) was gated on the env var ALONE, not on signer != nil — so a harbor serve boot with that env var set would seed fixtures AND drive the real planner/LLM on them; now gated on signer != nil too, making point 5's "dev-only surfaces stay un-mounted" fully true (proven by cmd/harbor.TestBootDevStack_ServeProductionBoot_GatesDevSurfacesAndVerifiesJWKS, added in the 114–118 wave-end checkpoint: a production boot with HARBOR_DEV_SEED_FIXTURES=1 set verifies a JWKS token to the surface while the seeder never fires and every dev route 404s). (W1) parseRSAJWK now enforces a 2048-bit minimum modulus (minRSAModulusBits) — a weak/compromised IdP RSA key is dropped rather than accepted, defense in depth against signature forgery. (W4) validateIdentity now rejects BOTH jwks_url and jwks_file being set (not just neither), so the config validator fails early with a clear message instead of deferring to ErrJWKSSource at keyset construction. (W2/W3) the keyset godoc now documents the refresh/staleness tradeoff: concurrent miss/stale lookups block on the in-flight single-flight fetch (bounded by the HTTP timeout; hits never block), and a refresh-fetch failure retains+serves the prior snapshot (availability over hard-fail), so a REMOVED/revoked key stays accepted until the next successful fetch — no max-stale ceiling. New regression tests: TestJWKSKeySet_RejectsUndersizedRSAKey (real 1024-bit key dropped) and a config both JWKS sources set case.

§4.3 deviations from the plan. The plan named auth.NewJWKSValidator(cfg) as the public surface; the shipped signature is NewJWKSValidator(ctx, cfg.Identity, ValidatorDeps{…}, …opts) — the ctx is required for the synchronous initial fetch and the deps carry the mandatory redactor + optional logger/bus the projection must wire (a bare cfg could not supply them). bootErrorToCLIError gained a subcommand parameter so serve / console errors attribute correctly (a latent cosmetic mislabel: console errors previously printed "harbor dev:").

Protocol additions. None — no method, error code, event type, or wire type changed. The JWKS keyset reuses the existing ErrUnknownKey sentinel and the existing auth.rejected bus event. make protocol-docs-gen-check is clean; no protocol.ts hand-sync needed.

Cross-references. D-219 (Phase 114 — the verified-identity steering authority this builds toward; 115 gives the JWKS consumer, 116 the lesser-privileged token), D-091 (the Console is served only by harbor console — serve embeds no Console), D-089 (the dev-only mock LLM stays gated; serve never enables it), D-025 (concurrent-reuse — the keyset is immutable after construction except the internally-synchronized cache; no per-call state on the artifact), D-026 (untouched — JWKS is an identity surface, not an LLM-context payload). RFC §5.4 (wire transport — the surface serve exposes), §5.5 (authentication — asymmetric-only, the Protocol rejects any request without a verified identity scope). CLAUDE.md §4.4 (the KeySet seam — interface + two drivers, no Supports* ceremony), §5 (fail-loud, JSON logging in production, D-025), §7 (asymmetric-only JWT allowlist, no hardcoded secrets — generated testdata keypairs are the sanctioned fixture), §13 (no new heavy dep — stdlib JWK parse; no test stub as production default — serve fails loud at boot; fail-loud when a required external dependency is missing), §17.8 (real-spec fixture on the seam — a JWK Set generated from real key material, plus a live-gated probe), §16 (authored from the master-plan detail block). Plan: docs/plans/phase-115-production-jwt-jwks-serve.md.


D-221 — Phase 116: the non-admin session-scoped token contract — session_user collapses into the owner tier; collision safety is structural

Date: 2026-06-15

Status: Accepted

Context. Phase 114 (D-219) moved steering authority to the verified context identity and explicitly DEFERRED the session_user tier, leaving a note in deriveSteeringScope's godoc that a real session-scoped principal — a non-admin token carrying a verified session claim — was "the seam where that tier becomes safe to grant." Phase 116 is that seam. Two things were missing for the Phase 114 derivation to be load-bearing: (1) a lesser-privileged token to judge — every token a Harbor binary minted carried admin, so the escalation 114 closed had no non-admin principal to exploit; (2) a settled, airtight rule for when the session-scoped tier is safe to grant given the hazard 114 named — session ids are client-chosen and NOT unique across users, so a same-tenant session-id collision must never confer authority over another user's run.

Decision. The calls that shape the contract:

  1. The session-scoped tier collapses into the owner tier — option (a), full-triple match. A run is keyed by the full triple (tenant, user, session), and a session belongs to exactly one (tenant, user): the principal authenticated into a run's session IS that run's owning user. Granting session_user therefore requires a full-triple match, which is a SUBSET of the (tenant, user) match that already earns the strictly-higher owner_user. So deriveSteeringScope hands a verified session participant owner_user, and never mints the distinct session_user tier. The distinct tier (where session_user is strictly below owner_user) is meaningful only for multi-PARTICIPANT sessions — a capability the runtime does not have at V1. Inventing it would be a §13 "primitive without a consumer" violation, so it stays RESERVED: the constant, the rank, and the per-control minimum in steering.CheckScope keep it defined for that future and for the admin/owner total order. Option (b) (granting on a bare (tenant, session) match with a different user under a uniqueness invariant) was rejected because the prerequisite invariant — session ids unique across users — does not hold in Harbor.

  2. Why a session-id collision cannot escalate (the load-bearing property). Collision safety is STRUCTURAL, not a special case: the only non-admin grant in deriveSteeringScope compares the user component, so a verified principal whose user differs from the run owner's earns nothing — a bare (tenant, session) match never confers authority. A verified token for (tenant-a, user-B, session-x) cannot steer (tenant-a, user-A, session-x)'s run despite the shared session-id STRING. This is proven directly: TestDeriveSteeringScope_Matrix ("session-id collision earns nothing"), TestDispatch_SessionIDCollision_NonAdminCannotSteerOtherUsersRun (surface-level, every control type rejected CodeScopeMismatch, inbox never touched), and the integration test's wire-level session_id_collision_rejected (the body-vs-JWT transport gate rejects user-B naming user-A's run with 401 — defence in depth over the surface derivation). console:fleet confers no steering write authority (only admin does); cross-tenant by a non-admin stays rejected (114, kept green).

  3. Per-control scope check moves to the edge (defence-in-depth ordering refinement). dispatchControl now runs steering.CheckScope at the Protocol edge — after deriveSteeringScope, before the inbox Lookup — in addition to the existing Inbox.Enqueue call. It is the SAME function (not a second validator, CLAUDE.md §13), run one step earlier so a caller who holds SOME authority over a run but not enough for THIS control (the owning user submitting the admin-only PRIORITIZE) is refused CodeScopeMismatch rather than leaking run existence via not_found. This also makes the live negative-escalation smoke robust: PRIORITIZE → 403 fires on a ghost run with no live inbox. Enqueue's CheckScope stays as the authoritative gate for any caller reaching the inbox by another path.

  4. The dev/test non-admin mint is the consumer. The loopback-gated POST /v1/dev/bootstrap.json endpoint gains an OPTIONAL request body: a full (tenant, user, session) triple overrides the minted token's identity, and a scopes array — INCLUDING an explicit empty [] — overrides its scope set (a nil/absent scopes keeps the default admin scopes, so the existing one-click Console-attach flow and every -d '{}' caller are unchanged). An empty scopes mints a non-admin token; a partial identity triple fails closed (400 — identity is mandatory). This is dev-only convenience behind the same loopback boundary as the default mint; harbor serve never mounts the endpoint. Production non-admin tokens come from the operator's IdP (Phase 115 JWKS verifies them) — 116's mint exists only to make the contract exercisable end-to-end and to let the live smoke run.

The session-scoped tier semantics (INJECT_CONTEXT / USER_MESSAGE accepted, owner-level controls rejected) remain covered at the tier level by steering.CheckScope and internal/runtime/steering/scope_test.go::TestCheckScope_PerEventSufficientScope; because deriveSteeringScope never mints the distinct tier in V1, there is no Dispatch-level path that yields a session_user-but-not-owner_user principal — the acceptance-criterion line for that is satisfied as RESERVED-and-documented, per the §4.3 deviation below.

The fixture mandate. Tests assert the contract directly: TestDeriveSteeringScope_Matrix + TestDeriveSteeringScope_NeverMintsSessionUserTier (white-box, every tier outcome incl. collision + the reserved-tier guard), TestDispatch_SessionIDCollision_NonAdminCannotSteerOtherUsersRun (mandatory collision-safety), TestDispatch_NonAdminOwnerPrioritize_RejectedBeforeLookup (edge ordering), TestDispatch_NonAdminOwner_OwnRunControls (the positive half — a non-admin owner exercises all eight owner/session controls). Dev-mint: TestBootstrap_NonAdminScopes_MintsLesserPrivilegedToken, TestBootstrap_NonAdminScopes_DefaultIdentity, TestBootstrap_DefaultBody_MintsAdmin (regression — {} still admin), TestBootstrap_PartialIdentity_Rejected, TestBootstrap_MalformedBody_Rejected. Integration (test/integration/nonadmin_steering_test.go): the real control transport behind the production JWKS auth.Middleware over a real httptest server with real RS256 tokens — non-admin owner injects (200), is refused on prioritize (403 scope_mismatch), admin prioritizes (200), the collision token is refused (401), unauthenticated is refused (401), and an N=16 concurrency stress drives distinct non-admin owners against distinct runs with no cross-talk.

§4.3 deviations from the plan. (a) The plan's "Public API surface" said "the token-claims shape gains a verified tier"; the shipped contract carries NO new token field — the tier is the verified identity-vs-run comparison the Phase 114 invariant established (a token-carried tier would re-open the body-trust escalation 114 closed). (b) The plan's acceptance criterion "a session_user: inject_context / user_message succeed; owner-level controls are rejected" describes a DISTINCT non-owner session participant; under option (a) that principal does not exist in single-participant-session V1, so the criterion is satisfied as the reserved-tier documentation here plus the tier-level CheckScope coverage — inventing a multi-user-session consumer to satisfy it literally would violate §13. (c) The edge CheckScope ordering refinement (point 3) was not in the plan; it is a defence-in-depth improvement that also removes a run-existence oracle for unauthorised callers.

Protocol additions. None — no method, error code, event type, or wire type changed. The bootstrap request body is a dev-only endpoint shape (not a canonical Protocol wire type). make protocol-docs-gen-check is clean; no protocol.ts hand-sync needed.

Cross-references. D-219 (Phase 114 — the verified-identity steering authority this completes; the deferred session_user tier resolved here), D-220 (Phase 115 — the JWKS verifier that verifies production non-admin tokens; this phase is its sibling consumer), D-070 (the steering Scope total order + per-event mapping), D-059 (agent_id is not an isolation principal — the steering authority tuple is (tenant, user) + the run, never agent_id), D-025 (concurrent-reuse — ControlSurface + the bootstrap handler stay immutable after construction; per-call authority rides ctx, the mint reads the per-request body). RFC §5.5 (the Protocol rejects any request without an identity scope), §6.3 (the per-event steering scope mapping + cross-tenant-requires-admin — resolves brief 02 Q-3). CLAUDE.md §6 (identity is mandatory; the isolation boundary is (tenant, user, session); fail closed), §7 (security — asymmetric JWT only; authority never from a request body; no hardcoded secrets), §13 (no silent degradation; no primitive without a consumer — the reserved tier is existing, not new dead code), §17.6 (the edge-ordering refinement + the bootstrap-default regression test fix both halves in one PR), §17.8 (the integration test drives real RS256 tokens through the production JWKS validator). Plan: docs/plans/phase-116-non-admin-token-contract.md.


D-222 — Phase 117: the chat module renders self-contained (D-091) — font inheritance, host identity + theme injected through the seam, a token contract, and a mechanical encapsulation guard

Date: 2026-06-15

Status: Accepted

Context. D-091 mandates the chat module (web/console/src/lib/chat/) be a self-contained component library — encapsulated in place now, extracted to web/shared/chat/ only when a second consumer (the packed dev UI in harbor dev that D-091 / brief 12 name) lands. The import boundary already held (zero imports of non-chat $lib/ from inside the module), but two implicit inheritances meant the module did NOT render standalone, and nothing mechanically prevented the boundary from silently regressing. (1) ChatPanel.svelte's .chat-panel root set background but no font-family — it relied on inheriting font-family: var(--font-sans) from the Console global html, body rule in fonts.css; mounted without that global stylesheet the module fell back to the UA serif. (2) app-bridge-host.ts baked the host identity (HOST_INFO = { name: 'harbor-console', version: '1' }) into the ui/initialize handshake and took theme as a positional constructor default — neither was injectable through the module seam, so a second framework surface could not advertise its own identity/theme. And the encapsulation invariants (no non-chat import, tokens-only) were enforced only by human review.

Decision. The calls that shape the hardening:

  1. The chat module root self-applies its typeface from a token. .chat-panel now declares font-family: var(--font-sans) (token, never a literal) so typography is correct without the Console global stylesheet. This is the litmus test that the module is self-contained: rendered outside the app shell it must not fall back to the UA serif.

  2. Host identity + theme are injected through the typed seam, defaults preserved. AppBridgeHostOptions gains optional hostInfo?: { name; version } and theme?: 'light' | 'dark'. The constructor reads opts.hostInfo ?? DEFAULT_HOST_INFO and opts.theme ?? 'dark' — the prior baked-in values become named, overridable defaults (DEFAULT_HOST_INFO is exported), so an existing caller is byte-unchanged in behaviour while a second surface CAN parameterize. The positional theme constructor parameter was folded into the options object (the only callers were the Console call site and the spec, neither passing it positionally). The Console call site (mcp-app.svelte) passes hostInfo: DEFAULT_HOST_INFO explicitly and relies on the theme default — behaviour identical.

  3. A documented token contract enumerates the module's design-token dependency surface. tokens.contract.json lists the 47 CSS custom properties the chat module references — the exact set a second surface must supply. JSON (not .ts/.css) so BOTH the node guard and the vitest tests read it natively without a TS loader or regex parsing; the description field carries the doc. Every token resolves in the single token surface tokens.css.

  4. A mechanical encapsulation guard makes the boundary un-regressable. web/console/scripts/check-chat-encapsulation.mjs (node stdlib only, no dependency) fails CI when the chat module (a) imports a non-chat Console internal ($lib/… outside $lib/chat, $app/…, or a relative specifier resolving outside the module), (b) references a var(--…) token absent from the contract, (c) declares a contract token that no longer resolves in tokens.css, or (d) carries a raw colour literal in CSS (backstop to stylelint). It is wired into npm run lint and re-run by a vitest test (tests/encapsulation.spec.ts) so the lint gate and the test gate share one scanner and cannot disagree. Test files (*.spec.*) are exempt from the scan — the boundary governs the production module surface, mirroring the _test.go carve-outs in the Go rules.

The §17.6 fix this surfaced. Building the token contract surfaced a latent bug: ReasoningAccordion.svelte referenced var(--space-05), which was NOT defined in tokens.css (the spacing scale jumped --space-0--space-1), so the intended tight gap silently resolved to normal (0). Fixed in the same PR by adding --space-05: 0.125rem (the clearly-intended half-step) to the single token surface — the contract now resolves and the accordion renders its intended gap.

The fixture mandate. tests/encapsulation.spec.ts runs the guard's scanner and asserts zero violations, asserts the .chat-panel root rule declares the --font-sans token (a structural assertion — jsdom does not compute the full CSS cascade, documented as such), and asserts every contract token resolves in tokens.css. renderers/app-bridge-host-injection.spec.ts mocks the official AppBridge to capture its constructor arguments and asserts (1) the default preserves the Console identity + dark theme and (2) an injected hostInfo/theme actually flows through. The guard was proven to FAIL on planted violations (a non-chat import, an undeclared token, a raw literal, an unresolvable contract token) and pass after revert.

Adversarial-review hardening (same PR). An adversarial pass over the implementation found and this PR fixed two real self-containment gaps the guard could not see, plus a guard blind spot: (F1) the token contract was INCOMPLETE — --border-hairline (in the contract, used in 10+ chat components) is defined as 1px solid var(--color-border), but --color-border was NOT in the contract; a second surface supplying only the 47 declared tokens would get an undefined inner var and every hairline border would vanish. Fixed by adding --color-border AND by teaching the guard a new check (e): a contract token whose tokens.css value references another var(--…) not in the contract now fails — so this transitive-dependency class cannot recur (verified: removing --color-border now fails the guard with "transitively depends on … required by --border-hairline"). (F2) .chat-panel self-applied background + font-family but NOT color, the same inheritance trap the font fix closed — a standalone mount would paint the panel dark while descendant text without its own color fell back to UA near-black. Fixed by adding color: var(--color-text) symmetric with the background, and the portability test now asserts BOTH properties at the root. (W1) the guard's dynamic-import regex matched only single/double quotes, so a backtick (template-literal) dynamic import specifier could dodge the boundary; fixed to accept backticks (verified: a planted backtick-quoted $lib dynamic import now fails the guard). The inline-style= raw-literal blind spot the review also noted is left to stylelint (the documented primary enforcement, which catches it in npm run lint).

§4.3 deviations from the plan. (a) The plan's acceptance line "no Console-specific literal remains in the chat module" is satisfied as "no BAKED-IN literal" — 'harbor-console' survives as the value of the exported, overridable DEFAULT_HOST_INFO default, which is the point of the seam (the caller CAN override it); removing the string entirely would leave the Console with no default identity. (b) The token contract is JSON rather than the plan's example tokens.contract.css, justified above (dual-consumer native parsing). (c) The web/shared/chat/ move stays explicitly NOT done — there is no second consumer yet; this phase is the in-place encapsulation that makes the eventual git mv mechanical.

Protocol additions. None — no Harbor Protocol method, error code, event type, or wire type changed. This is a Console-internal frontend hardening; the injected AppBridgeHostOptions is a module seam, not a Protocol wire type. No protocol.ts hand-sync needed.

Cross-references. D-091 (the chat-module encapsulation decision — encapsulate first, extract on second consumer; this delivers the encapsulation half in place), D-092 (Svelte 5 runes only — the touched components stay runes-mode, svelte-check --fail-on-warnings clean), D-121 (Console design-system conventions — tokens referenced never literal'd, the single tokens.css surface), D-173 (the manual-handler AppBridge invariant — the new AppBridge(null, …) first argument stays null; only hostInfo/theme now flow through the seam, the no-direct-transport posture is byte-identical). RFC §7 (the Console layer — the chat/playground surface as a Protocol client). Brief 12 (§11 the future packed dev UI reuses the chat components via the shared library — the legitimate second consumer; §26 tokens from a single location, raw literals rejected; §35–37 one component library serves two surfaces). CLAUDE.md §4.5 (Console conventions — Svelte 5 runes, tokens-only, the shared-chat-module rule #11: no imports of other Console internals, a typed host interface injected never a singleton), §13 (no raw literals in .svelte, no hand-rolled fetch, no Svelte 4 syntax), §17.6 (the --space-05 latent bug fixed in the same PR), §18 (operator-skill drift — the Playground operator steps are unchanged, so drive-the-playground needs no edit). Plan: docs/plans/phase-117-chat-module-encapsulation-hardening.md.


D-223 — Phase 118: the Protocol TS lockstep gate VERIFIES the hand-maintained Console client against the Go wire manifest (D-093's "generate" half deferred; generator name reserved)

Date: 2026-06-15 Status: Settled (shipping with Phase 118)

Where it lives: cmd/harbor-protocol-ts-lockstep/ (the Go manifest generator + lockstep tests); web/console/src/lib/protocol/wire-manifest.gen.json (the committed, generated wire manifest); web/console/scripts/check-protocol-ts-lockstep.mjs (the TS-source scan, wired into npm run lint); web/console/scripts/protocol-ts-untyped-allow.json (the justified untyped-type allowlist); Makefile (protocol-ts-gen + protocol-ts-gen-check); .github/workflows/docs.yml (the CI step); CLAUDE.md / AGENTS.md §4.5 rule 5 (the reworded rule); web/console/src/lib/protocol.ts (the reworded header).

Context. D-093 mandated a cmd/harbor-gen-protocol-ts generator that would REGENERATE the Console Protocol client from internal/protocol/singlesource.CanonicalWireTypes, with a make protocol-ts-gen-check CI gate; the generator was never built (D-132 corrected the formerly-false generated header to an accurate hand-maintained notice and tracked the work). Reality diverged from the D-093 assumption of a single protocol.ts: the Console's 221 canonical wire types are hand-authored across ~18 per-page modules (web/console/src/lib/protocol/*.ts plus sessions/types.ts and flows/types.ts). That per-page split is correct modularity and stays.

Decision. Build option A — a field-level LOCKSTEP GATE that VERIFIES the hand-written TS against the Go single source, NOT a generator that replaces it. A Go tool (cmd/harbor-protocol-ts-lockstep) reflects over CanonicalWireTypes (reusing the docs generator's typeInstanceIndex + struct-field-walk mechanism) and emits a committed JSON manifest of the wire surface: per canonical type, its JSON field keys, each field's canonical TS-type token (string / number / boolean / array / object / any), a named-type ref, and optionality; plus the sorted method, error-code, and event-type name sets (events read by a textual tree scan, so the tool needs zero driver imports). make protocol-ts-gen-check runs the gate in three halves.

What the gate catches — and the honest residual it does NOT.

  • Half 1 — Go↔manifest git diff. Regenerate the manifest and assert the tree is clean. Catches: any Go-side wire-shape change (new/removed/renamed type, field, method, error, event) not followed by make protocol-ts-gen. Residual: a worktree where the manifest is still untracked sees nothing from git diff until the manifest is committed — covered by half 2 in the meantime.
  • Half 2 — Go lockstep test (go test ./cmd/harbor-protocol-ts-lockstep/...). Catches: a new canonical wire type with no typeInstanceIndex instance (fails building the manifest), a stale committed manifest (a regenerate-in-memory vs committed-file byte comparison), and any manifest method/error/event that is not canonical. Runs in the main go test ./... CI job too.
  • Half 3 — TS-source scan (check-protocol-ts-lockstep.mjs, in npm run lint). Catches: a manifest type with neither an exported TS interface nor a justified allowlist entry (new/removed/renamed TYPE), and a typed wire type whose TS interface is missing a manifest field key (new/removed/renamed FIELD). Field PRESENCE is mandatory; a best-effort field-TYPE-token comparison (resolving named string-enum aliases to string) catches most type swaps. Residual: an in-place field-type swap WITHOUT a rename, where the TS type is one the cheap parser cannot resolve, is the one drift class presence-cannot-see — partially caught downstream by svelte-check at the use site. The transport-injected identity key on *Request types is the one sanctioned per-field omission (the shared client folds it in), and 58 server-only / inline-request / meta wire types the Console does not declare a named interface for are carried in the justified, hygiene-checked untyped allowlist.

Additional residuals the scan does NOT cover (honest coverage boundary). The TS-source scan is manifest ⊆ TS field-presence on NAMED typed shapes; it deliberately does not check: (1) extra/phantom TS fields (a Go field REMOVAL is caught by half 1's manifest regen, but the now-orphaned TS field is not flagged — TS ⊆ manifest is not enforced, because a per-page module may legitimately carry a Console-local field); (2) optionality drift (the manifest records optional but the scan does not compare it against the TS ?); (3) nested-ref identity (a field typed as the WRONG canonical object type passes as long as both reduce to the object token; the inner shapes are each checked independently, only the cross-reference is unenforced); (4) methods / errors / events on the TS side (the scan iterates manifest.types only — method-name and event-type STRING constants the Console hardcodes are covered Go-side by half 2's manifest presence, but are not cross-checked against the Console's usage); (5) the event list is a textual tree-scan for EventType = "..." declarations (a non-conforming declaration outside that shape would be absent from both the manifest and the re-scan with no independent catch — all current events conform). These are the gaps the deferred full generator (option "B") closes by construction; the tracking issue stays open.

Adversarial-review hardening (same PR). An adversarial pass found the allowlist's single false justification: TasksListStatusCounterStrip (the Live Runtime header strip) WAS declared in TS but as TaskListStatusCounterStrip (singular "Task"), a one-character mismatch from the canonical TasksListStatusCounterStrip — so the scan could not match it and it fell to the allowlist with an inaccurate "consumed inline" justification, leaving its five fields unguarded. Fixed by renaming the TS interface to the canonical name and removing the allowlist entry (the five strip fields are now field-guarded). Also typed the load-bearing StartResponse (task_id / reused / protocol_version) as a named interface in client.ts instead of an inline generic default and removed it from the allowlist, so a Go rename of task_id now fails the gate rather than silently passing both the gate and svelte-check. Both verified by planted-drift probes (dropping a field now fails the scan).

Pre-existing Go↔TS drift this fixed (§17.6). The first scan run surfaced genuine latent drift the hand-maintained client had accumulated, all corrected in this PR: SearchFilter declared singular tenant_id / user_id / session_id where the wire carries plural tenant_ids / user_ids / session_ids arrays plus since / until (the Console's filter never reached the runtime); GovernancePostureResponse consumed a non-wire tiers array + an invented latent flag while the runtime sends an identity_tiers map + protocol_version (the Settings governance card always fell through to "latent default" against a real runtime — fixed the interface AND the GovernancePostureCard.svelte consumer to iterate the map); RateLimitView.refill_interval (string) corrected to refill_interval_ms (number); LLMPostureResponse / GovernancePostureResponse missing protocol_version; IdentityScope missing run / scope / actor / requester / impersonating; SearchRequest missing facets (plus the SearchFacet interface); TaskDetail missing trajectory (plus the TaskTrajectoryRef / TaskTrajectoryStep interfaces).

Deviation from D-093. D-093's "generated, never hand-edited" client is superseded for the foreseeable future by "hand-maintained, mechanically lockstep-gated." The "generate" half — emitting per-domain generated TypeScript type modules that separate pure types from the hand-written client logic — is a deliberately deferred FUTURE phase ("B"); the cmd/harbor-gen-protocol-ts name is RESERVED for it and stays unused. The committed wire-manifest.gen.json IS generated and never hand-edited; the TS interfaces are hand-maintained and gated. This is a documented amendment to D-093, not a silent departure.

Protocol additions. None — no Harbor Protocol method, error code, event type, or wire type changed. The manifest is a read-only projection of the existing canonical surface; the Go tool is a build tool with no runtime surface.

Cross-references. D-093 (the original generate-the-TS-client decision this amends — "generate" to "verify lockstep", generator name reserved), D-132 (the per-page split context + the formal post-Wave-13 deferral this closes), D-209 (Phase 113a — the sibling cmd/harbor-gen-protocol-docs generator + protocol-docs-gen-check gate shape this mirrors), D-002 (the Go single source for wire types). RFC §5 (the Harbor Protocol contract), §5.3 (versioning — the manifest pins ProtocolVersion). CLAUDE.md §4.5 rule 5 (the reworded lockstep rule), §13 (no second driver blank-import list — the event tree scan avoids it), §17.6 (fix what the gate finds — the pre-existing drift above), §19 (the AGENTS.md ↔ CLAUDE.md mirror, edited identically). Plan: docs/plans/phase-118-generated-protocol-ts-client.md.


D-224 — Phase 109h: the MCP driver advertises its io.modelcontextprotocol/ui host capability on the initialize handshake — the write side of DisplayMode negotiation, preserving roots

Date: 2026-06-16

Status: Accepted

Context. The 109 MCP Apps wave shipped the READ side of UI capability negotiation: internal/tools/drivers/mcp/mcp.go::negotiateDisplayModes reads a server's io.modelcontextprotocol/ui capability (under extensions / experimental) to learn which display modes (inline / fullscreen / pip) the server's apps prefer. But the driver never advertised its OWN UI capability — mcpsdk.ClientOptions.Capabilities stayed nil, so ClientCapabilities.Extensions shipped empty (brief 14 §2 row 31: "Extension negotiation — Absent: ClientCapabilities.Extensions never populated"). A spec-conformant ext-apps server therefore could not learn that the Harbor host renders apps, and could not tailor the app references it returns to what the host can actually display. The negotiation was one-directional: Harbor read the server's modes but advertised none of its own.

Decision. The calls that shape the fix:

  1. The driver advertises the host's renderable display modes during the initialize handshake. A new hostCapabilities(displayModes) helper in mcp.go builds a *mcpsdk.ClientCapabilities that AddExtensions the io.modelcontextprotocol/ui key (the existing uiExtensionKey const) with a {"displayModes": [...]} settings object, filtered against the closed validDisplayModes set (the same set negotiateDisplayModes uses — symmetric read/write), deduplicated, advertised order preserved. New sets ClientOptions.Capabilities to this value only when modes are configured; with no modes it leaves Capabilities nil, preserving the SDK's default advertisement for an embedder that does not opt in (backward-compatible).

  2. The roots advertisement is PRESERVED, not dropped (the regression trap). The go-sdk advertises {"roots":{"listChanged":true}} by default when ClientOptions.Capabilities is nil; setting Capabilities to a non-nil value OVERRIDES that default, and (SDK #607) the deprecated Capabilities.Roots field is IGNORED in favour of Capabilities.RootsV2. So hostCapabilities MUST set RootsV2: &mcpsdk.RootCapabilities{ListChanged: true} to replicate the current roots advertisement — otherwise opting into the UI extension would silently drop the roots capability the runtime advertises today. This phase PRESERVES current roots behaviour exactly; it does NOT fix the roots honesty defect (brief 14 §3 — Harbor advertises roots without servicing it), which is the separate 85a stopgap scope. Sampling / elicitation remain inferred from their handlers (the SDK adds them after the explicit caps assignment, only overriding when the field is set in Capabilities — which this phase does not set).

  3. The advertised modes come from a deployment-level config field, defaulting to the inline baseline. A new tools.mcp_app_host.display_modes field (config.MCPAppHostConfig + ToolsConfig.MCPAppHostDisplayModes()) resolves a nil / empty block to [inline] — the mode the Console renders out of the box. It is a single deployment-level block, NOT a per-server field: the host's rendering ability does not vary per MCP server. The boot loader (internal/runtime/assemble/assemble.go) resolves it once and threads it into every attached provider via the new mcp.AttachDeps.HostDisplayModes, which doubles as the programmatic SDK seam (an embedder sets it without YAML). Validation (internal/config/validate.go) enforces the closed set + uniqueness, with allowedMCPAppDisplayModes mirroring the driver's validDisplayModes (the config package must not import the concrete driver — §4.4 — so the set is duplicated and a drift-mirror test pins it, same pattern as allowedMCPTransportModes).

  4. The integration test derives its capability fixture from the real SDK shape (§17.8). Two providers built from ONE resolved config value are paired to real SDK in-memory transports; each server's captured serverSession.InitializeParams().Capabilities is asserted to echo the configured modes AND to still advertise roots (Roots.ListChanged — the SDK syncs RootsV2Roots on the wire). The fixture is the SDK's actual InitializeParams, not a hand-authored blob — a hand blob could not tell a correctly-placed extension from a misplaced one. Identity still propagates on a real tool call after the handshake; an opt-out provider (no host modes) advertises roots with NO UI extension (the failure mode). A unit test asserts hostCapabilities preserves roots directly (the revert guard).

§4.3 deviations from the plan. This phase was authored from the live-test finding per §16 (no pre-existing plan file). One design call worth naming: the config validator's allowedMCPAppDisplayModes set is a duplicate of the driver's validDisplayModes rather than an import, because internal/config must not depend on a concrete driver package (§4.4); a drift-mirror test (TestValidateTools_MCPAppDisplayModeAllowlistMirrors_MCPDriver) pins the two together, exactly as the transport-mode allowlist already does.

Protocol additions. None — no Harbor Protocol method, error code, event type, or wire type changed. The capability is an OUTBOUND client→server advertisement on the MCP wire (southbound), not a Harbor Protocol (northbound) surface; there is no inbound method to probe, so the smoke is static-only.

Cross-references. D-172 / D-173 (the 109a–c MCP Apps wave + the manual-handler AppBridge invariant — 109h is the capability-negotiation counterpart to the rendering surface), D-214 / D-215 / D-216 (the wave; 109e corrected the discovery-side _meta.ui placement, 109g the read-side threshold, 109h adds the host-side capability write), D-218 (the read-side render fix this complements), D-025 (concurrent-reuse — HostDisplayModes is read once at New and immutable). RFC §6.4 (Tool catalog and transports — the MCP southbound driver), §7 (the Console as the host that renders the apps). brief 14 §2 row 4 + row 31 (the capability-negotiation + extension-negotiation gaps), §3 (the roots honesty defect this PRESERVES rather than fixes — 85a's scope). CLAUDE.md §4.4 (the config↔driver allowlist duplication + mirror test), §5 (fail-loud, D-025 immutability), §6 (identity propagation on the post-handshake tool call), §10 (the new config field + example), §13 (no silent degradation — opting into the extension never silently drops roots), §17.8 (real-spec fixtures on the seam — the SDK's actual InitializeParams). Plan: docs/plans/phase-109h-mcp-apps-host-capability.md.


D-225 — Phase 109i: MCP Apps tool-context capture + mcp.apps.tool_context — the Data-Delivery backend

Date: 2026-06-16

Status: Accepted

Context. The 109 MCP Apps wave lets the Console discover (mcp.app_available, D-215/D-216), fetch (mcp.servers.read_resource, D-218), and render a ui:// MCP App in a sandboxed iframe. But a rendered app had no way to read the tool context — the input arguments + the lowered result — that produced it. The MCP Apps "Data Delivery" lifecycle (brief 14 §6) is host-pushed: the host delivers the tool call's structured data to the rendered app; the app reads its data, it does NOT re-invoke the tool (re-invoking would double a side effect). Without a runtime capture + a Protocol read, the rendered app is inert — it can render its UI shell but cannot populate it with the data of the call that summoned it.

Decision. The calls that shape the backend half:

  1. Capture at the tool-invocation site, ride the existing StateStore. internal/tools/drivers/mcp/mcp.go::callTool — the same site that emits mcp.app_available — captures {tool, input, lowered result, is_error} whenever a result declares a ui:// app, through a new optional ToolContextCapturer seam on the MCP Config. The capturer (mcpconsole.ToolContextStore) persists a StateRecord through the runtime's own StateStore — so all three persistence drivers (in-mem / SQLite / Postgres) and identity isolation come free; NO new driver, NO new migration. The record is keyed by the caller's identity triple with an EMPTY RunID (session-scoped — the read, from a rendered app, knows the session but not necessarily the producing run) under kind = "mcp.apps.tool_context/<serverID>/<toolCallID>". A cross-identity Load is not found by construction (StateStore.Load filters by the triple — brief 14 §5 security context-binding).

  2. A deterministic tool_call_id, no mutable Provider state (D-025). The id is a content hash of run | server | tool | args (length-prefixed so field boundaries cannot alias), minted in callTool with no counter and no Provider field — the Provider stays an immutable compiled artifact. It is stamped on the mcp.app_available event (alongside tool_name; the payload stays SafeSealed — ids/names are not content), on the wire MCPAppRef, and on the app-tool-call proxy projection, so a client correlates a discovered app to its captured context.

  3. The read is a new identity-scoped Protocol method, heavy-aware. mcp.apps.tool_context (ToolContextRequestToolContextResponse) routes through the AppsSurface dispatcher (IsMCPAppsMethod); a new protocol.AppToolContextReader seam is implemented by mcpconsole.AppsAccessor (delegating to the ToolContextStore). Each of input / result is heavy-content-aware: at WRITE a payload ≥ the heavy threshold offloads to the ArtifactStore by reference through the SAME loud-bypass path the resource read uses (refactored into a shared offloadHeavy helper, the mcp.resource_offloaded event); at READ each half projects inline OR as an artifact_ref the Console resolves through the artifacts surface — exactly the discipline read_resource and the proxy already carry. An unknown or cross-identity (server_id, tool_call_id) fails with CodeNotFound (existence is never revealed across identities).

  4. Fail loud, never silently (§13). A capture failure (store error, encode error, missing identity) is logged loudly and observable, but does NOT fail the tool call — the planner's result is the source of truth, so a capture problem must not break the agent's turn; the app's later tool-context read then returns not-found (the Console handles it as "no context"). A missing identity fails closed on both Capture and Load.

  5. Wired in assemble, mirrored in devstack + cmd/harbor (§17.6). The ToolContextStore is constructed once in internal/runtime/assemble over the runtime's StateStore + ArtifactStore + Bus, exposed on the Stack, and threaded into every MCP Provider via AttachDeps.ToolContext AND into the AppsAccessor read seam — the production path and the harbortest/devstack fixture carry the SAME wiring, so a wave-end E2E can never pass on a fixture-only fix.

§4.3 deviations from the plan. This phase was authored from the 109 live-test program per §16 (there was no pre-existing plan file). One design call worth naming: capture is co-located with discovery in the Provider rather than in a tool-dispatch wrapper, because the planner-path tool call flows through the Provider's descriptor Invoke (not through the AppsAccessor, which only handles the app-initiated proxy), and the tool_call_id + the app reference are both already in scope there — co-locating keeps the id minted once and shared by the event, the capture, and the proxy projection.

Protocol additions. One method (mcp.apps.tool_context), three wire types (ToolContextRequest / ToolContextPayload / ToolContextResponse), one new field on the existing MCPAppRef (tool_call_id), and two new fields on the existing mcp.app_available SafeSealed payload (tool_call_id + tool_name). No error code or event type added. Single-sourced in internal/protocol/{methods,types}, hand-mirrored into web/console/src/lib/protocol/mcp.ts, with make protocol-ts-gen (wire manifest) and make protocol-docs-gen (the generated Protocol reference) regenerated + committed; both generators' typeInstanceIndex + method tables extended (their lockstep tests pin the join rows).

Cross-references. D-215 / D-216 (the mcp.app_available discovery event + the tool-definition _meta.ui placement the tool_call_id rides on), D-218 (109g — the heavy-aware inline/offload pattern reused at the capture seam; the shared offloadHeavy helper extracted here), D-172 / D-173 (the 109a–c MCP Apps wave + the manual-handler AppBridge invariant — app→host stays Protocol-proxied), D-026 (the context-window safety net — the heavy-content threshold the capture honours), D-025 (concurrent-reuse — the Provider / AppsAccessor / ToolContextStore are immutable, the tool_call_id is a pure hash, per-call identity rides ctx; N=128 tests under -race), D-002 (the Go single source for wire types), D-209 (the generated Protocol docs gate this regenerates), D-223 (the TS lockstep manifest this regenerates). RFC §6.4 (Tools), §6.5 (context-window safety net), §7 (the Console as a Protocol client). CLAUDE.md §4.4 (the seam), §5 (fail-loud, D-025), §6 (identity mandatory + fail-closed + the cross-identity isolation test), §8 (Protocol single-source), §9 (persistence — the StateStore ride), §13 (no silent degradation), §17.6 (the assemble/devstack/cmd wiring parity), §17.8 (real-spec fixtures + the HARBOR_LIVE_MCP probe). Plan: docs/plans/phase-109i-mcp-apps-tool-context.md.


D-226 — Phase 109j: Console pushes tool-input/tool-result into the rendered MCP App — the Data-Delivery Console half

Date: 2026-06-17

Status: Accepted

Context. The MCP Apps "Data Delivery" lifecycle (brief 14 §6) is host-pushed: after a rendered ui:// app sends ui/notifications/initialized, the host delivers the originating tool call's INPUT arguments and RESULT into the app; the app reads its data, it does NOT re-invoke the tool (re-invoking would double a side effect). D-225 (109i) shipped the backend half — capture at the tool-invocation site plus the identity-scoped mcp.apps.tool_context read method, with the correlation tool_call_id stamped on the mcp.app_available event and the wire MCPAppRef. But the Console host never called sendToolInput / sendToolResult, so a spec-conformant app that renders from host-pushed data booted empty. This phase closes the lifecycle on the Console side, consuming the 109i surface now on main.

Decision. The Console-side calls that close the lifecycle:

  1. Delivery lives INSIDE AppBridgeHost, on the injected client only (D-173). The push is wired on the existing oninitialized callback: once the app reports ui/notifications/initialized, AppBridgeHost.#deliverToolContext() fetches mcp.apps.tool_context(serverID, toolCallID) through the injected MCPAppHostClient and pushes bridge.sendToolInput({ arguments }) THEN bridge.sendToolResult({ content, isError }) — in that ORDER (the SDK requires initialized before sendToolResult, and input-then-result is the lifecycle order). The bridge module app-bridge-host.ts issues NO raw fetch: the heavy-payload byte fetch lives in fetchArtifactText on the adapter (mcp-app-host-client.ts, outside the chat module), so the no-direct-transport invariant (D-173) covers the delivery path too — the no-direct-transport spy test is extended to assert it.

  2. Heavy-aware, fail-loud (§13, mirrors 109f/D-217). A captured input / result at or above the heavy-content threshold (D-026) rides as an artifactRef; the host resolves + fetches the bytes at the iframe edge and delivers them. A heavy INPUT is JSON-parsed into the tool arguments. When a heavy result's bytes cannot be fetched (e.g. presign unsupported on a non-S3 store), the host delivers a FAITHFUL by-reference stub text block ([artifact <id> · <n> bytes — unavailable on this store]) — never silently empty.

  3. Best-effort delivery, never a render error. The whole delivery sequence is fire-and-forget and wrapped in try/catch: a failure (the fetch rejects, the runtime errors) is logged but NEVER thrown — the app has already rendered its shell, so a delivery problem is not a render problem. A context that does not exist (toolContextnull, the adapter mapping the Runtime's CodeNotFound onto null) yields no push and no error — the app simply boots without a delivered result. Any non-not_found Protocol error re-throws (fail-loud on real failures).

  4. The correlation id flows event → message → renderer → host. wire-events.ts::decodeAppAvailable decodes tool_call_id (an older runtime that predates 109i capture omits it → '' → no push); +page.svelte::applyAppAvailable carries it onto the message's MCPAppRefView; mcp-app.svelte passes app.toolCallId into the AppBridgeHost options. MessageBubble.svelte already forwards the whole app object, so the id rides inside it unchanged.

§4.3 deviations. None — the implementation follows the plan. One scope note honoured: only the FINAL input + result are pushed (no sendToolInputPartial streaming, no re-push on a later tool call within the same app session) — a documented post-V1 extension; ongoing interactivity uses the app's own tools/call, already wired.

Test / Protocol notes. No Go or Protocol change — this phase is a pure consumer of the 109i surface; the toolContext client method consumes the existing ToolContextResponse wire type, so no wire-manifest regeneration. Coverage is the vitest suite: app-bridge-host.spec.ts (a fake bridge + fake injected client asserts push order, payloads, heavy resolve+fetch, the by-reference stub fallback, not-found→no-push, and the no-direct-transport spy extended to the delivery path) and mcp-app-host-client.spec.ts (the adapter's toolContext incl. not-found→null + non-not_found re-throw, and fetchArtifactText). The Playwright render path is documented-as-deferred at the top of tests/mcp-app-host.spec.ts (that spec is a deliberately bridge-free security-primitive harness; a real sendToolResult render would mean standing up a full bridge-handshake harness disproportionate to the unit coverage — the gap is named, not faked).

Cross-references. D-225 (109i — the mcp.apps.tool_context capture + read surface this consumes, and the tool_call_id correlation it rides), D-173 (the manual-handler AppBridge no-direct-transport invariant the delivery preserves — the push uses only the injected client), D-217 (109f — the heavy artifact-fetch-at-the-iframe-edge pattern reused), D-218 (109g — the inline/offload discipline the captured payloads carry), D-091 (the chat-module encapsulation — app-bridge-host.ts imports nothing from $lib/protocol; the client is injected, the raw fetch lives in the adapter), D-026 (the heavy-content threshold), D-215 / D-216 (the mcp.app_available discovery the tool_call_id rides on). RFC §6.4 (Tools), §7 (the Console as a Protocol client). brief 14 §6 (the host-pushed Data Delivery dialect), §2 rows 18–19 (the lowered structured result the app receives), §5 (identity context-binding — the delivered data is the caller's identity-scoped 109i record). CLAUDE.md §4.5 (Console/Protocol-client conventions — Svelte 5 runes, injected typed client, no hand-rolled fetch in .svelte), §5 (fail-loud / best-effort), §13 (no silent degradation — the by-reference stub, never empty), §17 (the vitest coverage + the documented Playwright gap). Plan: docs/plans/phase-109j-mcp-apps-data-delivery-push.md.


D-227 — Phase 109k: MCP Apps spec-conformance hardening — mimeTypes UI capability, server-namespaced app→host calls, and the host-obligation gaps

Date: 2026-06-17

Status: Accepted

Context. The wave-end adversarial spec-compliance review of the MCP Apps band (109a–j) found two conformance-breaking FAILs that were green against Harbor's own fixtures but inert against a real io.modelcontextprotocol/ui ext-apps server — the D-216 failure class (a self-consistent hand fixture passes while the code is wired to the wrong field). FAIL-1: 109h (D-224) advertised the UI host capability as extensions["io.modelcontextprotocol/ui"] = {"displayModes": [...]}, but displayModes is NOT a field of the spec McpUiClientCapabilities — the spec field is mimeTypes (the SDK's getUiCapability(caps).mimeTypes gate, RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"). A conformant server reads mimeTypes to decide whether to register its ui:// tools; against Harbor it saw none and would not register. FAIL-2: an app-initiated tools/call passed the app's bare server-side tool name (get_weather) straight to mcp.apps.call_tool, but the Harbor catalog keys tools <source>_<tool> — so the call could not resolve, and (worse) nothing confined an app to its own server's tools. Plus a set of host-obligation gaps a conformant app relies on: the host ignored ui/notifications/size-changed, never sent ui/resource-teardown, never handled request-teardown, baked the theme to dark (no live theme / host-context-changed), and omitted host-context toolInfo / containerDimensions and the resources/templates/list handler.

Decision.

  1. FAIL-1 — the capability is the spec mimeTypes, advertised unconditionally. internal/tools/drivers/mcp/mcp.go::hostCapabilities now advertises extensions["io.modelcontextprotocol/ui"] = {"mimeTypes": ["text/html;profile=mcp-app"]} (mirroring the SDK's RESOURCE_MIME_TYPE, exported as mcp.ResourceMIMEType), and the non-spec displayModes capability payload is removed. It is advertised on EVERY initialize handshake (Harbor always hosts apps via the Console — there is no per-server opt-out), still preserving the SDK roots advertisement (RootsV2.ListChanged=true — the regression guard, since setting any capability overrides the SDK default). The negotiateDisplayModes / uiCapabilitySettings non-spec server reads are deleted; Provider.DisplayModes() now returns the deployment's configured host modes (Config.HostDisplayModes, filtered) so the Registry/Console column reports what the HOST renders, not a value scraped off the server.

  2. Config reconciliation (the key open decision) — display_modes is surfaced via runtime.info, NOT dropped. Display modes are not a capability field; the spec carries them in the ui/initialize host-context availableDisplayModes. The just-shipped 109h tools.mcp_app_host.display_modes config is therefore given a spec-correct consumer rather than silently dropped (§10 backward-compat): the configured modes are projected onto a new read-only RuntimeInfo.MCPAppDisplayModes wire field (internal/protocol/types/posture.go, set at both boot sites from cfg.Tools.MCPAppHostDisplayModes()), and the Console Playground reads it to seed the AppBridgeHost availableDisplayModes (replacing the hard-coded ['inline','fullscreen','pip']). This is the recommended option from the plan's Risks section; the field is mirrored into web/console/src/lib/protocol/settings.ts and the regenerated wire-manifest.gen.json + generated Protocol docs.

  3. FAIL-2 placement — frontend serverID prefix, NOT a Protocol server_id addition. app-bridge-host.ts::createAppHandlers.oncalltool prefixes the app-supplied bare tool name with the bridge's serverID (dispatching callTool against the qualified <serverID>_<name>) before the call. This both resolves the catalog key AND confines an app to its own server's tools for free — a cross-server or already-namespaced name is still prefixed, so it can never escape this bridge's <serverID>_ namespace. The minimal-surface choice: no Protocol method change, mcp.apps.call_tool semantics unchanged on the wire (the name is qualified host-side). A backend server_id on the method would be more defensive but a Protocol change for no added safety here.

  4. Host-obligation gaps closed (all on the injected client / the bridge — D-173 preserved). AppBridgeHost now: listens for sizechange (the SDK auto-emits size-changed from every app) and forwards it so mcp-app.svelte tracks the inline iframe height to the reported content height; sends ui/resource-teardown (bridge.teardownResource({})) BEFORE bridge.close() on unmount and handles the app's request-teardown (graceful close + an injected callback); threads the live Console theme into the ui/initialize host-context and re-pushes it via setHostContext (→ ui/notifications/host-context-changed) on a theme change (AppBridgeHost.setTheme); populates host-context toolInfo ({ id: toolCallId, tool: { name: toolName } }, with toolName threaded onto MCPAppRefView from the mcp.app_available event) and best-effort containerDimensions (the iframe box); and wires onlistresourcetemplates → a new injected MCPAppHostClient.listResourceTemplates that resolves to an empty list GRACEFULLY (no error) so the advertised serverResources capability is honestly serviceable — full resource-template support is a documented follow-up.

  5. Heavy-INPUT asymmetry (Fold A) — recorded, not silently degraded. The result-delivery path (D-226) delivers a faithful by-reference stub text block when a heavy artifact cannot be fetched; the INPUT-delivery path has no symmetric faithful stub because tool input is a Record<string,unknown> the app reads by key (there is no key-shaped stub) AND input is advisory pre-render data, not the source of truth (the result is). So on a heavy-input fetch/parse failure the host now LOGS loudly (console.warn, removing the prior silent catch { return {} }, §13) and delivers empty arguments. This is the deliberate, recorded resolution of the asymmetry — not a faithful-stub implementation.

  6. Fold A cleanups. The ToolCallID godoc is corrected (it newline-SEPARATES fields; it does not length-prefix). The harbortest/devstack AppsSurface construction is made fail-loud (parity with cmd/harbor/cmd_dev.go): it gates on the MCP/catalog band being present (the same MCPRegistry signal the sibling MCP-surface block uses — under SkipCatalog the whole band is legitimately nil), and WHEN the band is present constructs the accessor fail-loud instead of behind a per-dep non-nil guard. The catalog band always builds the catalog + artifact store + tool-context store alongside the registry, so a nil sub-dep within a present band is a real wiring regression the prior multi-nil guard would have silently masked (§17.6).

§4.3 deviations. (a) The FAIL-1 revert-guard is implemented as a CI-RUNNING real-SDK conformance probe (TestConformance_RealSDKServer_GatesUIToolOnMimeTypes: a go-sdk server reads the capabilities Harbor's client actually advertised during initialize, applies the real getUiCapability(caps).mimeTypes gate, registers its ui:// tool only on a pass, and the test asserts Harbor discovers it) rather than only an env-gated external probe. An in-memory real-SDK server has no API cost, so it runs in CI unconditionally — strictly stronger than the env-gated probe the plan describes, and §17.8-faithful (derived from the official package's wire behaviour, not a hand blob). The external-binary HARBOR_LIVE_MCP probe (TestLive_MCPAppAvailable_RealExtAppsServer) remains for the end-to-end discovery path. (b) The live Console theme toggle is OS-prefers-color-scheme-resolved in the Playground (the Console's profile-level theme toggle is deferred per 108b) — a genuine live signal that re-pushes host-context-changed, tested at the AppBridgeHost seam.

Sanctioned deviations preserved. D-173 (connect-src 'none' is NOT relaxed for a server-declared CSP connectDomains — all app traffic stays bridge-proxied; the new delivery/teardown/size/theme paths use only the injected client / the bridge, asserted by the extended no-direct-transport spy), D-224's deployment-declaration intent (the config still drives the host's renderable modes, now via the spec-correct slot), D-225 (the durable tool-context store), D-218 (the app-doc inline cap).

Test / Protocol notes. Go: the capability mimeTypes-shape + roots-preserved unit test, the real-SDK two-provider handshake echo test, DisplayModes() from config, the runtime.info MCPAppDisplayModes projection test, and the CI-running conformance gate. Frontend vitest: oncalltool bare-name prefix + cross-server confinement, onlistresourcetemplates graceful empty, the AppBridgeHost host-context toolInfo/containerDimensions/theme init, setTheme push-on-change, size-changedonSizeChanged, teardown-before-close + idempotency, request-teardown→close+callback, availableDisplayModes reaching the host-context, and the no-direct-transport spy extended to the new paths; the adapter's listResourceTemplates. The RuntimeInfo wire change regenerated wire-manifest.gen.json + the generated Protocol docs and was mirrored into settings.ts (the make protocol-ts-gen-check / make protocol-docs-gen-check gates pass). A binding pre-merge gate remains: the orchestrator live-tests the full MCP Apps surface against the test agent + Console (the surface worked pre-109 — this proves 109h–k did not regress it).

Correction (appended 2026-07-25, phase 207 / D-351 — do not read the Consequence above as delivered). The merge that shipped this phase ALSO reverted the entire Console half of it (an ui/initialize postMessage handshake regression; see D-342's Context). Only the Go/backend half landed: the mimeTypes capability, Provider.DisplayModes() from config, the RuntimeInfo.MCPAppDisplayModes projection, and mcp.apps.tool_context. D-342 (109l) later re-landed the live-theme and Data-Delivery halves. Items 3 and 4 above were therefore NOT delivered by this phase: the <serverID>_ app→host tool-call prefix, the size-changed consumer, ui/resource-teardown + request-teardown, host-context toolInfo / containerDimensions, and onlistresourcetemplates were absent from the source tree until phase 207 (D-351) re-landed them. The decision itself stands unchanged — item 3's FRONTEND placement for the confinement is re-affirmed by D-351, not reversed — and this note corrects only the record of what shipped when. See docs/notes/downstream-asks.md (HA-38, HA-41) and D-351.

Cross-references. D-224 (109h — the capability this corrects: displayModes → spec mimeTypes; roots-preservation kept), D-216 (the fix-the-field-vs-real-server lesson this closes for the capability + tool-call paths), D-225 (109i — the tool-context backend whose tool_call_id the host-context toolInfo now also carries), D-226 (109j — the Data-Delivery push whose result-path stub the heavy-input decision mirrors-by-contrast), D-218 (109g — the inline/offload discipline), D-173 (the manual-handler no-direct-transport invariant + the connect-src 'none' divergence held), D-091 (chat-module encapsulation — app-bridge-host.ts imports nothing from $lib/protocol; the client is injected, the new listResourceTemplates lives on the injected surface), D-093 / D-223 (the hand-maintained-but-lockstep-gated Console wire client the new RuntimeInfo field is mirrored into), D-209 (the generated Protocol docs regenerated), D-002 (the Go single source for the new wire field), D-026 (the heavy-content threshold the delivery paths honour). RFC §6.4 (Tools), §7 (the Console as a Protocol client). brief 14 §2–3 (extension negotiation in the shape the spec reads + the roots-honesty bar), §6 (the AppBridge host↔view dialect: size-changed, host-context-changed, teardown, tool-input/result). CLAUDE.md §4.4 (the seam), §4.5 (Console/Protocol-client conventions — Svelte 5 runes, injected typed client, no hand-rolled fetch, the lockstep manifest), §5 (fail-loud), §8 (Protocol single-source), §10 (config backward-compat — the display_modes field re-homed, not dropped), §13 (no silent degradation — the heavy-input log, the devstack fail-loud), §17.6 (the devstack/cmd parity fix), §17.8 (real-spec fixtures + the HARBOR_LIVE_MCP probe). Plan: docs/plans/phase-109k-mcp-apps-conformance-hardening.md.


D-228 — Phase 87: durable TaskService backend — a StateStore-backed TaskRegistry driver over an extracted shared engine

Date: 2026-06-18

Status: Accepted

Context. Background- and foreground-task records (tasks, groups, patches) did not survive a Runtime restart: the in-process TaskRegistry driver wrote every lifecycle transition through the StateStore, but (a) it never reloaded those records on open, and (b) it keyed every record under a single fixed (identity, Kind) slot per type (task.lifecycle / task.group / task.patch), so each Save clobbered the previous record — the write-through was vestigial, never a recoverable log. This closes D-006 (background-task persistence deferred to post-V1). RFC §6.8 illustrates post-V1 durable backends as "Postgres-as-queue / NATS JetStream"; this phase instead ships single-instance restart-survival over the existing StateStore triad — the lower-risk, DRY path the durable events driver (Phase 57) already proved, and orthogonal to the distributed-queue concern (Phase 86). A queue-backed driver remains a valid later driver behind the unchanged seam.

Decision.

  1. Extract a shared internal/tasks/engine package; inprocess and durable are thin wrappers. The full task/group/patch lifecycle state machine (FSM, idempotency dedup, cascade-cancel, group seal/resolve/fail-fast, retain-turn waiters, WatchGroup fan-out) moves verbatim into internal/tasks/engine as *Engine, parameterized by a Backend persistence seam. Both drivers construct an *Engine over a backend and return it (*Engine satisfies tasks.TaskRegistry). Rationale: the alternative — a standalone durable driver duplicating ~2000 lines of lifecycle logic (the literal "mirror events/drivers/durable" framing of the plan) — creates a maintenance fork that drifts, and a durable-imports-inprocess shortcut would leave a stale "no I/O" assumption sitting next to shared code. A neutrally-named engine package signals "this must hold for any backend, including a slow one." The shared conformance suite (D-031) runs against the engine and BOTH drivers, so the single state machine stays contract-locked.

  2. Backend seam. SaveTask(ctx, TaskRecord) / SaveGroup / SavePatch / Hydrate(ctx) (Snapshot, error). The engine never imports the StateStore; it persists only through the backend and rebuilds its indices from Hydrate in New. TaskRecord carries the task plus the engine's idempotency content hash out-of-band — the hash is computed over PRE-redaction content and cannot be re-derived from the post-redaction fields the task stores, so it is persisted explicitly (hex) and replayed; recomputing from stored fields would falsely reject a genuine retry after a restart when the redactor had erased caller tokens. The in-process driver's ephemeralBackend writes the same fixed-Kind slots as before (byte-identical behaviour) and Hydrate returns empty; the durable driver's backend keys each record in its own slot and replays it.

  3. Per-record keying + maintenance-scan hydrate. The durable backend writes task.durable.task/<id>, task.durable.group/<id>, task.durable.patch/<id> (disjoint from the durable event log's events.durable.* and the in-process task.lifecycle Kinds), each under the record's session-scoped identity (RunID dropped from the key; the full identity is preserved in the bytes). Hydrate scans by Kind prefix via StateStore.ListKind under an explicit MaintenanceScoped claim — a boot-time maintenance read; each record's bytes carry its own identity, so cross-identity scanning never widens the isolation boundary (the engine re-keys everything by the record's own identity).

  4. Shared StateStore, NOT owned StateDriver/StateDSN config (§4.3 deviation). The plan called for adding StateDriver/StateDSN to TasksConfig mirroring EventsConfig. Dropped as unnecessary: tasks.Open ALWAYS passes the runtime's shared deps.Store (internal/runtime/assemble), so the durable driver simply uses it; the owned-store machinery exists in events only because events.OpenDriver can be called without deps. The config change reduces to adding durable to allowedTasksDrivers. Cross-process survival therefore requires a durable state.driver (sqlite/postgres); with state.driver: inmem records survive only an in-process driver reopen — documented in docs/CONFIG.md, the example configs, and the define-the-agent-yaml skill.

  5. Recovery posture: Failed{code: "runtime_restarted"}, no FSM/wire change (settled with the operator). The open-time recovery sweep (Engine.RecoverInterruptedTasks, run by the durable driver in New) transitions every task left StatusRunning by a crash to the existing StatusFailed with the reserved error code runtime_restarted, emitting one task.failed event each — rather than widening the FSM/Protocol enum with a dedicated StatusRecovered. It recovers the record, not execution (auto-re-drive is a deferred runloop/steering concern, D-097). StatusPaused (a durable HITL/OAuth wait by design) and StatusPending (never started; still discoverable) are left untouched. It reuses the normal terminal-transition path, so a recovered group member correctly drives the group resolve gate (fail-fast cascade / sealed-group resolution). After the Running sweep it ALSO re-evaluates every non-terminal group's resolve gate against current member terminality (reconcileGroupsLocked) — healing a group whose resolution was computed in a prior session but whose group-record persist failed before the crash (members durably terminal, group durably non-terminal); without this, a diverged Sealed group would never re-resolve and a post-restart WatchGroup would never fire (caught by the second adversarial pass; the plan's Risks section requires exactly this recompute). Idempotent on re-open: a recovered task is persisted as Failed and a reconciled group as terminal, so a second open is not a sweep candidate and never double-writes or double-emits.

  6. No-StateStore posture: fail loud at boot (settled with the operator). Selecting tasks.driver: durable with no StateStore wired returns a boot error naming the missing state.driver rather than silently degrading to non-durable behaviour — an operator who asked for durability must get it (§13 "no silent stub default"). The default/unset path stays inprocess and needs no store. (This intentionally diverges from the durable events driver, which degrades-loudly to a ring buffer; tasks fail closed.)

  7. tasks.ErrUnserializable (new additive sentinel). A record that cannot be marshalled raises a wrapped tasks.ErrUnserializable from both backends (never a silent drop / nil record) — the §5 fail-loud contract. Additive only; the frozen TaskRegistry interface is unchanged.

Known properties. (1) The engine persists while holding its single RWMutex, so a durable backend serializes task mutations on that engine instance behind each StateStore write. Acceptable for the single-instance restart-survival this phase targets, and consistent with the durable event log (which persists under publishMu); a finer-grained scheme is a later concern if contention is measured. (2) Hydrate eagerly loads every persisted task/group/patch (across all identities, via a maintenance-scoped prefix scan) into the engine's in-memory maps on open — bounded by the live record set, fine for single-instance deployments, and the natural consequence of the engine being an in-memory state machine; a lazy/paged hydrate or a retention/GC policy for terminal records is the scaling follow-up (the durable event log accumulates the same way). (3) Error-path atomicity (added after the adversarial review): a mutating call that fails to persist rolls its in-memory mutation back (status / result / error / priority / tool-count), and a spawn whose group persist fails compensates the already-written task record through Backend.DeleteTask — and the group itself is pre-validated (exists / same-session / open) BEFORE the task is persisted — so a restart never replays a half-applied mutation or resurrects a half-wired spawn the caller was told failed. (4) Two recorded boundaries: AcknowledgeBackground state is in-memory only (no Acknowledged field on the persisted task record), so a previously-acknowledged terminal background task reads as un-acknowledged after a restart and can be re-acked (re-emitting task.background_acknowledged) — a documented limitation, durable ack is a follow-up; and a DOUBLE fault (group persist fails AND the compensating Backend.DeleteTask also fails) surfaces the compounded error but can leave a Pending orphan task in the store that resurrects on restart (benign — recovery ignores Pending, it is only List-discoverable) — the irreducible residue of a non-transactional per-record store.

§4.3 deviations. (a) Engine extraction instead of a standalone durable driver (item 1). (b) Shared StateStore instead of StateDriver/StateDSN config fields (item 4). Both are documented simplifications that still satisfy every acceptance criterion; neither reaches into RFC territory.

Tests. The durable driver passes internal/tasks/conformancetest.Run verbatim (the D-031 gate); restart-survival (tasks + groups + patches intact after close→reopen over the same store, identity isolation preserved, idempotency-key dedup preserved across restart); recovery sweep (Running→Failed{runtime_restarted}, exactly one event, idempotent on a second reopen, Paused/Pending untouched); the D-025 concurrent-reuse gate (N=128 against one shared driver under -race) + a goroutine-leak test; fail-loud (nil StateStore at boot, ErrUnserializable on a malformed record); and the §17 integration test test/integration/durable_tasks_test.go over REAL StateStore drivers (in-memory + a file-backed SQLite true restart) with a real EventBus on the seam, identity propagation, and a forced write-error failure mode — all under -race. The engine carries its own conformance + recovery + nil-arg + persist-error tests.

Cross-references. D-006 (background-task persistence deferred — closed here), D-074 / D-064 (the durable event log precedent + the Evaluations program durability rationale this mirrors), D-031 (the shared TaskRegistry conformance gate every driver inherits), D-025 (compiled-artifact concurrent-reuse contract — the engine is the reusable artifact), D-097 (the dead-task / re-drive lineage recovery defers to), D-027 (the typed-wrapper-over-StateStore persistence pattern). RFC §6.8 (TaskRegistry), §12 (durable backends roadmap). brief 05 (unified foreground/background task namespace, at-least-once idempotency on (TaskID, Edge, EventID)). CLAUDE.md §4.4 (the driver seam), §5 (fail-loud, immutable compiled artifacts), §6 (identity isolation — scoping unchanged), §13 (no silent stub default, no parallel implementations — the engine extraction is the "pick one and deepen it" response), §17 (the real-driver integration test). Plan: docs/plans/phase-87-durable-taskservice-backend.md.


D-229 — Phase 86: durable distributed bus backend — a StateStore-backed MessageBus driver with cross-instance fan-out

Date: 2026-06-18

Status: Accepted

Context. The MessageBus (RFC §6.12) is the at-least-once cross-worker fan-out edge. V1 shipped the contract + an in-process loopback driver (Phase 22) but no durable backend — RFC §6.12 / §12 name "NATS, Redis Streams, Postgres-as-queue" as the post-V1 driver set, and D-009 deferred a durable backend to "post-V1 once the operational shape is clear." Phase 86 ships the first durable driver. The MessageBus is a publish-only contract (Publish + Close); consumption is via projection onto the typed events.EventBus (the loopback driver established this — a distributed.bus_envelope event per publish). Note the seam is still contracts-only in production: nothing in the runtime opens a MessageBus yet (no OpenBus in assemble/cmd), so like loopback, the durable driver is registered + conformance/integration-tested, ready for when a production bus consumer lands.

Decision.

  1. StateStore-backed driver, NATS / Redis deferred (the operator-steered scope). internal/distributed/drivers/durable persists every BusEnvelope as one StateStore record keyed by a fresh time-ordered ULID under distributed.bus.entry/<ulid> (disjoint from the durable event log's events.durable.* and the durable task driver's task.durable.*), and projects it onto the local events.EventBus. On a shared Postgres store this is Postgres-as-queue across instances; on SQLite it is single-instance restart-replay. NATS / Redis Streams remain future drivers in the same set — each pulls a new client dependency, which RFC §10 requires be added via an RFC PR first; this driver reuses the existing pgx / modernc.org/sqlite deps (no new dependency). Mirrors the Phase 57 durable events + Phase 87 durable tasks precedent.

  2. The bus-projection contract is promoted to the distributed package. EventTypeDistributedBusEnvelope + BusEnvelopePayload (and the events.RegisterEventType registration) moved from internal/distributed/drivers/loopback into internal/distributed/projection.go. Both drivers (and the conformance suite + the protocol-docs generator) reference the one contract, so the durable driver projects the IDENTICAL event a loopback-era subscriber already consumes — and no driver imports another driver to reach it (the same "no smell in package decisions" discipline as the Phase 87 engine extraction, D-228). The event-type STRING (distributed.bus_envelope) is unchanged, so the generated Protocol docs are byte-identical.

  3. Cross-instance + restart-replay via a background poller; self-dedup in memory. A poller goroutine (started at New, joined at Close) scans the shared store (ListKind, maintenance-scoped, over the entry prefix) on a ticker and projects every entry it has not already delivered onto the local event bus, in ULID order. The instance that publishes an envelope reserves its entry key in an in-memory projected set BEFORE persisting + projects it locally immediately, so the poller never re-projects this instance's own publish. The projected set is NOT persisted: across a restart it is empty, so a fresh instance re-projects the persisted history (restart-replay) — at-least-once, consumers dedupe on (TaskID, Edge, EventID) per the contract. Cross-instance delivery: instance B's poller projects instance A's entries (not in B's set). Poll cadence is the operator-tunable distributed.bus_poll_interval (default 1s). A Postgres LISTEN/NOTIFY push fast-path is a recorded future optimization behind this same driver (the StateStore exposes no change-notification primitive, so delivery is poll-based here).

  4. Shared store via Dependencies.State; no production wiring in this phase. distributed.Dependencies gains a State state.StateStore field (the durable driver reads it; loopback ignores it), mirroring Phase 87's shared-store decision (D-228). Because the MessageBus seam has no production consumer yet (no OpenBus call in assemble/cmd), there is nothing to wire at boot — the field + the bus_poll_interval config are ready for when a production bus consumer lands. The plan's "wire State at OpenBus in assemble.go" line is therefore dropped (no such call exists); the driver is exercised by the conformance suite + the §17 integration test, exactly as loopback is.

  5. Fail loud (§13). New returns an error when no StateStore (or no EventBus) is wired — an operator who selected durable must get durability, never a silent non-durable fallback. A non-serializable envelope fails the Publish loudly (wrapped marshal error), never a silent drop.

Known properties. (1) The poller does a full prefix scan (ListKind) each interval and tracks an in-memory projected set — O(entries) per poll, growing memory; fine for single-instance + modest Postgres multi-instance, with a retention/GC policy for delivered entries as the documented scaling follow-up (the durable event log accumulates the same way; the StateStore contract has no range scan). (2) A restart re-projects the full persisted history (at-least-once; consumers dedupe) rather than resuming from a persisted cursor — the simplest correct model for V1; a persisted per-instance cursor is a future refinement. (3) No production bus consumer exists yet, so a live-Console test is structurally N/A; the real-driver §17 integration test (two instances over one file-backed SQLite store: cross-instance fan-out + a true close/reopen restart-replay) is the end-to-end gate.

§4.3 deviations. (a) StateStore-backed only / defer NATS-Redis (item 1; the operator's steer). (b) Projection-contract promotion to the distributed package (item 2; not in the plan's file list, adopted for the no-driver-imports-driver discipline). (c) No assemble.go wiring — the seam is unconsumed in production (item 4). (d) The live test is a real-process / real-driver integration test, not a Console test (the bus has no Console/Protocol/dev surface).

Tests. The durable driver passes internal/distributed/conformancetest.RunBus verbatim (the D-031 gate: at-least-once local delivery, mandatory identity, publish-after-close, the 128-worker no-race run, goroutine-leak-after-close); restart-replay + cross-instance fan-out (with a no-self-double-project assertion); the D-025 concurrent-reuse + cross-session-isolation gate (3 sessions × 40 = 120 concurrent publishers on one shared bus, each session's subscriber receives exactly its own) + a goroutine-leak test; fail-loud (nil store, nil event bus, unserializable envelope, ctx-cancelled, persist-error); poller error branches (ListKind error, corrupt entry) via an internal test; and the §17 integration test test/integration/durable_bus_test.go over REAL StateStore drivers (in-memory + a file-backed SQLite modelling two instances over one store) with real EventBus instances, identity isolation, and a forced write-error failure mode — all under -race. Coverage on internal/distributed/drivers/durable is 97%.

Cross-references. D-009 (durable distributed backend deferred — realised here), D-031 (the shared MessageBus conformance gate every driver inherits + the (TaskID, Edge, EventID) idempotency contract), D-074 (the durable event log precedent this mirrors — head/entry StateStore keying, fail-loud-on-empty-store), D-228 (the Phase 87 durable tasks sibling: shared-store decision + the no-driver-imports-driver discipline), D-025 (compiled-artifact concurrent-reuse contract — the bus is the reusable artifact), D-027 (the typed-wrapper-over-StateStore persistence pattern). RFC §6.12 (the MessageBus / BusEnvelope contract), §12 (durable backends roadmap). brief 05 (distributed contracts; at-least-once idempotency on (TaskID, Edge, EventID) — brief 05 Q-4). CLAUDE.md §4.4 (the driver seam), §5 (fail-loud, immutable compiled artifacts), §6 (identity isolation), §13 (no silent stub default, no parallel implementations — the projection-contract promotion is the "pick one and deepen it" response), §17 (the real-driver integration test). Plan: docs/plans/phase-86-durable-distributed-bus.md.


D-231 — Phase 92: admin-set tenant-default LLM overrides — the ModelOverride governance seam, applied next-turn via RunContext.LLMOverrides

Date: 2026-06-18

Status: Accepted

Context. RFC §6.15 reserves a ModelOverride governance seam: an operator changes a tenant's effective LLM behaviour live, without a redeploy. Today the model + sampling defaults are boot-baked — the model resolves at the LLM safety edge (req.Model == "" → req.Model = cfg.Model), temperature/max-tokens/reasoning come from the agent config / provider defaults, and the system prompt is fixed at planner construction. The master plan named a single governance.swap_model method. Two findings reshaped the phase. (1) The session next-turn override mechanism is set-only in production. runs.set_overrides records a PendingOverride (reasoning-effort / temperature / max-tokens / system-prompt) into an in-process Store, but Store.Consume/Peek are called from NO non-test code — there is no run-start apply seam that consumes a recorded override. (2) The operator expanded the scope: the tenant default must set "the same values as the session" — model, an ADDITIVE system-prompt extension ("extra instructions"), temperature, max-tokens, reasoning-effort — admin-authorized and tenant/session-isolated.

Decision.

  1. Scope = the tenant-level admin layer, carrying the FULL override field set. This phase ships the admin-set tenant default; the session-level next-turn override (extending runs.set_overrides with the apply seam) is a follow-up. The tenant override is a multi-field desired-state: Model, ExtraInstructions, Temperature, MaxTokens, ReasoningEffort (each optional; nil = inherit config).

  2. ExtraInstructions is ADDITIVE, deliberately distinct from the session layer's SystemPromptOverride (a full replace). A tenant admin appending org-wide guidance must never clobber the agent's base prompt, so the tenant facet extends the system prompt (rendered into the ReAct <additional_guidance> section, below the operator's baked guidance) rather than replacing it. The session layer keeps SystemPromptOverride (replace) for the operator's own one-shot session.

  3. A new apply primitive + its consumer land together (§13). planner.RunContext.LLMOverrides (a per-run, runtime-populated, planner-read-only bundle, mirroring ReasoningReplay) carries the resolved override. The ReAct planner is its consumer: scalars are stamped onto the llm.CompleteRequest after the builder runs (uniform across the default + custom builders); ExtraInstructions renders additively in the system prompt (default builder). The run loop resolves the bundle ONCE at run start (a D-025 snapshot, immutable for the run) and pins it into the run's RunContext — a swap lands on the NEXT run, never mid-flight.

  4. StateStore-backed governance policy, mirroring the cost accumulator. governance.TenantOverridePolicy persists one JSON record per tenant under a synthetic identity (Quadruple{TenantID: tenant, UserID: "__governance__", SessionID: "__tenant_overrides__"}, Kind governance.tenant_overrides) — state.Save requires a full triple, and the tenant default is a tenant-wide record with no natural user/session. The isolation boundary stays the tenant (a tenant-A record is invisible to tenant-B because Identity.TenantID differs). The policy validates the model against the configured ModelProfiles at SET time (fail loud with ErrUnknownModel), not deferred to call time. Set is a desired-state REPLACE (a nil field clears that dimension; an all-nil spec clears the record). The policy is NOT a Subsystem (a per-call PreCall read would change the model mid-run, violating next-turn-only) — the run loop reads it once at run start.

  5. Methods renamed (model-only names were wrong once scope expanded). governance.set_tenant_overrides (admin, mutate) + governance.get_tenant_overrides (admin, read-back for the Console) replace the planned governance.swap_model; the audit event is governance.tenant_overrides_set (replaces governance.model_swapped). The methods are admin-scoped: authority derives from the verified ctx JWT scope (auth.ScopeAdmin), never the request body (D-219). A new admin-method predicate methods.IsGovernanceAdminMethod routes them; the dedicated POST /v1/governance/{set,get}_tenant_overrides stream handler applies the gate (mirroring the agents-control + runs handlers). The audit payload carries the model name + per-dimension set flags, NEVER the extra-instructions prose.

  6. Resolution order: tenant default › config. With the session apply seam deferred, the run loop resolves tenant-default › config defaults. The session layer slots ABOVE the tenant layer when its Consume call is wired (the follow-up); the resolver is structured so that arm is additive, not a rewrite.

§4.3 deviations. (a) Scope narrowed to the tenant layer + the apply seam built net-new (the session model-swap field is deferred — the master plan's "extend RunOverrides.Model" half is follow-up). (b) Full override field set, not model-only (the operator's expansion). (c) Methods + event renamed from governance.swap_model / governance.model_swapped. (d) Additive ExtraInstructions rather than reusing the session's replace-semantics SystemPromptOverride. All recorded in the plan's "Scope reconciliation" block.

Tests. Governance policy unit suite (set/get round-trip + audit event, unknown-model reject, invalid temp/max-tokens/reasoning reject, tenant isolation, clear, durability across a fresh policy instance, empty-tenant fail-closed, closed, N=128 concurrent-reuse under -race); ReAct apply test (scalars stamped, extra-instructions additive + base prompt survives, nil bundle no-op, unknown reasoning ignored); the run loop's resolveLLMOverrides projection (nil resolver / no record / projects spec / error propagates) tested against production code in cmd/harbor; the governance-protocol service (nil-store fail-loud, identity-required, wire↔spec mapping); the stream handler (admin round-trip set/get, non-admin → 403 CodeScopeMismatch, no-identity → 401, unknown-model → 400, unknown route → 404, non-POST → 405); and the §17 integration test test/integration/tenant_overrides_test.go over REAL StateStore + EventBus + a REAL ReAct planner — resolution + apply, tenant isolation, next-turn re-resolution, the audit event, a state-read-failure-fails-loud mode, and an N=16 concurrency stress, all under -race. The Protocol surface is mirrored into the TS wire manifest (the new types registered in singlesource.CanonicalWireTypes + both typeInstanceIndex maps; the request/response bodies allow-listed pending the typed Console admin control) and the generated Protocol docs (make protocol-docs-gen); scripts/smoke/phase-92.sh static-guards the surface + live-probes the admin-gated route.

Cross-references. D-025 (compiled-artifact concurrent-reuse + next-turn-only — the override is per-run RunContext state, never on the shared planner; resolved once at run start), D-219 (authority from the verified ctx, not the request body — the admin gate), D-093 / D-223 (the Console TS-client lockstep gate the new wire types pass), D-209 (the generated Protocol docs lockstep the new method/event/types pass), D-089 (the cost accumulator's StateStore-backed pattern this policy mirrors). RFC §6.15 (the ModelOverride governance seam), §6.5 (the LLM edge). brief 05. CLAUDE.md §4.4 (the driver/policy seam), §5 (fail-loud, immutable artifacts), §6 (identity isolation — tenant-keyed record), §7 (admin scope + audit redaction), §8 (single-source Protocol types/methods), §13 (no silent degradation, no parallel mechanisms, admin-scope gate), §17 (the real-driver integration test), §18 (the operator-skill update). Plan: docs/plans/phase-92-console-model-swap.md.


D-233 — Phase 91: Console-driven LLM provider key rotation — atomic swap behind the bifrost Account, secret-free audit

Date: 2026-06-19

Status: Accepted

Context. RFC §6.15 + D-019 settled the key-rotation mechanism: the Console pushes a new key over the Protocol → Harbor's bifrost Account swaps the live key atomically → bifrost reads it on the next call via Account.GetKeysForProvider; no ReloadConfig, old key invalidated immediately. Phase 91 realises it as the admin-scoped governance.rotate_key method. Before this, the bifrost Account baked the resolved key as a plain string at construction (internal/llm/drivers/bifrost/account.go) — the GetKeysForProvider seam was already called per request, but the value behind it was immutable. (D-232 is reserved for Phase 92b — the tenant-override completion plan — and is filed at that phase's implementation.)

Decision.

  1. A shared atomic LiveKey holder is the swap seam. llm.LiveKey wraps the primary key in an atomic.Pointer[string] (internal/llm/livekey.go) — the ONE sanctioned mutable field behind the per-call read path (CLAUDE.md §5 carve-out: an atomic documented "internally synchronised"). The boot wiring (assemble.Assemble) creates ONE holder, injects it into the driver via llm.Deps.LiveKey (read path: Account.GetKeysForProvider reads holder.Get()), and exposes it on Stack.KeyRotator (a *llm.ProviderKeyRotator) as the admin write path. The same holder is read by the driver and written by the rotate service — no reaching through the wrapped LLM-client chain (the rejected alternative). When no holder is injected (tests / embedders that do not wire rotation) the account creates a private holder — the read path is unchanged, rotation simply has no external handle (backward-compatible).

  2. Immediate, not next-turn. Unlike Phase 92's per-run parameters (snapshotted at run start), a credential may be compromised, so the swap takes effect on the very next call and the old key is never read again. There is no run-start snapshot for the key.

  3. Admin-scoped, on the /v1/governance/ family. governance.rotate_key mirrors governance.set_tenant_overrides (Phase 92): the stream GovernanceHandler gates on the verified auth.ScopeAdmin claim (authority from the verified ctx, not the body), with the body-identity defence-in-depth check; a non-admin caller is rejected with CodeScopeMismatch. A new governance/protocol.KeyRotateService (sibling of the tenant-override Service) validates identity, drives a narrow KeyRotator seam (the *llm.ProviderKeyRotator concrete), and emits the audit event. The route is nil-safe: a runtime with no LLM driver leaves it returning 501.

  4. Secret hygiene (CLAUDE.md §7) is load-bearing. The new key value travels only on the request leg, is held only in the atomic holder, and is NEVER logged, audited, or echoed. The response + the governance.key_rotated event carry only a non-reversible sha256:<prefix> fingerprint (llm.Fingerprint). A privileged mutation is never silent (§13): a missing bus logs at Info, a publish failure at Warn — never the key. Backed by a secret-hygiene test (the key appears in no log line / audit payload / response) + an N≥100 concurrent rotate-while-read -race test (the holder is the reusable artifact, D-025).

  5. Persistence across restart — in-memory for V1 (the open decision D-019 left). The swap is in-memory; a runtime restart reverts to the config/env key. This is correct when rotation means "use a fresh key now" and the operator updates config/env out-of-band; its risk (a restart re-arming a rotated-because-compromised key) is documented, and encrypted persistence via the Phase 26a Sealer + an operator KEK is the recorded gated follow-up. V1 ships in-memory; the restart behaviour is documented (not silently dropped).

Scope. Single primary-provider key. Multi-key-per-provider / weighted sets / FailoverPolicy / CircuitBreaker / the full per-identity KeyResolver are post-V1 (Phases 93/94); the request carries provider for forward-compat (a non-bound provider is rejected with CodeInvalidRequest).

§4.3 deviations. None — D-019 settled the mechanism; this implements it. The shared-holder-via-llm.Deps injection seam (vs a capability accessor) is the plan's recommended option, adopted.

Tests. llm.LiveKey (Init/Get/Rotate/empty-reject, non-reversible Fingerprint, N≥100 concurrent rotate-while-read under -race); ProviderKeyRotator (provider match/mismatch, empty key); the bifrost account's GetKeysForProvider reflecting a rotate on the injected shared holder (in-package); the KeyRotateService (nil-rotator fail-loud, identity-required, error propagation, secret-free event emit); the stream handler (admin success with no key echoed, non-admin 403, empty-key 400, unknown-provider 400, not-wired 501); and the §17 integration test/integration/key_rotation_test.go over a real bus + redactor + holder + service — immediate swap, secret-free audit, log-scan secret hygiene, concurrency — under -race. The Protocol surface is in the TS wire manifest (request/response allow-listed — the key is a secret kept off typed surfaces) + the regenerated Protocol docs; scripts/smoke/phase-91.sh static-guards + live-probes the admin-gated route.

Cross-references. D-019 (the settled key-rotation mechanism — realised here), D-025 (compiled-artifact concurrent-reuse — the holder is the reusable artifact; the atomic is the sanctioned mutable seam), D-231 (the Phase 92 governance admin-verb pattern + /v1/governance/ family this mirrors), D-086 (the auth.rotate_token admin-rotate precedent), Phase 26a Sealer (encryption-at-rest, the persistence follow-up). RFC §6.15 (Governance / key rotation), §6.5 (the LLM edge). briefs 03 + 08. CLAUDE.md §4.4 (the seam), §5 (fail-loud, immutable artifacts + the atomic carve-out), §6 (identity isolation), §7 (secrets never logged/audited), §8 (single-source Protocol types/methods), §13 (no silent degradation, admin gate), §17 (the real-driver integration test), §18 (the operator-skill update). Plan: docs/plans/phase-91-console-key-rotation.md.


D-232 — Phase 92b: tenant-override completion — session apply seam, typed Console control, multi-replica freshness

Date: 2026-06-20

Status: Accepted

Context. Phase 92 (D-231) shipped the admin tenant-default override but deferred three follow-ups (recorded in its closeout): the session-level override was set-only in production (Store.Consume called from no non-test code), the five governance wire types were allow-listed pending a Console consumer, and the TenantOverridePolicy cache was load-permanent (a cross-replica Set was invisible to a replica that had already loaded the tenant). Phase 92b closes all three.

Decision.

  1. Session apply seam wired; RunOverrides gains Model. The run loop now Consumes the session's one-shot pending override at run start (runsprotocol.Store.Consume, keyed by the identity triple) and composes it OVER the tenant default into planner.RunContext.LLMOverrides. The runs Store is extracted in cmd_dev.go so the SAME instance is shared by the runs Service (SET via runs.set_overrides) and the run-loop driver (CONSUME at run start). RunOverrides/PendingOverride gain Model; runs.set_overrides validates it against the configured ModelProfiles at set time when WithValidModels is wired (fail loud, mirroring the tenant layer), else defers to the LLM edge.

  2. Resolution order session › tenant › config, composed per field. A non-nil session field wins; the tenant fills the rest. The session's SystemPromptOverride is a full REPLACE (session-only); the tenant's ExtraInstructions is additive (tenant-only) — both compose (replace the base, then append). planner.LLMOverrides gains SystemPromptOverride; the ReAct prompt builder replaces the base system prompt when it is set (an empty string is a valid clear), with the additive guidance still rendered below.

  3. Typed Console client + admin control. The five governance tenant-override wire types are now hand-written TS interfaces (web/console/src/lib/protocol/governance.ts) mirroring the manifest field-for-field; the five protocol-ts-untyped-allow.json entries are removed (the §13 "type it once a consumer exists" tightening — the lockstep guard now fails on a drift). A GovernanceNamespace (client.ts) issues governance.{set,get}_tenant_overrides; a TenantDefaultOverridesState + TenantDefaultOverridesCard.svelte on the Settings page (a new tenant-defaults runtime-posture section) read + write the tenant default through the typed client — admin-gated (disabled-with-tooltip for non-admin; the runtime also gates), four-state async (D-121), tokens only, no hand-rolled fetch. RunOverrides.Model stays allow-listed (regen-only — the Playground consumes it inline).

  4. Multi-replica freshness via per-read reload. The loaded-permanent cache gate is removed: every Get re-reads the StateStore (the per-tenant mutex serialises a tenant's read/write; the run loop already does StateStore reads at run start, so the per-Get Load is in the existing cost envelope). A cross-replica Set is now visible on the next Get. The per-tenant tenantOverrideEntry keeps spec/set only as a transient under the lock; loaded is dropped. The chosen mechanism (per-read freshness) over the alternatives (bus invalidation, TTL) — the plan's recommendation — for V1 simplicity with no new infra; the StateStore is the source of truth on every read.

§4.3 deviations. None — 92b discharges the three follow-ups D-231 deferred, per the merged plan.

Tests. Runs Service Model validation (valid/unknown-at-set-time/no-validModels-accepts-any); ReAct SystemPromptOverride replace (base replaced + additive survives + nil keeps base); the run-loop composition unit (session-over-tenant, session-only, one-shot consume) + the production runOne wiring gate (a spawned task whose session has a pending override sees the composed result on RunContext.LLMOverrides, session wins over tenant, consumed one-shot); governance multi-replica freshness (two policies over one shared StateStore — a re-Set on A lands on B; clear propagates) + the existing durability/concurrent-reuse green under per-read; the §17 integration over a real governance policy + runs Store + real ReAct planner (session-over-tenant reaches the LLM request; multi-replica fresh). Console: 3 GovernanceNamespace vitest specs (route + payload + 403→ProtocolError); svelte-check 0 errors + npm run lint (TS lockstep guard with the 5 entries removed) green. RunOverrides.Model regen in the wire manifest + generated docs.

Cross-references. D-231 (Phase 92 — the tenant layer + the resolver scaffold this completes; the apply primitive RunContext.LLMOverrides), D-025 (next-turn snapshot; the per-tenant entry mutex is the sanctioned synchronisation), D-093/D-223 (the TS lockstep gate the typed interfaces now pass + the de-allow-list tightening), D-121 (the Console four-state/tokens conventions the admin control follows), D-209 (the generated Protocol docs the RunOverrides.Model regen passes). RFC §6.15 (the ModelOverride seam), §7 (the Console). briefs 03 + 08. CLAUDE.md §4.4, §5 (fail-loud, per-read freshness inside the concurrency contract), §6 (identity-keyed session slot + tenant record), §8 (single-source wire types), §13 (no silent degradation; type-once-consumed), §17 (the real-driver integration + the runOne wiring gate), §18 (the operator-skill + Console-surface update). Plan: docs/plans/phase-92b-tenant-override-completion.md.


D-234 — Phase 92a: agent-config control plane — the versioned desired-state registry, next-turn projection, and the planner-snapshot / app-call-current asymmetry

Date: 2026-06-20

Status: Accepted

Context. Phases 91 (key rotation) and 92/92b (LLM-parameter overrides) established the governance control-plane pattern: an admin mutates identity-scoped desired state over the Protocol, and the runtime reconciles it into the next run. Phase 92a extends that pattern from LLM governance config into agent-definition config — the prompt, tool/MCP exposure, per-tool policy, and skills that RFC §6.16 already content-hashes into an agent's version_hash. The naïve framing (mid-flight reconnect, drain, atomic tool-set swap) rated this HARD. The reframing dissolves the difficulty: under Harbor's existing per-run immutable snapshot (D-025), a config change need only affect the next run. Every concrete control becomes "project a per-run view at run start from a durable desired-state record" — no coordination, no draining, no live swap. 92a is decomposed into a primitive phase (this decision) plus per-domain consumer phases; the first wave builds the primitive (92a) with skills (92c) as its mandatory same-wave consumer (CLAUDE.md §13) and MCP pause/per-tool policy (92d) alongside. The authorization matrix is D-235.

Decision.

  1. One unifying primitive: a durable, identity-scoped, versioned desired-state registry on the StateStore. No new persistence subsystem — it reuses the §9 driver triad (in-mem / SQLite / Postgres) for identity isolation, exactly as the governance TenantOverridePolicy does. It lives in a new package internal/agentcfg behind the §4.4 seam. Each edit produces a new immutable revision: a content-addressed record {revision_id, parent_revision_id, content_hash (full 32-byte SHA-256), author identity, created_at, payload}. The active configuration is a pointer record to a revision id. The registry is keyed by agent_id (the RFC §6.16 registration identity — NOT an isolation principal; the StateStore Kind + the isolation triple still scope every record per CLAUDE.md §6) plus a tenant-level synthetic-identity slot for tenant-default config, mirroring the governance __governance__ / __tenant_overrides__ keying. Parent pointers and content hash live INSIDE the record Bytes, never relying on EventID persistence (the StateStore UPSERT evicts the prior EventID at a slot — workflow finding, revision-model map). Revision enumeration uses ListKind under the elevated maintenance scope.

  2. Rollback = repoint; diff = server-side revision compare. Rollback is a new active-pointer write to an existing revision id — it never mutates or deletes a revision. Diff is a read method comparing two revision payloads: a text diff for the prompt layer, a structured set-diff for tool/MCP exposure, per-tool policy, and skills. Both are exposed as Protocol reads/writes (D-235 gates the writes); a successful revision emits the canonical event agent.config.revised (and agent.config.reverted on a rollback) carrying the agent_id, the new + parent revision ids, and the author identity — never the payload secrets. The Console Agents/Settings surface is a lens over these events + a registry snapshot (D-061): it renders the diff and the revision history, never holding the config itself.

  3. Next-turn projection is the ONLY application path (D-025-aligned). A run resolves its config view ONCE at run start, from the active revision, into its immutable per-run snapshot — extending the existing tools.NewPlannerView(cat, CatalogFilter{...}) seam (cmd_dev_runloop.go, run-start) and the run-start LLM/prompt resolution the 92/92b work added. The registry is read at run start; the artifact (engine, catalog, planner) stays immutable. In-flight and concurrent runs keep their snapshot; a config edit is invisible to them by construction. This is not a limitation bolted on — it is what makes the feature align with the concurrency contract instead of fighting it.

  4. The planner-snapshot / app-call-current asymmetry is intentional and load-bearing. An in-flight planner tool call uses the run's run-start snapshot. But an MCP App's app→host tools/call callback (D-173, the ext-apps surface) is a NEW invocation fired AFTER the run — it MUST be gated against the current desired state, not the run's stale snapshot. Concretely: the gate fires in internal/mcpconsole.AppsAccessor.CallTool BEFORE desc.Invoke() (it already holds the MCP registry handle and can extract the <source> from the resolved tool), as a defence-in-depth check ahead of the approval gate. So once an admin pauses a server, that server's App callbacks are rejected against current state while any still-running planner snapshot is undisturbed. The operator-legible "paused by a system administrator" overlay the Console/client renders is the UX for that real authorization rejection — it is functional, driven by the agent.config.revised / mcp.connection.paused canonical event, never a cosmetic reach into the App (Console-as-Protocol-client holds, D-061).

  5. Pause is projection-time, not teardown. Pausing an MCP server sets a desired-state flag; the next run's projection excludes that server's tools (and, per 92d, per-tool disabled/deferred maps directly onto the existing LoadingMode). The live transport stays WARM — resume is a flag flip, instant, no re-dial. Adding a genuinely NEW MCP connection (async dial + initialize handshake + possible OAuth via the unified pause/resume primitive, §7.4) is the one HARD, SEPARABLE piece, deferred to 92f and explicitly NOT in the first wave; fail-loud on dial/init failure (record failed, never silently drop) is pinned now so the later phase inherits it.

Non-goals (this primitive). No mid-flight reconciliation; no per-run config hot-swap; no new persistence backend; no add-new-connection dial path (92f); no session-user safe subset (D-235 reserves it for 92g). Revision GC is out of V1 scope — the StateStore has no TTL; high-frequency edits bloat the store, so a documented manual ListKind+prune path is the V1 answer and an automatic compaction is a recorded follow-up.

§4.3 deviations. None against the RFC — §6.16 already defines version_hash over exactly this content; 92a makes that content a durable, editable, revisioned record and lets version_hash follow a revision. The internal/agentcfg package is a new directory under the §3 layout (the agentcfg subsystem the master-plan row already names); it is added in the same PR per §3.

Cross-references. D-231/D-232 (the governance desired-state-over-Protocol + next-turn pattern this generalises), D-233 (the admin-verb /v1/governance/-family pattern + the immediate-vs-next-turn distinction — config is next-turn, credentials are immediate), D-025 (the per-run immutable snapshot that makes next-turn projection free), D-059/D-066 (RFC §6.16 — agent_id as registration identity not isolation principal; fleet observation vs control privilege tiers → D-235), D-061 (Console as Protocol lens; no shadow source of truth), D-173 (the App app→host tool-context surface the asymmetry gate protects), D-026 (heavy-content discipline the App-call path already honours). RFC §6.15, §6.16, §6.4, §7.4. briefs 11 + 05 + 09. CLAUDE.md §4.4 (the seam), §5 (fail-loud, immutable artifacts), §6 (identity scoping; agent_id is not an isolation filter), §8 (single-source Protocol types/methods), §13 (primitive-with-consumer — 92c/92d are the same-wave consumers; no silent degradation). Plans: docs/plans/phase-92a-agent-config-registry.md, phase-92c-agent-config-skills.md, phase-92d-agent-config-mcp-policy.md.


D-235 — Phase 92a: agent-config authorization scope matrix — elevated for capability changes, a reserved session-user safe subset

Date: 2026-06-20

Status: Accepted

Context. The agent-config control plane (D-234) lets a caller change what an agent can do — its tools, MCP exposure, base prompt, and skills. That is a capability surface, and the authorization model must be settled before any write method ships, so the scope gate is designed in, not retrofitted. RFC §6.16/D-066 already splits fleet observation from fleet control into distinct privilege tiers (control requires a more-elevated scope than observation); 92a extends that split to config mutation. Authority derives from the verified ctx JWT scope, never the request body (D-219).

Decision.

  1. Capability-changing edits require an elevated (admin / console:fleet) scope. The base/operator prompt layer, adding or removing MCP servers, and the per-tool allowlist are tenant/deployment-level capability changes → gated on the verified auth.ScopeAdmin (or fleet-control) claim, with the body-identity defence-in-depth check (mirroring governance.set_tenant_overrides / rotate_key). A non-elevated caller is rejected with CodeScopeMismatch. Every such write is audit-redacted and emits agent.config.revised. Adding a stdio MCP server (an RCE surface) is the most privileged action — beyond admin scope it is allowlist-gated and/or approval-gated via the unified pause/resume primitive (settled now, enforced when 92f lands).

  2. The session-user SAFE SUBSET is reserved, not built in the first wave. A session-scoped (non-admin) end user gets ONLY a safe subset: a user-instruction prompt layer that composes ABOVE the operator base (it can add guidance but cannot weaken or replace the base guardrails — the base is operator-owned and the composition order is the security boundary), enable/disable among ALREADY-allowed sources, and ephemeral personal skills. A session user MUST NOT add arbitrary MCP servers, widen the tool allowlist, or edit the base prompt. This subset and its gating land as a dedicated later sub-phase (92g) once the registry primitive + the admin path are proven live — keeping each PR's auth surface small. The first wave (92a/92c/92d) ships the ELEVATED path only.

  3. The layered prompt is the structural enforcement of the boundary. The prompt is modelled as layers: an admin-owned, versioned BASE layer + an optional higher user-instruction layer that composes above it without mutating it. Because the base is a distinct, separately-scoped revision and the user layer is always appended (never a replace at the base level), a session user physically cannot edit the base — the data model, not a runtime check alone, carries the guarantee. (The per-run session SystemPromptOverride from 92b is distinct: it is a one-shot, run-scoped REPLACE for the caller's own next message, not a durable base-layer edit.)

Non-goals. The session-user safe subset implementation (92g); a runtime-side enrollment allowlist of authorized control-plane clients (RFC §6.16 "decide later" — per-request JWT scope covers the V1 need); cross-tenant config administration.

§4.3 deviations. None — this is the §6.16/D-066 privilege-tier split applied to config mutation.

Cross-references. D-234 (the registry primitive these scopes gate), D-066 (fleet observation vs control privilege tiers — the precedent), D-219 (authority from verified ctx, not body), D-231/D-233 (the admin-scope governance-verb gate this mirrors), D-232 (the per-run session override, distinct from the durable base-layer edit). RFC §6.16, §6.15, §5.5 (JWT scope). briefs 11 + 09. CLAUDE.md §6 (identity), §7 (no credential passthrough; approval-gated privileged actions), §13 (admin-scope gate; no identity-downgrading knobs). Plans: docs/plans/phase-92a-agent-config-registry.md, phase-92c-agent-config-skills.md, phase-92d-agent-config-mcp-policy.md.


D-236 — Phase 92e: layered system prompt — operator base + user layer, composition order is the boundary

Date: 2026-06-20

Status: Accepted

Context. The agent-config registry (92a) declared ConfigPayload.PromptLayers{Base, User} forward-compatibly. D-235 §3 pinned that the layered prompt's data model IS the security boundary (operator-owned base; a user layer that composes above without mutating it). Phase 92e wires it, and must define how the durable layers compose with the per-run override systems Phase 92b already shipped (the session one-shot SystemPromptOverride REPLACE + the additive ExtraInstructions).

Decision.

  1. Two durable layers, resolved at run start (next-turn, D-025). The active revision's PromptLayers.Base, when set, becomes the run's base system prompt (overriding the agent's configured default base); an unset base inherits the configured default (backward compatible). PromptLayers.User, when set, composes ABOVE the base in the lower-trust guidance position (brief 13) — appended, never a base replace, never a position before the base. The composition is read once at run start via the shared projection.ActivePromptLayers (cmd/harbor + devstack twin, §17.6).
  2. The composition order is the structural boundary. Because Base and User are distinct fields and User is always appended below Base, a writer of only User (the 92g session-user tier) physically cannot alter or precede the operator guardrails — the data model carries the guarantee, the scope gate (92g) is defence-in-depth.
  3. Precedence with the 92b per-run overrides (pinned + tested). The effective order is: durable base layer → durable user layer → tenant additive ExtraInstructions → session additive ExtraInstructions, and the session one-shot SystemPromptOverride (92b) REPLACES the whole base+user spine for its single message (it is a per-message escape hatch, not a durable edit). This is documented in the prompt-builder godoc and pinned by a test so the durable-layer and per-run-override systems never silently fight.
  4. A prompt edit is a config revision. agent_config.set_prompt_layers REPLACES only the PromptLayers section, preserving Skills + ToolExposure (the bidirectional section-merge invariant 92d established); it records a revision (diff renders the base/user text delta; rollback repoints) and emits agent.config.revised. Admin-scoped; authority from the verified ctx (D-219/D-235).

§4.3 deviations. None — implements the D-235 §3 prompt-layer model on the 92a-declared envelope.

Cross-references. D-234 (the registry/revision/projection primitive), D-235 (the layered-prompt-as-boundary + scope matrix), D-232 (the 92b per-run SystemPromptOverride/ExtraInstructions this composes with), D-025 (next-turn snapshot). RFC §6.2 (ReAct prompt sections), §6.16. briefs 13 + 11. Plan: docs/plans/phase-92e-agent-config-layered-prompt.md.


D-237 — Phase 92f: runtime MCP attach — explicit async lifecycle, OAuth via the unified pause/resume primitive, stdio allowlist-gated

Date: 2026-06-20

Status: Accepted

Context. Pause/resume (92d) is a projection-time flag on an already-attached server. Adding a genuinely NEW MCP connection over the Protocol is the separable hard piece (the 92a master-plan block flagged it): it needs an async dial + the MCP initialize handshake + possibly OAuth. The boot-time Attach exists; this adds a post-boot, identity-scoped, admin-driven attach.

Decision.

  1. An explicit, fail-loud lifecycle. The add drives dial → initialize handshake → Discover → register, modelled as explicit states pending → online | failed | auth_required. A failure at any step records a failed connection with the reason and emits a loud lifecycle event; a half-attached server is NEVER registered (CLAUDE.md §13 — no silent drop). The connection descriptor is recorded as an agent-config revision (diff/rollback via 92a), preserving the other sections.
  2. OAuth reuses the unified pause/resume primitive — no new auth dance. A server that requires authorization parks on the existing pause/resume coordinator (tool-side OAuth, §7.4 lineage); the agent-bound token keys by the registration agent_id (D-059). A resume completes the attach. The runtime emits state; the Console (92h) renders the "awaiting authorization / paused by an administrator" advisory — it never reaches into the flow (D-061). As-built clarification (wave-end audit, 2026-06-22): the parking half shipped; the resume CONTINUATION that re-drives the attach to online is NOT yet implemented (resume currently only releases the pause), and persisted Connection descriptors are not reconciled at run-start (restart/rollback). Both are tracked in issue #375; the godoc + the 92f plan were downgraded to the parked-only behavior until #375 lands.
  3. Adding a stdio server is the most privileged action — allowlist + approval gated. A stdio server runs an operator-supplied command (an RCE surface). Beyond the admin scope (D-235), adding stdio is allowlist-gated and/or approval-gated via the pause/resume primitive, fail-closed, and audited; argv-form only (§7 rule 8 — never sh -c). The plan ships no path where a plain-admin token spawns an arbitrary process without the allowlist/approval.
  4. The driver stays a transport; the service orchestrates. The MCP driver exposes a guarded runtime-attach entry point; the agentcfg protocol service drives it + the registry + the pause/resume routing — the driver remains unaware of the registry (the §4.4 boundary).

§4.3 deviations. Scope is MCP-only (HTTP/A2A add deferred); destructive remove of an existing connection is deferred (pause/resume covers the disable need). Documented in the plan's non-goals.

Cross-references. D-234 (registry/revision), D-235 (admin scope + the stdio-approval rule), D-173 (the app surface the asymmetry gate protects), D-059 (agent-bound token keying), §7.4 (tool-side OAuth + the unified pause/resume primitive). RFC §6.4, §7.4, §6.16. briefs 14 + 09. Plan: docs/plans/phase-92f-agent-config-add-connection.md.


D-238 — Phase 92j: per-agent LLM parameters are a versioned agent-config section above a retained tenant-wide baseline

Date: 2026-06-21

Status: Accepted

Context. The tenant-default override work (Phase 92 + the 92b completion) set the effective LLM defaults — model / temperature / max-tokens / reasoning-effort / extra-instructions — tenant-wide: one TenantOverrideSpec keyed by {tenant, "__governance__", "__tenant_overrides__"} that applies to EVERY agent loaded in the tenant. The intent was per-agent: an operator wants to pin a specific agent's model without changing the default for every other agent in the tenant. A tenant-wide-only knob is the wrong granularity for that workflow.

Decision.

  1. Per-agent LLM parameters are a new versioned section on the agent-config ConfigPayload (internal/agentcfg): LLMParams{Model, Temperature, MaxTokens, ReasoningEffort}, all pointer-optional (each field independently set-or-unset; a partial section is valid). It rides the existing revision machinery — content-hash, set_revision (full payload), a sibling-preserving set_llm_params convenience verb, the server-side diff (a new LLM-params arm), rollback, and the agent.config.revised event — so per-agent model changes are durable, diffable, and roll back like any other config edit. Keyed by {tenant, "__agentcfg__", agentID}agent_id is the registry key, NOT an isolation filter (§6).
  2. The tenant-wide override is RETAINED as the baseline layer — this is additive, not a rescope. The operator's call: a tenant default that every agent inherits unless it pins its own. The per-agent section sits BETWEEN the session override and the tenant-wide baseline. The effective per-run resolution is session › per-agent (agentcfg) › tenant-wide baseline › config default, composed per-field (an unset per-agent field falls through to the tenant baseline, then config). ComposeLLMOverrides gains the per-agent arm; there is exactly ONE resolution path (no parallel mechanism — brief 03's "two parallel modes is a toggle smell").
  3. ExtraInstructions does NOT move into the per-agent section. Additive system-prompt text is already the agent-config prompt layers (92e); duplicating it in LLM-params would create two homes for prompt text. The per-agent section is sampling parameters only. The tenant baseline keeps its ExtraInstructions field.
  4. Resolution is shared between the production run loop and the devstack twin (D-094). projection.ActiveLLMOverrides reads the active revision's LLMParams and is called by BOTH cmd/harbor/cmd_dev_runloop.go::resolveLLMOverrides and harbortest/devstack; a twin test asserts identical resolution (the §17.6 one-binary-only failure mode is pre-empted).
  5. Writes are admin-scoped (the D-235 capability tier); authority from the verified ctx, never the body (D-219). Pinning an agent's model is a deployment-level change. The session safe subset (92g) gets NO LLM-params verb; per-run session sampling stays on runs.set_overrides. A pinned model with no resolvable ModelProfile fails loud at run-start (parity with the Phase 92 tenant swap) — never a silent fallback (§13).

§4.3 deviations. Per-tool / per-skill model routing is out of scope (a single agent-level sampling profile only). Console rendering (the "Model & sampling" area + the Settings "Tenant Default Overrides → tenant-wide baseline" copy clarification) is twinned in 92i, not this phase.

Cross-references. D-231 (the tenant model-swap + unknown-model fail-loud this composes with), D-234 (registry/revision), D-235 (admin scope tier), D-219 (verified-ctx authority), D-094 (the devstack twin), D-025 (next-turn snapshot semantics). RFC §6.15, §6.16, §6.5. briefs 03 + 08. Plan: docs/plans/phase-92j-agent-config-llm-params.md.


D-239 — Phase 92i: agent-config revision readability + safe rollback are a derived, client-side Console concern (no stored revision label)

Date: 2026-06-21

Status: Accepted

Context. The agent-config panel (92h) lists revisions by content-hash + author + timestamp. Two operator questions exposed a gap: "if I can see the history but can't read what each revision MEANT, how do I roll back safely?" and "can I change prompt + temperature + model in ONE revision instead of three?". The naive answer to the first is a stored free-text revision label/note; the naive answer to the second is more convenience verbs.

Decision.

  1. The per-revision change summary is DERIVED, not stored. The agent-config Revision stays metadata-only — no free-text label/note field is added. The Console derives a human-readable one-line summary (which sections changed vs the parent revision, ±) entirely client-side from the payload each AgentConfigRevisionView already carries (list_revisions returns full payloads). A derived summary is always accurate and never drifts from an out-of-date hand-typed message; a rollback revision (payload equals an ancestor) is recognised and labelled "Rolled back to <short-id>". No new Protocol round-trips, no backend primitive in a Console phase.
  2. Rollback is gated by a mandatory diff preview — never a blind repoint. Selecting "Roll back to this revision" renders the structured agent_config.diff (active → target) for explicit operator confirmation; the rollback (repoint) fires only on confirm. This is the safety answer to "can't read what they meant" — you confirm against the EXACT delta, not the opaque intent. Admin-gated, disabled-with-tooltip for a non-admin (the 92b "never faked" precedent, §13).
  3. Multi-section edits commit as ONE revision via set_revision. The panel's primary save path collects pending edits across all areas (prompt / skills / MCP policy / model & sampling) into one staged payload and commits a single set_revision (the full merged payload) → one revision, one agent.config.revised event, one diffable unit — answering "change prompt + temperature + model in one revision". The per-section convenience verbs remain for single-area quick edits, but the atomic Save-all is primary. set_revision is one atomic write, so Save-all is all-or-nothing by construction (no partial-revision risk); staged state clears only on confirmed success (a failed write keeps the edits + surfaces the error — no silent drop).

§4.3 deviations. Pure Console; no Go / Protocol surface added (consumes 92a + 92j surfaces). The end-user (non-admin) revision surface is out of scope (operator Console only). Revision pruning / retention is a separate concern.

Cross-references. D-234 (the revision/diff/rollback primitive), D-121 (Console conventions), D-238 (the LLM-params section the summary + Save-all + diff cover). RFC §7, §6.16. briefs 11 + 12. Plan: docs/plans/phase-92i-console-revision-ux.md.


D-240 — Runtime MCP tool-side OAuth: a runtime-added MCP connection authenticates via a runtime-registered, agent-bound OAuth config; resume completes the attach; reconcile attaches declared servers; detach-on-rollback is deferred

Date: 2026-06-22

Status: Accepted (planning — implementation parked). Decision 5 (detach-on-rollback deferral) superseded by D-287 (Phase 156); decisions 1–4/6 unchanged, still parked.

Context. The wave-2 §17.5 checkpoint audit filed issue #375: agent_config.add_mcp_connection's auth_required branch parks on the unified pause/resume primitive but registers NO resumable continuation (resume is a dead-end — the server never comes online), and the persisted Connection descriptors are written but never consumed by a read-path (on restart only config-declared servers re-attach; a rollback past an add does not detach). D-237 §2 / the addconnection.go + methods.go godoc / the 92f plan claimed "a resume completes the attach"; the checkpoint PR downgraded that to the parked-only as-built behavior pending #375.

Tracing the as-built code showed the faithful fix needs a credential the resume can present, and there is nowhere for it to come from today: the OAuth Provider's config set is immutable after construction (auth.NewProvider(configs, …)); the MCP driver does not consult auth.OAuthProvider at all (static headers only; auth_required is a string heuristic, looksLikeAuthRequired); and add_mcp_connection carries no OAuth config. So the right fix is a wave that wires MCP-add into Harbor's EXISTING agent-bound tool-side OAuth primitive (internal/tools/authScopeAgent, PKCE, RFC 7591 dynamic registration, RFC 8414 discovery, the sealed agent-bound TokenStore, InitiateFlow/CompleteFlow, the auth.CallbackHandler, and — load-bearing — CompleteFlow already resumes the parked pause via the unified Coordinator). This is an RFC-level addition (a new runtime OAuth surface), so it is recorded here.

Decision.

  1. Reuse the ONE tool-side OAuth primitive — no parallel auth path. A runtime-added MCP server authenticates through auth.OAuthProvider with BindingScope == ScopeAgent (the agent-bound token keys by the registration agent_id, D-059). The unified pause/resume Coordinator remains the ONLY pause path (CLAUDE.md §7 rule 4 / §13). InitiateFlow parks; the operator completes consent out-of-band; auth.CallbackHandlerCompleteFlow persists the agent-bound token and resumes the pause. No new auth dance, no new pause coordination.
  2. The OAuth Provider gains a runtime config-registration seam (92k). RegisterConfig / UnregisterConfig move the configs map behind a documented internally-synchronised mutex, preserving the concurrent-reuse contract (D-025): the Provider stays a compiled artifact; the config registry is shared mutable state guarded like coordinator.pauses. An in-flight flow keeps its captured config; re-registration applies to the next flow.
  3. The MCP transport authenticates via the provider (92l). The driver resolves an agent-bound token via provider.Token and injects Authorization; a missing token surfaces a TYPED auth.ErrAuthRequired (replacing the looksLikeAuthRequired heuristic). Static operator headers remain supported and take precedence when present.
  4. add_mcp_connection drives the flow (92m); the resume bridge completes the attach (92n). On a typed ErrAuthRequired the service calls InitiateFlow (replacing the bare parkForAuth) and returns the authorize URL + pause token. A long-lived agent-config pause.resumed subscriber re-drives the attach under the per-(tenant, agentID) write lock; success → online, failure → loud failed (never a silent re-park or drop, §13). This re-instates the "a resume completes the attach" claim (reverting the checkpoint downgrade in D-237 §2 + the godoc + the 92f plan).
  5. Run-start reconciliation attaches declared servers (92o); detach-on-rollback is DEFERRED. The shared run-start projection attaches a declared-but-absent connection (reading the agent-bound token), so a server authorized in a prior process comes online on the next run (idempotent, per-agent serialised). Detach-removed is deliberately scoped out: tearing down a live MCP transport on a rollback is inconsistent with the next-turn-projection / warm-transport model (D-237 / 92d) and carries ordering + draining risk; revocation is the pause/resume tool-exposure primitive's job ("pause/resume covers the disable need," D-237). The deferral is recorded loud, with the operator note that pausing is the revoke path. Revisited if a removal need emerges that pause cannot serve.
  6. Spec-faithful discovery removes the operator-config requirement for the common case (92p). 401 → WWW-Authenticate → RFC 9728 protected-resource metadata → RFC 8414 AS discovery → RFC 7591 dynamic registration → PKCE; the service synthesises the OAuthConfig from discovery. Conformance fixtures derive from the real spec / a real server transcript (CLAUDE.md §17.8) — never a hand-authored self-consistent blob.

§4.3 deviations. Planning only — no code lands in this PR; the wave (92k–92q) is parked after approval. Scope is MCP HTTP transports (stdio OAuth is not a pattern; A2A AUTH_REQUIRED already converges on the same primitive). Detach-on-rollback deferred (decision 5). Per-phase decisions D-241..D-247 are reserved and logged on ship (§17.7 step 3).

Cross-references. D-237 (the add-connection lifecycle this completes), D-059 (agent-bound token keys by agent_id), D-083 (the Phase 30 tool-side OAuth primitive reused), D-235 (admin scope + stdio gate), D-094 (devstack twin parity for the run-start projection), D-025 (concurrent-reuse contract for the Provider seam), D-061 (Console is a Protocol client). RFC §6.4, §6.16, §3.3, §7.4. briefs 09 + 14. Issue #375 (expanded, not closed by this PR). Plan: docs/plans/wave-mcp-oauth-decomposition.md + docs/plans/phase-92{k,l,m,n,o,p,q}-*.md.


D-241 — Narrow the D-026 heavy-content byte check to offloadable content; conversation text is governed by the token-window guard

Date: 2026-06-23

Status: Accepted

Context. D-026 / RFC §6.5 state the LLM-edge safety net asserts, uniformly, that "no message reaching the LLM carries raw heavy content": any string / byte slice / DataURL whose size ≥ the heavy-output threshold (default 32 KiB) that is not already an ArtifactStub fails loudly with ErrContextLeak. Live profiling (2026-06-23) found a false positive: a long-lived rolling_summary session's rolling summary — legitimate conversation context, injected as <read_only_conversation_memory> (brief 13) — grows past 32 KiB of bytes and trips ErrContextLeak at planner step 0, poisoning every subsequent run. The byte threshold exists to force offloadable content (tool / MCP results, binary inputs) into an ArtifactStub; conversation text is not offloadable that way, so a growing summary crossing the byte threshold is a false positive, not a leak.

Decision.

  1. The byte heavy-content check (internal/llm/safety.go::findContextLeak) governs OFFLOADABLE content only: (a) RoleTool message text (Content.Text + PartText) — tool / MCP observations, which the ObservationRenderer offloads to an ArtifactStub when heavy; and (b) binary DataURL parts (PartImage / PartAudio / PartFile) of ANY role — auto-materialized to an ArtifactRef above the threshold. The fail-loud ErrContextLeak invariant is UNCHANGED for these classes.
  2. Plain conversation text on RoleSystem / RoleUser / RoleAssistant messages is EXEMPT from the byte check (Content.Text and PartText, including an injected rolling summary). Its size is governed by the existing token-window guard (ErrContextWindowExceeded, RFC §6.5 step 2), which remains the governor for conversation size. The token-budget guard is unchanged by this decision.
  3. This narrows, but does not remove, D-026. The runtime still fails loudly when a producer leaks an un-offloaded tool result or raw binary into the prompt. What changes is that legitimate conversation text — which has no ArtifactStub offload path — is no longer measured against the byte threshold.

§4.3 deviations. Decoupling the token budget (tokens) from the heavy threshold (bytes) means an operator can still set a budget_tokens whose byte footprint exceeds the (now tool-only) heavy threshold — harmless after this decision, since conversation text is exempt. Documented as a known, benign interaction. The narrowing also trades the edge byte-backstop on the two derived-conversation-role paths (the legacy text-only-provider observation replay and the background-task outcome rendering, both rendered under RoleUser in the react planner's prompt builder) for reliance on dispatch-time source projection (dispatch.projectForLLM, which stubs heavy results at the source before rendering); binary DataURL parts remain byte-covered on all roles.

Cross-references. D-026 (the safety net this narrows), D-242 (the memory budget enforcement that bounds conversation size so the token-window guard rarely fires). RFC §6.5, §6.6. briefs 04 + 13. Plan: docs/plans/phase-123-memory-context-budget.md.


D-242 — The rolling_summary recent-turn set is operator-configurable via memory.recent_turns; FullZoneTurns becomes the default

Date: 2026-06-23

Status: Accepted

Context. rolling_summary stored its configured budget_tokens but NEVER enforced it — the assembled GetLLMContext patch (recent turns + rolling summary) grew without bound, which is the root cause of the session-poisoning bug D-241 addresses at the LLM edge. The strategy's contract was always "keep the recent-turn set verbatim, summarize the rest, and fit the token budget" (mirroring truncation's OverflowDropOldest); only the budget-enforcement step was missing. Separately, the recent-window size was the package constant strategy.FullZoneTurns (= 4) with the comment "an operator who needs to tune it files an RFC PR rather than fighting yaml" — too rigid now that the budget is enforced and operators need to balance verbatim recency against compaction.

Decision.

  1. rolling_summary enforces budget_tokens deterministically. On write (AddTurn), after the existing spill+summarize step, a bounded compaction loop (cap maxCompactionChunksPerAddTurn = 4 summariser calls per turn) folds the oldest recent turn into the summary — oldest-first, threading PreviousSummary so prior summary content is preserved, never discarded — until the assembled context fits the budget or the cap is reached. On read (GetLLMContext), a cheap, no-LLM final guarantee ALWAYS clamps the emitted patch to the budget on a COPY (never mutating executor state): drop oldest recent turns down to the newest, then deterministically truncate the summary string (rune-boundary, with a trailing marker) to the remaining token budget. This guarantees patch.Tokens <= budget_tokens even when the summariser is degraded or non-compressing. budget_tokens == 0 preserves the prior unbounded behaviour (back-compat). No safety-net auto-recovery is added — the strategy self-bounds to its own configured budget (D-026's "the planner owns recovery" is unchanged).
  2. The recent-turn set is operator-configurable via memory.recent_turns (MemoryConfig.RecentTurns, threaded through ConfigSnapshot → driver → strategy.Deps.RecentTurns). Zero selects the FullZoneTurns default (4); positive values override it; validated >= 0 in loader.go::Validate. Extends the operator-tunable surface beyond D-035's RecoveryBacklogMax; updates the superseded FullZoneTurns const comment. FullZoneTurns stays as the default constant.

§4.3 deviations. Artifact-spill of an oversized SINGLE conversation message (auto-offload one too-large turn to an ArtifactRef and embed the ref in the summary) is a genuinely new memory↔artifact integration and is deferred to a follow-up — a single turn larger than the budget is the one case the read-path guarantee cannot fully clamp (the newest turn stays verbatim). Documented as a non-goal.

Cross-references. D-241 (the LLM-edge byte-check narrowing this pairs with), D-035 (truncation's OverflowDropOldest + the operator-tunable-budget precedent), D-034 (the recovery-backlog cap), D-025 (concurrent-reuse: the executor stays a shared compiled artifact; per-key state is mutex-guarded). RFC §6.5, §6.6, §6.10. briefs 04 + 13. Plan: docs/plans/phase-123-memory-context-budget.md.


D-243 — The rolling_summary compaction summariser is operator-tunable: a switchable model and an append-only prompt extension

Date: 2026-06-23

Status: Accepted

Context. The production summariser (internal/llm/summarizer) is constructed at assembly with the main LLM client and the hardcoded systemPromptV1 (its versioned role/conciseness prompt). Two operator needs were unmet: (a) compaction always ran on the planner's model, so an operator who wanted a cheaper/faster model for the high-frequency compaction calls had no knob; (b) a dev who wanted compaction to preserve or target specific content (numeric thresholds, decisions, entity names) had no way to steer the summariser without forking the prompt — and a full prompt override would strip the baseline role framing and the conciseness / preserve-goals guarantees that keep the summary safe.

Decision.

  1. memory.summarizer.model switches the compaction model. A new MemoryConfig.Summarizer MemorySummarizerConfig sub-block carries Model, wired at the assembly construction site through the existing summarizer.WithModel option. Unset → the main LLM's default model (today's behavior, no change). A model with no matching model_profiles entry fails at runtime the same way any unsupported model does; it is NOT rejected at load time (consistent with how the planner's model is handled — no new validation that could reject a valid forward-looking config).
  2. memory.summarizer.prompt extends the baseline prompt, append-only. A new summarizer.WithSystemPromptExtension(string) option APPENDS the operator text to systemPromptV1 behind a fixed separator ("Additional operator instructions (extend the above; do not override it):") — it never replaces the baseline. The extension is trimmed; empty / whitespace-only is a no-op that leaves the system message exactly equal to today's systemPromptV1 (no behavior change). This lets operators steer what compaction preserves/targets without stripping the baseline role framing or conciseness/preserve-goals guarantees.
  3. Append-only by design — no full-replace override in this phase. A WithSystemPrompt replace form is a deliberate future non-goal: keeping the baseline as a mandatory floor preserves the safety guarantees the versioned prompt encodes.

§4.3 deviations. None — both fields are optional, default to today's behavior, and reuse existing seams (WithModel already existed; WithSystemPromptExtension is the only new primitive, and its first consumer — the assembly construction site — lands in the same change per §13).

Cross-references. D-242 (the recent-turn / budget-enforcement work this rides alongside), D-035 (the operator-tunable-knob precedent), D-089 / §13 (fail-loud + no-stub-default — the summariser stays a real LLM composition, the prompt extension never replaces the baseline). RFC §6.5, §6.6. briefs 04 + 13. Plan: docs/plans/phase-123-memory-context-budget.md.


D-248 — Engine per-run capacity trackers are reaped on a true run-end idle-TTL sweep, never the per-invocation refcount

Date: 2026-06-23

Status: Accepted

Context. The engine's per-run streaming-capacity map accumulated a tracker per run without a reap path, an unbounded growth over a long-lived process. The obvious hook — markRunDone — is wrong: it is a per-invocation refcount that brackets a worker's presence on a run, so activeRuns[runID] cycles 1→0→1→0 as an envelope hops nodes; reaping when it hits zero would delete a still-running run's tracker mid-flight. The cancellation maps already solve the same "bound a per-run map over time" problem with a TTL sweeper.

Decision. A capacity tracker is reaped by an idle-TTL capacity sweeper (mirroring the existing cancellation-map sweeper: stamp the tracker, prune once it has been idle past the TTL) keyed off the genuine run-end, NOT off markRunDone's per-invocation refcount. The live entry count is exposed through a lock-safe accessor (CapacityEntryCount()) for the observability gauge — no per-run mutable state added to the compiled artifact (D-025).

§4.3 deviations. None.

Cross-references. D-249 + D-250 (the sibling retention/ctx fixes in the same phase), D-251 (the harbor_runtime_engine_capacity_entries gauge that observes this bounded map), D-025 (concurrent-reuse: count via lock-safe accessor, no per-run artifact state). RFC §6. Plan: docs/plans/phase-119-runtime-retention-and-ctx-hardening.md.


D-249 — Governance cost ceilings and rate limits are keyed by identity, not per-run (RFC §6.15)

Date: 2026-06-23

Status: Accepted

Context. The governance cost-accumulator and rate-limit caches keyed their entries by a tuple that included RunID. RFC §6.15 scopes both to identity ("per-identity cost ceilings"; "token bucket per (identity, model)"), and RunID is not part of identity. Per-run keying therefore both fragmented the per-identity aggregate (each new run started with a fresh budget — a ceiling BYPASS) and grew the cache unbounded (a key per run).

Decision. Strip RunID from the governance cache key — from the in-memory key AND the persisted StateStore key TOGETHER, so the identity-scoped aggregate survives a restart. An identityScoped(q) helper performs the strip before both keying and persistence. The compound live entry count is exposed via a lock-safe CacheLen() accessor for the observability gauge. This is the RFC-valid fix for a pre-existing correctness bug; there is no per-run alternative to weigh.

§4.3 deviations. None — RFC-settled.

Cross-references. D-248 + D-250 (sibling phase-119 fixes), D-251 (the harbor_runtime_governance_cache_entries gauge), master-plan rows 36a/36b (cost accumulator + rate-limit + MaxTokens). RFC §6.15. Plan: docs/plans/phase-119-runtime-retention-and-ctx-hardening.md.


D-250 — The rolling_summary recovery loop runs under a cancellable context tied to Close()

Date: 2026-06-23

Status: Accepted

Context. The rolling-summary recovery-loop goroutine called the summariser with a non-cancellable context. A summariser that hangs (or a degraded backend) could pin shutdown — Close() blocks on loopWG.Wait() forever — because the in-flight Summarize never returns.

Decision. The executor derives recoveryCtx via context.WithCancel(context.Background()) at construction, stores the cancel func, and calls it in Close() BEFORE loopWG.Wait(). An in-flight Summarize that honours cancellation then unblocks promptly, bounding shutdown. (A summariser that ignores ctx can still block its current batch — bounded only by its own behaviour, not by this loop.) The context.Background() here is the correct root of the recovery loop's own lifecycle tree, not a business-path context (§5 "never store ctx; never context.Background() in business code" is not violated — this is an explicitly-documented unmanaged-async-boundary root).

§4.3 deviations. None.

Cross-references. D-248 + D-249 (sibling phase-119 fixes), D-025 (long-lived goroutines cancellable + joined on shutdown), the goleak.VerifyTestMain leak tests added in the observability phase. RFC §6.6. Plan: docs/plans/phase-119-runtime-retention-and-ctx-hardening.md.


D-251 — Observability foundation: standard collectors on a per-instance registry, runtime gauges on the MetricsRegistry, pprof only on a gated loopback listener

Date: 2026-06-23

Status: Accepted

Context. Harbor shipped no goroutine / heap / process visibility and no runtime gauges; diagnosing the live runtime required attaching a profiler externally. The foundation had to add this without (a) polluting a process-global registry, (b) inventing a new Protocol method, or (c) ever exposing pprof on an operator-reachable surface.

Decision.

  1. Standard collectors on a per-INSTANCE registry. The Go + Process collectors register on a per-instance prometheus registry (not the global default), exposed at /metrics; a per-registry isolation test guards against cross-instance bleed.
  2. Runtime gauges on the MetricsRegistry. harbor_runtime_{active_runs,engine_capacity_entries,governance_cache_entries,events_dropped} register via a new RegisterRuntimeGauges seam wired once in the shared assemble.Assemble, fed by lock-safe accessors (D-248/D-249; no per-run artifact state, D-025). Registering on the MetricsRegistry means they reach /metrics, OTLP, AND the already-shipped metrics.snapshot projection — zero new Protocol method.
  3. pprof is never on the Protocol/Console mux. It is served only by a gated loopback debug listener on its OWN *http.Server + private ServeMux, enabled via server.debug_addr / HARBOR_DEBUG_ADDR (loopback-validated), with a [DEV-ONLY ...] stderr banner. A smoke check asserts /debug/pprof/ is 404 on the main mux.

§4.3 deviations. Engine-capacity gauge production wiring is deferred where the binary has no engine.Engine host (the dev binary is planner/RunLoop-shaped): the mechanism + accessors + tests ship, and the governance-cache + events-dropped gauges are the live consumers — so the primitive has a real consumer in-wave (not a primitive-without-consumer violation, §13).

Cross-references. D-248 + D-249 (the bounded maps these gauges observe), D-252 (the Console surfacing of these gauges), D-111 / Phase 72f (the metrics.snapshot + posture surface reused), D-025. RFC §5.2, §6. Plan: docs/plans/phase-120-runtime-observability-foundation.md.


D-252 — Runtime gauges surface through the shipped metrics.snapshot; the Console extends the existing health panel (no new Protocol method)

Date: 2026-06-23

Status: Accepted

Context. The Console Live Runtime page needed to show the Phase 120 runtime gauges. The runtime-health Protocol surface ALREADY ships (Phase 72f / D-111: runtime.health, runtime.counters, metrics.snapshot), and the Console already registers live-runtime/health-panel.svelte via the 108e capability→panel registry. Adding a new method or panel would duplicate that surface.

Decision. Route the runtime gauges through the SHIPPED metrics.snapshot (the gauges ride the existing MetricsSnapshot.Gauges projection — zero new Protocol method) and EXTEND the existing health panel to render the harbor_runtime_* family. The Console reads only via the typed Protocol client (never an internal Runtime type, §4.5 #10/#11); the hand-maintained TS wire types stay in lockstep with the Go manifest (the D-223 gate). Scope follows the implemented posture gate (RFC §5.5), not an invented session-vs-fleet view.

§4.3 deviations. None.

Cross-references. D-251 (the gauges this surfaces), D-111 / Phase 72f (the reused runtime-health + metrics.snapshot surface), D-093 / D-223 (TS↔Go wire-type lockstep), §4.5 (Console-as-Protocol-client). RFC §5.2, §7.1, §7.2. Plan: docs/plans/phase-121-console-surface-runtime-gauges.md.


D-253 — Shared SQL migration runner (internal/persistence/sqlmigrate); searchcache deliberately excluded; the driver-registry generic deferred

Date: 2026-06-23

Status: Accepted

Context. The four SQLite drivers (state / memory / artifacts sqlite, skills / localdb) and the three Postgres drivers each carried a near-identical private migration runner (filename version parse + numeric ordering + idempotent apply + schema_migrations bookkeeping; the PG copies additionally derived an advisory-lock key via FNV). Seven copies of one algorithm is a maintenance hazard — they had already begun to drift.

Decision.

  1. Extract one runner. sqlmigrate.RunSQLite(ctx, db, migrationsFS, errPrefix) + RunPostgres(ctx, db, migrationsFS, errPrefix, advisoryLockName) + the centralised fnv64aSigned advisory-key derivation. All seven drivers delegate, carrying only their own migrationsFS + error prefix (+ a unique advisory-lock name per subsystem). Behaviour is UNCHANGED: forward-only, numeric-version-ordered, idempotent on re-run, fail-loud on a malformed filename, advisory-lock-serialised on Postgres. The parameter is fs.FS (not embed.FS) for testability.
  2. searchcache is DELIBERATELY EXCLUDED. It is materially divergent — its own tool_cache_migrations table, no BeginTx, version recorded from the SQL body (not the runner), and a silent continue on a malformed filename — and the shared signature cannot express that policy without erasing its behaviour. It stays standalone; a smoke assertion guards that sqlmigrate is never referenced from it.
  3. The generic driverreg.Registry[T] consolidation is DEFERRED (§4.3 scope cut). The three registry "deviants" (tools/auth returns errors + carries three sentinels; events has RegisterForTest + an atomic; distributed is a dual bus/remote registry) do not fit one generic cleanly; this decision records the extraction that DID land plus the sentinel-wrapping registry contract for when the generic is revisited.

§4.3 deviations. The driverreg generic is deferred (above); the phase scoped to the migration-runner extraction only.

Cross-references. §4.4 (the driver-seam pattern), §9 (persistence: forward-only / append-only migrations — no migration was edited), §13 (the append-only-migrations + no-second-blank-import-list rules). RFC §6. Plan: docs/plans/phase-122-persistence-and-driver-registry-dedup.md.


D-254 — state.history ships the State-snapshots surface as a tail-first windowed read of the durable event stream (not memory); additive (no ProtocolVersion bump)

Date: 2026-06-24

Status: Accepted

Context. RFC §5.2's State-snapshots row names state.history / state.list_trajectories / state.load_planner_checkpoint, but no state.* Protocol method existed. A generic Protocol client (third-party console, IDE client, the SDK) — and Harbor's own open-source Console — reopens a conversation by reducing the EVENT STREAM (web/console/src/lib/sessions/trajectory.ts projects the events.subscribe frames; the Playground hydratePastTurns replays prior turns). The only replay primitive shipped was forward-only and unbounded: internal/protocol/transports/stream/stream.go::replayFromCursor replays every event with Sequence > cursor to the tail, and the SSE id: cursor is the raw Sequence (frame.go::encodeEvent). Reopening a long session therefore streams the entire history before the newest turn renders. An earlier draft proposed sourcing a transcript from MemoryStore; that is an inert-feature trap — the default StrategyNone makes AddTurn a no-op, so memory holds no transcript. The durable, gap-free event log (Phase 124) is the real substrate.

Decision.

  1. Ship state.history only — the first of the three State-snapshots methods — as a windowed event-replay read. Single-sourced: the name in internal/protocol/methods (MethodStateHistory + canonicalStateMethods + IsStateMethod), the wire shapes in internal/protocol/types/state.go (StateHistoryRequest / StateEvent / StateArtifactRef / StateHistoryResponse), reusing existing internal/protocol/errors codes. state.list_trajectories and state.load_planner_checkpoint are deferred to their own phases.

  2. The substrate is the durable event stream, not memory. Two reads are added to the event-bus seam as an optional events.HistoryReplayer capability (sibling to events.Replayer): Bounds (discover the session's head/tail sequence) and Window (a bounded backward read — events with Sequence < before, the most-recent K, returned oldest-first). Both V1 replay-capable drivers implement it (events/drivers/durable over the persisted per-session sequence list; events/drivers/inmem over the ring). MemoryStore sourcing is dropped (the StrategyNone inert-feature trap).

  3. Reduction stays client-side; the surface returns events.state.history returns a page of flat StateEvent (the same field set the SSE wireEvent carries), plus HeadSequence / TailSequence / a NextCursor scroll-up cursor / HasMore / Truncated. The events → chat-messages reduction stays on the reducer the Console already owns (trajectory.ts + the Playground reducers) — no second server-side transcript model is forked from the live stream's.

  4. Heavy content by ROUTABLE reference. A replayed event whose payload was offloaded above the heavy-output threshold (RFC §6.5 / §6.10) surfaces a flat StateArtifactRef with a content-addressed ID (+ SHA256 / SizeBytes / MimeType / Filename). The ref-extractor walks the durable RedactedMap shape (events are persisted post-redaction) to pull the id/sha after redaction. The ID routes to artifacts.get_ref: on an S3-compat Presigner store the resolver returns a presigned URL; on the default CGo-free inmem/fs stores (deliberately NON-Presigner per internal/artifacts/presigner_test.go) it returns the typed CodePresignUnsupported (HTTP 501) — which still proves the id is well-formed and reached the resolver. The Console mirrors its existing fallback-Download degradation on 501. This deliberately does NOT reuse ArtifactRefSummary (metadata-only, no ID/SHA256 — unroutable); it follows the SearchArtifactRef / MemoryArtifactRef precedent. No inline heavy bytes ever travel through the surface.

  5. Identity-mandatory, fail-closed. A non-admin caller is hard-scoped to its own (tenant, user, session); adminScoped is derived SOLELY from auth.HasScope(ctx, ScopeAdmin) on the verified ctx — the request body carries no elevation knob (D-219). An unknown or cross-identity session is CodeNotFound (404 — existence is never revealed across identities; the smoke pins this to 404 exactly, never 403), mirroring tasks.get / sessions.list. Honest about retention gaps via Truncated; an un-projectable event fails loud with CodeRuntimeError (CLAUDE.md §5 / §13).

  6. First consumer lands in the same wave (§13), through the real boot path. The open-source Console session-reopen hydration (hydratePastTurns in the Playground +page.svelte, today a full-load via tasks.list + N×tasks.get) is rewired to call state.history tail-first through the typed HarborClient and scroll up by NextCursor. The surface is wired through BOTH cmd/harbor/cmd_dev.go::bootDevStack and harbortest/devstack.Assemble (the §17.6 dual-site lesson — not just httptest), and the LIVE preflight server returns OK for the windowed round-trip including a routable artifact ref.

  7. Additive — no ProtocolVersion bump. A new method + the CapStateSnapshots capability is a backward-compatible surface addition (internal/protocol/types/version.go Version.Minor taxonomy). The pinned ProtocolVersion stays 0.1.0 while V1 is in flight (the version.go contract + the CapEventsSubscribe / CapRuntimePosture / CapTopologySnapshot precedent); clients negotiate via VersionHandshake.Accepts(CapStateSnapshots). Bumping the pinned string is an RFC change (RFC §5.3) and is NOT done here.

§4.3 deviations. Departs from the earlier v16 draft's MemoryStore sourcing (the StrategyNone inert-feature trap) — the substrate is the durable event stream. Deliberate scoping to one of the three State-snapshots methods. No RFC drift — RFC §5.2 already names the method.

Cross-references. RFC §5.2 (the State-snapshots row), §6.5 / §6.10 (the heavy-output threshold + by-reference rule), §6.9 (the session triple), §6.13 (the durable event log). internal/protocol/types/version.go (additive-vs-breaking taxonomy). D-026 (the by-reference event payload shape), D-219 (verified-ctx scope authority), D-223 (the TS lockstep gate), D-209 (the generated Protocol-docs lockstep), D-027 (consumer-side typed reads atop the durable substrate). brief 05. Tight dep: Phase 124 (the gap-free durable stream). Plan: docs/plans/phase-125-session-state-history-surface.md.


D-255 — Durable event-bus sequence counter is rehydrated from the persisted log on restart

Status: Accepted.

Context. The StateStore-backed durable event-log driver (internal/events/drivers/durable) assigns each event a monotonic, gap-free bus sequence from an in-memory nextSeq counter initialised to 0 at construction. That counter was never recovered from the persisted log: after a Runtime restart against the same StateStore the first Publish re-issued Sequence=1,2,3…, colliding with pre-restart tokens. A Protocol client reconnecting with a high Last-Event-ID then had every post-restart event silently skipped by Replay (which returns only sequences strictly greater than the cursor) until the counter climbed back past the old high-water mark — a direct violation of the §6.13 gap-free, resumable-across-restart contract. The original driver design noted "there is no list/scan method" and therefore did no max-sequence recovery; that assumption was made stale by the later addition of StateStore.ListKind (D-207, RFC §6.11), the explicitly-elevated maintenance scan. The same silent-skip class had a second instance: transient, non-persisted notices (audit.admin_scope_used, audit.redaction_failed) advanced the shared nextSeq and the SSE transport (stream.encodeEvent) emitted id: for them, so a live client could anchor Last-Event-ID on a transient tick that the post-restart recovery floor (max persisted) would not exceed.

Decision. At construction, in durable mode only, the driver rehydrates nextSeq from the persisted per-session head records: it calls ListKind(ctx, ListScope{MaintenanceScoped: true}, "events.durable.head"), decodes each head record, and sets nextSeq to the global maximum sequence found across every record's Sequences list (0 for an empty log). It is another maintenance-scan consumer alongside the pause sweeper's crash-orphan rescan and the durable task / distributed / agent-config recovery paths. The recovery acts read-only and per-record under each record's own identity — it only reads sequence numbers, never widens a mutation scope — and follows the pause sweeper's posture of recording the cross-identity scan via structured slog, not a dedicated audit event. It is fail-loud: a scan error or an undecodable head record makes New(...) return a wrapped error (boot fails); the driver never silently starts at 0 (CLAUDE.md §13 "no silent degradation"). Best-effort ring mode (store == nil) persists nothing and skips recovery. To close the second instance of the skip class, transient bus-internal notices are removed from the replay sequence space: publishInternal assigns the non-replayable sentinel Sequence == 0 and no longer advances nextSeq, and stream.encodeEvent omits the SSE id: line for any event with Sequence == 0, so a reconnecting client can never anchor Last-Event-ID on a transient notice. New gains a leading ctx context.Context (it now does construction-time I/O — CLAUDE.md §5); the ctx-free events.Register factory closures bridge with context.Background() (the §5 unmanaged-boundary case, matching newWithOwnedStore's existing state.Open precedent), with threading ctx through the factory contract itself tracked as a follow-up.

Consequences. Post-restart sequences are strictly greater than any pre-restart persisted token; a client reconnecting at the pre-restart high-water mark receives every post-restart persisted event with no silent skip, and no transient notice can be a reconnect anchor. The fix is two binding regression tests: one that publishes AFTER a simulated restart (the prior TestDurable_ReplayAcrossRestart_NoGaps missed the bug because it only replayed pre-restart events), and one where a transient notice is the highest pre-restart emission. Boot does one O(sessions) head-record read; a single global-max checkpoint record is the noted follow-up if that ever bites. The SSE-framing change is additive — ProtocolVersion stays 0.1.0 (internal/protocol/types/version.go: Major bumps only on a breaking change; RFC §5.3 governs only that bumping is an RFC change, which this phase does not do); no TS lockstep engaged.

Cross-references. D-207 (ListKind maintenance scan this consumes), D-028 (the event-bus surface reconciliation), D-025 (concurrent-reuse contract the recovered bus still satisfies). RFC §6.13, §6.11. brief 06. Plan: docs/plans/phase-124-durable-bus-sequence-rehydration.md.


D-256 — A durable USER-scope agent-config tier sits between the admin/tenant durable config and the ephemeral session overlay, and is the band's one durable user write surface

Context. The agent-config control plane had exactly two durable ownership positions and one ephemeral one. Admin/tenant durable config is keyed under a synthetic __agentcfg__ user slot (so it is agent-level and two non-admin users of the same agent share one slot); the only non-admin write path is the session overlay, keyed by the full real triple, ephemeral (dies with the session), with no versioning, diff, or rollback. The canonical scope set was binary (admin, console:fleet) — there was no user tier. A non-admin caller therefore could not own a durable, versioned config variant spanning their own sessions, which a richer generic Protocol client (a third-party console, an IDE client, the SDK) needs.

Decision. Introduce a durable USER-scope tier and make it the ONE durable user-scope write surface for the agent-config band:

  1. A new closed-set authority scope auth.ScopeAgentConfigUser ("agent_config:user"). It is the durable-agent-config-ownership entitlement; unknown scopes stay dropped, so it cannot be forged (RFC §5.5 fixes the closed scope universe). It is strictly below admin and orthogonal to it — the user verbs gate on this scope specifically, not on admin.
  2. An agentcfg.ConfigScope discriminator (ConfigScopeAgent / ConfigScopeUser) threaded through the agentcfg.Registry methods. ONE implementation, two keyings: ConfigScopeAgent keeps the synthetic agent-level keying AND the existing agentcfg.* record kinds (byte-identical to before — no migration); ConfigScopeUser keys the variant under the caller's REAL (tenant, user) with agent_id in the session slot, the run zeroed, AND a DISTINCT agentcfg.user.* record-kind prefix. The distinct kind prefix is the structural guarantee the two key spaces can never alias regardless of identity values; a ConfigScopeUser call whose verified user_id equals the reserved __agentcfg__ sentinel is REJECTED (ErrReservedUser) as fail-loud defence-in-depth — closing a latent privilege escalation where that identity value would have aliased the agent-level admin chain. The discriminator is named with the ConfigScope prefix to disambiguate from tools/auth.BindingScope (ScopeAgent/ScopeUser = OAuth binding) and from auth.ScopeAgentConfigUser (the Protocol JWT scope). The isolation tuple is NOT widened: the real user is the isolation principal for the user variant, agent_id stays a key, never a WHERE-clause isolation filter (RFC §6.16 / §6 clarifying note). Threading the scope parameter through the interface (rather than a parallel user-scope method set) breaks every existing caller; all are migrated in the same PR to pass ConfigScopeAgent — the projection (×4), the sibling protocol verbs, and the MCP-console apps surface — so the tree builds green.
  3. A versioned agent_config.user.* verb family (get / set_revision / list_revisions / diff / rollback) as the in-phase consumer, with full diff/rollback parity to the admin registry verbs. Its input is a structurally-bounded safe-subset payload (AgentConfigUserPayload) that carries the BAND-COMPLETE field set — user_prompt, disabled_servers, disabled_tools, personal_skills — and NO base / connections / enable / model field, so a USER caller physically cannot widen a capability or edit the operator base; the verified-ctx scope gate is defence-in-depth. The per-owner write lock is scope-aware (ConfigScopeUser keys by (scope, tenant, real-user, agent) so distinct users never serialise; ConfigScopeAgent keys by (scope, tenant, agent)). Adding an MCP connection, editing the operator base, widening the allowlist, and swapping the model stay admin-only and fail-closed.

Consequences. This is the band's single durable user write surface: a user revision is a "virtual agent" (a personal instruction layer + a narrow-only tool subset written atomically). The two sibling phases that follow are PROJECTION-ONLY — Phase 126b projects user_prompt into the run-start <user_instructions> composition and Phase 126c projects disabled_servers/disabled_tools into the narrow-only tool-exposure exclusion set; neither adds a write verb, because every projection-fed field is pinned in AgentConfigUserPayload here. Authority derives from the verified ctx, never the request body (consistent with the steering edge and the session safe subset). The user write is audited under the real (tenant, user) author anchor. The interface gains a scope parameter rather than a parallel method set (avoiding a §13 two-implementations smell); existing admin call sites pass ConfigScopeAgent. The methods + wire types are additive (Minor-class per internal/protocol/types/version.go) — no ProtocolVersion bump.


D-257 — The durable USER-scope prompt layer is PROJECTION-ONLY: one writer (126a set_revision), one reader (run-start projection)

Date: 2026-06-24

Status: Accepted

Context. Phase 126a persists a durable, versioned, user-keyed config revision and pins AgentConfigUserPayload.user_prompt as one of its band-complete fields — written through the ONE durable user-scope write verb agent_config.user.set_revision. But nothing reads user_prompt back at run start, so the field is inert: a user can store a standing personal instruction and it never reaches the LLM. ApplyPromptLayers (internal/runtime/agentcfg/projection/projection.go) composes the admin Base as the always-spine, then joins the admin User layer and the session overlay's user prompt into ONE lower-trust <user_instructions> block via composeUserLayer. The durable user layer has no slot in that composition yet.

Decision. Add the run-start CONSUMER of 126a's durable user_prompt, PROJECTION-ONLY — no new store, no new verb, no new wire surface.

  1. One writer, one reader. The durable user_prompt is written ONLY by 126a's agent_config.user.set_revision (ConfigScopeUser revision) and read back ONLY by this projection. An earlier cut proposed a SECOND user-keyed store + a SECOND agent_config.user.set_prompt verb feeding the same projection — that is a §13 "two parallel implementations of the same conceptual feature" (two writers, one reader) with a guaranteed drift bug the instant the two writers disagree on the durable user prompt. Rejected. The durable user prompt has exactly one home: 126a's payload field.
  2. Read via 126a's existing registry read. ApplyPromptLayers reads the caller's active USER-scope revision with reg.Active(ctx, identity.Quadruple{Identity: id.Identity}, agentID, agentcfg.ConfigScopeUser) and extracts rev.Payload.UserPrompt(). nil reg / empty agentID / no active user revision / no user prompt yields "" (the backward-compatible "no durable user layer" path); a registry read error is returned so the run fails loudly (no silent drop).
  3. Precedence admin Base > admin User > USER-durable > session User. The admin Base stays the always-present spine. The other three compose, in that order, into the SINGLE existing lower-trust <user_instructions> block — composeUserLayer is extended from two ordered segments to three (admin user, durable user, session user); the prompt builder's escaping is unchanged. An empty durable layer leaves the run byte-identical to the prior composition. The composition order is the security boundary (D-235): a caller layer can extend the operator's standing instruction, never precede, replace, or weaken the operator base. The durable layer carries no base field (126a's payload has none), so base-unwritable-by-user stays structural.
  4. No new authority surface. The durable write already passed through 126a's user-scope tier (auth.ScopeAgentConfigUser); the read-side projection adds NO new auth gate, scope, verb, method constant, or wire type. It reads under the run's already-verified identity. The exported ApplyPromptLayers signature is UNCHANGED (the durable layer reads from the registry the function already takes), so both run-loop twins reach the new behaviour through the one shared seam and cannot drift (§17.6).
  5. Consumer of the 126a primitive (the "no primitive without a consumer" rule). 126a's durable user write surface is the primitive; this projection is the run-start consumer that makes user_prompt load-bearing, with a round-trip integration test (write via set_revision → appears in the next run's <user_instructions>).

§4.3 deviations. None beyond the re-scope: this phase ships no store, verb, wire type, or Console surface (all explicitly deleted from an earlier cut in favour of consuming 126a's). No new wire element, so ProtocolVersion is untouched (RFC §5.3 / CLAUDE.md §8 / internal/protocol/types/version.go govern only a bump, which this phase does not approach).

Cross-references. D-256 (126a — the durable USER-scope tier + the user write surface this phase consumes), D-235 (the prompt-layer composition-order security boundary), D-025 (the concurrent-reuse contract for the shared projection seam — no NEW artifact here), D-061 (the Console is a Protocol client; the layer's write+readback flow over 126a's Protocol verbs). RFC §6.16, §5.5. brief 13, brief 11. Depends on Phase 126a (the durable write surface), Phase 92e (the admin layered prompt + composeUserLayer), Phase 92g (the session overlay). Plan: docs/plans/phase-126b-user-scope-prompt-layer.md.


D-258 — Phase 126c: the USER-scope tool policy is a projection-only run-start consumer of 126a's durable disable set, unioned (order-independent) into the grow-only exclusion set

A user's tool/server toggles must survive the session that set them AND shape that user's runs against the agent. The durable disable set is ALREADY persisted: Phase 126a's user-scope revision payload (AgentConfigUserPayload) carries disabled_servers / disabled_tools as versioned fields, written atomically through the one user-tier agent_config.user.set_revision verb (gated on agent_config:user, audited, keyed under the caller's REAL (tenant, user) with agent_id in the session slot). What was missing was the run-start effect.

The decision: 126c is a PROJECTION-ONLY consumer — no new store, no new verb, no new authority scope, no binary rewiring. The run-start tool-exposure projection (ActivePlannerCatalogView) reads the active user-scope revision via 126a's reg.Active(..., agentcfg.ConfigScopeUser) and unions its PausedServers() / DisabledTools() into the existing exclusion set.

Threefold rationale. (1) Narrow-only by construction, reusing the existing projection. The three disable sets — admin (ConfigScopeAgent), user (ConfigScopeUser), session (the overlay) — are UNIONED (order-independent; union is commutative and idempotent) into a single grow-only exclusion set. There is NO precedence for tool exposure; the set can only GROW, so neither the user nor the session tier can re-widen past the admin-provisioned palette. The data model (no enable field anywhere) is the first guard, the union the second. (2) No duplicate store, no second write path, no extra auth tier. An earlier draft proposed a separate useroverlay store and get/set_tool_policy verbs; both are DROPPED. The narrow-only disable set is persisted and audited once, at 126a's user tier. A second store would be a §13 "two parallel implementations" smell; a second write verb would duplicate 126a's auth gate. (3) agent_id is a record/key discriminator, NEVER an isolation filter. The ConfigScopeUser read isolates by the run's (tenant, user); the session + run components are zeroed ONLY inside the registry's key derivation (126a's pinned keying), never in the projection; agent_id rides the session slot as the per-agent key, never a WHERE-clause isolation filter (RFC §6.16). brief 09 §170's peer-principal recommendation is declined.

The privilege boundary is untouched: adding a NEW MCP connection (esp. stdio) stays admin-only + fail-closed (CodeScopeMismatch); 126c opens no widening path. This is the run-start consumer of 126a's durable user-scope tier and lands in the same band, satisfying the no-primitive-without-a-consumer rule. 126c adds no Protocol method or wire type — it consumes 126a's already-additive fields — so ProtocolVersion holds at 0.1.0 (per internal/protocol/types/version.go; RFC §5.3 governs only that bumping the constant is an RFC change, not done here).


D-259 — Protocol wire-surface digest: connect-time wire-drift detection for any Protocol client via an additive runtime.info field + a manifest-stamped digest

Date: 2026-06-24

Status: Accepted (planning)

Context. The Phase 118 / D-223 lockstep mechanism (CanonicalWireTypes → reflected wire-manifest.gen.json → field-level .mjs guard + git-diff / go-test / lint gates) is robust for Harbor's OWN Console but is NOT downstream-consumable: the manifest + guard + allowlist live inside web/console/ with no module export, and the manifest is never served over the wire. runtime.info advertises only ProtocolVersion + Capabilities, so a connected Protocol client (a third-party Console, an IDE/TUI client, an SDK consumer) cannot detect, at connect-time, that the runtime it attached to drifted from the wire shapes it was built against. This is a §13 primitive-with-consumer gap read one layer up: the lockstep build artifact exists, but no runtime surface exposes it to a client.

Decision.

  1. A canonical wire-surface digest, computed from the Go single sources. A new light package internal/protocol/wiresurface exposes Digest() string = "sha256:" + hex(sha256(serialization)) over a deterministic, harbor-wire-surface/v1-prefixed, lexicographically sorted, newline-labelled encoding of: types.ProtocolVersion, methods.Methods(), protoerrors.Codes(), types.Capabilities() (the canonical capability UNIVERSE, not a per-instance subset), and the singlesource.CanonicalWireTypes keys. The function is pure, sync.Once-memoised, and imports only the light canonical packages (no internal/protocol, no drivers) — no import cycle, no dependency balloon for the lockstep tool that also calls it. The digest is a coarse NAME-LEVEL fingerprint: it covers the shape of names and EXCLUDES both field shapes and event-type names.
  2. Additive runtime.info field, not a new method. types.RuntimeInfo gains WireSurfaceDigest string \json:"wire_surface_digest"`; PostureSurface.handleInfopopulates it. A newprotocol.wire_manifestmethod was rejected: the digest is opaque posture data,runtime.info` is the attach-time negotiation call already, and an additive field is backward-compatible (old clients ignore it) and one fewer round-trip.
  3. Digest stamped into the committed manifest. The cmd/harbor-protocol-ts-lockstep Manifest gains a top-level wire_surface_digest (= wiresurface.Digest()); a lockstep test pins Manifest.WireSurfaceDigest == wiresurface.Digest(), and the existing make protocol-ts-gen-check git-diff + go-test gates keep the manifest and the runtime in lockstep automatically. A client vendors the manifest at build time and compares its wire_surface_digest against the live runtime.info.wire_surface_digest.
  4. The consumer lands in the same wave (§13). The Console app-shell status bar (web/console/src/lib/components/ui/AppStatusBar.svelte), which already fetches runtime.info at onMount, compares the attached runtime's digest against the manifest's digest (via a pure compareWireDigest helper exported from connection.ts) and surfaces a loud, operator-visible drift signal on a drift mismatch — never a silent swallow. A runtime that reports no digest is classified unsupported ("predates digest support") and surfaced as an informational note, NOT a drift alarm. connection.ts itself stays a pure synchronous resolver — the fetch + surfacing live in the component, not in the resolver.
  5. Name-level scope; field shapes stay build-time and off the wire. The digest hashes method/error/capability/type names + version, not field shapes (RFC §5.1: shapes are not exposed over the wire). A field-type swap on a same-named struct does not move the digest; that drift remains the build-time .mjs gate's job for any manifest-vendoring client.
  6. Events excluded. Runtime event-type enumeration would require seating driver registries (the manifest uses a build-time textual scan to avoid driver blank-imports, §13); including events would re-introduce that coupling into the runtime path. The digest is scoped to the request/response/capability contract a client binds to. A driver-free canonical event registry could let events join the digest later.
  7. No version bump. The new field is additive — a Minor-class change in the internal/protocol/types/version.go Major/Minor/Patch taxonomy ("a new optional wire field is backward-compatible"). RFC §5.3's rule that bumping ProtocolVersion is an RFC change is precisely why this stays additive: ProtocolVersion holds at 0.1.0. The generated Protocol type reference (docs/site/protocol/types.md) is regenerated with make protocol-docs-gen and committed in the same PR (D-209).

§4.3 deviations. STRETCH/optional: the vendor-and-gate interim needs zero Harbor code, so no client is blocked without this phase; it is recommended for V1.6 as the minimal high-value slice and is the first in its band to cut if capacity is tight (a recorded cut, not a silent drop). The npm-publish pipeline and the full cmd/harbor-gen-protocol-ts type generator (reserved name) stay out of scope.

Cross-references. D-223 (the lockstep gate this stamps the digest into), D-209 (the generated Protocol-docs regen gate), D-093 (the original generate/verify Protocol-client decision), D-061 (Console is a Protocol client), D-025 (concurrent-reuse contract for the memoised digest + the shared PostureSurface). RFC §5, §5.2, §5.3. internal/protocol/types/version.go (additive-vs-breaking taxonomy). brief 06. Plan: docs/plans/phase-127-protocol-wire-manifest-consumability.md.


D-260 — Advertise the agent-config control plane as a Protocol capability (agent_config) via an additive runtime.info-conditional capability, not a new method

Date: 2026-06-25

Status: Accepted (planning)

Context. The agent-config control plane (agent_config.* — the admin verbs, the session-safe subset, and the durable user tier) is mounted CONDITIONALLY (transports.WithAgentConfigService; when not supplied the /v1/agent_config/* routes are absent). But runtime.info.capabilities advertises nothing for it, so a Protocol client (a third-party Console, an IDE/TUI client, an SDK consumer) can only discover the surface by firing a real call and catching the 501/unknown_method (or a transport 404) — a clumsy, racy, wasted round-trip. Every other conditionally-mounted Protocol surface (topology_snapshot) is negotiable via a capability; agent-config was the gap.

Decision.

  1. One canonical capability constant. internal/protocol/types/version.go gains CapAgentConfig Capability = "agent_config" in the Capability const block and a canonicalCapabilities entry — the ONE home for capability constants (no second definition site, no registration escape hatch). types.Capabilities() / CurrentHandshake() enumerate it unconditionally (the negotiable universe).
  2. Conditional per-instance advertisement, wired to the actual mount.PostureDeps gains AgentConfigAvailable bool; wiredCapabilitiesFor appends CapAgentConfig only when set; runtime.info.capabilities advertises agent_config iff this runtime mounted the surface — the topology_snapshot conditional pattern. Each boot path sets the flag from the SAME condition that gates WithAgentConfigService ON THAT PATH: stack.AgentConfig != nil on harbortest/devstack (where the mount is conditional and agentConfigService is not yet in scope at posture construction) and true on cmd/harbor/cmd_dev.go (where the service is mounted unconditionally, so the capability is always-on by construction). The advertisement can never claim an absent surface.
  3. The consumer lands in the same phase (CLAUDE.md §13). The primitive (the capability constant) ships with its consumer (the runtime-side conditional advertisement wired to the real surface mount) and a conformance/handshake test asserting a runtime with the surface advertises agent_config and one without it does not. A Console phase that gates the agent-config control panel on caps.has('agent_config') (replacing its method-probe) is the natural next consumer, under the §13 "no Console page without its feeding Protocol surface" rule.
  4. No new method. A dedicated agent_config.available method was rejected: runtime.info.capabilities already carries the per-instance wired subset, the client calls runtime.info at attach anyway, and a capability is strictly less surface and one fewer round-trip.
  5. sessions.* and artifacts.* capabilities deferred. Evaluated and scoped out: each needs its own PostureDeps flag, boot wiring, and integration test (its own advertisement-vs-mount drift surface); bundling them would dilute focus. Clean siblings for a follow-up phase, same pattern. A recorded scoping decision, not a silent drop.
  6. No version bump. A new capability is a Minor-class, backward- compatible surface addition per the internal/protocol/types/version.go Major/Minor/Patch taxonomy; the four capabilities added since 0.1.0 (events_subscribe, runtime_posture, topology_snapshot, state_snapshots) set the precedent — none bumped ProtocolVersion. RFC §5.3's rule that bumping the version is an RFC change is precisely why this stays additive: ProtocolVersion holds at 0.1.0. The capability addition changes types.Capabilities(), hence wiresurface.Digest(), hence the committed wire-manifest.gen.json digest — regenerated via make protocol-ts-gen and pinned by the existing lockstep gate (D-223/D-259). The conformance handshake set goes 5→6.

§4.3 deviations. None. Additive, follows the established conditional-capability pattern exactly.

Cross-references. D-259 (the wire-surface digest the manifest stamps, which this capability addition shifts), D-223 (the lockstep gate), D-209 (the generated-docs regen gate), D-234..D-237 (the agent-config control plane this advertises), D-256/D-257 (the durable user tier under that plane). RFC §5.2, §5.3, §6.16. internal/protocol/types/version.go (additive-vs-breaking taxonomy + CapTopologySnapshot precedent). brief 06, brief 11. Plan: docs/plans/phase-128-agent-config-capability.md.


D-262 — Session erasure: an identity-scoped sessions.delete that cascades State/Memory/Artifact deletion, refuses fail-loud on a running task, and audits content-free

Date: 2026-06-25

Status: Accepted (planning)

Context. The Harbor Protocol exposes read surfaces for sessions (sessions.list / sessions.inspect / state.history) and a single-object artifacts.delete, but NO operator- or client-initiated way to erase a whole session and its scoped data. The SessionManager GC (RFC §6.9) only reaps idle sessions on its own schedule and never reaps a session with a RUNNING task. A generic Protocol client (a third-party Console, an IDE/TUI client, an SDK consumer) therefore cannot satisfy a data-lifecycle / right-to-erasure request — the session's State, Memory, and Artifacts (each identity-scoped, possibly on different drivers) persist with no deletion verb. This is a Protocol-surface gap, not a Console gap: erasure must be a canonical, identity-mandatory, fail-loud method any client invokes through the same wire contract.

Decision.

  1. A new additive method sessions.delete (single-sourced in internal/protocol/methods, wire route POST /v1/sessions/delete, reusing the existing POST /v1/sessions/ handler with a delete branch). Identity-mandatory. Request body carries only the identity scope (no elevation knob); response carries non-sensitive deletion telemetry only (session_id, deleted, state_records_deleted, artifacts_deleted, memory_purged) — never erased content.

  2. A fail-loud running-task refusal. sessions.delete consults the same RunningProbe seam the GC uses; a session with a RUNNING task is refused with a distinct new error code session_running (HTTP 409) and NO store is touched — mirroring the RFC §6.9 GC never-reap-running invariant. The runtime never partially erases a running session.

  3. A three-store cascade + session-record hard-delete. A new internal/sessions cascade orchestrator runs, in order: refuse-if-running (Registry.Erase load+verifies the record under the caller's identity and probes the running seam) → ArtifactStore.List/ Delete each → MemoryStore.FlushStateStore.DeleteScope (a new cascade primitive added to the single mandatory StateStore interface that deletes every record matching the (tenant, user, session) triple regardless of run_id/kind — including the session.lifecycle SessionRegistry record itself, so DeleteScope performs the durable record hard-delete, NOT a Closed=true tombstone — with conformance parity across in-mem / SQLite / Postgres) → Registry.Erase clears the in-memory open-sessions / id-index map entry (no durable delete of its own; DeleteScope already removed the record) → emit a redacted session.erased event under the actor's identity scope. The order is deliberate: running DeleteScope before a separate durable Registry.Erase delete would leave nothing for the latter to load. There is no ACID transaction across the independent stores; the cascade is fail-loud and idempotent — a mid-cascade error returns loudly and is safe to re-invoke to convergence (each per-store delete is idempotent).

  4. An own-session-only scope contract. A caller may erase only their own verified (tenant, user, session). A body identity mismatching the verified identity is identity_required (401) — the handler's assertSessionsIdentity defence-in-depth rejects any mismatch before any further scope logic, so a foreign target can never be named; a session absent under the verified triple is not_found (404, existence never revealed). There is NO auth.ScopeAdmin path, NO scope_mismatch (403), and NO audit.admin_scope_used emit in this phase: cross-tenant / admin erasure of another identity's session is a deferred non-goal (a separate elevated verb with its own scope-claim + audit design).

  5. Hard-delete the data; tombstone only the fact. Right-to-erasure requires the State / Memory / Artifact bytes to be actually removed, not flagged. The only durable trace is the redacted session.erased audit record (session id + actor + counts + timestamp, NO content) in the audit/compliance sink — NOT re-persisted under the erased session's own identity. Mechanism: the durable event bus encodes every published event as a StateRecord keyed by ev.Identity.{Tenant,User,Session}, so the event is emitted under the actor's observability scope (the erased session id rides as a payload field), never the erased triple — a post-erasure state.history for that triple returns empty.

  6. A negotiable capability. A new CapSessionLifecycle (session_lifecycle) is advertised on runtime.info only when an eraser is wired, so a client detects erasure support via capability negotiation rather than a 404 at call time.

  7. The consumer lands in the same phase (§13). The cascade handler over the production State / Memory / Artifact drivers + a real Registry is the consumer; an integration test proves delete → subsequent read not_found, cross-store erasure, foreign-target 401, running-task 409 end-to-end. A Console "delete chat" UI is a follow-on consumer (the typed TS client gains the request/response types regardless, per the D-223 lockstep gate).

  8. No version bump. Method + error code + capability + wire types + the StateStore method are all additive — a Minor-class change in the internal/protocol/types/version.go taxonomy. RFC §5.3's rule that bumping ProtocolVersion is an RFC change is exactly why this stays additive: ProtocolVersion holds. The generated Protocol reference (docs/site/protocol/{methods,errors,types}.md) is regenerated with make protocol-docs-gen and committed in the same PR (D-209).

Out of scope. Bulk/user-wide/tenant-wide erasure (one session per call); purging the USER-scope agent-config revision store (keyed by (tenant, user), survives a session, a separate config-plane concern); a new transport or scope.

§4.3 deviations. None — additive surface on existing seams.

Cross-references. D-223 (the TS lockstep gate the new types regen into), D-209 (the generated Protocol-docs regen gate), D-061 (Console is a Protocol client), D-025 (concurrent-reuse contract for the shared erasure Service). RFC §5.2, §6.9, §6.11, §6.13, §7. brief 05, brief 06. Plan: docs/plans/phase-130-session-erasure-method.md.


D-261 — JWKS max-stale / revocation ceiling: fail closed instead of serving a possibly-revoked key forever

Date: 2026-06-25

Status: Accepted (planning)

Context. The production JWKS-backed auth.JWKSKeySet prefers availability over revocation: when a refresh fetch fails (IdP unreachable or returning malformed data) it retains and serves the prior key snapshot, and there is no upper bound on how long it does so. A key the IdP has rotated out / revoked therefore stays accepted until the next successful fetch — indefinitely during a prolonged outage. The keyset's own godoc named this as a known gap. For any external-issuer deployment this is a real security weakness: revocation never takes effect while the IdP is unreachable.

Decision.

  1. A configurable max-stale ceiling on the keyset (the primitive).auth.JWKSKeySet gains an immutable maxStale (set via WithJWKSMaxStale, defaulting to defaultJWKSMaxStale = 1h). When the cached snapshot's age (time since the last successful fetch) reaches the ceiling, KeyByID fails closed with a wrapped ErrJWKSStale regardless of whether the kid resolves — a possibly-revoked key in a too-old snapshot is never served. The ceiling gates the KeyByID hot-path early return (so a cached key is served without a refresh only when its age is within maxStale — this enforces a ceiling tighter than the 5m cache TTL, which the hot path's own freshness check would otherwise miss) AND the post-maybeRefresh() re-check (so a request that arrives as the snapshot ages out still gets one recovery fetch first). The default (1h) and floor (1m) each live in a single named const (defaultJWKSMaxStale in the auth keyset; jwksMaxStaleFloor in the config validator, which cannot import the auth package).

  2. The Validator is the same-phase consumer. A primitive with no consumer bit-rots; the consumer here is the Validator keyfunc, which propagates ErrJWKSStale distinctly (not masked as ErrUnknownKey); mapParserError honors it ahead of ErrUnknownKey; and middleware.reasonForWire emits "jwks_stale". Proven end-to-end by a controllable-clock test (no time.Sleep): a key served past the ceiling with failing refreshes is rejected, and a successful refresh resets staleness so the same token verifies again.

  3. Fail loud, distinctly. Operators see "JWKS too stale", not a generic auth failure: the Go sentinel ErrJWKSStale, the wire reasonjwks_stale, an escalated slog.Error on the rate-bounded refresh failure path, and the existing auth.rejected audit + bus emit carrying the reason (rejections surface on the canonical bus, not a side channel). No raw token is logged.

  4. A new config field, fail-closed by default. IdentityConfig gains JWKSMaxStale time.Duration (identity.jwks_max_stale). Validation rejects negative and below-floor (< 1m) values; zero applies the safe default. There is intentionally no opt-out — the field tunes the ceiling, it does not remove it (no identity-downgrading knobs). Existing deployments thus gain a bounded default where they previously had unbounded staleness: a deliberate security-posture improvement, documented in the README/changelog.

  5. No wire change, no version bump. A dedicated availability-class Protocol error code (e.g. key_source_unavailable) was considered and deferred — a new Code is a wire-surface change (single-source errors, manifest + generated-docs regen, deprecation-window consideration). This phase reuses CodeAuthRejected (HTTP 401) and delivers operator distinctness through the sentinel + the free-form wire reason value + logs/events. internal/protocol/types/version.goProtocolVersion is untouched; no make protocol-ts-gen / make protocol-docs-gen regen.

Operational note (binding on the docs). The ceiling bounds — it does not make instantaneous — revocation. The IdP should publish overlapping signing keys across a rotation (so a normal rotation never trips the ceiling), and deployments should pair the ceiling with short token TTLs (the token's exp is the first-line revocation bound; the ceiling is the key-material backstop).

Deviations. Departs from the current code's documented "no max-stale ceiling" behavior (a deliberate gap-closure, not a brief departure). The dedicated availability error code is the only deferred sub-decision.

Cross-references. The production JWKS keyset + from_config projection this extends, the JWT validation core (sentinel / errors.Is discipline), the Protocol auth middleware mapping, D-025 (concurrent-reuse contract for the shared keyset/validator). RFC §5.5. brief 06. Plan: docs/plans/phase-129-jwks-max-stale-ceiling.md.


D-263 — Serve-attach: keep harbor serve strictly IdP-/JWKS-config-driven; solve first-attach with two documented on-ramps, never by minting inside serve

Date: 2026-06-26

Status: Settled (shipped in the v1.8.0 Adopter-Path wave)

Context. Harbor advertises three adopter paths (embed / CLI / protocol). The PROTOCOL path has a P0 cliff: harbor serve boots a correct JWKS verifier and (by design, D-220) mints no token, but there was no documented way to obtain a JWT and attach a client — and no on-ramp at all for an adopter with no IdP or who issues their own tokens. The original wave plan proposed a gated dev-token mint inside harbor serve. That was rejected in the wave review: it silently reversed D-220, never explained how a minted token would pass serve's IdP-only JWKS verifier, and would have widened serve's production trust edge (a second signing key baked into the production auth surface).

Decision.

  1. harbor serve stays strictly IdP-/JWKS-config-driven. Its verifier is untouched. It accepts a token for exactly one reason: the operator explicitly configured identity.jwks_url / identity.jwks_file to trust that key — identical whether the JWKS belongs to Auth0 or to a key the operator generated. No code change to serve's auth edge, no composite keyset, no trust-edge widening.

  2. First-attach is solved by two documented on-ramps, never by minting inside serve:

    • Have an IdP (Auth0 / Okta / Keycloak / Cognito) → the 131a production-identity guide (docs/site/protocol/production-identity-setup.md) plus the 131c worked OIDC client and binding serve round-trip smoke.
    • No IdP / issuing your own131d, a separate harbor token subcommand (keygen + mint) that produces operator-managed self-issued JWTs; the operator points serve's jwks_file at the emitted JWKS and mints with --issuer/--audience matching serve.yaml. D-264 records that subcommand and its §7/§13 posture.
  3. The claim shape both on-ramps target is lifted from the authoritative parser internal/protocol/auth/auth.go (the JWT claim parser is the source of truth, not the illustrative godoc comment): the mandatory (tenant, user, session) triple, optional scopes, plus the iss/aud exact-match contract serve hard-rejects on mismatch — and Config.Validate mandates a non-empty identity.issuer / identity.audience for the serve profile, so those optional verifier checks become mandatory at runtime.

Why this is correct. D-220 stays literally true — harbor serve still mints nothing; a separate subcommand mints, and serve only accepts the result because the operator chose to trust its JWKS. This is the "your chosen issuer mints; serve verifies" contract D-220 already describes, now with a tool for the self-issuing case and a manual for the IdP case. The self-issuing on-ramp carries an explicit grade callout (single-issuer / self-hosting; graduate to a real IdP for multi-user SSO), so honesty is preserved.

Scope. 131a is documentation-only: a guide page + same-PR site nav, cross-links, and a §18 skill forward-pointer. No Go, no Protocol surface, no config schema change.

Cross-references. D-220 (Phase 115 — production JWT verification + harbor serve; the "serve mints nothing, your issuer mints" contract this preserves and does not supersede), D-264 (the harbor token bring-your-own-issuer subcommand — the no-IdP on-ramp this guide forward-references), D-261 (the JWKS max-stale ceiling on the same verifier). RFC §5.5 (Authentication), §4.2 (mandatory identity), §8 (CLI layer). brief 06, brief 09. Plan: docs/plans/phase-131a-production-identity-setup.md. Wave coordination: docs/plans/wave-v18-coordination.md §3.


D-265 — The embed one-call runner is a single blocking Stack.RunOnce; NewRunContext is the shared RunContext factory

Date: 2026-06-26

Status: Settled (shipped in the v1.8.0 Adopter-Path wave)

Context. Harbor advertises three adopter paths; the embed path was honest about assembly (assemble.Assemble is a genuine one-call composer) but not about running a goal: after assembly an embedder still hand-built a planner.RunContext and a steering.RunSpec and drove RunLoop.Run itself — ~15–27 lines of ceremony per run that re-derived the memory / skills / artifact / streaming projections the run-loop drivers already compose. There was no production one-call runner (RunOnce existed only as the harbortest test kit), and the RunContext-population projection lived as two near-identical bodies (cmd/harbor/cmd_dev_runloop.go and harbortest/devstack) that already share the underlying helpers but build the RunContext shell twice.

Decision. Ship two paired symbols (primitive + consumer, same wave, §13):

  1. runctx.NewRunContext(ctx, src, quad, goal, opts...) — the ONE shared factory that projects stack-derived subsystem handles into a fully-populated planner.RunContext, composing the EXISTING projection helpers (FetchMemoryBlocks, ProjectSkillsDirectory over Directory.View, ResolveInputArtifacts, the bus chunk publisher) — never a third hand-rolled construction site. Identity is mandatory and fails loud. A parity test pins field-equality with each helper called directly, so a refactor cannot fork the factory off the shared helpers. The dev-driver bodies are NOT rewritten (their per-task projection threads control-plane resolutions the headless factory omits by design); they keep calling the same helpers.

  2. Stack.RunOnce(ctx, goal, identity, opts ...RunOption) — a SINGLE blocking runner (no sync/async split, no Sync suffix) that builds the RunContext via NewRunContext, drives the assembled RunLoop, writes the session memory turn on a goal-satisfying finish (best-effort, mirroring the drivers), and returns the canonical planner.AnswerEnvelope. Blocking matches the verified house style (harbortest.RunOnce and steering.RunLoop.Run both block on the calling goroutine; "async" is the caller's own go). A non-runnable stack (no planner/run loop) returns ErrNotRunnable, never a silent no-op. Both symbols gain sdk/ facade aliases (sdk/assemble, sdk/runctx).

Why a single blocking method (no sync/async split). A second RunOnceAsync would be a parallel implementation of the same conceptual feature (§13 forbids that) — the caller's go stack.RunOnce(…) already gives async with zero new surface. The streaming variant is the sibling D-266 (132-stream): a WithStream sink on the SAME blocking RunOnce, not a separate method. RunOption / runctx.Option are functional-option types so the sink lands without a signature change.

Concurrency (D-025). The compiled Stack is immutable after Assemble; RunOnce reads run-specific data only from its arguments and allocates fresh per-run RepairCounters / Trajectory / projections. An N≥100 concurrent-reuse -race test against one shared Stack pins no data races, no context bleed, no cross-cancellation, and a restored goroutine baseline after Close.

Deviations. None from a brief; the dev-driver bodies are deliberately left intact (documented above), not a silent departure.

Cross-references. assemble.Assemble + the Stack shape (D-197), the production driver aggregator (D-196), the shared RunContext-population helpers, planner.AnswerEnvelope (D-194), the SDK facade (D-204/D-205), D-266 (the WithStream sibling), D-025 (concurrent-reuse contract). RFC §3.6 / §6.2 / §6.4. brief 01, brief 02. Plan: docs/plans/phase-132-embed-runonce.md.


D-267 — The scaffold's golden test exercises the register-and-dispatch path, not just compilation, whenever the agent declares tools

Date: 2026-06-26

Status: Settled (shipped in the v1.8.0 Adopter-Path wave)

Context. Harbor advertises a CLI adopter path: harbor init → scaffold → validate → dev. The scaffold, when the operator's harbor.yaml declares tools (tools.custom and/or tools.built_in), generates a RegisterTools function in agent.go and a typed stub per custom tool. The standing external-module gate (scripts/smoke/phase-112b.sh, D-206) proved the tool-declaring scaffold COMPILES as an external module — the headline SDK-friction break. But it stopped at go build: the generated agent_test.go only exercised the toolless EchoAgent round-trip and never called RegisterTools. Because Go does not flag an unused exported function, a tools-declaring agent COMPILED and its tests PASSED while no tool was ever registered or invoked — a textbook §13 false-green on the operator-facing CLI surface, and exactly the §1 honesty problem the v1.8.0 adopter-path wave exists to close.

Decision. The scaffold's golden test exercises the register-and-dispatch path — not just compilation — whenever the agent declares tools. Concretely:

  1. agent_test.go.tmpl gains a {{if or .BuiltIns .CustomTools}} block that, when tools are declared, calls RegisterTools(cat) on a real tools.NewCatalog() AND drives ≥1 declared tool through the catalog/executor under a Harbor identity (via harbortest.RunOnce), then asserts an observable dispatch signal. The signal is a real catalog dispatch — cat.Resolve(name) (registration actually placed the tool) followed by desc.Invoke(ctx, args) (the executor ran the handler) — not a check that RegisterTools is merely defined. For a custom tool the asserted effect is the tool's typed ToolResult.Value; for a built-ins-only scaffold, reaching the executor is the signal and only ErrToolNotFound (registration produced nothing) fails the gate.
  2. When no tools are declared, the rendered test is unchanged — no tool block, no sdk/tools / customtools imports.
  3. scripts/smoke/phase-112b.sh adds a go test ./... execution leg on the tool-declaring external scaffold (the EXTERNAL EXECUTION GATE), and a self-test that rewrites the registration name so the module still COMPILES but go test FAILS — proving the gate bites (§17.8 anti-rubber-stamp). The heavy gate extends phase-112b.sh (which already pays the external-module build cost) rather than spawning a new smoke; scripts/smoke/phase-133.sh carries the static template-surface pins.

Correction (v1.13.1, 2026-07-13). Item 1's {{if or .BuiltIns .CustomTools}} gate — and its built-ins-only {{else}} dispatch branch — assumed built-ins travel through RegisterTools. They do not, and a registrar that registered them killed the boot with duplicate tool name (see the same-dated correction on D-154). The gate is now {{if .CustomTools}}: it covers what the registrar actually carries — the module's COMPILED tools. The built-in-only shape renders an empty RegisterTools and no dispatch test (there is nothing the registrar registers to dispatch); scripts/smoke/phase-112b.sh's built-in-only leg now asserts the registrar carries NO built-in registration and that the scaffold still compiles + tests green, and scripts/smoke/phase-133.sh gains two absence pins on agent.go.tmpl (builtin.RegisterWith / the sdk/tools/builtin import). The decision's substance — a tools-declaring scaffold must prove register-AND-dispatch, not mere compilation — is unchanged.

Why dispatch, not just registration. The runtime owns tool dispatch at the catalog/executor level (RFC §6.4 "Code-level tool dispatch"), so the honest proof that a scaffolded tool is live is a catalog round-trip: resolve the descriptor off the catalog and invoke it. A test that only referenced RegisterTools (e.g. var _ = RegisterTools) would compile and pass with a no-op body — the very false-green this closes.

Consequences. A tools-declaring scaffold that registers nothing — or registers a tool under a name it never dispatches — now FAILS its own go test, and the smoke gate fails loud. Operators who scaffold a tools-declaring agent get a generated test that is a working example of register-and-dispatch, not an inert stub. No Go API changes: the template and the smoke are the only surface touched; RegisterTools's signature is unchanged.

Deviations. None from the RFC. A §4.3 call records that the heavy execution gate EXTENDS phase-112b.sh (no new heavy smoke file); the thin phase-133.sh carries the §16-mandated static surface pins.

Cross-references. D-204/D-206 (the SDK facade + the standing external-module compile gate this extends), the scaffold engine + the per-custom-tool stubs (harbor scaffold), harbortest.RunOnce (the identity-bearing run harness the dispatch test drives), D-025 (the catalog/descriptor concurrent-reuse contract). CLAUDE.md §1, §13 (false-green), §17.6 (test↔production parity), §17.8 (a test that can't tell right from wrong is a rubber stamp). RFC §8, §6.4. brief 06, brief 07. Plan: docs/plans/phase-133-scaffold-tools-execution.md.


D-268 — harbor dev is honest about .go changes: WARN + manual-rebuild guidance, not a false hot-reload success

Date: 2026-06-26

Status: Settled (shipped in the v1.8.0 Adopter-Path wave)

Context. harbor dev's fsnotify hot-reload supervisor drove an in-process bootDevStack rebuild on every watched change and emitted dev.hot_reload.completed{Success=true} when the rebuild returned. The in-process rebuild re-reads harbor.yaml and re-wires the stack, but it never invokes the Go compiler — so a .go source edit was never picked up, while the runtime still reported a successful hot-reload. That is a loud false-success: the inverse of the §13 no-silent-degradation rule (do not claim success for work that did not happen). The dangling cmd/harbor/cmd_dev_hot_reload.go package-doc sentence ("This is documented in.") was the visible tail of the same gap.

Decision.

  1. Classify, don't blanket-reboot. The watcher now classifies every fsnotify event: config / YAML / scaffold changes take the existing in-process bootDevStack rebuild path (unchanged), and a Go-source (.go) change takes a WARN-and-guide path. shouldTrigger is retained as the rebuild-gate predicate (classifyEvent(ev) == reloadRebuild) and now returns false for .go.

  2. WARN, never a false completed. A .go change logs a WARN — "Go source change detected — harbor dev does not recompile Go; run make build and restart harbor dev to pick it up" — and does NOT reboot the devStack and does NOT emit dev.hot_reload.completed. The WARN is throttled to one line per debounce window so an editor's rename-and-replace save burst does not spam the log.

  3. policy: rebuild-binary is deferred. The optional auto-compile + re-exec policy (wrap bootDevStack in a go build plus a process re-exec on a .go change) is out of scope for this phase — WARN + guidance only. It can layer on later without changing the supervisor's shape.

  4. Documented where operators read. The dev-loop recipe (docs/recipes/run-harbor-dev.md) gains the .go-vs-config caveat and completes the package-doc reference. The run-the-dev-loop skill is already honest about .go reload and is left untouched; no other skill over-claims .go hot-reload. The README carries no hot-reload claim; the marketing-site over-claim is the sibling honesty-sweep phase's target, not this one.

Verification. The gate is a LIVE edit-and-observe — a static binary strings grep cannot distinguish this case because the dev.hot_reload.completed string remains in the binary for the YAML rebuild path. scripts/smoke/phase-65.sh writes a .go file into the watched examples/ dir against the running preflight dev server and asserts the honest WARN fires and the rebuild-path log marker count is unchanged (the observable proxy for "no completed emitted"). The in-package cmd/harbor/cmd_dev_hot_reload_test.go pins both halves: a live .go edit warns and does not swap the stack, and a subsequent YAML edit DOES swap the stack (the rebuild path is unchanged).

§4.3 deviations. Departs from the supervisor's original "every watched change drives a rebuild" behavior (D-099) for the .go case — a deliberate honesty correction, not a brief departure. The rebuild-binary policy is the only deferred sub-decision.

Cross-references. D-099 (the in-process-rebuild hot-reload shape this corrects), §13 (no silent degradation / no false-success), §17.8 (the live edit-and-observe gate over a false-green static probe). RFC §8. brief 06. Plan: docs/plans/phase-138-hot-reload-go-honesty.md.


D-264 — harbor token: a separate bring-your-own-issuer subcommand that self-issues operator-managed JWTs; serve's verifier unchanged

Date: 2026-06-26

Status: Settled (shipped in the v1.8.0 Adopter-Path wave)

Context. harbor serve verifies every /v1/* JWT against the JWK Set the operator configures via identity.jwks_url / identity.jwks_file and mints nothing itself (D-220). That is correct for an operator with an external identity provider, but it left a cliff for the adopter who runs no IdP and wants to issue their own tokens: Harbor documented no self-issuing path and shipped no tool for it. The temptation — a gated dev-token mint inside harbor serve — was rejected (D-263): it would silently reverse D-220 and widen serve's production trust edge with a second baked-in signing key.

Decision. Ship harbor token, a separate CLI subcommand (not a serve mode) that lets the operator self-issue the JWTs serve already verifies:

  1. harbor token keygen --out <dir> [--alg ES256|RS256] — generate an asymmetric keypair (ES256 default; RS256 opt-in — both on the §5.5/§7 asymmetric allowlist), write private.pem (mode 0600, parent dir 0700; refuse to overwrite without --force; stderr "keep this out of version control" warning) and a public jwks.json (RFC 7517 JWK Set) whose kid is the RFC 7638 JWK thumbprint of the key — content-derived, not a hardcoded constant. The JWK Set emitter is hand-written stdlib (the internal/protocol/auth JWKS surface is consumer-only — it parses JWKs, it never emitted one).
  2. harbor token mint --key … --tenant T --user U --session S --issuer ISS --audience AUD [--kid …] [--scopes …] [--ttl 1h] — mint a Harbor JWT with the claim shape the authoritative parser (internal/protocol/auth/auth.go) enforces, signed with the keypair. --issuer / --audience are mandatory and must equal the operator's identity.issuer / identity.audience (serve hard-rejects a mismatch with 401, and Config.Validate mandates both non-empty for the serve profile). Least-privilege defaults: no scopes unless --scopes is passed (NOT the dev signer's admin default); a short --ttl (1h) echoed to stderr.

The operator points identity.jwks_file at the emitted jwks.json and attaches with the minted token.

§7 / §13 compliance. Asymmetric algorithms only (ES256/RS256). The private key is operator-managed: written 0600 under a 0700 directory, never logged, never printed (only the minted token — the command's product — reaches stdout). The two CLI signers share only the JWT claim shaper (harborClaims); the harbor token signer is a distinct persistable, issuer/audience-parameterized signer with its own keygen / PEM I/O / JWK-emit / RS256 branch (the dev signer has none of these and never touches disk).

Cross-reference to D-220 — the tension, named. D-264 reintroduces a self-issuing single-key posture, which D-220 ("serve mints nothing; your chosen issuer mints, serve verifies") deliberately kept out of harbor serve. The tension is resolved, not by superseding D-220, but by bounding the self-issuing to an explicit operator opt-in: a subcommand the operator runs deliberately, never a silent runtime default, and serve trusts the key only because the operator configured jwks_file to point at it — identical to pointing at an external IdP. harbor serve itself still mints nothing and is unchanged (cmd/harbor/cmd_serve.go is untouched; no composite keyset — Config.Validate forbids one). Because the mint path is a deliberate operator action rather than a stub wired as a binary default, §13's no-stub-default / dev-only-escape-hatch rules are satisfied without a runtime banner (there is no runtime surface to banner — the subcommand is the opt-in).

Honesty callout. These tokens are signed by a key the operator manages: eval- / single-issuer- / small-production-grade. For multi-user SSO, graduate to a real IdP (docs/site/protocol/production-identity-setup.md).

Cross-references. D-220 (production JWT verification + harbor serve "serve mints nothing" contract — preserved, not superseded), D-263 (serve-attach resolution: two on-ramps, never mint inside serve), D-261 (JWKS max-stale ceiling on the same verifier). RFC §5.5 (Authentication), §7 (security), §8 (CLI layer). brief 06, brief 09. Plan: docs/plans/phase-131d-harbor-token.md. Wave coordination: docs/plans/wave-v18-coordination.md §3 / §4 (131d detail block).


D-266 — WithStream is one sink on the same blocking RunOnce, wired to the synchronous OnChunk + OnToolDispatched seam

Date: 2026-06-26

Status: Settled (shipped in the v1.8.0 Adopter-Path wave)

Context. D-265 shipped the embed one-call runner Stack.RunOnce as a single blocking method and deliberately deferred streaming to a sibling phase, leaving RunOption / runOnceConfig functional-option-shaped so a sink could land without a signature change. An agent framework without first-class streaming is a 2026 adoption blocker — an embed adopter needs live token output as a run progresses, not just the terminal envelope. The run loop already fires two callbacks SYNCHRONOUSLY on the run goroutine: planner.RunContext.OnChunk (per-token deltas + a done=true terminal per LLM call) and steering.RunSpec.OnToolDispatched (after each successful executor dispatch). No separate streaming package sits on the RunOnce path.

Decision. Add WithStream(func(StreamEvent)) RunOption — ONE sink on the SAME blocking RunOnce. RunOnce still blocks and returns the terminal planner.AnswerEnvelope; the sink receives StreamEvents as they occur. The sink is wired to the existing seam:

  1. planner.RunContext.OnChunk carries token deltas (mapped to StreamToken, with Reasoning distinguishing the thinking channel) and the per-LLM-call done=true terminal (mapped to StreamStep, a planner-step boundary). The wiring WRAPS any bus-backed chunk publisher runctx.NewRunContext already installed — the sink is additive, so a Console on the run's event bus and an embed sink both observe the chunks.
  2. steering.RunSpec.OnToolDispatched carries tool dispatches (mapped to StreamToolDispatched; the dispatch is the signal — raw tool arguments/results are never streamed, §7).

A new public StreamEvent type carries three sealed kinds (token / tool_dispatched / step). sdk/assemble re-exports WithStream + StreamEvent + the kind constants.

Why no sync/async method split. A second RunOnceStream / RunOnceAsync would be a parallel implementation of the same conceptual feature (§13 forbids that). The sink rides the one blocking call; the caller's own go stack.RunOnce(…, WithStream(sink)) gives async with no new surface.

Why deterministic ordering (chunks before the envelope). Both seam callbacks fire INLINE on the run goroutine inside RunLoop.Run, which RunOnce blocks on. Every StreamEvent therefore reaches the sink before RunOnce returns — the ordering is structural, not a race to engineer around. A test pins it: a sink that observed any event after the envelope returned fails loud.

Concurrency (D-025). Each RunOnce call captures its own sink in its own per-run OnChunk/OnToolDispatched closure (built from per-call runOnceConfig, never on the shared Stack). The N≥100 concurrent-reuse -race test is extended: each run gets its own sink and asserts it received ONLY its own run's chunks (no cross-run chunk bleed). A blocking sink stalls its own run goroutine only — natural per-run backpressure, never cross-run (brief 01 §"Backpressure inside streaming").

Deviations. None from a brief.

Cross-references. D-265 (the RunOnce runner this extends), D-025 (concurrent-reuse contract), the SDK facade (D-204/D-205). RFC §3.6 / §6.2 / §6.4. brief 01. Plan: docs/plans/phase-132-stream-withstream.md.


D-269 — cmd/harbor-protocol-ts-types: a distinct external-client TypeScript wire-type generator; the D-223 manifest gate and the reserved Console-generator name are untouched

Date: 2026-06-27 Status: Settled (shipping with the v1.8.0 Adopter-Path wave)

Where it lives: cmd/harbor-protocol-ts-types/ (the generator: main.go, emit.go, render.go, the independently-pinned typeindex.go, and the lockstep tests); examples/protocol-clients/event-viewer-ts/ (the committed generated module harbor-protocol.gen.ts + the worked event-viewer.ts client + package.json / tsconfig.json / README.md); Makefile (protocol-ts-types-gen + protocol-ts-types-gen-check); scripts/smoke/phase-135.sh; docs/skills/use-the-harbor-protocol/SKILL.md (the three updated TS-generation references); docs/site/protocol/build-a-client.md (the updated "doors up" section).

Context. TS wire-type generation has been deferred since D-132 (the post-Wave-13 checkpoint that corrected protocol.ts's false generated header to a hand-maintained notice) and reaffirmed by D-223 (the Phase 118 lockstep gate, which builds a Go-side wire manifest and VERIFIES the hand-maintained Console TypeScript client against it, rather than generating the client). D-223 explicitly RESERVES the name cmd/harbor-gen-protocol-ts for the FULL Console-protocol.ts generator — the deferred "B" deliverable that would emit the per-domain TypeScript type modules — and leaves it unused. Meanwhile a third-party adopter building a non-Console TypeScript client had no generated artifact at all: the only typed sources were the hand-maintained Console protocol.ts (carries client logic, modular across ~18 per-page files) and the generated markdown types reference (read-only, not importable). The adopter-path review flagged this as the standing TS-generation gap.

Decision. Ship a DISTINCT generator, cmd/harbor-protocol-ts-types, that reflects over internal/protocol/singlesource.CanonicalWireTypes (plus the canonical method / error-code / event-type sets) and emits a single vendorable external-client TypeScript module — a .ts file of interfaces for every canonical wire type, HarborMethod / HarborErrorCode / HarborEventType string-union types, and the pinned PROTOCOL_VERSION / WIRE_SURFACE_DIGEST constants. It carries TYPES ONLY, no client logic. The committed output lives at examples/protocol-clients/event-viewer-ts/harbor-protocol.gen.ts; a third-party client copy-vendors that one file. The worked event-viewer-ts client (the TypeScript sibling of the existing Go event-viewer) consumes it against the dev runtime, satisfying the §13 primitive-with-consumer rule. New make targets protocol-ts-types-gen (emit) and protocol-ts-types-gen-check (regenerate + git diff --exit-code + the Go lockstep tests) gate it.

What this does NOT touch — the non-interference contract. The generator reads only the canonical Protocol packages and writes only to the example directory. It does NOT touch the Console's hand-maintained protocol.ts / per-page wire modules, the committed D-223 wire manifest (web/console/src/lib/protocol/wire-manifest.gen.json), the cmd/harbor-protocol-ts-lockstep tool, or the make protocol-ts-gen / protocol-ts-gen-check targets — all of which stay exactly as D-223 shipped them. The two gates are independent and additive: make protocol-ts-gen-check (D-223, the Console-client lockstep gate) and make protocol-ts-types-gen-check (this decision, the external-client types gate) verify separately and neither invokes the other. The generator carries its OWN copy of the wire-type reflection index, pinned to CanonicalWireTypes by an in-package lockstep test — the same sanctioned duplication the docs generator and the manifest generator already use to stay decoupled.

Amendment note — the reservation and the D-223 gate are NOT silently overridden. cmd/harbor-protocol-ts-types is deliberately a DIFFERENT name from the D-223/§4.5(5)-reserved cmd/harbor-gen-protocol-ts. The reserved slot STAYS reserved and unused: it is for the FULL Console-protocol.ts generator (the "B" deliverable that would regenerate the per-domain Console TypeScript modules), which remains deferred. This decision does not consume that name, does not build that generator, and does not weaken D-223's lockstep VERIFICATION posture for the Console client. D-132 is PARTIALLY retired — external clients now have a generated TS types artifact — while the full Console-protocol.ts generation stays deferred under D-132 / issue #179.

§7 / safety. None affected — the generator is a build tool with no runtime surface; it reads source and writes a committed .ts file. The event-viewer-ts client is dependency-free (Node built-ins + global fetch), runs under Node's --experimental-strip-types, imports no Harbor package, and authenticates with a bearer token exactly as the Go event-viewer does.

Protocol additions. None — no Harbor Protocol method, error code, event type, or wire type changed. The generated module is a read-only projection of the existing canonical surface.

Cross-references. D-132 (the TS-generation deferral this partially retires for external clients), D-223 (the Console lockstep gate + the reserved cmd/harbor-gen-protocol-ts name, both untouched), D-209 (the sibling cmd/harbor-gen-protocol-docs reflect-over-CanonicalWireTypes generator whose shape this mirrors), D-002 (the Go single source for wire types). RFC §5 (the Harbor Protocol contract), §5.3 (versioning — the module pins PROTOCOL_VERSION), §3.6 (the adopter-facing client surface). CLAUDE.md §4.5(5) (the lockstep rule + the reserved name), §8 (Protocol single source), §13 (primitive-with-consumer — the event-viewer-ts consumer), §18 (the same-PR skill-drift update). Plan: docs/plans/phase-135-ts-types-generator.md.


D-270 — Native tool-calling sanitizes catalog tool names to the provider-safe form, and resolves them back on dispatch

Date: 2026-06-27

Status: Settled (shipped in the v1.8.0 Adopter-Path wave)

Context. Live verification of the scaffold-with-tools adopter path (Phase 133) found that Harbor's dotted tool-naming convention — the built-ins clock.now / text.echo and scaffolded custom tools like inventory.check — breaks native tool-calling against OpenAI-compatible providers. Those providers reject any function name not matching ^[a-zA-Z0-9_-]{1,64}$ with a 400. The React planner declared the catalog name verbatim in req.Tools and replayed it verbatim in the assistant tool_calls history, so any dotted-tool agent failed on the first (declaration) or follow-up (history) LLM call. The 107c/107d native-tool-calling tests and the 133 scaffold gate all used a scripted LLM, which bypasses provider name-validation — the §17.8 "fixture that can't tell right-field from wrong-field" hazard.

Decision. The React planner sanitizes every tool name it sends to the LLM (the req.Tools declarations and the replayed assistant tool_calls) to the provider-safe form via sanitizeToolName (disallowed characters → _, capped at 64), and resolves a provider-returned name back to the real catalog name via resolveDeclaredToolName before building the CallTool decision. The catalog name stays the canonical key — sanitization is a wire-edge transform, invisible to operators and to the executor. Declarations dedup on the sanitized name so the LLM never sees two identical function names; a (pathological) sanitized-name collision drops the second tool from the turn. This is the same provider-rejection class D-already-shipped handled for undeclared reserved-control names, now extended to invalid-character catalog names.

Consequences. Dotted-name agents (the default scaffold output and every built-in) work against real OpenAI-compatible providers. No operator-facing change; no catalog/config/API change. A deterministic round-trip unit test (a dotted name through declaration + projection) is the regression guard the scripted-LLM tests lacked.

Cross-references. D-167 / D-169 (107c/107d native tool_calls), Phase 133 (scaffold-with-tools, whose live verification surfaced this). RFC §6.2, §6.4.


D-271 — External tool-credential provisioning is PULL-based token exchange behind the D-095 driver seam (tokenexchange); push injection over the Protocol is rejected as credential passthrough

Date: 2026-07-01 Status: Shipped (Phase 142, V1.9) Where it lives: docs/plans/phase-142-tool-credential-exchange.md, RFC §6.4 (the "External tool-credential provisioning (D-271)" paragraph), internal/tools/auth/registry.go (the D-095 seam this extends), internal/tools/auth/drivers/tokenexchange/ (the shipped driver), internal/tools/auth/events.go (tool.credential_exchanged + ErrNonInteractive), docs/glossary.md ("Credential broker", "tokenexchange driver").

The question. A fleet orchestrator coordinating many Harbor runtimes as a Protocol client wants to hold each user's DOWNSTREAM integration credentials (M365, Google Workspace — foreign-IdP tokens used to call third-party tools) in ONE central place and provide them to whichever runtime's tool call needs them, instead of today's shape: every runtime independently OAuth-acquires and seals its own copy (N consents, N encrypted copies). Should Harbor support external provisioning of tool-side credentials, and in what shape?

Decision. Yes — as a pull: a new non-interactive acquisition driver named tokenexchange on the EXISTING D-095 OAuth flow-strategy registry, performing an RFC-8693-shaped token exchange against an operator-configured external credential broker. The runtime, at token-miss time, presents its own env-indirected broker credential plus the VERIFIED ctx identity triple and receives a short-lived, audience-bound downstream token. Three binding properties:

  1. Never persisted. Brokered tokens are TTL-cached in memory only (single-flight per (scope, tenant, user, source) — tenant included, per the phase-PR adversarial review); TokenStore.Put is never called. The broker stays the single source of truth — a per-runtime sealed copy would recreate the N-copies problem AND build a shadow store with a revocation hole (the D-061 shadow-source-of-truth smell read southbound).
  2. Fail-loud, one mode per source. Broker unreachable/refusing → typed ErrExchangeFailed to the run. NEVER a silent fallback to the interactive flow — that would silently void the central-custody policy (N consent prompts reappear), the exact silent-degradation shape §13 forbids. A source is EITHER interactive (oauth2) or brokered (tokenexchange), declared in config — no dual path (§13 two-parallel-implementations rule).
  3. One pause path. A broker consent_required refusal surfaces the existing typed *auth.ErrAuthRequired (AuthorizeURL = the broker's consent URL when supplied) so the run parks on the unified pause/resume primitive (§7 rule 4); a resume after central consent re-drives Token(). Interactive-flow methods (InitiateFlow/CompleteFlow/DenyFlow) return a new typed ErrNonInteractive — never a silent no-op.

Rejected: push injection (a per-run credential riding the Protocol / tool-call path). A downstream credential arriving in-band from a northbound client is the credential passthrough §7 rule 3 forbids, and the D-219 anti-pattern (authority from request body, not verified ctx): the runtime cannot verify the token's provenance, audience, or that it belongs to the ctx identity, and the secret rides channels the codebase is engineered to keep secrets OUT of (the ErrAuthRequired SafePayload discipline, the HTTP driver's ErrTemplateSecretLeak gate). D-025 is NOT the objection — per-run ctx state is mechanically fine; the Protocol trust boundary is. Recorded here so it is not re-litigated.

Rejected as the whole answer: MCP-layer-only custody. Provisioning the credential to the MCP server works only when the server is per-user (per-session stdio spawn with an orchestrator-injected env — a legitimate operational pattern needing zero Harbor changes, documented as a recipe). For a SHARED/pooled MCP server the server must select WHICH user's credential to use per call, and MCP carries no verified per-user identity southbound (headers are per-attachment static) — an invented identity side-channel the server blindly trusts recreates the confused deputy one layer down, at a layer with no identity contract. Harbor's runtime is the last point where the verified (tenant, user, session) triple exists; per-user credential selection belongs where verified identity lives.

Why the seam is already right. internal/tools/auth/registry.go is the §4.4 seam FOR acquisition strategies, and its godoc already anticipates non-interactive drivers ("device-code, client-credentials, vendor-specific extensions add a new driver … without changing this registry's shape"). §7 rule 3's own text names the sanctioned form ("OAuth flows use token exchange (RFC 8693)"), and the northbound precedent is exact: Harbor already trusts an external identity authority via jwks_url, with the TokenIssuer seam reserved for an external RFC-8693 issuer (D-129). The tokenexchange driver is the southbound mirror. Headlessness survives the same way it survives an external Postgres: a config-declared dependency that fails loudly.

Trust model, named honestly. V1 is RFC 8693 impersonation semantics: the broker trusts the runtime's client credential to assert the subject triple (subject_token_type=urn:harbor:oauth:token-type:identity-triple, a Harbor-defined URN). The northbound JWT is deliberately NOT forwarded as subject_token — durable runs outlive the initiating request's JWT, so request-token forwarding breaks exactly the durable-run cases Harbor exists for. A signed runtime-side subject assertion is the named post-V1 upgrade path. Every actual exchange emits the new canonical tool.credential_exchanged event (SafePayload — zero token bytes), satisfying §7's "explicit configuration AND audit events".

Semver. Fully additive: a new driver directory + one internal/drivers/prod blank import (D-196), a new accepted driver: value with Extra-carried knobs (audience, cache_ttl_cap), one new sentinel + one new event type. The interactive path, OAuthProvider, TokenStore, WrapWithOAuth, and all wire types are untouched.

Cross-references. D-083 (the tool-OAuth subsystem this extends), D-095 (the driver registry — the seam), D-090 (WrapWithOAuth, the §13 consumer path), D-011/D-067 (the unified pause primitive the consent path rides), D-025 (per-run state discipline), D-219 (authority from verified ctx), D-061 (no shadow store), D-196 (the blank-import home), D-240..D-247 (the 92k–92q runtime MCP OAuth band this composes with — 92l's typed-ErrAuthRequired park must also handle ErrNonInteractive), D-129/D-220/D-264 (the northbound external-authority precedents). RFC §6.4, §3.3. Brief 09. CLAUDE.md §4.4, §6, §7, §13.


D-272 — Run-level structured output is an opt-in RunOnce run option: buffered, validated, retried terminal payload on an additive envelope key; partial-object streaming is the named follow-up

Date: 2026-07-01 Status: Settled; shipped in Phase 143 (V1.9 band) Where it lives: docs/plans/phase-143-run-level-structured-output.md, RFC §6.5 (the "Run-level structured output" paragraph), RFC §6.2 ("schema mode" among the runtime-level run options), internal/runtime/assemble/runonce.go (WithOutputSchema + the runtime-edge validation), internal/runtime/runctx/newruncontext.go (schema threading + single compile site), internal/planner/output_schema.go (OutputSchemaValidator + ErrOutputInvalid), internal/planner/answer_envelope.go (AnswerPayload), internal/planner/react/react.go (applyOutputSchema — ResponseFormat + tool-call-aware Validator), sdk/assemble + sdk/planner (re-exports), docs/glossary.md ("Run output schema", "Answer payload").

The question. The v1.8 embed surface answers in a string: AnswerEnvelope.Answer. Every SDK adopters compare Harbor against leads with a typed, schema-validated final result (output_type-style). Where does run-level typed output live, and how does it interact with streaming?

Decision. An opt-in assemble.WithOutputSchema(schema) RunOption — filling the "schema mode" slot RFC §6.2 already enumerates among "runtime-level run options, not planner state". Four binding properties:

  1. Opt-in with zero default-path change. No schema → byte-identical v1.8 behavior; the substrate it consumes (CompleteRequest.ResponseFormat, the Validator retry-with-feedback wrapper, the OutputMode strategy + downgrade chain — all shipped, nearly consumer-less) stays nil-default. This half of the posture is universal across every surveyed framework (14/14).
  2. Validation is runtime mechanism, planner-agnostic; generation steering is the React driver's concern. The terminal Finish payload is validated against the schema at the RunOnce edge for EVERY planner — no Supports* capability ceremony (§4.4). The React driver additionally constrains the terminal completion (riding the profile's EXISTING OutputMode selection — no new toggle) and engages the corrective retry bounded by ModelProfile.MaxRetries. Schema-invalid after the budget → typed planner.ErrOutputInvalid. Never a silent fallback to unvalidated text (§13).
  3. Buffered terminal delivery; streaming preserved. WithStream composes: tool_dispatched + step events stream as today; assistant-content token chunks are suppressed for a schema-constrained run and the validated answer arrives once, as the additive answer_payload envelope key (Answer keeps the string rendering; the pinned envelope bytes are untouched). Rationale: a validate-and-retry loop cannot compose with live-streaming the constrained answer — tokens already emitted cannot be retracted on a corrective re-ask. The survey confirms the pairing: the Claude Agent SDK (buffered structured_output on the terminal result + re-prompt-on-mismatch) and LangGraph response_format (a separate post-loop call) are the closest published precedents; no surveyed framework that retries also live-streams the constrained answer. Suppressing terminal token deltas is deliberately MORE conservative than OpenAI/Anthropic (which stream raw JSON deltas) — a documented behavior choice, stated in the option godoc + the embed recipe, not a regression.
  4. Partial-object streaming is the NAMED follow-up, not silence. Progressively-delivered partial payloads (validated partials or DeepPartial-style frames) ship in roughly half the surveyed surfaces and are the UI-builder default in two of them. It maps onto an additive StreamEventKind when demand arrives; recorded here so the v1 buffered-only choice reads as sequencing, not ignorance.

Implementation note (terminal-turn strategy, resolved). The React driver installs ResponseFormat{FormatJSONSchema} + a Validator on EVERY turn when the run carries a schema; the profile's already-selected OutputMode strategy + downgrade chain (owned transparently by the LLM client) decides the actual wire shaping — candidate A (OutputModeNative) passes the schema through alongside the tool declarations, and Tools/Prompted ride their existing envelopes. No new toggle (brief 03's no-toggle rule). The Validator is tool-call aware: it passes a turn that emitted tool calls (a non-terminal planner step whose content is a preamble) and validates content only on the content-bearing terminal turn, so a schema-invalid answer engages the retry-with-feedback loop bounded by ModelProfile.MaxRetries without wasting retries on tool-calling turns. The output schema is compiled ONCE per run at the runtime edge (runctx.NewRunContext) and the same compiled planner.OutputSchemaValidator serves both the planner's per-turn steering and the runtime-edge final validation; the validated raw JSON is captured at the validation site (never through ExtractAssistantAnswer's lossy string collapse) so AnswerPayload carries exact, order-stable bytes. OutputModeTools unwrap: the downgrade layer's write half instructs the model to emit a {"name":"respond_with","arguments":{...}} envelope; internal/llm/output.ParseRespondWith is the matching read half, and the react terminal-answer path (both the per-turn Validator and the Finish payload capture) unwraps the envelope BEFORE validating/capturing, so AnswerPayload carries the caller's schema shape, never the envelope wrapper.

Known accounting gap (recorded, not fixed here). Governance composes OUTSIDE the retry wrapper (governance(retry(downgrade(corrections(safety(driver))))), D-044, deliberate) — CostAccumulator.PostCall fires once per governance-visible Complete call, so it sees only the FINAL (resp, err) of whatever the retry-with-feedback loop and the downgrade chain did internally. Every intermediate corrective re-ask (up to ModelProfile.MaxRetries retries) and every intermediate downgrade attempt (up to 3, the downgrade chain's cap) inside one governance-visible call is a real provider call with real token cost that never reaches the accumulator — worst case (MaxRetries+1)×3 uncounted provider calls per planner turn. This gap predates Phase 143 (D-044's compose order is unchanged), but Phase 143 is the first production Validator consumer that actually drives the retry loop on a live path, so the latent gap goes live with this phase rather than staying theoretical. No fix lands in Phase 143; a future governance phase either moves cost accounting inside the retry/downgrade wrappers (each attempt self-reports) or accepts the undercounting as a documented cost-ceiling slack margin. (Closed by D-275 — the attempt-cost tap; each attempt self-reports.)

Cross-references. D-043 (the LLM-edge chain + Validator-keyed retry this consumes), D-194 (the pinned AnswerEnvelope this extends additively), D-265/D-266 (RunOnce + WithStream), D-026 (heavy-output guard on the task-result path the payload rides), D-025 (schema is per-run state, never on the Stack), D-274 (ToolCallsSeen/tool-count producer sites this phase builds on), D-273 (the typed binding consuming this option), D-044 (the governance compose-order + latent-cost-accounting gap named above). RFC §6.5, §6.2, §3.6. Briefs 03, 07, 08. CLAUDE.md §4.4, §13.


D-273 — The typed embed binding is a generic free function (assemble.RunTyped[T]), the facade's SECOND documented generic-func carve-out (amending D-205); it is deliberately not named Agent

Date: 2026-07-01 Status: Settled; shipped in Phase 144 (V1.9 band) Where it lives: docs/plans/phase-144-typed-embed-binding.md, RFC §3.6, internal/tools/schema/ (the promoted deriver), internal/runtime/assemble/runtyped.go (RunTyped), internal/runtime/flow/flow.go (WithSchemasFrom, re-based on the shared deriver — closing a pre-existing §13 seam violation where the flow engine imported the concrete inproc driver directly), sdk/assemble/runtyped.go (the second generic-forward carve-out), scripts/smoke/phase-112a.sh (the two-func allow-list gate), examples/embed-runonce/main.go + sdk/assemble/example_test.go (Example_runTyped) (the §13 consumers), test/integration/phase144_runtyped_test.go, docs/glossary.md ("RunTyped").

The question. Phase 143 delivers schema-in/raw-JSON-out. The output_type T ergonomics adopters expect need a generic entry point — but sdk/ is alias-only (D-204) and D-205 item 1 pins "exactly ONE func" (sdk/tools/inproc.RegisterFunc). Where does the typed surface live, and what is it called?

Decision. assemble.RunTyped[T any](ctx, stack, goal, id, opts...) (T, planner.AnswerEnvelope, error) — derive the schema from T, run with WithOutputSchema, unmarshal the validated payload. Four binding properties:

  1. D-205 item 1 is amended, not eroded. Go cannot express a generic function as a var forward — the IDENTICAL rationale that justified the first carve-out. The facade's no-behavior smoke flips from "exactly one func" to an ENUMERATED allow-list of exactly two ({sdk/tools/inproc.RegisterFunc, sdk/assemble.RunTyped}) and fails on any third; the gate stays mechanical, additions stay decision-gated. The wrapper body lives internally; the forward adds no behavior.
  2. One derivation implementation, §13-compliant home. The Go-type→JSON-Schema derivation is promoted from the inproc tool driver into the neutral internal/tools/schema package (callers must not import a concrete driver — §13); the driver re-bases on it, golden-pinned byte-identical. RunTyped and RegisterFunc consume ONE implementation.
  3. Not named Agent, and no stateful binding object. The noun is taken twice (harbortest.Agent, the Agent Registry's registration entities — whose agent_id is explicitly not an isolation principal, D-059); a production type named Agent meaning "bound config + typed output" invites exactly the confusion §6's clarifying note exists to prevent. The bind-once surface remains config + AssembleStack (D-025-immutable, identity per-call); RunTyped is a free function over it. A future stateful binding object is a NEW decision against this one, not an extension.
  4. Fallback named up front. If the amendment is rejected in review, v1.9 still ships whole: Phase 143 alone delivers typed output with two caller-side lines (WithOutputSchema(schema) + json.Unmarshal(env.AnswerPayload, &out)); Phase 144 slips without blocking the wave.

Implementation note (shipped as designed). The amendment landed exactly as decided: scripts/smoke/phase-112a.sh's no-behavior guard now enumerates the allow-list (sdk/tools/inproc/inproc.go, sdk/assemble/runtyped.go) and enforces it FUNC-level — any third func-bearing file under sdk/ fails, AND each allow-listed file must declare exactly ONE func body (methods count) matching the enumerated forward's name, so behavior cannot silently accrete inside an already-allow-listed file (injection-verified: a sneaky helper appended to either file fails both this gate and scripts/smoke/phase-144.sh's twin). The promotion (item 2) surfaced a PRE-EXISTING §13 violation while moving the deriver: internal/runtime/flow imported the CONCRETE internal/tools/drivers/inproc package directly just to reach DeriveSchema for flow.WithSchemasFrom — exactly the "callers must not import a concrete driver" violation item 2 anticipates. Per CLAUDE.md §17.6 ("fix what the integration test finds — no matter where the bug lives"), the flow engine was re-pointed at the neutral internal/tools/schema package in the SAME PR rather than filed as a follow-up. A golden corpus test (internal/tools/schema/schema_test.go) pins the derived-schema shape byte-identical pre/post promotion. The mandatory D-025 concurrent-reuse test (TestRunTyped_ConcurrentReuse_MixedTypes_NoBleedNoLeak) mixes three distinct T types plus plain RunOnce calls against one shared Stack, N=160, under -race.

Cross-references. D-204/D-205/D-206 (the facade + the carve-out being amended), D-272 (the mechanism this sugars), D-024 (the derivation machinery's origin), D-059 (agent identity vocabulary), D-025 (immutability + per-call identity). RFC §3.6, §6.2, §6.4. Briefs 03, 07. CLAUDE.md §4.4, §6, §13.


D-274 — Pre-wave v1.9 integrity fixes: ToolCallsSeen is redefined to a true tool-invocation count, and CascadeEraser's event-bus fence fails the erasure loud

Date: 2026-07-01 Status: Settled Where it lives: internal/planner/trajectory.go (CountToolInvocations, DecisionInvocationCount), internal/planner/answer_envelope.go (AnswerEnvelope.ToolCallsSeen), internal/runtime/steering/runloop.go (RunSpec.OnToolDispatched), internal/runtime/assemble/runonce.go, cmd/harbor/cmd_dev_runloop.go, harbortest/devstack/devstack.go, sdk/planner/planner.go, docs/recipes/embed-harbor-headless.md; internal/sessions/erasure.go (CascadeEraser.Erase, fenceSession).

The question (item 1 — envelope semantics). AnswerEnvelope.ToolCallsSeen was computed as len(Trajectory.Steps) at five producer sites. One trajectory Step holding a CallParallel decision represents N tool dispatches, not one (undercount); a SpawnTask / AwaitTask step is a runtime decision, not a tool dispatch, yet was counted (overcount). The Console Tasks-page tool_count (driven by the same-shaped OnToolDispatchedIncrementToolCount hook, firing once per successful dispatch regardless of decision shape) carried the same two miscount shapes.

Decision (item 1). ToolCallsSeen now means the true tool-invocation count: internal/planner.CountToolInvocations(t *Trajectory) int sums DecisionInvocationCount(step.Action) across steps, where a CallTool counts 1, a CallParallel counts len(Branches), and SpawnTask / AwaitTask / anything else counts 0. All five producer sites (internal/runtime/assemble/runonce.go, cmd/harbor/cmd_dev_runloop.go, harbortest/devstack/devstack.go, and the two integration-test envelope builders in test/integration/phase110d_assemble_test.go / phase112a_sdk_facade_test.go) now call the shared helper instead of len(Steps). The adjacent tool_count counter adopts the same PER-DECISION counting rule: steering.RunSpec.OnToolDispatched gains a count int parameter (the runloop passes DecisionInvocationCount(decision) and skips the hook entirely when count is 0, so SpawnTask/AwaitTask no longer advance the counter), and every caller (the dev binary's IncrementToolCount loop, devstack's mirror, and the assemble.WithStream wrapper emitting one StreamToolDispatched event per underlying tool) loops count times — preserving "one stream event per dispatched tool" instead of collapsing a CallParallel dispatch into one event. The two counters deliberately diverge on the FAILURE axis, and the divergence is documented at both sites: tool_count counts SUCCESSFUL dispatches only (the hook is skipped on an executor error), while ToolCallsSeen counts ATTEMPTED invocations recorded on the trajectory (a failed dispatch still appends its step, so the envelope counts it) — a run with three CallTool steps of which one failed reports ToolCallsSeen 3 and tool_count 2, by design.

Why this is safe as a value-semantics change, not a breaking one. ToolCallsSeen's JSON shape, key, and type are untouched (the golden encoding test in answer_envelope_test.go pins bytes, not the counting rule); no runtime code enforces or gates on the field's value (it is observability only); and no previously-shipped test pinned the len(Steps) reading as a semantic contract. It IS SDK-visible (the field is public on an exported sdk/planner alias), so the change is called out explicitly in the field's godoc and in the PR description for embedders who may have assumed step-count semantics.

The question (item 2 — fence fail-loud). CascadeEraser.fenceSession's doc said "a Fence error fails the erasure loud," but the body only logged Error and returned — Erase then reported unqualified success even though the late-event window stayed open. This is a CLAUDE.md §13 silent-degradation violation on a GDPR-shaped (sessions.delete) path, and the fence had no prior docs/decisions.md entry at all.

Decision (item 2). fenceSession now returns error; Erase propagates a Fence error immediately, BEFORE the artifacts delete — the first destructive step — ever runs. This is safe specifically because the fence step already ran first in the ordering: nothing has been deleted when a Fence error surfaces, so the cascade is a clean, retry-safe no-op on this error, converging once the transient fault clears (the same idempotent-retry contract a mid-cascade store error already gets). The not-a-Fencer branch is UNCHANGED behavior: a bus that does not implement events.Fencer at all is a documented capability downgrade (Warn + proceed on the primary State sweep alone), never an error — two different failure shapes, two different postures, both now stated coherently across the method doc, the inline body comments, and the CascadeEraser type doc.

Tests. Both branches were previously untested. internal/sessions/erasure_fence_test.go adds: TestCascadeEraser_FenceError_FailsLoud_NothingDeleted_RetrySafe (a flakyFencerBus wrapping the real durable bus forces Fence to fail; asserts Erase errors, the session record / memory turn / artifact all survive untouched, and a retry after the fault clears converges to a full, successful erasure) and TestCascadeEraser_NonFencerBus_Erase_Succeeds_WarnOnly (a nonFencerBus that deliberately does not implement events.Fencer; asserts Erase still completes with Deleted: true). internal/planner/trajectory_test.go and internal/runtime/steering/runloop_test.go add the matching coverage for item 1: a CallParallel step/dispatch with N branches yields count N, mixed CallTool + CallParallel sums correctly, and SpawnTask/AwaitTask are excluded from both the envelope's ToolCallsSeen and the Console tool_count hook.

Cross-references. D-194 (the AnswerEnvelope this amends), D-197 (internal/runtime/assemble, home of RunOnce), D-025 (per-run state discipline — CountToolInvocations is a pure function over the trajectory, no artifact mutation), D-204/D-205 (the sdk/ facade this field is visible through). RFC §6.9 (session erasure), §6.2 (the Decision sum), §3.4 (fail-loudly). CLAUDE.md §5 (fail-loudly), §13 (silent degradation forbidden).


D-275 — Governance accounts every LLM attempt via an in-band attempt-cost tap: wrappers report consumed attempts synchronously into a ctx-carried per-call accumulator; the compose order and PreCall semantics are unchanged

Date: 2026-07-02 Status: Settled; shipped in Phase 145 (v1.10 band) Where it lives: docs/plans/phase-145-governance-attempt-accounting.md, RFC §6.15 (PostCall cost accumulation), RFC §6.5 (the retry/downgrade layers made accounting-visible), internal/llm/attempt_cost.go (the tap primitive), internal/llm/retry/retry.go (report site: validator-rejected non-final attempts), internal/llm/output/downgrade.go (report sites: every errored attempt), internal/governance/wrap.go (tap install; the "Known accounting gap" comment this closes), internal/governance/cost.go (PostCall drain-and-fold), internal/llm/drivers/bifrost/cost.go (stale "subscriber" doc-comment corrected), docs/glossary.md ("attempt-cost tap").

The question. Governance composes OUTSIDE retry (governance(retry(downgrade(corrections(safety(driver))))), D-043/D-044 — deliberate: PreCall's ErrBudgetExceeded must short-circuit before any retry or downgrade attempt burns spend). Consequence, recorded as D-272's "Known accounting gap" and at internal/governance/wrap.go:64-77: CostAccumulator.PostCall fires once per governance-visible Complete, so every intermediate corrective re-ask (up to ModelProfile.MaxRetries) and every intermediate downgrade attempt (chain cap 3) is a real provider call whose cost never reaches the accumulator — worst case (MaxRetries+1)×3 uncounted calls per planner turn, live since Phase 143 made the Validator loop production-real. How does attempt spend reach governance without inverting the settled compose order or re-litigating the settled in-band (non-subscriber) accumulator path?

Decision. A synchronous in-band attempt-cost tap: governance.Wrap installs a per-call accumulator handle into ctx after PreCall permits; the retry and downgrade wrappers report each attempt they consume into it; CostAccumulator.PostCall drains it exactly once and folds the total with the final resp.Cost. Five binding properties:

  1. The propagate-or-report invariant is the exactness proof. Every wrapper between governance and the driver treats each inner Complete outcome in exactly one of two ways: PROPAGATE the response to its caller (its resp.Cost reaches PostCall at the outer boundary — PostCall accumulates regardless of callErr, so retry's ErrRetryExhausted return of lastResp is already counted), or CONSUME it (loop onward / discard), in which case the consuming site MUST report its cost into the tap. Never both, never neither — each provider attempt is counted exactly once. Concretely: retry reports validator-rejected NON-final attempts only; downgrade reports EVERY errored attempt (its non-schema-error and exhaustion paths return a zero CompleteResponse, discarding the errored attempt's response entirely). The invariant is compose-order-independent: a hand-composed chain with governance inside retry simply sees no tap — each attempt then crosses Wrap individually and PostCall counts it — degrading to a no-op, never a double-count.
  2. No settled decision moves. D-043/D-044's compose order is unchanged; PreCall short-circuit semantics are unchanged (no per-attempt ceiling gating — the within-call overshoot bound widens to in_flight × per_turn_max_cost, an honestly documented consequence of the settled eventually-consistent-ceiling posture, D-044 item 4); the accumulator stays in-band synchronous per the pinned rationale at internal/governance/cost.go:165-167 — an llm.cost.recorded subscriber path (per-attempt events already fire from the innermost bifrost driver) would reintroduce exactly the bus-delivery race that rationale exists to preclude, and would be a second parallel accumulator implementation (§13). The stale claim in internal/llm/drivers/bifrost/cost.go:12-15 that governance "subscribes against this emit site" is corrected in the same PR.
  3. The tap lives in internal/llm, not internal/governance. The wrappers must not import governance (layering: LLM-edge layers stay governance-agnostic); llm is the shared vocabulary package all parties already import. Report is a no-op when no tap is installed — governance latent/disabled means no accounting consumer exists (D-044 item 1's latent posture, not silent degradation).
  4. Per-call state rides ctx; nothing mutable lands on wrappers (D-025). The tap is created per governed call, internally synchronized (atomic CAS over packed float64, the addAtomic pattern), one-shot on Drain. Identity keying is untouched: the fold lands under the existing identity-triple-with-RunID-cleared key; resp.Cost semantics are untouched (the tap is a side channel — other consumers keep reading the final call's cost).
  5. Fail-loud accounting on error paths (§13). Attempt costs accumulate even when the outer call ultimately errors — spend is spend. A PostCall failure that strands attempt cost names the at-risk amount in its error (surfaced via the existing observability-only Warn path; RFC §6.15's "PostCall errors do not supplant the call's result" is unchanged), never silently zeroed. The exactness test drives distinct per-attempt costs (powers of ten) through retry × downgrade so any drop or double-count is arithmetically unambiguous, across all three terminal shapes (success-after-retries, ErrRetryExhausted, ErrDowngradeExhausted).

Implementation note (shipped). The tap is internal/llm/attempt_cost.go: AttemptCostTap (an atomic-CAS-over-packed-float64 total + attempt count + a one-shot drained gate), installed by ContextWithAttemptCostTap(ctx) and reached through four ctx-scoped helpers — ReportAttemptCost(ctx, Cost) (no-op without a tap), PeekAttemptCost(ctx) (non-destructive), DrainAttemptCost(ctx) (one-shot), plus the exported (*AttemptCostTap).Drain() for direct/test callers. governance.Wrap.Complete installs a fresh tap AFTER PreCall permits and passes the tap-carrying ctx to both the inner chain and PostCall. The retry wrapper reports on the loop-continuation branch only (internal/llm/retry/retry.go — validator-rejected NON-final attempts; the exhaustion path returns lastResp, whose cost PostCall already counts). The downgrade wrapper reports once immediately after any errored inner attempt (internal/llm/output/downgrade.go — every errored branch discards the response, so one report covers the loop-continuation, non-schema-immediate-return, and exhaustion paths). CostAccumulator.PostCall peeks the tap for its extended zero-work early return, then — only after keyState resolves — drains once and folds tapTotal + resp.Cost.TotalCost in one addAtomic delta under the identity-triple key; a keyState failure before the drain and a persist failure after the fold each surface loudly naming the at-risk USD amount (the tap value is never silently zeroed). The stale "governance subscribes against this emit site" claims at internal/llm/drivers/bifrost/cost.go and (found during implementation) internal/llm/llm.go's Cost godoc are both corrected to name the in-band posture. Tests shipped: the tap primitive unit suite; retry + downgrade report-site suites (every branch's propagate-or-report classification pinned); governance fold / zero-guard / stranded-tap / persist-fail-loud unit tests; an in-package exactness test over the full production wrapper chain (llm.Open + governance.Wrap) with distinct powers-of-ten per-attempt costs across all three terminal shapes; the ceiling test where a folded intermediate attempt trips the NEXT PreCall; the N=128 shared-chain D-025 concurrent-reuse stress (distinct identities, exact per-identity folded totals, goroutine baseline restored); a conformance case so the fold persists identically across state drivers; and test/integration/phase145_attempt_accounting_test.go (real inmem drivers, identity propagation, retry-exhaustion-accounts-all + state-fail-surfaces-loud modes). Deviation (§4.3): the integration test composes governance via the documented headless governance.Wrap(inner, sub) path rather than the process-global SetFactory seam, to stay parallel-safe against other integration suites that toggle that global — the seam under test (a Subsystem factory's product consumed by Wrap) is identical.

Cross-references. D-272 (records the gap this closes — its "Known accounting gap" paragraph's "a future governance phase either moves cost accounting inside the retry/downgrade wrappers (each attempt self-reports) or accepts the undercounting" resolves to the former), D-044 (compose order + the in-band accumulator rationale, both preserved), D-043 (the retry/downgrade chain instrumented), D-025 (per-call state in ctx), D-026 (unrelated LLM-edge guard, shares the fail-loud posture). RFC §6.15, §6.5. Briefs 03, 06. CLAUDE.md §4.4, §5, §11, §13.


D-276 — Per-task structured output rides a per-run output_schema field on the start wire request; the per-task run loop compiles, steers, and validates through the ONE Phase-143 implementation and fails the task loud on a schema-invalid answer

Date: 2026-07-02 Status: Settled; shipped in Phase 146 (v1.10 band) Where it lives: docs/plans/phase-146-per-task-structured-output.md, RFC §6.5 (the "Run-level structured output" paragraph gains the per-task producer sentence), RFC §6.8 (the task envelope contract), RFC §5.2 (the start method), internal/protocol/types/control.go (StartRequest.OutputSchema), internal/protocol/control.go (dispatchStart edge validation), internal/tasks/tasks.go (SpawnRequest.OutputSchema / Task.OutputSchema + the TaskResult godoc flip), internal/planner (TaskErrorCodeOutputInvalid), internal/runtime/runctx (the promoted shared envelope builder), internal/runtime/assemble/runonce.go (re-based on it), cmd/harbor/cmd_dev_runloop.go + harbortest/devstack/devstack.go (the twin per-task drivers), internal/runtime/dispatch/dispatch.go (taskOutcomeObservation godoc flip), web/console/src/lib/protocol/client.ts + wire-manifest.gen.json (D-223 lockstep), docs/glossary.md ("Task output schema").

The question. Phase 143 (D-272) shipped run-level structured output for the embed runner only; the per-task Protocol path was left as a recorded reservation — the answer_payload key is documented as "reserved" at both internal/runtime/dispatch/dispatch.go::taskOutcomeObservation and internal/tasks/tasks.go::TaskResult ("per-task Protocol runs do not yet produce it — the per-task run loop has no output-schema plumbing"), while the machinery the per-task path needs (planner.RunContext.OutputSchema, planner.CompileOutputSchema, the React per-turn steering, the RunOnce-edge validation) already exists. Where does a Protocol caller's schema enter, and how does the per-task run loop consume the mechanism without forking it?

Decision. A per-run output_schema json.RawMessage field (omitempty, additive) on the start request wire type (types.StartRequest) — the request that spawns a task-shaped run — flowing request → tasks.SpawnRequest → the persisted tasks.Task record → the per-task RunLoop drivers. Five binding properties:

  1. Per-run wire field, NOT agent config (user-signed-off). An output schema is a property of one caller's one run. The agent-config control plane (D-234) is per-agent desired state projected next-turn — the wrong granularity: a config-level schema would constrain EVERY run of the agent and race concurrent callers wanting different shapes. A config-level default schema, if ever wanted, is a NEW decision against this one.
  2. One implementation end-to-end (§13). Compile: planner.CompileOutputSchema — invoked at the Protocol edge (dispatchStart rejects an empty/non-compiling/over-cap schema with CodeInvalidRequest before Spawn runs) and at driver run start (the compile the run consumes). Steering: setting RunSpec.Base.OutputSchema engages the React driver's existing per-turn applyOutputSchema (ResponseFormat + tool-call-aware Validator + retry-with-feedback) with zero planner change. Terminal validation: because steering.RunLoop.Run deliberately does NOT validate terminal output (D-272 made validation the caller's edge — RunOnce validates after Run returns), the RunOnce-edge validation + capturePayloadJSON are PROMOTED into one shared internal/runtime/runctx envelope builder consumed by RunOnce AND both per-task drivers (production + devstack twin, §17.6/D-094) — byte-identical envelopes golden-pinned across the promotion. No second validator, no third envelope constructor.
  3. Fail-loud, never a schemaless success. A schema-invalid answer after the retry budget fails the task with the new typed terminal code planner.TaskErrorCodeOutputInvalid ("output_invalid"), on both failure shapes: a RunLoop.Run error carrying the retry exhaustion, and an edge-validation planner.ErrOutputInvalid on a returned goal Finish. No code path MarkCompletes an unvalidated envelope when the task carries a schema. The awaiting parent's observation carries the {code: output_invalid} error.
  4. The consumers this closes come for free — and stay heavy-safe. tasks.get surfaces the validated answer_payload through the opaque result_inline projection unchanged; taskOutcomeObservation surfaces it to an awaiting parent via its existing generic parse; and the projection already routes through projectForLLM, so a large payload rides the EXISTING D-026 heavy-output offload (its godoc promised exactly this — the promise goes live, pinned by test, no second content-size mechanism). Streaming keeps the D-272 posture: schema-constrained tasks suppress assistant-content/reasoning token deltas at the drivers' OnChunk seam; step boundaries and tool-dispatch events stream; the answer arrives once, validated.
  5. Additive wire change = the full lockstep dance. make protocol-ts-gen regen of wire-manifest.gen.json; the typed Console client's start() gains an outputSchema opt (StartRequest stays on the justified untyped allowlist); make protocol-ts-gen-check green (D-223); make protocol-docs-gen regen committed same-PR (D-209). No Protocol version bump — omitempty additive per RFC §5.3.

Non-goals (recorded so sequencing reads as decision, not silence). Planner-emitted SpawnTask decisions carrying schemas (the Decision sum is sealed, D-047 — a future decision with its own emission design); agent-config-level default schemas (item 1); partial-object streaming (issue #444, the named D-272 follow-up); exposing the stored schema on TaskDetail (no consumer yet — lands with the first Console surface that wants it, D-062 ordering).

Cross-references. D-272 (the run-level mechanism + the reservation this closes), D-273 (the typed embed binding — the sibling consumer of the same mechanism), D-194 (the canonical AnswerEnvelope + the dispatch executor home), D-170 (SpawnTask/AwaitTask dispatch — the parent-observation surface the consumer E2E exercises), D-234 (the agent-config granularity argument), D-223/D-209 (the lockstep + docs-gen gates), D-026 (the heavy-output offload the payload rides), D-025 (schema compiled into per-run state, never on a shared artifact), D-094/§17.6 (the devstack twin parity). RFC §6.5, §6.2, §6.8, §5.2, §5.3. Briefs 05, 07. CLAUDE.md §4.2, §8, §13, §17.6, §4.5 item 5.

Implementation note (shipped as designed). Landed exactly as decided. The terminal validation + envelope construction was promoted into internal/runtime/runctx.FinishAnswerEnvelope(fin, traj, schema) (with capturePayloadJSON moving alongside it); assemble.RunOnce re-bases on it and its existing goldens stayed byte-identical, and both per-task drivers (cmd/harbor/cmd_dev_runloop.go + the harbortest/devstack twin) call the same builder after RunLoop.Run returns — one validator, one payload-capture rule, one envelope shape across all three call sites (golden-pinned in runctx/answer_envelope_test.go: the absent-schema three-key golden and the with-payload four-key golden). The idempotency posture went the STRONGER way the risk section flagged as optional-if-cheap: output_schema is folded into the task's content hash and the spawnRequestsEqual byte-compare, so a reused idempotency key carrying a DIFFERENT schema surfaces as a loud ErrIdempotencyConflict rather than silently adopting the original schema (the "original spawn's schema governs" fallback was not needed). Both output_invalid failure shapes are wired: a run-start compile failure and a RunLoop.Run error whose chain carries llm.ErrRetryExhausted / llm.ErrDowngradeExhausted map to MarkFailed{output_invalid}, and an edge-validation ErrOutputInvalid on a returned goal Finish maps the same — no path MarkCompletes an unvalidated envelope on a schema task. Task.OutputSchema carries json:",omitempty" (load-bearing: a nil json.RawMessage round-trips through a durable whole-record marshal as the 4-byte null, not nil, without it — which would trip the drivers' len()>0 run-start guard). The §13 consumer E2E (test/integration/phase146_task_output_schema_test.go) drives a schema-constrained task through the real devstack driver + real dispatch AwaitTask executor (happy + schema-invalid-fails-loud + D-026 heavy-payload offload legs, the offload stub's artifact_ref pinned), and the D-025 stress interleaves 120 distinct-schema + plain tasks against one shared stack asserting no payload bleed, no cancellation cross-talk (every tenth task cancelled mid-flight), and the goroutine baseline restored — all under -race. The streaming posture is bus-subscriber-verified end-to-end: a schema-constrained task emits ONLY empty-delta Done:true step boundaries on llm.completion.chunk (the drivers forward the done signal with an empty delta, never the chunk's own text) while a plain task on the same stack streams content deltas untouched. The idempotency schema fold is conformance-tested across all drivers: same key + different (or dropped) schema → typed ErrIdempotencyConflict; same key + same schema → Reused, no spurious conflict. Full D-223 lockstep (manifest regen, typed-client outputSchema opt) and D-209 docs regen committed in the same PR.


D-277 — Multi-driver events scenarios live in internal/events/conformancetest; duplicated per-driver copies are folded, not maintained

Date: 2026-07-02 Status: Settled; shipped in Phase 147 (v1.10 band) Where it lives: docs/plans/phase-147-events-conformance-suite.md; internal/events/conformancetest/conformancetest.go (the suite: Harness, Factory, Run), internal/events/drivers/inmem/conformance_test.go + internal/events/drivers/durable/conformance_test.go (the two consumers), scripts/smoke/phase-147.sh, docs/glossary.md ("events conformance suite").

The question. Where do multi-driver events scenarios live? CLAUDE.md §11 pins the rule — "Conformance suites: subsystems with multiple drivers have a single conformance test suite that all drivers must pass" — and the identity / state / memory subsystems all honor it (internal/<area>/conformancetest, exported Run(t, factory)). The events subsystem never got its home: the inmem and durable drivers grew hand-copied twins of the same bus-contract scenarios (fence, Bounds/Window, replay cursors, subscribe scoping, close lifecycle) that drifted independently — the durable driver never gained inmem's Window_ReplayDisabled assertion, inmem never gained durable's Window_EmptySession / Window_ReachesHead, and durable never asserted publish-side identity-mandatory rejection at all. The v1.9 wave audit named the debt and deferred it (NIT 7: "no shared events conformance home both drivers run; creating one is a structural refactor — deferred"), immediately after D-274 item 2 hardened the erasure fence whose bus-side contract is exactly one of the duplicated scenario pairs.

Decision. Phase 147 builds internal/events/conformancetest mirroring the settled precedent exactly — package conformancetest, exported func Run(t *testing.T, factory Factory) (NOT RunConformance; the package name is the qualifier) — and folds the verified duplicated scenarios into it. Binding properties:

  1. The Harness shape, chosen for configurations, not capabilities. The state suite's bare func() (state.StateStore, func()) presumes one default fixture per subtest; the events scenarios span three bus CONFIGURATIONS (default replay-capable / replay-disabled / bounded-retention). The Factory therefore returns a memory-precedent-style Harness carrying three MANDATORY constructors (NewBus, NewReplayDisabledBus, NewBoundedRetentionBus(t, capacity)), each registering cleanup via t.Cleanup. No Supports* flags, no skip-if-absent ceremony (§4.4): both V1 drivers express all three configurations today, and the suite FAILS loudly — never skips — when a driver's default bus does not type-assert to events.Replayer + events.HistoryReplayer + events.Fencer.
  2. Genuine divergence is parameterized as configuration. The one real semantic divergence — the durable driver in durable mode has unbounded persisted retention and can never return ErrCursorTooOld; only its documented best-effort ring mode can — is expressed by each driver supplying its own bounded-retention configuration to the shared scenario, not by a capability declaration.
  3. The fold discipline: no coverage loss, mapped row-by-row. The phase plan carries a binding mapping table (20 pinned scenario names ↔ every folded old test with file:line for both drivers); each old test's assertions map to a suite scenario before the old test is deleted. Six mapped cells are coverage GAINS (a driver lacked the scenario); a gain-side failure is a real finding fixed same-PR per §17.6, never a dropped scenario. Identity-scoping scenarios (empty-triple rejection on Publish/Subscribe/Replay/Bounds, cross-tenant live isolation, cross-identity replay isolation, fence non-bleed) are unconditional suite members (§6 rule 10).
  4. Pure test refactor; consumers by construction. Zero production code change — the diff is the suite package (a non-test .go importable by drivers, containing assertion bodies only — the state/memory posture), driver _test.go deletions, the two conformance_test.go consumers, the smoke, and docs. Both consumers land in the same PR, satisfying §13 primitive-with-consumer by construction; no redundant self-applied suite test is added (the state precedent's TestRun_SelfApplied existed because that suite shipped ahead of its second consumer).
  5. What stays driver-specific, and the named second tranche. Durable recovery/restart (D-255), OpenWith shared-store, persist-failure fail-loud, cancellation-bounds-persistence, exact replay-across-restart, inmem ring-truncation reporting, and every D-025 concurrent-reuse test stay in their driver packages — §5's reuse contract is per-artifact by design. The remaining near-duplicates (drop-policy, redaction-failure, reaper, admin-subscribe-audit pairs) are real duplication but timing-/config-sensitive; they are the NAMED second-tranche follow-up once this tranche proves stable — deferred deliberately here, not silently.

Cross-references. CLAUDE.md §11 (the conformance-suite rule this repays), §4.4 (no optional-capability ceremony), §6 rule 10 (isolation tests mandatory), §13 (primitive-with-consumer), §17.4/§17.6 (no sleep-sync; fix what the suite finds, same PR). D-274 (item 2 — the fail-loud erasure fence whose bus-side contract is a folded scenario pair), D-262 (session erasure, the fence's product anchor), D-255 (durable sequence rehydration — the recovery tests that stay driver-specific), D-254 (HistoryReplayer Bounds/Window), D-058 (pinned conformance-scenario names, the planner-pack discipline), D-025 (per-driver reuse tests untouched). RFC §6.13, §6.9. Brief 06 §5/§6. Precedents: internal/identity/conformancetest, internal/state/conformancetest, internal/memory/conformancetest.

Implementation note (shipped as designed; zero production change held). All 21 subtests (the 20 pinned scenarios plus a Capabilities_ReplayerHistoryReplayerFencer_Present fail-loud capability-assertion subtest, added as suite-internal infrastructure and not a fold-mapping row) pass against both drivers with NO production code change — every one of the six coverage-GAIN cells (the scenarios one driver previously lacked) passed cleanly the first time, so the plan's named risk ("a coverage-gain scenario fails on the driver that lacked it") did not materialize and no §4.3 deviation from the zero-production-change property was needed. The suite's synthetic events use events.SubscriptionIdleClosedPayload (a SafePayload) uniformly so assertions stay independent of each driver's redaction/rehydration shape (inmem preserves the typed payload; durable's persistence round-trip always rehydrates as RedactedMap, SafePayload or not) — the suite asserts identity, sequence and error-sentinel behavior, never exact payload type. Coverage moved from ~93.5–93.8%→~93.8–94.3% (inmem; the package's timing-dependent reaper/drop-window branches make single runs noisy, so the honest statement is the band — no pre/post pairing regresses) and 88.3%→88.6% (durable, stable across runs) — a small gain, consistent with the suite exercising previously-uncovered gain-side paths. The fold mapping table's 20 rows were realized exactly as specified; both fence files (inmem_fence_test.go, durable_fence_test.go) were removed entirely per the plan.


D-278 — MCP southbound per-identity OAuth bearer: a non-secret oauth_provider binding on the connection surfaces, per-call ctx-carried injection, and _meta provenance enrichment (agent_id + operator annotations)

Date: 2026-07-02 Status: Settled; shipped in Phase 148 (v1.10 band) Where it lives: docs/plans/phase-148-mcp-southbound-oauth.md; shipped in: internal/config/config.go (MCPServerConfig.OAuthProvider / .MetaAnnotations) + validate.go, internal/agentcfg/agentcfg.go + internal/protocol/types/agentconfig.go (the descriptor fields), internal/tools/agent_provenance.go (the ctx seam), internal/tools/drivers/mcp/ (Attach resolution, the per-call token fetch, bearerInjectingTransport, the enriched buildIdentityMeta), internal/runtime/assemble/assemble.go + cmd/harbor/cmd_dev_mcp_attacher.go (the OAuthProviders resolution seam), cmd/harbor/cmd_dev_runloop.go + harbortest/devstack/devstack.go (the agent-provenance producers), web/console/src/lib/protocol/agentconfig.ts + the regenerated wire manifest (D-223), docs/site/protocol/types.md (D-209), examples/dev.yaml + docs/CONFIG.md (the operator surface), docs/glossary.md ("Southbound OAuth binding", "Agent provenance", "Meta annotations").

Implementation note (shipped). The binding is resolved once at construction and lands immutable on mcp.Config.OAuthProvider; resolveBearerCtx fetches the per-identity token and stashes it on the call's ctx BEFORE dispatch on all five identity-stamped RPC paths (tool call, resource-descriptor read, prompt get, ReadResource, SubscribeResource), so a Token() failure aborts with NO wire request (fail-closed). The bearerInjectingTransport is the innermost RoundTripper (layered under the static-header transport) and sets Authorization last from req.Context(), holding no mutable state — one shared transport serves N concurrent identities with no bleed. The isolation gate (TestConcurrentReuse_OAuthBearer_NoTokenBleed, N=128 distinct triples) and the integration test (TestE2E_Phase148_*, reusing Phase 142's RFC-8693 broker fixture + a go-sdk streamable-HTTP fixture server) both pass under -race. Config + attach validation reject an unknown provider name (error lists declared names), a binding without an http(s) URL, an Authorization-header conflict, and reserved/spec-prefixed/empty annotation keys.

Adversarial-review hardening (same PR). (1) The stdio rejection was strengthened to "no http(s) url ⇒ no binding" at BOTH validation sites: an omitted/auto transport with only a command auto-selects stdio at connect, so the original mode-string check let a binding pass validation and then silently never inject — the §13 silent-degradation shape, now closed and pinned by validate + attach + full-Attach tests. (2) The devstack twin attacher threads OAuthProvider/MetaAnnotations from the AttachRequest and carries the provider registry into AttachDeps (it previously dropped both — a runtime add_mcp_connection would have attached unauthenticated); TestE2E_Phase148_DevstackAttacher_BindingOverAddConnection proves binding-over-runtime-add end-to-end plus the unknown-provider fail-loud leg. (3) The run-loop provenance PRODUCERS are pinned by twin tests (TestPerTaskRunLoopDriver_StampsInvokingAgentProvenance / TestDevStackRunLoopDriver_StampsInvokingAgentProvenance + empty-id absence legs) — the run ctx demonstrably carries the agent id, not just the integration test's manual stamp. (4) The three per-package reserved-_meta-key sets were consolidated onto ONE shared authority, config.IsReservedMCPMetaKey (config validation, the runtime-add gate, and the driver's merge-time re-check all delegate to it; golden-pinned by TestIsReservedMCPMetaKey_GoldenSet), so the reserved set cannot drift between surfaces.

The question. Phase 142 (D-271) shipped pull-based external credentials — the tokenexchange driver mints a per-user downstream bearer, TTL-cached, never persisted. But the token never reaches the MCP wire: the MCP call path (internal/tools/drivers/mcp/mcp.go::callTool) has NO auth beyond mcp.Config.Headers frozen at connect into a static header-injecting transport, the catalog WrapWithOAuth pre-check deliberately DISCARDS the token it fetches (injection is "a per-driver concern," per its own godoc), and no connection surface can even NAME an OAuth provider. Meanwhile a second consumer needs per-identity southbound credentials on SHARED MCP servers — and those servers need call attribution beyond the bare (tenant, user, session) triple buildIdentityMeta stamps today. How does a brokered per-identity credential reach a shared MCP server, and what provenance rides alongside it?

Decision. Four parts, one phase (148):

  1. A non-secret provider-NAME binding on every MCP connection surface. oauth_provider (yaml/wire) / OAuthProvider (Go) references a declared tools.oauth_providers[] entry (the D-095 registry) on config.MCPServerConfig, agentcfg.MCPConnectionDescriptor, and wire AgentConfigMCPConnectionDescriptor (+ the add_mcp_connection request's embedded descriptor). A NAME is not secret material — the descriptor's "secret auth material is NEVER part of this descriptor" invariant holds: the name selects a config-declared acquisition strategy; secrets stay env-indirected on the provider entry and minted tokens stay in memory (D-271). Unknown name → fail loud at validation/attach, listing registered providers (§4.4 factory-error convention). A stdio connection binding a provider is rejected loudly (no HTTP request to inject into — a misconfiguration, never an ignorable hint, §13). One auth mode per connection: a static Authorization header alongside oauth_provider is rejected at validation (the D-271 "no dual path" rule read per-connection); defence-in-depth, the bearer transport sets Authorization last.

  2. Per-call bearer injection via a context-aware RoundTripper — net-new, D-025-clean. Every identity-stamped per-call RPC path (tool calls, resource reads, resource subscribe/unsubscribe, prompt gets — the rule is binary: identity-stamped call ⇒ bearer-injected call, because a per-user-authorizing server 401s resource reads exactly as tool calls) resolves prov.Token(ctx, source) (the provider's TTL cache + single-flight absorbs per-call cost, D-271) and threads the bearer through the per-call ctx into a bearerInjectingTransport that sets Authorization on THAT request only. No mutable transport state; the token rides req.Context() (the go-sdk's long-lived session still threads the per-call ctx into the outbound *http.Request — asserted by the integration test, never assumed). The connect-frozen static header map stays for connect-time auth (initialize/discovery run before any per-call identity exists — a documented limitation, not a silent gap). Fail-closed is the load-bearing invariant: a bound provider whose Token() fails aborts the RPC with NO wire request — never a fallback to an unauthenticated call. A consent_required refusal propagates the existing typed *auth.ErrAuthRequired and parks on the unified pause/resume primitive (§7 rule 4) — zero new pause coordination.

  3. _meta provenance: agent_id — provenance, NEVER an isolation principal. buildIdentityMeta additionally stamps agent_id when a new ctx seam (tools.WithInvokingAgent / tools.InvokingAgentFrom, produced by the run loop + its devstack twin from the run's agent-config id) carries one. Per the §6 clarifying note + D-059: servers MUST NOT treat it as an isolation filter, and Harbor-side nothing keys storage, event filters, or token caches by it — the bearer's subject/cache-key stays user-scoped (D-271). Absence is valid (a bare embed run has no agent). The key is agent_id (not bare agent, unlike the triple's bare keys) to match the Protocol wire vocabulary and avoid reading as an agent name.

  4. Operator-declared meta_annotations. A static, non-secret map[string]string on the connection surfaces, merged verbatim into _meta on every identity-stamped call — deployments carry their own attribution vocabulary without Harbor encoding foreign keys. Reserved keys (tenant, user, session, agent_id, traceparent, tracestate — the D-073 _meta carrier idiom — plus the spec-reserved io.modelcontextprotocol/ prefix) are rejected at validation, fail loud.

The seam agreement (binding on 85b / 92l). The Pending interactive-OAuth phases (85b's RFC 9728 / WWW-Authenticate discovery; 92l's agent-bound token + typed replacement of the looksLikeAuthRequired heuristic) REUSE this phase's binding + RoundTripper seam when they land — one injection mechanism; a second injection transport is the §13 two-parallel-implementations violation. This phase deliberately does NOT implement their scope.

Non-goals, recorded. Agent-scoped brokered bearers (a future decision against D-271/this entry); push injection (rejected, D-271); reshaping WrapWithOAuth's pre-check (untouched); HTTP-driver oauth_provider binding (Phase 149's manifests keep their static AuthRef; convergence is a future phase); remove_mcp_connection (deferred; pause covers disable, D-237/D-240).

Wire-change hygiene. The descriptor change runs the full D-223 lockstep (TS mirror + make protocol-ts-gen + the three-way gate) and make protocol-docs-gen (D-209) in the same PR. Fully additive: two optional fields on existing types; no method, error code, or event change.

Isolation test mandate. The D-025 concurrent-reuse test doubles as the isolation gate: N≥100 concurrent calls through ONE shared Provider with distinct identity triples, the fixture MCP server asserting per-request that the received bearer matches the same request's _meta triple — a token bleed across identities is the failure this surface must make impossible.

Cross-references. D-271 (the pull-based credential this carries to the wire; subject stays user-scoped), D-095 (the provider registry the binding names), D-234/D-237/D-240 (the agent-config control plane + attach lifecycle the descriptor extends; 92l seam agreement), D-073 (the _meta carrier idiom whose keys are reserved), D-219 (authority/identity from verified ctx, never request data), D-059 + CLAUDE.md §6 clarifying note (agent_id is not an isolation principal), D-025 (per-call state rides ctx), D-223/D-209 (wire-change gates), D-061 (no shadow custody). RFC §6.4, §3.3, §6.16. Briefs 09, 14, 03. CLAUDE.md §4.4, §6, §7, §13, §17.8. Plan: docs/plans/phase-148-mcp-southbound-oauth.md.


D-279 — The HTTP-manifest boot loader is wired (tools.http_manifests[] loads at boot; validate flips reject→validate); a config-declared manifest tool is the black-box vehicle for catalog OAuth wrapping — no test tool is invented, no manifest-level oauth field is added

Date: 2026-07-02 Status: Settled; shipped in Phase 149 (v1.10 band)

Implementation note. Shipped as designed: assembleCatalogBand walks cfg.Tools.HTTPManifests after builtin.RegisterWith and before the catalog Builder's Apply, calling http.LoadManifest + http.RegisterManifest per entry; config.Validate flips reject→validate (non-empty, unique after Clean); config.Load resolves relative entries against the config file's directory with the FULL internal/skills/importer/path_safety.go posture replicated (not imported): the lexical Clean+canonical-prefix check always, plus the symlink-evaluation containment re-check when the joined path exists (a symlink inside the config directory crossing outside it is rejected; a non-existent manifest skips the symlink leg — boot is the existence home). Escapes fail with a fieldError naming tools.http_manifests[i]. harbor validate reaches the same resolution through config.LoadFromBytesAt (empty-path calls degrade to LoadFromBytes pass-through semantics), and config.WithOverrides documents that post-Load manifest overrides skip resolution entirely (no config directory is retained on *Config) — absolute paths only on that seam. The integration test (test/integration/phase149_http_manifest_boot_test.go) proves the full chain against real drivers: a fixture httptest.Server round-trip through the manifest's static auth_ref header, a tools.entries[].oauth binding to the Phase 142 tokenexchange driver pre-checking the shipped RFC-8693 broker fixture before dispatch, two boot failure modes (missing manifest, unknown OAuth provider) failing Assemble loudly, and a D-025 N=128 concurrent-reuse run through one shared catalog under -race. No deviation from the plan.

Where it lives: docs/plans/phase-149-http-manifest-boot-loader.md, RFC §6.4 ("manifest is the operator deployment shape"), internal/runtime/assemble/assemble.go (assembleCatalogBand — the load+register loop), internal/config/validate.go + loader.go (the validate flip + the §7 rule 5 relative-path resolution), internal/tools/drivers/http/manifest.go (LoadManifest/RegisterManifest, shipped in Phase 27 and boot-consumer-less since), docs/CONFIG.md (tools.http_manifests), docs/notes/sdk-friction-audit.md §1 (the dead-knob finding this closes), docs/glossary.md ("HTTP-manifest boot loader", "UTCP manifest").

The question. How does an adopter exercise catalog OAuth wrapping — tools.entries[].oauthcatalog.Builder.wrapWrapWithOAuth, including runtime-initiated token exchange via the D-271 tokenexchange driver — black-box, end-to-end, from operator config alone? Today NOTHING config-declarable can: built-ins are a closed allowlist, custom tools require Go wiring, MCP tools attach after the Builder applies entries, and the one config surface designed for declarative tools (tools.http_manifests) is documented, exemplified, and REJECTED at validate time because its boot loader was never wired (the SDK friction audit's §1 dead knob; the validator fails loud per §13 rather than letting a populated knob silently do nothing).

Decision. Wire the documented-but-unwired boot loader; do not invent a tool. assembleCatalogBand loads each tools.http_manifests[] file via the HTTP driver's existing LoadManifest + RegisterManifest and registers the manifest's tools by name — AFTER built-ins, BEFORE the catalog Builder applies tools.entries[], so the existing by-name OAuth/approval/loading middleware binds to manifest tools with ZERO new machinery. config.Validate flips from rejecting a populated list to validating it (non-empty, unique after Clean); config.Load resolves relative entries against the config file's directory with the path_safety.go posture (Clean + canonical-prefix; escapes fail loud naming tools.http_manifests[i]) while absolute entries are Cleaned and accepted (the documented /etc/harbor/tools/*.yaml deployment shape). Boot posture is §13-loud end to end: missing/unparseable/ErrManifestInvalid manifests and tool-name collisions (tools.ErrToolDuplicateName) fail Assemble naming the file and the config key — never a silent skip. Because both the binary and harbortest/devstack are thin wrappers over Assemble (D-196/D-197), the ONE wiring home serves both; no second projection is grown.

Rejected: a config-declarable test/echo tool to hang OAuth on. Shipping a stub-grade tool so the OAuth path has a config-reachable consumer is precisely the §13 "test stubs as production defaults on operator-facing seams" prohibition — a canned-response tool as the sanctioned OAuth vehicle would be a stub shipped as the golden path. The runtime already owns a real, spec'd, shipped declarative tool surface whose ONLY missing piece is the boot wiring; wiring it delivers a production-grade vehicle and closes an audit-pinned dead knob in the same stroke.

Rejected: an oauth field on ManifestTool. The by-name tools.entries[].oauth path (D-090/D-095) is THE OAuth binding home; a manifest-level field would be two parallel implementations of the same conceptual feature (§13). A future decision may converge them — that is a new entry against this one, not an extension.

Scope fence. WrapWithOAuth semantics are untouched: the pre-check acquires and DISCARDS the token (availability gating + pause-on-ErrAuthRequired), and the HTTP request's credential remains the manifest's static auth_ref. Southbound credential injection is transport-specific and Phase 148 owns it for MCP. Hot reload of manifests is out (boot-only, restart-required per §10); MCP/A2A manifest loaders are out.

Cross-references. D-036 (the HTTP driver + manifest security boundary this loads), D-090/D-095 (the by-name binding home + provider registry), D-196/D-197 (the single assembly home the wiring lands in), D-271 (tokenexchange — the exchange this makes config-reachable), D-025 (catalog as compiled artifact — the concurrent-reuse gate), D-061 (no shadow config home). CLAUDE.md §4.2 item 7, §7 rule 5, §10, §13, §18. RFC §6.4, §3.4. Briefs 03, 07. Plan: docs/plans/phase-149-http-manifest-boot-loader.md; findings: docs/notes/sdk-friction-audit.md §1.


D-280 — The run-completion hook is a RunLoop-terminal runtime mechanism whose egress is a named catalog tool through the existing executor; failure never alters the run outcome; config pairs yaml runtime.hooks with a versioned agent-config hooks section

Date: 2026-07-02 Status: Settled; shipped in Phase 150 (v1.10 band) Where it lives: docs/plans/phase-150-run-completion-hook.md, RFC §6.17 (the new "Run-completion hook" subsection this decision's phase adds), RFC §6.3 (the run-loop seam), §6.4 (the tool-catalog egress), §6.13 (the bus events), §6.16 (the agent-config content surface); internal/runtime/steering/runloop.go (RunSpec.CompletionHook, the deferred terminal fire over the named returns + the per-run steering-entry accumulator), internal/runtime/steering/completion.go (CompletionHookSpec, RunCompletionPayload, transcript assembly, outcome mapping, the WithoutCancel+timeout bridge, the fire), internal/runtime/steering/events.go (run.hook_dispatched / run.hook_failed + SafePayloads), internal/config (runtime.hooks.run_completion), internal/agentcfg (ConfigPayload.Hooks + DiffHooks), internal/runtime/agentcfg/projection (ActiveRunCompletionHook), internal/protocol/types/agentconfig.go (AgentConfigHooks + AgentConfigHooksDiff + the AgentConfigDiff hooks arm), both run-loop driver twins (cmd/harbor/cmd_dev_runloop.go + harbortest/devstack/devstack.go), test/integration/phase150_run_completion_hook_test.go, docs/glossary.md ("Run-completion hook", "Run transcript").

Implementation note (shipped). The hook fires from a deferred closure over RunLoop.Run's named (fin, err) returns, registered after identity/runCtx are established, so it covers every terminal exit uniformly and never fires for a pre-run misconfiguration; a defensive recover() inside the fire guarantees an executor-internal panic can never replace the settled run result. Steering USER_MESSAGE / REDIRECT text is captured in the drain-apply loop tagged with the trajectory index it precedes; the transcript assembler interleaves the goal, per-step assistant preambles + compact {tool: ok|err} lines, the captured steering entries, and the final answer. The payload's format_version is pinned to 1 with a golden test; TranscriptEntry.At is a *time.Time so entries without a captured timestamp omit it. The projection helper ActiveRunCompletionHook(ctx, reg, agentID, id, yamlDefault) takes the static yaml default and resolves the full agent-config › yaml › none precedence in ONE place (a small deviation from the plan's 4-arg signature, so the precedence is pinned by a single table test and both driver twins share it); the yaml half itself is the shared projection.RunCompletionHookFromConfig, applied by the production driver, the devstack twin, AND the embed path — Stack.RunOnce resolves the hook from the stack's static config (the assemble Stack carries no agent-config registry) with a WithCompletionHook RunOption for per-call override or explicit-nil disable, so every run type (embed one-call, foreground task, background task) is covered uniformly. The wire edge validates the section at set time (ErrInvalidHooks on a negative timeout_ms, → 400) in parity with the yaml validator; the normalizer's negative→0 coercion is defence-in-depth behind that gate. Outcome-fidelity boundary: the hook outcome is the run-loop terminal outcome — post-run processing outside the loop (the run-level output-schema backstop that can fail a goal-finished answer after Run returns, and driver-level task-status mapping) is not reflected in the payload; a schema run whose terminal answer fails the runtime-edge validation delivers goal to the sink while the task lands Failed{output_invalid}. No new Protocol method; wire impact is additive types only (D-223 lockstep + D-209 docs regen ran in the same PR).

The question. Memory, audit, and analytics sinks need the full conversation at run completion — for runs no client observes: background and disconnected runs have no observer to pull it. Verified v1.9 state: the RFC has NO run-lifecycle hook or egress surface; the runloop's only per-run hook is RunSpec.OnToolDispatched (runloop.go:391); there is NO generic run-completion bus event (only task.completed/task.failed from the tasks engine and the best-effort runtime.run_cancelled from Engine.Cancel) — a plain foreground Stack.RunOnce completing emits nothing, so a bus subscriber cannot cover all run types; and mid-run steering USER_MESSAGE text is not durably captured anywhere (apply.go:93-100 is in-process per-step state; the AppliedControl history drops payloads, history.go:21-34; the only durable record is one Memory.AddTurn goal/answer pair on FinishGoal). Where does the hook live, what carries the egress, and how is it configured?

Decision.

  1. The hook is runtime MECHANISM on the RunLoop, not planner policy and not a bus subscriber. RunSpec gains CompletionHook; RunLoop.Run fires it exactly once, via a deferred fire over the named returns, at its single terminal boundary — covering ALL exits: the terminal planner.Finish (runloop.go:701-702), the REJECT / pause-timeout constraints-conflict terminals, cancel-while-paused, and error returns. Every run type terminates through RunLoop.Run (assemble/runonce.go:305; cmd_dev_runloop.go:972 for both task kinds — brief 05's "runs are tasks of kind foreground" unification), so one seam covers embed, foreground, and background runs uniformly. The outcome (goal / no_path / cancelled / deadline_exceeded / constraints_conflict / error) rides IN the payload; the hook never fires mid-run or on pause (a pause is not an exit). Planner concretes do not know the hook exists (brief 02 §5's steering-at-planner-level lesson applied).
  2. The egress is a NAMED CATALOG TOOL dispatched through the existing ToolExecutor path — a bespoke HTTP egress client is the rejected alternative. The hook dispatches a synthetic planner.CallTool{Tool: <configured name>, Args: <payload>} via spec.ToolExecutor.ExecuteDecision. This is the load-bearing DNA argument: provenance stamping, identity capture, the per-tool ToolPolicy retry/timeout shell, and args-free audit events (tool.invoked/tool.completed/tool.failed carry identity + tool name + transport, never args — §7 rules 6-7 by construction) all come FREE from the existing machinery; a parallel webhook/HTTP-callback subsystem would re-implement all of it (§13 two-parallel-implementations, rejected — the catalog IS Harbor's egress). The executor holds the full catalog while the planner's prompt sees the filtered view, so a hook target is dispatchable without being LLM-exposed. The dispatch does NOT invoke OnToolDispatched, append a trajectory step, or advance the D-274 counters — it is not a planner tool invocation.
  3. A bounded detached ctx bridges the cancel case; the run outcome is untouchable. The hook runs under context.WithTimeout(context.WithoutCancel(runCtx), timeout) (default 10s) — values (identity quadruple) preserved, cancellation detached, detachment bounded; the in-repo precedent is the tool-auth post-cancellation pattern (internal/tools/auth/provider.go:323). The hook fires AFTER (fin, err) are settled and cannot mutate them: a hook failure emits run.hook_failed (SafePayload: identity, tool, outcome, error class — never transcript content or raw caller-quoting error text) + a Warn log — never silent (§13), never a run failure. Success emits run.hook_dispatched (identity, tool, outcome, duration, transcript bytes + entry count) — the symmetry earns its taxonomy slot because a healthy hook is otherwise invisible as a hook (the generic tool events don't say "this was the completion hook"); both are steering-registered, mirroring control.received/control.applied. One dispatch attempt at hook level; bounded retries live in the target tool's own policy shell — no second retry loop, no queues.
  4. The payload is a typed, versioned public contract assembled from LIVE run state. RunCompletionPayload (format_version: 1, golden-pinned JSON): run metadata (quadruple; optional agent_id — registration metadata per D-059, never an isolation key; outcome; timings; the D-274 tool-invocation count) plus the ordered conversation — initial goal, steering USER_MESSAGE/REDIRECT entries in arrival order (accumulated by the runloop as per-run stack-local state with step indices, the carryEvents pattern — D-025-clean), assistant preambles + compact {tool, ok|err} lines, final answer. Steering entries are Protocol-edge-bounded (RFC §6.3 payload caps); raw tool observations are excluded. D-026 does not apply — the payload travels as tool args and never traverses the LLM edge; the audit events around the dispatch carry no args at all.
  5. Config pairs static yaml with a versioned agent-config section; resolution is next-run projection; no new Protocol verb. Static: the reserved RuntimeConfig slot gains its first body — runtime.hooks.run_completion: {tool, timeout} (validated; examples updated per §10). Durable: agentcfg.ConfigPayload.Hooks rides the existing revision machinery (content-hash, set_revision full-payload with section-merge preservation, a new diff arm, rollback, agent.config.revised) — mirroring the D-236/D-238 section pattern. Run-start resolution: agentcfg hooks › yaml runtime.hooks › no hook, via projection.ActiveRunCompletionHook called by BOTH the dev binary and the devstack twin (§17.6). In-flight runs keep their snapshot (D-234 §3). Wire impact is types-only: AgentConfigPayload sections are schema'd per-section on the wire, so the new section means types.AgentConfigHooks + a diff arm + the hand-mirrored TS interface + make protocol-ts-gen manifest regen + make protocol-docs-gen (D-223 lockstep) — but no new method and no version bump. A set_hooks convenience verb (the set_llm_params shape) is a named possible follow-up.
  6. Identity composes with the credential seams for free. The dispatch ctx carries the run's quadruple, so an MCP hook target on a connection with Phase 148's per-identity OAuth binding resolves the per-identity bearer automatically — 148's non-planner consumer. Phase 149's config-declared HTTP tools are a soft dependency (additional target kind, not a blocker).

Non-goals (settled here so they are not re-litigated). Pre-run injection (the runs.set_overrides next-message path covers it; a prepend field there is a future ergonomic); durable steering-turn capture into a store (the hook reads live state; durable capture is a named follow-up with its own retention/erasure questions); a generic webhook/eventing-to-external subsystem (the catalog is the one egress); hook points other than run-completion (additive future decisions against this entry); hook-level retry machinery; transcript delivery of raw observations.

Cross-references. D-025 (per-run state on the run goroutine; executor reuse post-terminal), D-274 (the invocation-count semantics the hook must not disturb), D-234/D-236/D-238 (the agent-config section + next-run projection pattern the hooks section extends), D-223 (wire-type lockstep the new section triggers), D-152 (the ToolExecutor seam the hook dispatches through), D-071 (the RunLoop the hook terminates with), D-059 (agent_id as registration metadata), D-026 (why the payload needs no LLM-edge treatment), D-047 (the FinishReason vocabulary the outcome field projects). RFC §6.17 (new), §6.3, §6.4, §6.13, §6.16. Briefs 02, 05, 06. CLAUDE.md §5 (ctx bridge documented), §6 (identity mandatory), §7 (rules 4, 6, 7), §13 (no silent degradation; no parallel implementations; primitive-with-consumer), §17.6 (devstack twin). Plan: docs/plans/phase-150-run-completion-hook.md.


D-281 — Runtime loading-mode control rides the ONE tool-exposure section: ToolExposure gains per-server + per-tool loading-mode overrides with a single pinned precedence order; applied next-turn via a LoadingOverrideView at the existing run-start projection seam

Date: 2026-07-02 Status: Settled; shipped in Phase 151 (v1.10 band) Where it lives: docs/plans/phase-151-tool-loading-exposure.md, RFC §6.4 + §6.16, internal/agentcfg/agentcfg.go (the ToolExposure loading maps + DiffToolExposure loading arms), internal/runtime/agentcfg/protocol/service.go + mcppolicy.go (edge validation on agent_config.set_tool_exposure and the full-payload set_revision), internal/runtime/agentcfg/projection/projection.go (ActivePlannerCatalogView — the one shared run-start seam — plus EffectiveLoadingMode and the LoadingResolverAdapter the tools.describe read surface consumes), internal/tools/planner_view.go (LoadingOverrideView, sibling of ExclusionView), internal/tools/tools.go (Tool.Form), internal/tools/drivers/mcp/mcp.go (the LoadingAlways tool default reframed as overridable; Form stamps on resources/prompts), internal/tools/protocol/catalog_projector.go (effective loading_mode on tools.describe via optional agent_id + the injected LoadingResolver seam), internal/protocol/types/agentconfig.go + tools.go (the additive wire fields), web/console/src/lib/protocol/agentconfig.ts + tools.ts + client.ts (the D-223 TS mirror), docs/glossary.md ("loading-mode override", "LoadingOverrideView", "ToolForm").

Implementation note (shipped). The design below shipped as planned with no material deviations. Confirmed at implementation time: (1) the projection composes LoadingOverrideView INSIDE ExclusionView (ExclusionView wraps the base-or-loading-override view, so disable stays strictly stronger than defer, pinned by TestActivePlannerCatalogView_LoadingOverride_ExclusionStaysStrongerThanDefer); (2) resolveEffectiveLoading in projection.go is the ONE shared precedence implementation both ActivePlannerCatalogView (run-start) and EffectiveLoadingMode (the tools.describe read surface, wrapped by LoadingResolverAdapter for the tools/protocol.LoadingResolver seam — satisfied structurally, no cross-package import) call, closing the "one canonical implementation" risk noted below; (3) set_revision's full-payload path validates the loading maps too (parity with set_llm_params), not just set_tool_exposure; (4) the integration test drives the REAL cmd/harbor-mcptest-stdio fixture through the production devstack.NewMCPConnectionAttacher per §17.8 (test/integration/agentcfg_loading_exposure_test.go), and a real FTS5 searchcache.SearchCache proves tool_search stays unaffected by the override — both real drivers, no mocks at the seam.

The question. The deferred-loading engine is fully shipped (D-167: LoadingMode on Tool, CatalogFilter.LoadingModes, tool_search + the two-turn discovery cycle) and the boot config already drives it by name (tools.entries[].loading_mode, validated pre-boot, materialized into the catalog by the Builder). But the RUNTIME has no loading control at all: the MCP driver pins every injected tool LoadingAlways (mcp.go:539 — while its resources/prompts register LoadingDeferred), agentcfg.ToolExposure carries only PausedServers + DisabledTools, and no Protocol verb mutates loading. Which surface controls deferred-vs-always for injected tools at runtime — and how do two operator knobs (boot yaml, runtime desired state) avoid becoming the §13 parallel-implementation smell?

Decision. Extend the ONE existing exposure mechanism — agentcfg.ToolExposure on the D-234 revisioned desired-state registry, carried by the existing agent_config.set_tool_exposure verb — with two loading-override fields mirroring the shipped per-server/per-tool split: ServerLoadingModes map[string]string (keyed by ToolSourceID) and ToolLoadingModes map[string]string (keyed by full catalog name). Six binding properties:

  1. One precedence order, stated once and everywhere: tool_loading_modes[name] > server_loading_modes[source] (tool-form descriptors only) > boot tools.entries[].loading_mode > driver/registration default. The bottom two layers are already materialized into the catalog's Tool.Loading at boot, so the projection applies exactly two runtime layers over the boot-effective mode. Undefined precedence between the yaml knob and the runtime knob is precisely the two-parallel-knobs smell §13 forbids; the order is pinned by a table test, the ToolExposure godoc, and this entry. Flip-back is desired-state REMOVAL (the section-replace semantics 92d ships), never a counter-override.
  2. Next-turn projection at the existing seam, discovery semantics intact. projection.ActivePlannerCatalogView (both run-loop drivers call it — D-094) resolves the effective map from the ADMIN active revision and wraps the base view in a new tools.LoadingOverrideView: List() applies the prompt-time predicate against the EFFECTIVE mode; Resolve() never filters on loading (matching PlannerView.Resolve — what keeps a deferred tool callable through D-167's two-turn tool_search cycle); both rewrite Tool.Loading. ExclusionView composes outside, unchanged — the semantic split is load-bearing: disable removes capability (hidden from List AND Resolve); defer removes prompt presence only (hidden from List, resolvable, tool_search-surfaceable). In-flight runs keep their snapshot (D-025/D-234); the compiled catalog is never mutated.
  3. Loading is not capability-narrowing, so it lives in the admin tier only. A deferred tool remains in the run's reachable set (tools.VisibleNames spans both modes — the skills capability envelope is untouched), and the D-234 app→host current-state gate ignores loading (pause/disable stay its only inputs). The D-256/D-258 user/session tiers stay narrow-only grow-only disable SETS — a precedence-bearing map has no commutative cross-tier merge, so no user/session loading fields.
  4. Per-server scope = tool-form descriptors only, via the additive Tool.Form classification. A server-level always must not blanket-surface a server's wrapped resources/prompts (the MCP driver registers those LoadingDeferred deliberately — flipping dozens into every prompt recreates the prompt-budget problem deferred loading solves). The MCP driver stamps Form (tool zero / resource / prompt) at build time; per-TOOL overrides stay exact and unconditional. Rejected mechanism: projection-side sniffing of the driver's resource./prompt. name conventions — cross-package convention coupling the §4.4 seam exists to prevent.
  5. Fail-loud edge validation; audit rides the revision. A value outside always|deferred fails the request CodeInvalidRequest (400) BEFORE any registry write — no revision, no event. Normalization keeps content hashes stable (sorted keys, empty entries dropped); DiffToolExposure gains structured loading arms so agent_config.diff (and the Console diff rendering) shows loading changes. No new event type — agent.config.revised + the diff are the audit surface.
  6. The read surface reports the projection's truth. tools.describe reads the raw catalog today (boot-effective); it gains an optional agent_id and an injected resolver seam (the projector's existing annotator precedent) reporting the EFFECTIVE mode; absent agent_id, behavior is byte-compatible. Wire changes throughout are additive (no ProtocolVersion bump) with full D-223 lockstep + D-209 docs regen in the same PR.

Rejected alternatives. (a) A new agent_config.set_loading_mode verb — a second mutation path over the same envelope section, splitting the section-replace semantics and the diff surface (§13). (b) A descriptor-level runtime field (mutating Tool.Loading in the catalog on command) — violates D-025 compiled-artifact immutability and would hit in-flight runs, breaking the settled next-turn model (D-234 item 3). (c) Per-run run-options (non-revisioned) overrides — no diff/rollback/audit, a hidden-state branch beside the revisioned path; a run-scoped knob is a different primitive requiring its own decision. (d) Transport-level control (per-driver config on the MCP connection) — loading is a catalog/projection concern, not a wire concern; the projection seam covers HTTP/A2A sources for free, which per-driver knobs would fragment.

Amendment note (disable is a projection-layer control, not an executor-edge gate). The "disable removes capability (hidden from List AND Resolve)" property (item 2) is enforced at the projection layerExclusionView filters List() (prompt) and Resolve() (the planner-facing dispatch path). It is NOT re-checked at the executor edge: ToolExecutor.ExecuteDecision resolves a CallTool name against the FULL catalog, so a planner-originated dispatch of a remembered disabled name (a name the model carries from a prior turn's prompt, no longer in the projected view) is not blocked at that edge, and the run-completion hook (D-280) deliberately resolves its target against the full catalog — a disabled tool named as a hook target still dispatches (the documented full-catalog hook semantics; pinned by a unit test so the behaviour cannot silently flip). Disable narrows the projected view the planner is offered each turn; it is not a capability revocation at the executor. A run-loop pre-dispatch membership check of planner-originated CallTool names against the run's projected view (closing the remembered-name gap at the executor edge) is a NAMED FOLLOW-UP, not part of this decision.

Cross-references. D-234/D-235 (the desired-state registry + next-turn projection + admin scope gate this extends), D-167 (the deferred-loading engine + meta-tools whose semantics are deliberately untouched), D-256/D-258 (the narrow-only user/session tiers deliberately NOT given loading fields), D-025 (per-run snapshot; immutable compiled artifacts), D-094 (the shared projection helper both run-loop drivers call), D-223 (TS lockstep gate on the wire changes), D-209 (generated Protocol docs regen), D-059 (agent_id is registration identity, never an isolation filter). RFC §6.4, §6.16. Briefs 15, 03, 13. CLAUDE.md §4.4, §6, §8, §13, §17.8, §18. Plan: docs/plans/phase-151-tool-loading-exposure.md.


D-282 — The §13 godoc-hygiene gate extends to the public SDK facade sdk/; harbortest/ keeps a documented carve-out for its load-bearing D-094-mirror annotations

Date: 2026-07-02 Status: Settled Where it lives: scripts/drift-audit.sh (the godoc_jargon_patterns scan, its directory set widened to include sdk/), sdk/ (the ~50 rewritten process-language godoc instances), CLAUDE.md §13 (the godoc-hygiene prohibition this enforces one layer wider), docs/glossary.md (no new term).

The question. CLAUDE.md §13 forbids internal process jargon — Phase NN / phase-NN, inline D-NNN, brief NN, wave-band references — in godoc-visible (non-test) Go source, because pkg.go.dev renders those comments as Harbor's public API docs and phases/decisions/briefs are contributor concepts. The drift-audit enforces it mechanically, but only over internal/ and cmd/. The sdk/ facade (D-204/D-205 — the curated alias-based re-export tree) is ALSO a published pkg.go.dev surface — in fact the MOST adopter-visible one — yet it was outside the gate, and its package docs and a handful of inline comments carried exactly the jargon the gate exists to keep off the public surface (; D-204, Re-exported in Phase 112b, Attachment disposition policy (Phase 84b — D-189), AnswerEnvelope.ToolCallsSeen (D-274), …). Does the gate extend to sdk/, and does the sibling test kit harbortest/ come with it?

Decision. Extend the gate's directory set to include sdk/ non-test Go, and rewrite every offending sdk/ godoc instance to name the FEATURE / RFC section / behavior instead of the internal number (RFC §N.M references stay — they carry design authority and are not process jargon; the _test.go exemption is unchanged). harbortest/ gets an EXPLICIT carve-out and stays outside the gate. Three binding properties:

  1. sdk/ is held to the same bar as internal/ because it is the same kind of surface — public godoc — only more adopter-facing. The rewrite is comment-only: trailing (D-NNN) / (Phase NN, D-NNN) citations are deleted (with punctuation repaired); jargon woven into a sentence is rephrased to name the feature. No code, identifier, or import changes — the alias-only D-204 invariant is untouched.
  2. harbortest/ is a documented carve-out, not an oversight. The public test kit carries ~150 D-094-mirror annotations that are LOAD-BEARING maintenance markers: they pin each devstack/twin surface to the internal decision it must stay in lockstep with (§17.6). Rewriting them to "name the feature" would destroy the one signal that tells a maintainer which internal contract a twin mirrors — a net hygiene LOSS. The carve-out is recorded here so a future audit does not read the un-gated harbortest/ as drift.
  3. The gate is mechanical and injection-verified. The godoc_jargon_patterns loop now greps sdk/ alongside internal/ and cmd/ (a planted D-999 / Phase 99 comment under sdk/ FAILs the audit; verified by injection, reverted). harbortest/ is deliberately absent from the directory set.

Cross-references. CLAUDE.md §13 (the godoc-hygiene prohibition, now enforced over sdk/), D-204/D-205 (the alias-based facade whose public godoc this covers; the no-behavior invariant preserved), D-094 (the twin-mirror annotations the harbortest/ carve-out protects), §17.6 (the fix-what-the-audit-finds discipline that surfaced this). Enforced by: scripts/drift-audit.sh (godoc hygiene scan).


D-283 — Every section-scoped agent-config setter carries ALL sibling sections forward; a rebuild-completeness guard makes the invariant mechanical

Date: 2026-07-04

Status: Shipped (Phase 152, V1.11)

Where it lives: docs/plans/phase-152-agentcfg-hooks-carry-forward.md, internal/runtime/agentcfg/protocol/ (the five section-scoped setters + rebuild_completeness_test.go).

Context. The agent-config revision model replaces ONE section per edit and promises the rest of the envelope survives ("the bidirectional section-merge invariant"). But each setter rebuilds ConfigPayload by hand, enumerating the sibling sections that existed when it was written. When the Hooks section landed (D-280), none of the five existing setters gained the carry-forward — so any tool-exposure / connection / skills / prompt-layer / LLM-params edit silently erased a pinned run-completion hook. Deterministic, same-surface, no error: the §13 silent-degradation shape. The second consumer (a coordinator control plane pinning an auto-save hook, then driving exposure edits) trips it on its golden path.

Decision. (1) All five setters carry Hooks forward, symmetrically with their existing sibling carry-forwards. (2) A rebuild-completeness guard test makes the invariant mechanical: a seed constructor populates every ConfigPayload section and reflection-asserts it covers every struct field (a newly added section fails the SEED first, with a message naming the field); each setter is then invoked against the fully-populated active revision and every non-target section must survive byte-identically. Adding a seventh section without extending every setter fails go test — the omission class is closed, not just this instance. (3) There is deliberately still NO section-scoped hooks setter — hooks ride set_revision; this decision changes carry-forward only.

Cross-references. D-280 (the Hooks section), D-281/D-234/D-237 (the exposure section + next-turn revision model this preserves), CLAUDE.md §13 (silent degradation). Phase 156 extends the guard to the removal setter it adds.


D-284 — Fleet enumeration of tasks + agents rides the EXISTING admin scope claim as widened, audited projections; no new scope vocabulary, no session-optional reads

Date: 2026-07-04

Status: Shipped (Phase 153, V1.11)

Where it lives: docs/plans/phase-153-fleet-scoped-tasks-agents.md, internal/tasks/protocol/, internal/runtime/registry/protocol/.

The question. A fleet observer (coordinator control plane) must enumerate tasks and registered agents across sessions on a runtime — today tasks.list / agents.list project only the caller's own (tenant, user, session) triple, so a synthetic observer session sees nothing; unlike sessions.list, which widens to (tenant, user) and, under the verified admin claim, to named tenants. What is the sanctioned widening shape?

Decision. Mirror the sessions precedent exactly, per CLAUDE.md §6 rule 5 (cross-session observers — "admin, Console fleet view" — require the elevated claim): (1) auth.ScopeAdmin is THE claim — no new "fleet scope" vocabulary. (2) Widening is explicit on the wire and gated LOUD: a widened request without the verified claim fails with the existing scope-mismatch error, never silently narrows. (3) The registry seams gain an EXPLICIT tenant-scoped enumeration method — never an optional/blank session on the identity-scoped read (no identity-downgrading knob, §13; the session-scoped path keeps mandatory full-triple identity). (4) Every widened call emits audit.admin_scope_used. (5) Cross-RUNTIME federation stays coordinator-side over per-runtime reads, the same division as sessions/events. This lands the "future cross-runtime aggregating projector" the tasks projector godoc reserved, behind the unchanged Projector interface.

Cross-references. D-059 (agent_id is never an isolation principal — the widened agents read still scopes rows by the tuple), D-262/Phase 130 (the sessions admin/audit precedent shape), D-228 (durable task driver — conformance parity for the new read), D-223/D-209 (wire lockstep + docs regen), CLAUDE.md §6 rules 2/5/10, §13.


D-285 — OAuth provider client credentials resolve through a credential-source seam: env (boot-time, default) or remote (coordinator-served PULL at first need); push over the Protocol stays rejected

Date: 2026-07-04

Status: Shipped (Phase 154, V1.11)

Where it lives: docs/plans/phase-154-broker-credential-source.md, internal/tools/auth/credsource/ (seam + drivers), internal/tools/auth/build_providers.go, RFC §6.4 (the D-271 paragraph's additive credential-source sentence).

The question. A coordinator that mints a runtime's broker credential (for the D-271 tokenexchange provider) AFTER the runtime booted has no way to deliver it: BuildProviders resolves client_id_env/client_secret_env once at boot and a running process's environment is fixed at exec. The one-reboot provisioning step survives every front-load. How does a post-boot credential reach the runtime?

Decision. A §4.4 credential-source seam on the provider entry: credential_source: env (today's boot-time resolution, the default — existing configs byte-compatible) or remote (the runtime PULLS client_id/client_secret from a coordinator-served endpoint, authenticated by the runtime's own service token from env, at first need — memory-only TTL cache, single-flight, strict parse, format_version-ed response). Fail-loud everywhere: boot validates the declared source's shape; a remote fetch failure fails the tool call with a typed sentinel + a SafePayload audit event — NEVER a fallback to env, to an unauthenticated call, or to the interactive flow (one mode per source, §13). Declaring both env and remote fields on one entry is a validation error — no dual path.

Rejected: hot-reload / an admin verb that installs a provider. Two independent reasons: a verb carrying the secret is a secret riding the Protocol — the credential-passthrough shape D-271 already rejected (recorded so it is not re-litigated); and a secretless "re-read the env" reload is incoherent (the env cannot change post-exec). The provider LIST stays boot-declared; only credential resolution is late.

Why this is the D-271 posture, one level up. The broker client credential is itself an externally-owned credential: pulled per-need from the authority that owns it, cached in memory only, never persisted (a sealed per-runtime copy would recreate the shadow-store revocation hole D-061/D-271 name). Defense-in-depth win recorded: with remote, the broker secret never enters the runtime's environment at all.

Shipped notes (§4.3 deviations). (1) The two canonical fetch events (tool.provider_credential_fetched / tool.provider_credential_fetch_failed) and the ErrCredentialSourceUnavailable sentinel live in the internal/tools/auth/credsource package, NOT internal/tools/auth/events.go as the plan's file list sketched: internal/tools/auth (BuildProviders) imports credsource to build the source, so homing the sentinel/events in auth and emitting them from the remote driver (which lives under credsource) would cycle the import graph. Keeping them in credsource keeps the seam a leaf. (2) remote is validated as supported ONLY for the tokenexchange driver. The interactive oauth2 flow bakes the client credential into the underlying auth.Provider at construction (this decision's own non-goal: the interactive flow is unchanged), so a late/lazy remote pull cannot attach to an identity-bearing ctx for the mandatory SafePayload fetch event, and a boot-time eager remote fetch has no run identity to attribute it to. oauth2 still consumes the seam (via the env source, resolved eagerly at construction; direct constructors use credsource.Static) — criterion 6's "consume through the source seam, zero behavior change on env" holds. The zero-touch benefit accrues to the non-interactive tokenexchange driver, which resolves lazily under a verified identity. (3) The two new canonical event-type NAMES touch the D-223 wire manifest's event catalog (regenerated via make protocol-ts-gen + make protocol-ts-types-gen) and the D-209 generated Protocol docs (make protocol-docs-gen); no Protocol method or request/response wire type changed.

Cross-references. D-271 (the pull model + push rejection this mirrors), D-278 (per-identity southbound binding — unchanged), D-090/D-095 (WrapWithOAuth + the flow-strategy registry — untouched; the provider instance is still boot-constructed, so no catalog re-wrap), D-196 (blank-import home for the two source drivers), CLAUDE.md §4.4, §7 rules 2/3, §10, §13, §17.8 (fixture-server gate).


D-286 — The session-erasure audit record is part of the erasure's success criteria: durable record-of-fact ordering + cumulative deletion counts

Date: 2026-07-04

Status: Shipped (Phase 155, V1.11)

Where it lives: docs/plans/phase-155-erasure-audit-integrity.md, internal/sessions/erasure.go.

Context. Issues #409/#410 (v1.7 band-end review, deferred from PR #406). D-262 designates session.erased as the record-of-fact for a right-to-erasure operation, but the emit is best-effort AFTER the irreversible clear: one bus/redactor failure loses the only audit record while the call reports success, and a re-invoke returns not_found — no second chance. Separately, a retried mid-cascade erasure re-runs idempotent deletes that find fewer records, so reported counts reflect only the final converging attempt.

Decision. (1) The ordering invariant becomes binding: at no point may (data irrevocably gone) ∧ (no durable audit record) ∧ (success returned) hold — nor the inverse (record written, data present, success returned). A record/emit failure fails the sessions.delete call loud with a typed sentinel, with the session still re-invokable; a re-invoke converges. (2) Deletion counts accumulate across converging attempts — the response and event report true totals. The #410 alternative (document per-attempt semantics) is REJECTED: the counts feed a compliance surface; accurate beats documented-as-inaccurate. (3) No wire-shape change; field docs state the cumulative semantics.

Implementation note (shipped). The chosen mechanism is "persist a durable compliance checkpoint before the irreversible clear," not bounded in-call retry. A new erasureLedgerRecord (artifacts/memory/state counts) is persisted via the StateStore's ordinary Save/Load/Delete surface — NOT the erasure scope delete — under the actor's (tenant, user, <erasure-audit>) observability scope (the SAME reserved slot session.erased already publishes under), keyed per erased session id (session.erasure.pending.<sessionID>) so distinct concurrent erasures under one actor never collide and the checkpoint survives StateStore.DeleteScope's clear of the erased triple. It is tenant/user-scoped OPERATOR AUDIT DATA about a deleted session, never user content — the phase plan's identity-scoping risk note. CascadeEraser.Erase checkpoints the ledger immediately after every destructive step (artifacts, memory, state), each save adding this attempt's local count onto whatever a prior interrupted attempt already contributed (loaded at the top of Erase) — this is what makes counts cumulative (#410). Because the ledger is checkpointed BEFORE StateStore.DeleteScope ever runs, the compliance record is always durably complete strictly before the irreversible clear. The final record-of-fact leg (completeErasure/emitErased) reads ONLY from the ledger (never recomputing) and is now part of Erase's success gate: a redactor refusal or a bus.Publish failure both return the new sentinel sessions.ErrErasureRecordFailed (mapped through sessions/protocol.ErrErasureRecordFailed to HTTP 500) instead of logging-and-continuing — the ledger survives the failure, so a re-invoke finds the session already gone (ErrSessionNotFound) AND a pending ledger, recognizes a CONVERGING retry, skips every destructive step, and re-attempts only the record. Concurrent Erase calls for the SAME session are serialized by a striped in-process mutex array (eraseLocks, 256 shards, the same FNV-1a striped-lock pattern as agentcfg/protocol.Service.writeLocks) so exactly one goroutine ever runs the cascade for a given session — the loser observes the genuine not-found path once the winner (through ledger cleanup) is done, never a double event. Documented residual gap: because there is no ACID transaction spanning a destructive store and the StateStore the ledger itself rides on (a generalized transactional-outbox subsystem is an explicit non-goal), a failure in the narrow window between a destructive step succeeding and its OWN checkpoint save committing under-counts that step on a converging retry — strictly narrower than the pre-Phase-155 gap (which spanned the whole cascade) and pinned by its own test (TestCascadeEraser_LedgerSaveFailure_LoudAndRetrySafe) rather than left silently untested; it never threatens the fail-loud / no-lost-record invariant, only count exactness on that one interleaving. Tests: internal/sessions/erasure_audit_test.go — the #410 cumulative-count regression, final-emit bus-publish failure + redactor-refusal fault injection (each: fails loud, re-invoke converges, exactly one durable record), the same-session concurrent race (one wins, one not-found, never a double event), and the ledger's own persistence-seam fault injection (load/save/delete/DeleteScope failures + a corrupt-ledger integrity check). Adversarial-review hardening (same PR): (a) the ledger carries the session lifecycle's OpenedAt stamp as its discriminator — when the target session still EXISTS (a fresh attempt, never a converging retry) and a leftover ledger's stamp mismatches the live session's, the leftover belongs to an ABANDONED prior lifecycle of a reused session id and is DISCARDED with a loud Warn (its never-emitted record-of-fact is forfeited; inflating the new lifecycle's compliance counts to preserve a stale checkpoint would be the worse corruption) — closing the count-inflation repro (5 stale + 2 real must report 2, never 7; regression TestCascadeEraser_StaleLedger_SessionIDReuse_CountsOnlyNewLifecycle); (b) the final emit is IDEMPOTENT per (session, lifecycle): completeErasure consults the observability scope's retained history (capability-gated on events.HistoryReplayer, bounded scan, lifecycle matched via the record's Extra["erased_session_opened_at"] metadata) and skips the publish when the record already exists — closing the benign-retry double-emit after a publish-succeeded-cleanup-failed attempt (TestCascadeEraser_ReinvokeAfterCleanupFailure_ExactlyOneEmit); when the guard cannot verify, it emits — a duplicate compliance record is the acceptable failure direction, a lost one never is; (c) HONESTY LIMIT recorded: the striped erase lock is PER-PROCESS — two runtime replicas racing the same session are not serialized (ledger saves are last-writer-wins; both replicas' destructive steps are idempotent so the data outcome converges; residuals are count skew and a possible cross-replica duplicate emit where the history check races the other replica's publish — never a lost record, never resurrected data); cross-replica serialization would need a StateStore CAS/lease primitive, the same recorded limitation as the agent-config registry's per-process owner locks.

Cross-references. D-262 (the erasure method + record-of-fact designation), D-274 (the loud-fence precedent on this same cascade), CLAUDE.md §13 (fail loudly), §17.6 (fix-what-the-review-finds). Closes #409, #410.


D-287 — Connection removal is a first-class revision verb + the detach leg of run-start reconciliation; supersedes D-240 decision 5's deferral via its recorded revisit clause

Date: 2026-07-04

Status: Shipped (Phase 156, V1.11)

Where it lives: docs/plans/phase-156-remove-mcp-connection.md, internal/runtime/agentcfg/protocol/removeconnection.go, internal/runtime/agentcfg/projection/ (the detach leg).

Context. D-240 decision 5 deferred detach-on-rollback with an explicit revisit condition: "revisited if a removal need emerges that pause cannot serve." It emerged: a coordinator's delete flow must actually remove a runtime-added MCP connection — pause cannot express removal (the descriptor persists forever; resume resurrects the server). This supersession travels D-240's own sanctioned path, not re-litigation.

Decision. (1) agent_config.remove_mcp_connection: a new revision whose connections section drops the named descriptor AND prunes that server's tool-exposure residue in the same atomic revision (sibling-safe: an entry also claimed by a REMAINING server's <name>_ prefix is never pruned — an over-prune would silently re-enable a sibling's admin-disabled tool); all sibling sections (incl. Hooks) carried forward under the D-283 completeness guard, which this setter joins. Unknown name and boot-declared (yaml) name each fail loud with distinct typed errors — the verb governs revisioned state only. (2) Run-start reconciliation gains the detach leg: declared-vs-attached diff deregisters undeclared servers from the catalog + MCP registry and closes the transport at a run-start reconcile — never in the middle of the run that triggered it. Honest in-flight semantics (amended by the PR-464 adversarial review): EXPOSURE correctness is next-turn and independent of teardown (a removed server never appears in any catalog view projected after the removal revision — D-025/D-234); TEARDOWN is process-global — the catalog + MCP registry are shared across sessions, so a different session's in-flight run whose next step calls the detached server fails LOUDLY (typed catalog not-found at dispatch / closed-transport error), never a hang, a panic, or a silent success (test-pinned). A refcount/drain protocol was considered and rejected: the loud mid-run failure is the same class as an operator stopping a boot-declared server, and drain complexity buys nothing the removal semantics need. Reconcile holds no cross-run lock; safety comes from the atomic registry read + idempotent detach + internally-synchronised primitives (N-concurrent reconciles converge, test-pinned). Rollback past an add detaches through the SAME reconcile path (one mechanism, §13). (3) Agent-bound sealed tokens are NOT deleted on remove: re-add reuses completed consent; credential revocation is provider-side. Documented; a revoke surface is a named follow-up if the need emerges. (4) New canonical mcp.connection.removed event (SafePayload).

As-built note (§4.3 deviation). The 92k–92q MCP-OAuth band this plan named as dependencies (docs/plans/wave-mcp-oauth-decomposition.md) is still parked — planning-only, unshipped. The add_mcp_connection verb (92f) shipped and wires a ConnectionAttacher, but the run-start reconciliation (projection.ReconcileConnections, 92o) did NOT exist. Phase 156 therefore BUILDS the run-start reconcile mechanism as the home for its detach leg, DETACH-ONLY (attaching a declared-but-absent server — the restart-survival 92o attach leg — stays deferred; the live add verb is the attach path). New primitives added to carry the detach: mcpdrv.Registry.Deregister + SourceIDs, serverProvider.Close (interface-widened), tools.CatalogSourceDeregisterer (optional companion mirroring CatalogReplacer), a driver-agnostic projection.ConnectionDetacher seam + the two concrete detachers (cmd/harbor + devstack, D-094 twins), and the run-loop wiring in both drivers. The "re-add reuses the persisted agent-bound token (no second consent)" leg is exercised as a re-add-works E2E; the full OAuth-consent fixture flow is deferred with the parked 92m/n band (D-287 call 3's token-retention is structurally guaranteed — the remove verb never touches the token store). Two accepted windows until the attach leg lands (both code-commented): a reconcile racing a concurrent re-add of the same name can detach the freshly re-added server (stale declared-set read; heals at the next add or restart), and AttachedSources() is a process-global enumeration (fine for the single-agent dev wiring; the multi-agent attach leg must scope the attached set per agent).

Cross-references. D-240 (the superseded deferral — decision 5 only; decisions 1–4/6 unchanged), D-241..D-247 (the 92k–92q band this completes — still parked; see the as-built note), D-237 (warm-transport / next-turn model preserved — teardown is a projection-boundary act), D-283 (the completeness guard), D-223/D-209 (lockstep + docs regen), CLAUDE.md §6 rule 10, §13, §17.8.


D-288 — Session titles live on the session record; sessions.set_title writes manual-only; the title never rides an event payload

Context. Sessions display as raw ids everywhere (Sessions page, Playground switcher) because the canonical session record carries no human-readable name and no verb exists to set one. A coordinator consumer wants content-derived conversation names; under the D-061 shadow-store rule the attribute belongs where the entity lives — on the runtime's session record — not in any consumer's datastore. Three design tensions needed settling: (1) where auto vs manual provenance is enforced (a wire verb accepting a source field would let any caller forge auto and have a later auto-namer overwrite a human's title); (2) what scope a title write gets (erasure is own-session-only per D-262, but the motivating consumer renames sibling sessions from the Sessions list, which already reads at (tenant, user)); (3) whether the title can ride the change event (every canonical event either uses SafePayload — whose contract uniformly forbids raw user input, since the bus skips the redactor — or passes through a redactor that only matches secret-shaped keys, so user-derived free text would leak either way).

Decision. (1) Title string + TitleSource (unset | auto | manual) land on the session record (additive JSON round-trip through the existing session.lifecycle StateStore kind — zero migration; erased with the record by the existing DeleteScope cascade). The sessions.set_title wire verb ALWAYS writes manual (empty title clears both to unset); auto is not expressible over the wire — Phase 158's internal registry path is its only producer, making manual-wins structurally unforgeable. (2) The write scope is the owning (tenant, user) — the same scope sessions.list reads at — for this metadata-only mutation: body identity must equal the verified identity (the sessions.delete discipline), the target session_id is a request field that may name a sibling session of the same owner, cross-user/cross-tenant refuse loudly, and there is no elevation knob and no admin widening at v1.12. Manual titles are bounded (MaxSessionTitleLen 200 runes, single-line): over-bound input fails loud with a typed error and a 400 — never a silent clamp (§13). (3) session.title_changed is a SafePayload carrying identity scope + session id + source ONLY; consumers refetch the projection for the string. The title equally stays OFF the session.erased record-of-fact (D-286's content-free posture) and all logs/audit payloads. Consumers ship in the same wave per §13: Sessions-page display + inline rename and Playground switcher display + active-session rename.

Cross-references. D-061 (no shadow store), D-262/D-286 (erasure scope + content-free record-of-fact), D-223/D-209 (lockstep + docs regen), D-289 (the auto producer), CLAUDE.md §6, §13; RFC §6.9, §5.2, §6.13.


D-289 — Session auto-naming is opt-in runtime mechanism at the run loop's terminal boundary: one governed Complete call, a naming agent-config section riding set_revision, caps with no unlimited value

Context. With D-288's field + verb in place, content-derived titles still need a producer. Candidate shapes each violated something settled: riding the run-completion hook would overload D-280's single tool-egress semantics (RFC §6.17: "no generic webhook subsystem — one egress path" — and naming is not a tool dispatch); spawning an internal task/run per title pollutes tasks.list and drags planner machinery into a one-shot summarization; reading the transcript from the memory subsystem is silently inert under the default strategy: none (the §13 silent-degradation shape); and an uncapped repeat_every re-names forever on long sessions — cost without benefit and weird UX. §6.17's non-goals explicitly reserve "further hook points [as] additive future decisions against D-280," which is the door this walks through.

Decision. (1) Opt-in, default off, binding: with no naming config anywhere, behavior is byte-identical to v1.11 — zero counters, zero LLM calls, zero events. (2) The trigger is a SIBLING of the completion hook at the run loop's terminal boundary (fires after (fin, err) settle; never alters them; recover()-contained; detached-but-bounded ctx preserving identity values) — runtime mechanism, planner-invisible (brief 02's steering lesson). (3) The titling act is ONE Complete call on the run's already-wrapped LLM client — governance outermost keys the session identity from ctx (a ceiling/rate block SKIPS naming with a classified event; the run is untouched; the spend is deliberately the tenant's — naming is work done on their behalf; cheap-model mitigation via model); input is a deterministically bounded digest of the completion-boundary transcript (≤ 4 KiB — D-026's ErrContextLeak unreachable by construction); NOT a task, NOT a planner run, NOT a second LLM client. (4) Policy home: agentcfg.NamingSection{auto, after_turns, repeat_every, max_repetitions, model, max_title_len} riding the existing set_revision (the D-280/D-283 hooks precedent — no new verb, additive wire types only, joins the D-283 completeness guard), with yaml runtime.naming as the fleet default; precedence agentcfg › yaml › off, resolved once at run start (D-234 next-turn projection). (5) Caps: max_repetitions (total auto-namings including the first, default 5) is REQUIRED ≥ 1 whenever repeat_every > 0 — no unlimited value exists, so unbounded periodic re-naming is unrepresentable rather than discouraged. (6) Manual-wins: the internal SetTitleAuto path refuses manual titles with a typed error; auto output is deterministically clamped to max_title_len (trusted-internal post-processing — the asymmetry with D-288's reject-on-oversize manual boundary is intentional). (7) Counters (TurnCount, AutoNameCount, LastAutoNamedTurn) live on the session record, written only when a policy is active (counts start at enablement; no write amplification for the naming-off fleet). (8) Failure is loud but contained: session.naming_failed (SafePayload, stable error class, never content) + Warn; a concurrent-completion race producing one redundant naming call is accepted and documented (registry writes serialize; same posture as D-287's accepted window).

As-built note (§4.3 deviations, shipped in Phase 158). Three mechanical deviations from the plan's file/API sketch, none touching RFC territory: (1) the eligibility check needs the session's title provenance + counters, which neither RecordCompletedTurn (returns only TurnCount) nor SetTitleAuto exposes, so a third read helper — SessionRegistry-concrete AutoNamingState(ctx, id, ident) (sessions.AutoNamingState, error) returning {TitleSource, CurrentTitle, TurnCount, AutoNameCount, LastAutoNamedTurn} — was added on *sessions.Registry (the plan's Public API listed only the two writers). The steering trigger consumes it through a narrow steering.SessionTitler interface (the three methods) so the run loop imports internal/sessions for the interface + the ErrManualTitle sentinel, and internal/llm for the NamingCompleter seam (the run's already-wrapped llm.LLMClient satisfies it) — no import cycle (neither sessions nor llm imports steering). (2) Effective-model resolution splits: projection.ActiveNamingPolicy returns the policy's model string, and each run-loop driver (cmd/harbor + devstack twin, D-094) computes the fallback — policy model, else the run's LLMOverrides.Model, else "" (the client's configured default) — mirroring ActiveRunCompletionHook's one-place-resolves-precedence deviation. (3) Normalization PRESERVES any non-nil naming section verbatim (trims model) — section PRESENCE is the operator's signal. The round-1 shipped behavior dropped an "inert" section (auto false + all-zero) at normalize time, justified as "the bool zero-value cannot be distinguished from unset"; that justification is RETRACTED (presence-vs-absence IS the distinguishable signal), because the inert-drop silently discarded a bare {auto: false} opt-out revision — 200 OK, section normalized away, agent keeps auto-naming and spending over a yaml-on fleet default (the adversarial-review M1 finding, empirically confirmed through the real wire handler). As fixed, the projection's section-present branch treats Auto=false as the explicit per-agent off that wins over yaml; regression-pinned at all three layers (normalization, projection, and an end-to-end set_revision leg). The trigger fires SYNCHRONOUSLY inside Run's deferred region (not a spawned goroutine), so the "goroutine baseline returns after naming completes" guarantee holds trivially — and the ordering at the terminal boundary is HOOK FIRST, NAMING SECOND (the naming defer registers before the hook defer; LIFO runs the hook first) so a slow naming call can never inflate the hook payload's CompletedAt/DurationMS or delay transcript egress (the adversarial-review S1 fix, ordering test-pinned). Operational posture recorded (the adversarial-review S3 note): a naming FAILURE does not consume the max_repetitions cap — a still-due title is retried at every subsequent completed run until one succeeds (deliberate: a transient outage must not permanently un-name a session), so a naming-on fleet with a DOWN naming LLM pays one failing ≤10s synchronous attempt + one session.naming_failed per completed run until recovery or opt-out; worst-case post-run latency is hook timeout + naming timeout serialized (10s + 10s default; the naming timeout is a FIXED runtime constant, not an operator knob). NamingPolicy.WithDefaults also implements the documented max_repetitions default (5) for a repeating policy whose cap is unset — real for programmatically-built policies, while the wire/yaml edges keep requiring an explicit cap ≥ 1. Deviations recorded in the plan file's As-built section too.

Cross-references. D-288 (the field/verb/event), D-280 (the terminal-boundary + detached-ctx pattern; the additive-hook-point clause), D-283 (the guard the section joins), D-234 (next-turn projection), D-043/D-044 (the one wrapped LLM chain), D-026 (the input bound), CLAUDE.md §13 (opt-in default, no silent degradation, no parallel implementations); RFC §6.9, §6.17, §6.16, §6.15.


D-290 — Agent-config hooks section presence is authoritative: a present empty-tool section is the explicit per-agent no-hook that overrides the yaml fleet hook

Context. Phase 157/158's wave-end audit found a presence footgun on the run-completion hooks section identical in shape to the D-289 naming-section M1 finding (which D-289's as-built note RETRACTED and fixed). NormalizePayload dropped any hooks section whose run-completion tool was empty/whitespace (hooks:{} or {run_completion:{tool:""}}), and ActiveRunCompletionHook gated on set && rc.Tool != "". So a set_revision carrying a bare hooks:{} returned 200 OK, the section vanished at normalize time, and run-start resolution fell through to the static yaml runtime.hooks.run_completion fleet hook — a per-agent opt-out of run-transcript egress was silently discarded and the yaml hook kept dispatching (empirically confirmed). This is the same class of bug the naming section closed: section PRESENCE is a semantic signal, and dropping a present-but-empty section as "inert" erases an operator's intent.

Decision. Mirror the D-289 naming-section presence rule onto the hooks section. (1) NormalizePayload PRESERVES any non-nil hooks section: a run-completion hook with a non-empty tool is kept (whitespace-trimmed, negative timeout floored to 0); an empty/whitespace tool canonicalises the RunCompletion to nil so {run_completion:{tool:""}} and a bare {} share ONE canonical form (equal states hash equal — no phantom revision), while both stay distinguishable from an ABSENT (nil) section. (2) ActiveRunCompletionHook treats a PRESENT hooks section as authoritative: a non-empty tool pins the hook; a present section with no/empty run-completion tool is the explicit per-agent NO-HOOK that WINS over the yaml default (returns (nil, false)) — this is the literal reading of D-280's "agent-config section over yaml over no-hook" precedence. Only an ABSENT section falls through to yaml. (3) HooksDiff gains a SectionPresent dimension ("present" vs "") so an absent→present-empty toggle is a visible revision in agent_config.diff (mirroring NamingDiff's tri-state Auto); without it the opt-out revision would render every field empty and be invisible in the Console diff view. The dimension reaches the wire: AgentConfigHooksDiff gains the additive section_present_changed / section_present_from / section_present_to fields (the naming tri-state precedent), mapped in diffToWire, mirrored into the Console's hand-maintained TS interface, and regenerated into the wire manifest + the generated Protocol reference — wire-pinned in both directions by TestDiff_Hooks_ExplicitNoHookVisible (mirroring TestDiff_Naming_BareOptOutVisible). (4) The AgentConfigHooks request shape is unchanged and the diff fields are additive, so the Protocol holds at 0.1.0 (no error-code or version change). The old projection test that pinned "empty agentcfg tool falls through to yaml" is deliberately flipped.

Cross-references. D-280 (the run-completion hook + the "agent-config section over yaml over no-hook" precedence this implements the letter of), D-283 (the rebuild-completeness guard the hooks section already joins), D-289 (the naming-section presence rule + its M1 retraction this mirrors), CLAUDE.md §13 (no silent degradation; section presence is the operator's signal). RFC §6.17.


D-291 — External Protocol serving is a decided contract: one promoted serve constructor + a curated sdk/server facade, production-only posture

Context. harbor serve (stock binary, yaml) serves the Protocol, but a scaffolded agent carrying compiled in-process Go tools cannot — the config→listener composition (bootDevStack, devBootOptions, the devStack serve/close lifecycle in cmd/harbor/cmd_dev.go) is trapped in package main, unreachable to any importer. This was the last boot-fan-out below the network surface still living in a binary: the layer below it (subsystem assembly) was already promoted to assemble.Assemble (D-197), and the SDK facade (D-204/D-205) exposes everything up to a running headless stack — but not a way to mount the wire surface from an external module. The three advertised adopter paths therefore diverged: embed and CLI reach the Protocol, but an external binary with compiled tools had no serving on-ramp, silently reversing RFC §5's "the Protocol is consumable by third-party servers too." Two design tensions needed settling: (1) what stays dev-only vs. what promotes (the promoted band must not drag the mock-LLM escape hatch, the dev-token mint, or the Console embed into a production-serving package); (2) how production vs. dev posture is selected without a with-flag/without-flag serve fork (§13).

Decision. External Protocol serving is a decided contract, superseding the SDK's deliberate omission of a Protocol server (RFC §5.6, new; the omission was recorded in D-205 item 2's curation posture — "wire/Protocol adapters … are OUT" — which gains an "(Amended by D-291)" marker in place per the D-273 precedent). (1) One promoted serve constructor. The serve band is promoted out of cmd/harbor into ONE importable internal package, internal/runtime/serve (naming: internal/server is already the protocol-server package — deliberately distinct; the promoted package is the config→listener composition, not the server itself). harbor serve / harbor dev / harbor console become thin callers — the same collapse D-197 made for assembly and D-094 made for subsystem wiring. (2) Dev-only policy stays in cmd/harbor, composed CALLER-SIDE through explicit injection seams. The mock-LLM escape hatch (validateLLMProvider + the devmock.go blank import, D-089), the hot-reload supervisor (D-099), the dev signer + dev-token mint/print, the bootstrap-token endpoint, draft scaffolding, post-boot fixture seeding, and Console embedding (D-091) NEVER promote. cmd/harbor composes them through seams the promoted options/handle expose: extra pre-CORS routes (the draft + bootstrap mounts, today cmd_dev.go:1429/:1460), the transports auth-surface option (the dev key-rotate surface threaded at :1331), an LLM snapshot override (the mock's config mutation at :440-443), the Console mount (:1496-1502), and a post-boot hook receiving subsystem handles (the fixture seeding at :1599). The constructor itself carries NO allowMock knob and NO dev-signer, and the sdk/server facade curates the injection seams OUT — a production-serving facade cannot seat a mock, a self-signer, or a dev route. (3) The auth-validator factory is REQUIRED; posture is caller composition. The promoted constructor takes a mandatory non-nil auth-validator factory — identity is mandatory (§6); a nil factory is a loud construction error, never an unauthenticated listener. harbor serve injects its JWKS factory (newJWKSValidatorFactory(), unchanged) and composes no dev surfaces, so those surfaces 404; harbor dev injects a factory built from its ephemeral dev signer and composes the dev surfaces through the item-2 seams. ONE constructor parameterized by the factory + the seams, never two serve builders (§13). (4) The curated sdk/server facade (D-292 wires the scaffold consumer) exposes server.Open(ctx, cfg, Options{RegisterCatalog}) → a handle with Serve/Close over the promoted constructor — a thin alias/forward per the D-204/D-205 facade posture, production-only by construction (always builds the JWKS validator from cfg.Identity; fails loud naming the missing field when absent; exposes none of the item-2 injection seams). (5) The §13 primitive-with-consumer rule is honored in-phase: the promotion (Phase 159) re-wires harbortest/devstack onto the promoted band as the second consumer, deleting its hand-mirrored transports/mux block — the same D-197 move that proved the assembly promotion. This is a re-homing with an honestly-enumerated NEW options/handle seam surface (the item-2 injection seams — pre-CORS routes, the auth-surface option, the LLM snapshot override, the post-boot hook — are new exported fields the two real callers need today; no speculative additions), but ZERO wire changes: no new Protocol methods, no ProtocolVersion bump. The served Handle is a compiled artifact (D-025): a concurrent-reuse test (N≥100 requests against one served instance, -race) + goroutine-baseline teardown are mandatory.

Why. Brief 01 §5: "an egress endpoint with two bolted-on modes is a trap … pick one model and ship it. There is no legacy 'before' mode to be compatible with" — two coexisting compositions of the serve surface (cmd/harbor's and devstack's hand-mirrored copy) are that trap, and they had already drifted on which transport surfaces they mount; the serve band mounts the auth middleware, so single-homing it is integrity-relevant. D-197's recorded lesson ("when the only assembly prior art is package main + a *testing.T fixture, every embedder re-implements boot") applies one layer up unchanged: the serve band was exactly that shape. Brief 07 §1/§8 (the single-dispatch architecture — one mechanism the runtime owns, parameterized, collapsing a mode matrix into a single dimension Harbor controls): production and dev are one constructor parameterized by the factory + caller-side seams, not a fork. The embed-headless posture (no listener) stays the DEFAULT — sdk/server is the additive opt-in for a binary that wants the wire surface too; most embedders keep assemble.Assemble + Stack.RunOnce.

Findings I'm departing from. None.

Protocol additions. None — Go re-homing plus new Go-side option/handle seams; zero schema or wire changes. The RFC §5.3 deprecation-window obligation is amended (this PR) to state explicitly that it extends to embedder binaries mounting the surface: a self-hosted binary serving the Protocol is a Protocol server like any other.

Cross-references. D-197 (the assembly promotion this parallels one layer up), D-094 (the subsystem-wiring mirror precedent), D-204/D-205 item 2 (the facade curation posture whose Protocol-server omission this supersedes; marker appended there), D-089 (the mock escape hatch that STAYS dev-only), D-099 (hot-reload supervisor stays), D-091 (Console embed stays on harbor console), D-220/D-264 (harbor serve mints nothing; harbor token is the local-dev JWKS loop the facade documents), D-292 (the scaffold consumer + the pre-policy registrar seam), D-025 (the served handle as compiled artifact). CLAUDE.md §4.4 (the seam pattern), §6 (identity is mandatory — the required-factory rule), §13 (no parallel implementations; primitive-with-consumer), §17.3/§17.6 (real drivers + fix-both-sides on the devstack mirror). RFC §5.6 (new), §3.6, §5.3, §5.4, §5.5, §8. Plan: docs/plans/phase-159-serve-band-promotion.md.

Status: shipped (Phase 159). As-built: internal/runtime/serve exports Boot / Options / Handle (Serve/Close/Handler/BindAddr), the single-homed BuildMux surface fan-out (MuxInput/BuiltMux), and the promoted collaborators (RunLoopDriver + RunLoopDriverOptions, MCPConnectionAttacher/MCPConnectionDetacher, NewSessionEnsurerAdapter, Enricher). The four caller-side seams landed as Options.BuildLLMSnapshot (mock gate + snapshot override), Options.BuildAuthSurface (the token-rotate surface, built after assembly so it sees the redactor+bus), Options.ExtraRoutes (draft + bootstrap + Console mounts), and Options.PostBoot (fixture seeding). cmd/harbor/devcompose.go composes the dev policy; harbortest/devstack deleted its mux mirror + driver/glue mirror files and now composes the promoted blocks, gaining the omitted agents/auth-rotate/governance-override/governance-key-rotate surfaces (an anti-drift integration test pins the parity). Deviations (three, §4.3 — full text in the phase plan): the BuildLLMSnapshot builder shape, the once-per-invocation dev signer (the hot-reload supervisor's onReboot hook re-mints + re-prints the token and the mock banner per reboot, so every boot announces its posture and the printed expiry restarts), and the kit composing the promoted building blocks rather than serve.Boot. Promotion-found bug fixed in-PR: the bind-address production/dev discriminator collapsed when the factory became mandatory (a dev boot could inherit a non-loopback config bind_addr, exposing the dev-token stack off-box); fixed via the explicit Options.PreferConfigBindAddr opt-in that only harbor serve sets, with in-package, caller-level, and live-listener regression pins.


D-292 — The compiled-tool registrar rides the pre-policy catalog seam; harbor scaffold --with-server is the opt-in consumer

Context. With D-291's promoted serve constructor + sdk/server facade in place, an external binary can serve the Protocol — but a served agent's whole point is its COMPILED in-process Go tools, and those tools must reach the planner with their declared reliability shell, approval gates, and OAuth bindings intact. The catalog Builder applies that wrapping (the tools.entries per-entry shell) at assembly time; a tool registered AFTER the Builder ran is unwrapped. The assembly already exposes the correct injection point — assemble.Options.PreRegisterTools registers descriptors on the catalog BEFORE builtin registration and before the Builder's tools.entries wrapping (internal/runtime/assemble/assemble.go: the PreRegisterTools field applied ahead of the builtin/wrapping bands). The tension: a naïve sdk/server API would tempt a consumer to register tools via a post-assembly Catalog.Register, silently dropping the approval/OAuth/policy wrapping — a security-adjacent regression happy-path tests would miss. And the scaffold surface needed a boundary: the DEFAULT scaffold is headless RunOnce (D-206); serving must be opt-in, not a silent behavior change.

Decision. (1) The registrar rides the ONE existing pre-policy seam, via a named sibling callback. The assembly gains a NEW optional assemble.Options.RegisterCatalog func(tools.ToolCatalog) error field, invoked at the existing PreRegisterTools application point (the catalog band in assemble.go, today :703) — the same seam and the same callback shape, before builtin registration and the tools.entries Builder wrapping — and sdk/server's Options.RegisterCatalog forwards to it. A compiled tool therefore receives the identical approval/OAuth/policy shell an operator's YAML-declared tool gets. It is an ADAPTER over that one seam, never a second registration path (§13). The post-assembly Catalog.Register bypass is the documented trap: the plan names it, and a companion negative test pins that a post-assembly register does NOT get the wrap (so a future refactor cannot silently move the seam). (2) harbor scaffold --with-server is the opt-in consumer. The default (flagless) scaffold stays headless RunOnce (D-206) — --with-server is purely additive, generating a cmd/<agent>/main.go that loads yaml (--config/--bind flag trio mirroring harbor serve), blank-imports sdk/drivers/prod, passes agent.RegisterTools to server.Open, and serves; the module compiles as an EXTERNAL module against the sdk/ facade only (the D-206 external-compile gate extended to serving). harbor serve itself calls the promoted constructor with a NIL registrar via the internal package directly (not through the sdk/server facade) — the internal path and the facade path are the SAME constructor, parameterized by the registrar. (3) The parity gate is the acceptance centerpiece — scoped per leg (§13 primitive-with-consumer, §17). Legs (a), (d), (e) run against BOTH binaries booted from the SAME base config: (a) a manifest-driven method-status parity probe (driven in-module from the Go-side methods.Methods() registry — NOT mux introspection, so a method present in one mux but not the other is caught, never a self-compare false-green; a script-side probe reads the wire-manifest.gen.json methods key); (d) dev-only surfaces (bootstrap-token endpoint, dev-token mint) 404 on BOTH; (e) §17.3 real drivers + identity propagation + ≥1 failure mode (a bad/absent token rejected 401) + N≥10 concurrency stress + -race, using an in-test ES256/JWKS harbor token-style issuer (the D-264 fixture pattern) so the production JWKS posture is real, not mocked. Legs (b) and (c) run against the SCAFFOLDED BINARY ONLY — (b) discovery + dispatch of the generated custom tool through the catalog, (c) a tools.entries approval-gate wrap of the generated tool FIRES (the empirical proof that RegisterCatalog landed at the pre-policy point; §17.8: the gate must OBSERVE the wrap firing, not merely that the tool is registered) — because the tools.entries[] block naming the generated tool lives in the scaffolded binary's config OVERLAY: a stock harbor serve booted against that overlay has no compiled registrar and fails loud with ErrToolNotRegistered, which is the deliberate fail-closed behavior (a declared-but-unregistered tool is a misconfiguration, never a silent no-op) and MAY be asserted as a negative leg. A wrap-fires assertion MAY additionally be mirrored on both binaries using a builtin tool (present in both) to prove the wrapping band itself is identical. (4) The proof splits into a CI leg and a live leg (§17.8): the CI-runnable gate for (b)/(c) mechanics is an in-module test/integration test driving the promoted serve band + the registrar seam with the scripted-LLM pattern (the Phase 83l / Phase 158 precedent) under -race; the wire-level end-to-end against the real scaffolded subprocess binary is an env-gated live leg (the HARBOR_LIVE_* pattern, the Phase 131d precedent) run as the wave's live-verification step, not in CI.

Why. Brief 07 §1/§8 (the single-dispatch architecture: the dispatcher and catalog are runtime-owned, one registration/dispatch protocol — the mode matrix collapses into a single dimension Harbor controls): a tool's declared policy is applied by the catalog Builder at assembly time, so registration MUST ride the seam that runs before it, never a second path. Brief 03 §5 ("two parallel modes … shipping in parallel … is an anti-pattern — Harbor picks one architecture and bakes the correction in"): a second registration path that bypasses the wrap is exactly that toggle smell. Brief 03 §7 H-2 (tool-side approval gates on the unified pause/resume primitive): the approval-gate wrap is the declared-policy shell the gate must observe FIRING — per §17.8, a check that only sees "tool is registered" is a rubber stamp that cannot tell pre-policy from post-policy registration. D-197's recorded lesson ("when the only assembly prior art is package main + a *testing.T fixture, every embedder re-implements boot") is why the scaffold + a standing external-compile-and-boot smoke make the serving path honestly reachable rather than merely documented. Production-only first-run friction (a first sdk/server boot fails loud without a JWKS — by design, §13 no-stub-default) is answered with docs (the harbor token three-command loop leads the scaffold output + skill), NOT a dev-signer knob (which would reopen the D-089/D-220 posture the whole design avoids).

Findings I'm departing from. None.

As-built (Phase 160, shipped). Four faithful realizations worth recording. (1) The facade forwards to an internal sibling package, internal/runtime/serve/external. It holds the production Open (config re-validation + the shared JWKS factory + the registrar adaptation + build identity resolved from the Go build info so runtime.info never reports an empty version) and a thin Handle wrapper whose Close(ctx) error matches the plan's public API — rather than adding those to internal/runtime/serve directly, which would collide with the existing serve.Handle name and mutate the just-shipped Phase-159 surface. sdk/server is alias/forward over it: type Handle = external.Handle, var ErrConfigRequired = external.ErrConfigRequired, and the single Open adapter func (the D-205 carve-out, enumerated in phase-112a.sh/phase-144.sh). The production JWKS factory and the instance-id shape are SINGLE-HOMED as serve.NewJWKSAuthValidatorFactory / serve.InstanceID, reused by the serve subcommand and the external band — never two hand-rolled copies. (2) The registrar threads through two additive seams: serve.Options.RegisterCatalog forwards to assemble.Options.RegisterCatalog, invoked at the existing PreRegisterTools application point; and serve.Options.Config *config.Config lets the facade pass a pre-loaded config that Boot re-validates with the full-binary profile (the no-bypass guarantee; both-set precedence — Config wins over ConfigPath — is pinned in-package). The PreferConfigBindAddr opt-in from D-291 is set. (3) The parity gate covers dispatch in CI and observes the wrap FIRING. The gate boots the stock composition via serve.Boot (the serve subcommand's posture) and the scaffolded composition via external.Open (the shipped path); leg (b) dispatch runs in CI with the scripted-LLM pattern — a canned tool-call drives the compiled tool through the served catalog over the wire, the terminal envelope carries tool_calls_seen ≥ 1, and the handler's fixture marker round-trips into the follow-up prompt — and leg (c) is behavioral: the deny-all entries wrap on the compiled tool FIRES on dispatch (tool.approval_requested + a Coordinator pause token), proving the SERVED descriptor is the wrapped one (the assembly-seam Gates pin and the errors.Is(err, catalog.ErrToolNotRegistered) fail-closed negative complete the leg; the served mux wires no wire-side approval annotator, so tools.get reads auto for config-declared gates — a projector gap, not a wrap gap). (4) Both halves of the method-parity probe exist: in-module from methods.Methods() (status-CLASS comparison), and script-side in scripts/smoke/phase-160.sh — stock harbor serve + the scaffolded binary boot from the same probe yaml and every wire-manifest.gen.json method is status-class-compared across both. The env-gated live leg (HARBOR_LIVE_SERVE=1 + a real provider key) executes the FULL adopter choreography in-test: build the CLI → token keygen → scaffold --with-server → implement the generated stub → external build (replace directive) → subprocess boot → mint → control.starttasks.get → fixture-answer + tool_calls_seen assertions. scripts/smoke/phase-160.sh additionally scaffolds→builds→boots the external binary behind a harbor token-minted JWKS and probes healthz + discovery + 401 + the manifest parity (OK ≥ 3, no LLM turn).

Protocol additions. None — no new Protocol methods, types, error codes, or events; no D-223 manifest churn, no D-209 docs regen. Stated explicitly so a reviewer does not look for a wire diff.

Cross-references. D-291 (the promoted serve constructor + sdk/server facade this consumes), D-197 (assemble.Options.PreRegisterTools — the pre-policy seam), D-205/D-206 (the SDK facade + external-compile gate this extends), D-267 (scaffold-with-tools execution gate — the register-and-dispatch discipline the parity gate builds on), D-264/D-220 (harbor token + serve-mints-nothing — the production posture + the local-dev loop), D-089 (why no dev-signer/mock on the facade). CLAUDE.md §13 (one registration path; opt-in default; no stub default), §17.3/§17.8 (real-driver parity gate; spec-derived observation, not a rubber stamp), §18 (same-PR skill + recipe + docs-site updates). RFC §3.6, §5.6, §5.5, §6.4, §8. Plan: docs/plans/phase-160-sdk-server-scaffold-parity.md.


D-293 — Durable-log read-back carries content-free turn metadata (usage/cost/latency/model/tool-name+status); session reopen reconstructs what the live stream showed

Context. A live test (operator-confirmed, 2026-07-10) found that reopening a Playground session hydrates message content correctly but loses everything else: header stats show "no turns yet" (tokens/cost/latency), the TOOL CALLS "INVOKED" badges vanish, and the model chip resets. The Console's history reducer (web/console/src/lib/sessions/history.ts::reduceHistoryTurns) folds state.history event windows, and its doc header already CLAIMS tool-call reconstruction from llm.completion.chunk / planner.* / tool.* events — but the code folds only chunk deltas + task-terminal flags, and a wire probe of a reopened session's history page found no tool name and no usage keys to fold. Root-cause investigation (verified against a live dev boot + code trace) found the read path strips NOTHING — state_history_handler.go's projection passes payloads through, the durable driver persists the full post-redaction payload map, the inmem ring returns stored payloads, and a probe showed planner.decision{DecisionKind, Tool, …} and chunk payloads intact in read-back. The gaps are all PRODUCER-side: (b1) llm.cost.recorded (the only carrier of Model/Cost/Usage/ContextWindowTokens) is emitted only inside the bifrost driver — no driver-neutral emit exists, so the dev-posture mock and any future driver produce no usage/cost/model records at all; (b2) tool.invoked/tool.completed/tool.failed are emitted only by the in-process transport driver — MCP/HTTP/A2A tools emit no lifecycle events anywhere; (b3) even the inproc emits stamp the event envelope from the ctx identity TRIPLE (empty RunID) with no payload TaskID, so tool events are attribution-dead on BOTH the live SSE path (the Console's taskIDOf yields '' and drops the frame) and any replay reducer — the live badges actually render from planner.decision, which does carry the full quadruple.

Decision. (1) One driver-neutral emit seam per producer, on the one bus. The llm.cost.recorded emit is promoted from the bifrost driver to the MANDATORY safety wrapper llm.Open composes around every driver (the wrapper is unconditional — registry.go:438-460 — the innermost mandatory band of the composed chain; the client Open RETURNS is the outermost wrapper, governance outermost per D-043, so the registry godoc's stale "returned client is a *safetyClient" claim is not inherited and is rewritten by the phase; Deps.Bus is required non-nil at Open, with the model profiles in the ConfigSnapshot, so bus + ContextWindowTokens are in hand; CompleteResponse carries Cost+Usage; the request carries Model); the driver-internal emit is deleted in the same change — one emit per DRIVER-LEVEL completion for EVERY driver, never two. Cadence pinned: per driver-level completion means per attempt under retry-with-feedback, preserving today's bifrost per-attempt cadence and staying aligned with the per-call attempt-cost governance tap. The tool lifecycle emits land at the CATALOG-BUILD DESCRIPTOR-WRAP seam: catalog.Register wraps every registered descriptor's Invoke in ONE universal lifecycle-emitting shell (bus carried by the catalog; the run quadruple read from the invocation ctx), because desc.Invoke has FOUR production call sites — the single-tool executor (dispatch.go:238), the parallel executor's branches (parallel.go:538, the default for native N>1 tool calls), the MCP-Apps proxy (mcpconsole/apps.go:270), and the declarative-action re-invoke (declarative_action.go:236) — and an executor-side emit would silently regress lifecycle events (and the trace bridge's span closure) on three of them while a single-run equivalence gate stayed green (the §17.8 rubber-stamp shape). All four call sites inherit the emit BY CONSTRUCTION; the inproc driver's per-driver emits and the orphaned tools.WithBus DescriptorOption are deleted in the same change. MCP/HTTP/A2A tools gain lifecycle events for the first time, and every tool event becomes turn-attributable — which also fixes the latent LIVE attribution bug (§17.6 fix-both-sides). The binding coverage pins: a native CallParallel turn emits ≥2 quadruple-stamped tool.invoked (one per branch), and a catalog-resolved descriptor invoked OUTSIDE the executors still emits (covering the Apps-proxy + declarative-action shapes; a dedicated Apps-proxy E2E is excluded, justified — same wrapped Invoke, and Apps E2E coverage lives in the 109-band suites). (2) Content-free is the boundary, unchanged. Payload shapes and keys stay byte-compatible and carry tool NAME + transport + status + attempts + duration and usage/cost/model figures ONLY — never tool args or results (CLAUDE.md §7 rule 7); the audit-redactor publish path stays mandatory (§7 rule 6); D-026's heavy-content posture is untouched. A sentinel-redaction test pins that args/results appear nowhere in a full read-back page. (3) The read path is untouched. state.history keeps its D-254 shape (flat events, client-side reduction, MatchesScoped by-id identity scoping, admin never widens) — after (1), read-back ≡ live stream for the named keys because both sides are the same bus events. Works identically on the inmem ring (within process lifetime) and the durable log (across restarts) — no durable-only feature. (4) Zero wire changes: no new method, no new wire types, no new canonical event types, no ProtocolVersion bump — no D-223 lockstep churn, no D-209 docs regen (the implementation PR proves a zero diff on both gates). (5) The §13/D-062 consumer ships in the same phase: HistoryTurn gains content-free stats fields; reduceHistoryTurns folds usage/cost/model sums, tool rows (planner.decision CallTool opens a row; tool.completed/tool.failed resolve status + duration/error-class summary), and per-turn duration; the Playground rehydration populates the header stats, per-message badges, TOOL CALLS badges, and model chip from hydrated turns — leave-and-return renders IDENTICAL to the live view, which is the acceptance centerpiece.

Why. Brief 06 §5's "two-channel split" lesson read forward: the live view and the reopen must fuse the SAME one-bus records; the regression existed because two producers published driver-locally (or not at all), so the replay channel silently diverged from the live channel. Brief 07 §8: dispatch is runtime-owned machinery — the Dispatcher and the catalog, not each transport driver — so the dispatch-lifecycle record belongs on the runtime-owned descriptor shell every transport's Invoke passes through; per-driver emits are how three transports ended up silent, and the wrap-at-registration shape (over an executor-side emit) is what keeps all four dispatch paths covered by construction instead of by enumeration. Brief 11 LR-6 designed the durable event log as the source for historical per-task detail (tool name, identity at invocation, post-redaction boundary) — this decision makes that source real for the reopen path.

Findings I'm departing from. None.

Protocol additions. None — the enrichment is the content of already-registered, already-flowing events; the state.history surface, wire types, methods, errors, and the canonical event-type registry are unchanged.

Cross-references. D-254 (the windowed read + flat-events/client-side-reduction posture this preserves), D-026 (heavy-content boundary, untouched), D-062 (surface + Console consumer in the same wave), D-025 (per-run attribution asserted under the existing concurrent-reuse stress), D-288 (session titles — the sibling reopen-surface work), CLAUDE.md §6 (read-path identity scoping unchanged), §7 rules 6+7 (redactor path mandatory; no args/results in persisted payloads), §13 (primitive-with-consumer; no parallel emit paths), §17.6 (the latent live attribution bug fixed in the same PR), §17.8 (the MCP leg proven against the real stdio fixture). RFC §6.13, §5.2, §6.4, §6.5, §7. Plan: docs/plans/phase-161-session-rehydration-metadata.md.

As-built refinements (§4.3, implementation PR). Six points where the landed code refined the plan without changing its design: (1) The tool lifecycle shell lives on a new internal/tools/lifecycle.go (wrapDescriptorLifecycle) applied inside catalog.Register; the bus rides the catalog via a new tools.WithCatalogBus option (assembled in internal/runtime/assemble). Register wraps ONCE; Replace (the wiring builder's approval/oauth re-install) does NOT re-wrap — pinned by a no-double-shell test. (2) Attempts at the shell-level emit is the terminal attempt count (1 for a resolved call, 0 for policy-exhausted), not per-attempt internals — the policy shell runs INSIDE the wrapped Invoke, exactly the Risk the plan named. (3) The safety-wrapper cost emit fires only on a nil-error driver completion, and the error path returns the driver's response UNCHANGED (return resp, err) so an errored attempt's provider-reported Cost still reaches the outer retry/downgrade attempt-cost tap — preserving the prior passthrough semantics (a caught regression: zeroing it dropped intermediate-attempt governance spend). (4) Deleting the orphaned tools.WithBus DescriptorOption rippled to its 8 skill/builtin/generator call sites and the sdk/tools re-export (removed) — the bus now flows only through the catalog. (5) The bifrost driver keeps its bus field (still used for provider-file-uploaded events); only the two cost emits + cost.go were deleted, and the now-unused id params were dropped from unaryComplete/streamComplete. (6) Cross-phase §17.6 fix — pre-existing trajectory-append data race. The wave-end -race run surfaced a data race (NOT introduced by this phase; git-blame roots it in phases 158/159) between serve.Enricher.Trajectory (an out-of-band tasks.get reader projecting the reasoning trace of an IN-FLIGHT run) and steering.RunLoop's per-step Trajectory.Steps append — the RunLoopDriver.trajMu guarded only the trajectory MAP, never the Steps slice, and the Trajectory type delegates concurrency to the Runtime by contract (its godoc: "the Runtime's responsibility ... MUST serialise the two"), so the type cannot self-synchronise (and adding a mutex to it trips copylocks on the serialize walker's value-copy). This phase's added tool-lifecycle events perturbed scheduling enough to make the latent race reproduce. Fixed the RIGHT way per the contract: a per-run *sync.RWMutex (RunSpec.TrajectoryMu, new) shared between the steering append (which now locks it around the Steps append) and the serve driver's trackedTrajectory map entry; TrajectoryByTaskID returns a DEFENSIVE SNAPSHOT (Query + a copied Steps slice) taken under that same lock, so an in-flight reader never observes a mid-append slice header. Lock-free when no external reader is wired (TrajectoryMu nil). Pinned by a deterministic regression test (TestRunLoopDriver_TrajectoryByTaskID_ConcurrentDuringAppend (internal/runtime/serve)) that hammers TrajectoryByTaskID DURING a multi-step run's append window under -race — race-detected without the mutex, green with it. Both zero-diff gates (protocol-ts-gen-check, protocol-docs-gen-check) verified clean.


D-294 — events.list is the durable, time-ranged, cross-session raw-event read: the existing EventFilter + a tail-first cursor over the one bus's retained rows, same projection as state.history, fleet-widened only by verified admin scope

Context. A second Protocol consumer (an operator-filed ask, 2026-07-10) needs "the raw events across the fleet from 7 days ago to now, scrollable." No surface answers it: events.subscribe is the forward-only live tail, events.aggregate returns bucketed counts with no payloads, state.history is per-session and sequence-windowed (web/console/src/lib/protocol/state.ts:20-26), and search.events is the text-search index surface (internal/protocol/methods/methods.go:179-183; the indexFor mapping at search.go:115), not a window enumeration. A pure Protocol client that wants historical rows would otherwise need its own copy of runtime events — the exact shadow store the architecture forbids (D-061 read from the client side). Notably the wire EventFilter already carries Since/Until (internal/protocol/types/events.go:46-51), whose only consumers today are live/count predicates (events.MatchWire, filter.go:158/:187-191; the aggregate window clamp, aggregate.go:159-164) — no historical ROW read consumes them.

Decision. (1) One additive method, events.list: request = the EXISTING EventFilter + limit + an opaque tail-first cursor; response = {events, next_cursor, has_more, truncated} mirroring state.history's paging grammar, with rows reusing the EXISTING flat StateEvent projection (the same payloadWireValue pass-through + artifact-ref seeding the state-history handler uses) — no new row shape. (2) The cursor is SEQUENCE-based (stable under clock skew); since/until filter on OccurredAt (semantic bounds) — the split is explicit so paging never skips or duplicates. (3) Scope: a non-widened caller reads only rows matching its verified triple; fleet widening follows the CLOSED TWO-SCOPE read set the reused EventFilter contract documents and the aggregate handler enforces — the verified auth.ScopeAdmin OR auth.ScopeConsoleFleet claim (types/events.go:41-44; handlers.go:141-143) — derived server-side (never the request body — CLAUDE.md §6 item 5), emitting audit.admin_scope_used once per request; a console:fleet deployment therefore reads historically exactly what it can subscribe/aggregate over (live/historical authz parity). (4) Substrate: the read extends the events.HistoryReplayer seam both V1 drivers already implement (the D-254 precedent) — durable serves real windows from the persisted global-sequence log; inmem returns what its ring holds with an honest truncated at the edge; no capability ceremony (§4.4/§9). The seam's recorded never-fan-in godoc contract (events.go:562 block; MatchesScoped's disclosure history, events.go:403-417) is AMENDED, not silently violated: the one sanctioned exception is this authority-gated, explicitly-requested fleet read on a distinct method — the identity fan-in precedent exists on Subscribe/Replay only (inmem.go:600), and Bounds/Window contribute only the Bounds-then-Window single-audit pattern (inmem.go:748); the by-id state.history reads keep never-fan-in verbatim. (5) Redaction and heavy content unchanged: rows stay the bus-redacted projection (single redactor at publish, §7 rule 6) and heavy payloads stay by-reference (D-026). (6) The D-062 consumer ships in the same phase: the Console Events page — whose empty state already points at durable read-back (events/+page.svelte:249-258) — drives events.list from its existing window picker, keeps the live SSE tail unchanged, and renders the retention-gap notice. (7) Operator latitude on adjacent reads was exercised as NO additions: inspect-by-id adds nothing (rows are self-contained), an event-type catalog is derivable from events.aggregate's per-type buckets, and cross-page links ride identity fields the rows already carry.

Why. Brief 06 §5's one-bus lesson: the replay/read channel must be the SAME records the live channel fans out, or every consumer fuses two paths forever. Brief 11 LR-3 designed the events surface around an older-window replay read from the start — events.list is that read, landing beside the tail and the counts rather than replacing either.

Findings I'm departing from. None.

Protocol additions. events.list (method + EventsListRequest/EventsListResponse, rows = existing StateEvent); additive, no ProtocolVersion bump; full D-223 lockstep + D-209 regen in the same PR.

As-built — cross-user disclosure gate (review fix). The first review confirmed a §6 multi-isolation FAIL: the shared events.FilterFromWire gated the TENANT axis on != caller but NOT the USER axis, so a non-admin caller supplying {own-tenant, foreign-user, foreign-session} kept RequiresAdminScope=false, the handler folded only elided axes, and the foreign user survived into MatchWire — returning another user's rows (the row-returning sibling state.history closes this via its cross-identity Tenant||User gate; events.list did not). Fixed in the shared helper (closes events.list AND events.aggregate in one place, §17.6): the USER case 1 branch now sets RequiresAdminScope when != callerUser, mirroring the tenant branch. The SESSION axis is deliberately NOT gated on a single foreign value — a user legitimately reads their own other sessions (the Console Sessions/Playground history flow), matching state.history's posture (its cross-identity gate covers Tenant+User, not session). After the fix a cross-user read requires admin OR console:fleet (more correct than state.history's admin-only, right for a fleet console). Tests: TestFilterFromWire_RequiresAdminOnCrossUser + _SessionAxisNotGatedOnForeignSingle (helper), TestEventsList_CrossUserSameTenant{Without,With}Scope_* + _OwnUserOtherSession_OK (handler), the integration IsolationAndFleetRead cross-user leg, and the acceptance-named TestEventsList_SentinelRedactionHolds (a secret published in a non-safe payload is masked at the bus boundary and absent from the read-back). Deferred to the wave checkpoint (do not re-open in 162): the broader §6.5 tension — "a cross-session observer needs elevated scope" vs "a user reads their own sessions" — across ALL event surfaces (subscribe/aggregate/list) is a posture question, not a 162 bug; and the durable fleet read's candidate-gather is O(events-below-cursor) (entry I/O is limit-bounded, candidate gather is not) — a merged global-sequence index is tracked as HA-13.

As-built (no deviations). The seam extension is HistoryReplayer.ListWindow(ctx, events.EventListQuery) (events.EventListPage, error) — a THIRD member beside Bounds/Window, not a variant of them; both godoc blocks (the HistoryReplayer interface and MatchesScoped) were amended in place to name the one sanctioned fleet-read exception. Both drivers reuse two shared events helpers so the ring scan lives once: ListWindowFromSnapshot (the ring/best-effort page) and WireFilterMatchesTriple (the durable head-record identity pre-filter). The durable fleet fan-in enumerates session heads via the existing StateStore.ListKind maintenance scan (each head carries its identity), then loads entries lazily merged in global-sequence-descending order, bounded to limit+1 per page (no unbounded entry scan); a non-admin read fast-paths to a single loadHead. The handler (internal/protocol/transports/stream/events_list_handler.go) reuses the state-history projection via the extracted package-shared projectStateEvent, so the two surfaces' rows are byte-identical (pinned by TestEventsList_RowShapeMatchesStateHistory). Routing predicate: methods.IsEventsListMethod (its own O(1) member excluded from IsControlMethod), mounted at POST /v1/events/list via transports.WithEventsList. Console consumer: web/console/src/lib/events/history.svelte.ts (EventsHistory + the pure mergeEventRows fold), wired through EventsPageState.

Cross-references. D-254 (state.history — the paging grammar, projection, and HistoryReplayer seam this extends), D-061 (the shadow store this makes unnecessary), D-026 (by-reference discipline), D-284 (the admin-widened read pattern), CLAUDE.md §6 item 5 (elevated scope server-derived), §4.4/§9 (no capability ceremony), §13 (consumer same phase). RFC §6.13, §5.2, §4, §7. Plan: docs/plans/phase-162-events-list-durable-read.md.


D-295 — flows.runs.list gains optional since/until, mirroring TaskFilter exactly; the flow detail page's run history is the same-phase consumer

Context. A second Protocol consumer rendering "flow runs in the last 7 days" must page the ENTIRE run history and filter client-side: FlowRunsListRequest filters only by flow_id/tenants/page/page_size (internal/protocol/types/flows.go:303-320; Console mirror web/console/src/lib/flows/types.ts:133-140) — the one run-history read with no time bound, though the run rows carry and sort by StartedAt (flows.go:289-290, :323) and the sibling reads already have bounds (TaskFilter.Since/Until, internal/protocol/types/tasks.go:211-214; the sessions started-window).

Decision. Add optional since/until (RFC-3339 UTC, inclusive-lower/exclusive-upper on StartedAt) to FlowRunsListRequest, mirroring TaskFilter's shape and semantics EXACTLY — additive, absent ⇒ unbounded, zero behavior change for existing callers; bounds apply before pagination; until < since fails CodeInvalidRequest; the existing tenant/admin scope rules are unchanged and re-pinned (scope derived server-side, never the body). The same-phase D-062 consumer is the flow detail page's run-history table (flows/[flow_id]/+page.svelte:147 via lib/flows/detail.svelte.ts), which gains a server-side date-range filter replacing the walk-and-filter interim.

Why. Brief 11 designed every observability view around time-bounded reads (LR-3) and enumerated the per-flow run/test history view; tasks and sessions got their bounds — flows is the straggler, and mirroring the existing shape (rather than inventing a window object) keeps the wire vocabulary singular.

Findings I'm departing from. None.

Protocol additions. Two additive optional fields on FlowRunsListRequest; no new method, no ProtocolVersion bump; D-223 lockstep + D-209 regen in the same PR (bundled with D-296's fields in Phase 163).

As-built. flows.runs.list uses a CLOSED window — INCLUSIVE lower AND inclusive upper — matching TaskFilter.Until (internal/tasks/protocol/list.go:256 .After(Until)) and the sessions started-window (internal/sessions/registry.go / filter.go) exactly, so every list method behaves identically on a time boundary. Now "mirrors TaskFilter exactly" is true of BOTH the field shape (optional omitempty time.Time; until<sinceCodeInvalidRequest) and the boundary semantics. The initial implementation shipped an exclusive upper (half-open window); that deviation was REVERTED to inclusive during review for cross-method Protocol consistency — a versioned surface must not have one lone half-open list method, an off-by-one footgun for consumers reusing a window helper across tasks.list / sessions.list / flows.runs.list. The Console date-filter maps each picked day directly to its UTC midnight with no boundary compensation.

Cross-references. D-294 (the sibling window read shipping in the same wave), CLAUDE.md §6 (scope discipline). RFC §6.1, §5.2, §7. Plan: docs/plans/phase-163-windowed-reads-honesty.md.


D-296 — Retention horizons surface as Protocol data on runtime.health — the OBSERVED oldest-retained-timestamp per durable surface (the ask's configured-retention premise was false against the tree); the counters/metrics TSDB stays a decided-NO

Context. A consumer promising "the last 7 days" across a fleet must know whether a runtime actually retains that window BEFORE reading; today the only signal is the per-read truncated retention-gap flag after the edge is hit (state.history; events.list after D-294), and the point-in-time surfaces carry no horizon (RuntimeCounters.SnapshotAt is a single now-stamp). The filed ask premised "the events durable driver already has a retention setting; this just surfaces it" — VERIFIED FALSE: no retention/prune knob exists anywhere (EventsConfig, internal/config/config.go:814-823, carries none; the durable log is explicitly "gap-free and untrimmed in V1", internal/events/drivers/durable/durable.go:776; the inmem driver's horizon is its ReplayBufferSize ring capacity).

Decision. (1) RuntimeHealth gains an additive retention block: one entry per durable surface — events, tasks, sessions — each carrying the OBSERVED oldest_retained_at (RFC-3339; absent when the surface holds no rows). Observed, never a configured claim: it is derivable today (log/ring head's OccurredAt; oldest retained task/session record), strictly more honest than a configured duration (a configured "7d" on a 2-day-old deployment retains 2 days), and correct under the session registry's GC ("oldest retained", not "oldest ever" — the godoc says so). state.history's substrate is the event log, so the events entry covers it. Placement: runtime.health (the polled operational surface; horizons advance over time) rather than runtime.info (identity/build/capability-shaped). (2) A configured-retention field is NOT minted now — no knob exists to echo; if a retention/pruning knob ever ships (a separate lifecycle feature), its field is additive then. (3) The forward-looking horizon pairs with the at-read truncated flag: "expect gaps past X" before the read, "this read hit the gap" after. (4) The deliberately-rejected sibling is re-recorded so it is not re-opened (operator-decided, 2026-07-10; the rationale is encoded here in full so this entry is self-contained): Harbor does NOT become a time-series DB for counters/metrics. runtime.counters/metrics.snapshot stay now-only snapshots; trend series derive from the durable event log (rebuildable by any consumer — never a shadow store), and metrics route to an out-of-band scrape of the MetricsRegistry (RFC §6.14). A consumer sampling snapshots into its own history table would hold the only copy — forbidden on both sides of the Protocol.

Why. Brief 06 §5's metrics posture (bounded-cardinality derivation from the one bus; snapshots are roll-ups, not substrates) is exactly why the horizon belongs to the DURABLE surfaces and the TSDB stays rejected. Honesty at the window edge is the §13 no-silent-degradation rule applied to fleet views: a merged 7-day view over heterogeneous runtimes must mark "this runtime retains only back to X" rather than imply completeness.

Findings I'm departing from. The filed ask's "surface the existing retention config" premise — corrected to the observed horizon per the verified evidence above (recorded in the plan's Findings-departure section too).

Protocol additions. The additive retention block on RuntimeHealth (+ its per-surface entry type RetentionHorizon); no new method, no ProtocolVersion bump; D-223 lockstep + D-209 regen in the same PR (bundled with D-295 in Phase 163).

As-built. (1) The events horizon is sourced through a new optional capability interface events.RetentionReporter.OldestRetainedAt both V1 bus drivers implement, type-asserted at the posture wiring seam (the DroppedCounter precedent — a single read surface discovered by type assertion, NOT a Supports* flag, so §4.4's no-ceremony rule holds). The durable driver seeds its horizon from the persisted head at boot (recovered from the global-minimum sequence's entry) and floors it on persist; the inmem/best-effort ring reads its head live (advances on eviction). (2) The events horizon is runtime-wide (a bare timestamp, no identity content); the tasks/sessions horizons are the oldest retained WITHIN THE READ'S SCOPE — mirroring CountersProvider's established posture scoping (tasks per verified session via TaskRegistry.List; sessions per tenant via SessionLister.ListSnapshots incl. closed-but-retained) rather than a cross-tenant maintenance scan. TaskSummary gained an internal CreatedAt field (not a wire type — no lockstep) so the tasks horizon is a true spawn time. The Retention PostureDeps seam is optional (nil ⇒ block omitted) so existing surface constructions are unaffected; the production mux wires it.

Cross-references. D-294 (the windowed read whose edges this makes predictable), D-254 (the truncated at-read flag this pairs with), CLAUDE.md §13 (honest degradation), §4.4/§9 (both drivers answer, no ceremony). RFC §5.2, §6.13, §6.14, §7. Plan: docs/plans/phase-163-windowed-reads-honesty.md.


D-297 — Harbor discovers a connected MCP server's advertised OAuth requirement (401 challenge → RFC 9728 → RFC 8414 chain) and surfaces it VERBATIM as inert Protocol data; it never runs the flow, never holds a token, never follows discovered endpoints; one discovery mechanism shared with the parked runtime-side-flow sibling

Context. A second Protocol consumer brokers downstream credentials centrally: the runtime PULLs a fresh token at call time via the tokenexchange credential source (D-271) and never persists or refreshes a per-connection credential. Provisioning that credential today requires a hand-declared provider descriptor (authorization endpoint, token endpoint, scopes) — yet the MCP authorization spec (2025-06-18) makes servers ADVERTISE exactly this: 401 + WWW-Authenticate: Bearer resource_metadata="…" → RFC 9728 protected-resource metadata → authorization_servers[] → RFC 8414/OIDC metadata (endpoints, scopes, PKCE, optional RFC 7591 registration endpoint, RFC 8707 resource). The runtime is the natural discoverer — the consumer is a pure Protocol client that may have no network path to the southbound server — and the detection edge is empty today (no 401/WWW-Authenticate handling exists anywhere in internal/tools/drivers/mcp/, grep-verified), while the RFC 8414 half already exists (Provider.resolveEndpoints, internal/tools/auth/provider.go:858). TWO sibling phases plan the same chain with flow-execution on top: the parked Phase 92p (reserved D-246 — synthesizes config, parks a runtime-side consent flow) and the ready Phase 85b (docs/plans/phase-85b-mcp-http-oauth.md, master row "Ready now (scope ↑)" — wires auth.Provider into the MCP driver with RFC 9728 discovery, the WWW-Authenticate 401 step-up, RFC 8707 resource indicators, running the interactive flow through pause/resume). Their discovery halves must not fork.

Decision. (1) Discover on challenge or probe, never in the background: the MCP http(s) transport edge captures the 401 challenge (recording it on the connection state without altering call semantics or attaching credentials), and mcp.servers.probe triggers the chain walk on demand. (2) One chain walker, composing what exists: the RFC 9728 fetch is net-new in internal/tools/auth; the RFC 8414 fetch/parse REUSES resolveEndpoints; RFC 7591's ensureClient stays UNUSED — the registration endpoint is reported, never invoked. Partial chains surface with typed per-step statuses, never a silent empty (§13). (3) Surfaced as inert data: an additive oauth_requirement field on MCPServerView — the verbatim chain + discovered_at + source (challenge|probe) + source_url. Report, don't follow: discovered metadata is UNTRUSTED server-supplied input, a proposal an operator confirms at the consumer's consent gate, never auto-applied to any Harbor config. (4) Hard custody boundary (binding): Harbor never runs the authorization-code exchange, never holds/refreshes/caches a per-connection credential — D-271 stays PULL, custody stays consumer-side, and the declared oauth_provider binding remains the acquisition path; this decision only turns the descriptor from hand-typed into auto-discovered. (5) SSRF guardrails on the discovery fetches, specified PER HOP (the metadata pointer is attacker-influenceable): the RFC 9728 hop defaults to same-origin-as-server with an explicit per-connection origin allowance; the RFC 8414 authorization-server hop is inherently CROSS-origin (the AS is normally a different host), so it ALWAYS requires the explicit per-connection allowance for the AS origin (stated UX consequence: most real discoveries need an operator-granted allowance before the AS half populates, and a chain refused there surfaces partially — the 9728 half plus a typed needs-allowance status — never silently empty); every allowed cross-origin fetch additionally refuses private-range / IP-literal destinations (RFC 1918/4193/loopback/link-local, bare-IP hosts); every hop enforces bounded redirects, per-fetch timeout, response size cap, https-only off-loopback, and no credentials of any kind — each refusal a typed loud error, each guardrail negative-tested. (6) §17.8 fixtures derive from the real spec artifacts — the concrete committed testdata/ artifacts are RFC 9728 §3.2's protected-resource-metadata example document and RFC 8414 §3.2's authorization-server-metadata example document (verbatim, with provenance comments), plus a captured WWW-Authenticate challenge line per the MCP 2025-06-18 authorization spec; a wrong-field-name mutation of the fixture must fail the test (the right-field/wrong-field discriminator). An env-gated live leg against a real OAuth-protected server is the wave's live verification. (7) Sibling reconciliation (§13 one-mechanism, N consumers): this decision ships the shared discovery mechanism + the report-only consumer (the Console MCP Connections page renders the requirement, same phase per D-062); when Phase 85b lands — or if 92p is unparked — each REUSES this chain's output and adds only its flow-execution leg (the Phase 148 precedent: one injection transport, later phases reuse it). One discovery implementation, N consumption postures. Both sibling plan files carry pointer notes recording the single-homing (85b's added in the plans PR; 92p's in the implementation PR); the probe return stays the existing MCPProbeRow and the discovered requirement is read via the mcp.servers.get/list view projection — no probe-row wire change.

Why. Brief 14 named the gap verbatim ("No RFC 9728, no RFC 8707" on the MCP HTTP auth edge); brief 09's bifrost lessons show discovery is what removes hand-registration toil AND that dynamic registration is a footgun-rich act — reporting the endpoint while never invoking it keeps the enabler and drops the risk. Surfacing-as-data is what keeps the credential plane clean: the whole point of the broker model is that the runtime holds nothing, and a discovery leg that acted on what it found would silently reintroduce per-runtime custody.

Findings I'm departing from. None.

Protocol additions. The additive oauth_requirement view types on the mcp_servers surface (projected by mcp.servers.list/get/probe); no new method, no new event type, no ProtocolVersion bump; D-223 lockstep + D-209 regen in the same PR.

Cross-references. D-271 (tokenexchange PULL — the custody posture this preserves), D-246 (reserved for the parked 92p sibling this shares its mechanism with), D-240/D-287 (the MCP-connection control-plane surface the view extends), CLAUDE.md §7 (no credentials on discovery fetches; SSRF posture), §13 (inert-data / no silent degradation / one mechanism), §17.8 (spec-derived fixtures). RFC §6.4, §5.2, §6.15, §7. Plan: docs/plans/phase-164-mcp-oauth-discovery-surfacing.md.

As-built (Phase 164, §4.3). Shipped as designed, with one recorded refinement that CORRECTS the Decision's point-(2) "RFC 8414 fetch/parse REUSES resolveEndpoints" wording: only the PARSE shape is reused, NOT the fetch. The RFC 8414 authorization-server hop is an intentionally SEPARATE SSRF-guarded fetch (internal/tools/auth/discovery.gofetchHop at :394, the issuer→metadata-URL derivation authServerMetadataURL at :498) — NOT a call into resolveEndpoints / Provider.fetchDiscovery composition, and NOT "no second parser at all": the metadata PARSE struct discoveredMetadata (internal/tools/auth/provider.go:117) IS single-homed and shared with the interactive-flow resolver, but the FETCH forks because the report-only hop needs per-hop SSRF guardrails + typed per-step statuses (DiscoveryStepStatus) that resolveEndpoints lacks, and the existing fetch carries no SSRF guardrails and caches into flow-execution state which report-only discovery must not touch. This strengthens the single-homing: 85b / 92p reuse this guardrailed walker (auth.Discovererauth.OAuthRequirement), never the ungated flow fetch. The SSRF cross-origin allowances ride a new additive per-connection config field tools.mcp_servers[].oauth_discovery_allowed_origins (IP-literal and non-https origins rejected at config load, fail-fast). The private-range / IP-literal refusal is enforced at two layers: the IP-literal check in validateHop (pre-dial), and the load-bearing DNS-rebinding backstop as a net.Dialer.Control hook that runs POST-DNS-resolution against the resolved ip:port (a pre-resolution DialContext wrapper would see the hostname, net.ParseIP → nil, and let a name resolving to a private address through; Control sees the actual IP and fails closed). The test bypass WithPrivateNetworkAccessForTest panics outside a test binary (testing.Testing()). An aggregate walk budget caps the authorization-server fan-out (maxAuthServers, excess reported truncated) so a hostile 9728 doc cannot sum many per-fetch timeouts into minutes. Challenge capture lives at the shared buildHTTPClient choke point (SSE + streamable-HTTP) as the outermost challengeCapturingTransport, recording onto the registry via RecordAuthChallenge; mcp.servers.probe triggers the walk in mcpconsole.RegistryAccessor.Probe, and the requirement rides the mcp.servers.get detail projection only (nil on the hot list row). ProtocolVersion unbumped; D-223 + D-209 regen in the same PR.


D-298 — Session reopen reconstructs the ordered reasoning-step↔tool-call interleaving (structured trajectory) from durable events, not just flat reasoning text; zero-wire, Console reducer only

Context. Phase 161 (D-293) rehydrates per-turn stats, flat reasoning TEXT, and tool-call badges on session reopen, but the STRUCTURED reasoning steps — the ordered per-ReAct-step native thinking the live view renders as a ReasoningAccordion of "Step N: <trace>" entries, interleaved in order with the tool calls each step preceded — do not survive reopen: the reopened agent message renders only 161's flat reasoningText, never the ordered reasoningSteps. The operator wants the reopened conversation to show the ordered "reasoning → tool call → reasoning → tool call" sequence, materially better UX than one undifferentiated reasoning blob with no ordering over the tools.

Source model (corrected against the tree). reasoning_trace is NOT a ReAct "Thought:" scratchpad — the planner's former textual Reasoning action field was deliberately removed (internal/planner/decision.go:36-38). Both reasoningText and reasoningSteps draw from the SAME native-model thinking channel, llm.CompleteResponse.Reasoning: the ReAct planner threads resp.Reasoningrc.OnReasoning (internal/planner/react/react.go:720,:730-731); the runloop copies that terminal string onto trajectory.Step.ReasoningTrace (internal/runtime/steering/runloop.go:724-725,:921); the enricher projects each non-empty step's {Index: i, ReasoningTrace} onto the wire (internal/runtime/serve/enricher.go:61-67). So reasoningSteps = native thinking bucketed per ReAct step (which thinking preceded which tool call) — the exact UX this delivers.

Verdict — ZERO-WIRE, Console reducer only (verified by live probe + code trace, 2026-07-11). The live path's reasoning steps come from the tasks.get enricher trajectory projection, which is IN-MEMORY-ONLY (internal/tasks/protocol/registry_projector.go:205 sets detail.Trajectory = enricher.Trajectory(...), and Enricher.Trajectory reads trajectoryFn(taskID) — the in-memory trajectory — returning nil when it is "unavailable (evicted …)", internal/runtime/serve/enricher.go:49-56): on reopen the task record survives, but the trajectory projection does NOT — tasks.get carries no trajectory field once the run's in-memory trajectory has been reaped, so the enricher can never serve a reopened run's reasoning steps. The ONLY durable source is the event stream, which already carries everything: planner.decision events (one per trajectory step, ordered by sequence, each carrying ReasoningTrace + DecisionKind + Tool) plus tool.invoked/tool.completed/tool.failed. state.history already delivers these rows (D-254), and 161's reducer already reads planner.decision for the tool-call badges — it merely ignores the ReasoningTrace key. Byte-equivalence is by construction: emitDecision(rc, final, resp.Reasoning) and rc.OnReasoning(resp.Reasoning) feed the SAME resp.Reasoning into the event's ReasoningTrace and into trajectory.Step.ReasoningTrace, and the enricher projects the step trace verbatim — so planner.decision.ReasoningTrace in read-back equals the reasoning_trace the live enriched tasks.get serves. Live probe (sess-ca866eb2-ccb / run 01KX7KPCQ0ZCK7EQHXW4FTEEE2): planner.decision(CallTool, seq 10) → tool.invoked(seq 11) → tool.completed(seq 12) → planner.decision(Finish, seq 19) — the interleaving preserved by sequence, and note the Finish decision emits a planner.decision event but appends NO traj.Step (see the decision rule below). (Traces were empty only because the run used the mock LLM, which emits no native thinking — the separate, already-resolved Anthropic-thinking question; the STRUCTURE reconstructs regardless. Redaction is a non-issue: DecisionPayload is SafeSealed, so the bus skips the audit redactor and the persisted ReasoningTrace is the RAW value — byte-identical to the enricher's raw in-memory trace; internal/planner/events.go:142-143, internal/events/drivers/inmem/inmem.go:393-403 (the SafePayload check at :397).)

Decision. (1) reduceHistoryTurns (web/console/src/lib/sessions/history.ts) folds each planner.decision event's ReasoningTrace into a new HistoryTurn.reasoningSteps: {index, reasoning_trace}[] (matching the wire ReasoningStep shape), in event/sequence order. (2) The index matches the enricher's i-over-traj.Steps (internal/runtime/serve/enricher.go:61), which counts STEP-APPENDING decisions ONLY. The correctness point: emitDecision fires for EVERY decision — including Finish and RequestPause, each writing resp.Reasoning verbatim to the event's ReasoningTrace (react.go:559,:720) — but the runloop appends a traj.Step ONLY in the default branch of its decision switch (CallTool / CallParallel / SpawnTask / AwaitTask, internal/runtime/steering/runloop.go:917-923); case planner.Finish: return d, nil (:795-796) and case planner.RequestPause (:798) append no step. So the reducer folds a step ONLY for decisions whose DecisionKind ∈ {CallTool, CallParallel, SpawnTask, AwaitTask}, increments the per-run step ordinal ONLY on those, and emits only when the trace is non-empty; Finish / RequestPause — and any non-step-appending kind — are excluded from both the ordinal and emission, expressed as an allowlist of the step-appending kinds so unknown/future kinds auto-exclude — mirroring the runloop's default-branch gate. (Folding on every decision would phantom-add a reasoning-bearing Finish step the live view never shows and, for a mid-run RequestPause, shift every later index — the bug the mandated fixture, with a reasoning-bearing Finish + a mid-run RequestPause, pins. DecisionKind is already in the payload and already read by 161's reducer at history.ts:285, so the fix stays zero-wire.) (3) hydratePastTurns sets the reopened agent message's reasoningSteps; MessageBubble already prefers reasoningSteps over reasoningText (:176-178ReasoningAccordion), so a reopened turn renders the ordered accordion IDENTICAL to a live turn, and a turn with no non-empty steps cleanly falls back to 161's flat reasoningText (no regression). (4) The flat reasoningText path (161) is untouched — this adds the structured steps ON TOP. (5) ZERO wire/runtime change: no method/type/event, no ProtocolVersion bump, no D-223/D-209 churn; the reconstruction is Console-internal over already-flowing events. The acceptance centerpiece: reopen renders the ordered reasoning↔tool-call interleaving identical to the live view.

Why. Brief 06 §5's two-channel-split lesson: the reopen must reduce the SAME one-bus records the live view does — and here the live projection (enricher over in-memory trajectory) literally cannot answer on reopen, so the durable event reduction is not just the preferred path but the only correct one. This is D-293 read one layer deeper: 161 reconstructed the stats + badges from the event stream; the ordered reasoning structure lives in the same stream (planner.decision.ReasoningTrace) and is byte-equivalent to what the enricher served live.

Findings I'm departing from. The motivating brief's characterization of reasoning_trace as a ReAct "Thought:" scratchpad — corrected (above) to the native-thinking channel; not an RFC/decision departure, a source-model correction.

Protocol additions. None — Console reducer + rendering over already-registered planner.decision / tool.* events; the state.history surface, wire types, methods, errors, and the event registry are unchanged.

As-built (Phase 165). Implemented exactly as the corrected index rule above — no design deviation. reduceHistoryTurns gained a per-run stepOrdinal map and a STEP_APPENDING_DECISION_KINDS allowlist ({CallTool, CallParallel, SpawnTask, AwaitTask}); the fold advances the ordinal and emits {index, reasoning_trace} (non-empty only) exactly on those, so a reasoning-bearing Finish/RequestPause produces no step and never shifts a later index. hydratePastTurns sets reasoningSteps beside the existing reasoningText. The mandated §17.8 byte-equivalence vitest (reduceHistoryTurns — byte-equivalence vs parseReasoningSteps) pins reconstruction against the live parseReasoningSteps projection over a fixture carrying a reasoning-bearing Finish + a mid-run RequestPause + an empty-reasoning step; it was confirmed to FAIL under the (rejected) every-decision rule. Per-run ordinal isolation is pinned by reduceHistoryTurns — reasoning-step per-run ordinal isolation (two tool-calling runs in one window — sequential AND sequence-interleaved — each restart at index 0), confirmed to FAIL under a shared (non-runID-keyed) ordinal. Two plan cleanups landed in this PR: the fictional "Cancelled" DecisionKind was reworded out of the plan + this decision (the allowlist already handles unknown kinds); and the pre-existing imprecise internal/planner/events.go DecisionPayload.ReasoningTrace godoc — which claims the audit redactor processes the payload before persist, false for the SafeSealed payload (the bus skips redaction at internal/events/drivers/inmem/inmem.go:393-403, the check at :397), so the reopen carries the raw trace, which is exactly what this phase's byte-equivalence relies on — is tracked in #482. That godoc's behavior is out of this Console-only phase's scope and was left unchanged.

Cross-references. D-293 (the 161 rehydration foundation this extends — HistoryTurn/reduceHistoryTurns/hydratePastTurns + the tool-call fold), D-254 (state.history — the durable rows the reducer reads), D-062 (surface + Console consumer in the same phase — here the consumer IS the change, zero-wire), CLAUDE.md §13 (no parallel emit paths; reconstruct from the one bus), §17.6 (fix-what-the-reopen-shows). RFC §6.13, §5.2, §7, §6.2. Plan: docs/plans/phase-165-reasoning-steps-rehydration.md.


D-299 — A single own-other-session read is own-data and needs NO elevation; the elevation trigger is cross-PRINCIPAL (different tenant/user) OR a multi-value fan-in (len>1 on any axis), never a single own-session read. §6.5's "cross-session observer" means cross-PRINCIPAL, not a user reading one of their own other sessions

Context. CLAUDE.md §6.5 says "Cross-session/cross-tenant observers (admin, Console fleet view) require an elevated subscription with the matching scope claim." Read literally, "cross-session" could be taken to mean that a user reading a DIFFERENT session of their own — the everyday Console Sessions-list / Playground-history flow — needs an elevated scope. That reading is wrong and would break a valid same-user flow. The v1.13 events-window wave (events.list / events.aggregate windowed reads, Phase 162) surfaced the ambiguity at internal/events/filter.go: FilterFromWire gates tenant and user (empty → caller's; a different value → RequiresAdminScope) but deliberately uses a named session AS-IS with NO elevation, and left a comment punting the "is that the right posture?" question to "the wave checkpoint" (filter.go, the Sessions branch). This decision resolves it.

Decision. The isolation triple (tenant, user, session) is the storage/filter boundary (CLAUDE.md §6.1). Elevation (auth.ScopeAdmin) is triggered by two things, and neither is "the session axis exists": (1) a CROSS-PRINCIPAL read — a filter naming a tenant or user other than the verified one; and (2) a MULTI-VALUE FAN-IN — a filter carrying more than one value on ANY axis (len>1), which is the uniform "wants many principals' rows in one read" gate every axis shares. What is explicitly NOT elevated is the common Console flow this decision protects: a read scoped to the caller's own (tenant, user) and a SINGLE session — whether that session is the caller's current one or one of their OWN OTHER sessions (a named single session_id is used AS-IS, never gated on != callerSession). So "session is an elevation axis" is FALSE for a single own-session read (the everyday Sessions-list / Playground-history flow), and the same len>1 ⇒ admin fan-in that governs the tenant and user axes also governs a MULTI-session set — a caller asking for several sessions at once is a fan-in, elevated exactly like a multi-tenant or multi-user set. §6.5's "cross-session observer" is therefore read as cross-PRINCIPAL / fan-in observation (the admin / Console fleet view reaching across principals or pulling many rows in one read), NOT a user reading one of their own other sessions.

Why. The tree already agrees on every observability surface, so this decision RECORDS the settled posture rather than changing behavior:

  • EventsFilterFromWire (internal/events/filter.go, the Tenant/User/Session branches): each axis uses the SAME switch — empty ⇒ caller's; a single value used as-is (tenant/user additionally elevate on a value != caller's); len>1 (the default branch) sets RequiresAdminScope=true. So a single own-session read is un-elevated, while a MULTI-session set fans in and elevates exactly like a multi-tenant/multi-user set. events.list / events.aggregate / events.subscribe all route through it.
  • Statestate.history (internal/protocol/transports/stream/state_history_handler.go): the crossIdentity gate keys on Tenant/User only; a session-scoped read of one's own history is never cross-identity.
  • Taskstasks.list (fleet read, internal/tasks/protocol): widening past the caller's own rows elevates on the tenant/user (admin) axis; a narrow read stays within the caller's sessions with no session-level elevation.

Forcing elevation on a single own-other-session read would break the single most common Console interaction (a user opening one of their own past sessions), contradicting the multi-isolation promise that "one user can be in multiple sessions concurrently" (CLAUDE.md §1, §6.1) — those sessions are all the same principal's own data. The len>1 fan-in gate is orthogonal: it is not "the session axis elevates" but "asking for MANY principals' rows in one read elevates", applied uniformly to every axis.

Findings I'm departing from. None — this ratifies the existing tree posture and the Phase 162 filter.go deferral it was waiting on.

Protocol additions. None. No method, type, event, or ProtocolVersion change. The only code touch is a one-line godoc pointer in internal/events/filter.go replacing the "tracked at the wave checkpoint" punt with the settled ruling (the decision number is referenced as "the decisions log ruling", not inlined, per the §13 no-D-NNN-in-godoc hygiene rule).

Cross-references. CLAUDE.md §6 (multi-isolation; §6.5 the clarified rule), §13 (no identity-downgrading knobs — this does not add one; it clarifies that same-principal own-session reads were never gated). RFC §6.13, §5.2, §7. Resolves the Phase 162 deferral in internal/events/filter.go (docs/plans/phase-162-events-list-durable-read.md).


D-300 — The v1.14 credential-plane invariant: no admin-writable field may determine where a credential is sent; every credential sink (token endpoint, downstream host, audience/scope ceiling) is boot-declared, and the shipped token-exchange edge is hardened in-band

Context. HA-15 (an external white-label implementor's ask) opened the v1.14 wave: a Protocol write for the MCP OAuth-discovery allowed origins and the oauth-provider binding. The first-cut design guarded it with the rule "no environment-variable NAMES on the wire." Two independent adversarial reviews returned NO-GO and CONVERGED: that rule is a SYMPTOM rule, not the invariant. Both proved the hole was still open, and both proofs are of SHIPPED Harbor (92f's add_mcp_connection add path + D-278's southbound oauth_provider binding), not an HA-15 regression:

  • token_url stayed on the writable descriptor. internal/tools/auth/drivers/tokenexchange/tokenexchange.go:582 POSTs the broker-resolved client_id + client_secret (and the identity subject_token) to exactly that URL. An admin names a LEGITIMATE boot-declared broker plus an attacker token_url and receives the org's real OAuth client_secret. The named-broker indirection RENAMED the exfil sink; it did not remove it.
  • Removing every URL from the descriptor is still not sufficient: the CONNECTION url is admin-writable, and the exchanged downstream token is injected there as a Bearer. resolveOAuthBinding (internal/tools/drivers/mcp/attach.go:288-320) checks transport / empty-URL / static-header conflict / provider existence and places NO constraint on the host; add_mcp_connection validation only checks "http transport requires a url", never WHICH url. And the provider name IS the default token audience (tokenexchange.go:214-217), so the caller picks the audience+scopes of the token they exfiltrate.
  • The token-exchange HTTP client is a bare &http.Client{Timeout: 30s} (tokenexchange.go:242-245) — default transport, default redirect policy (follows up to 10; Go REPLAYS the body on 307/308), so the client_secret form is re-POSTed to a redirect target. Its sibling credsource/drivers/remote/remote.go:127-134 ALREADY refuses every redirect precisely because it carries a bearer.

Decision. The v1.14 credential-plane invariant is: NO ADMIN-WRITABLE FIELD MAY DETERMINE WHERE A CREDENTIAL IS SENT. Every credential-sink-determining value — the token endpoint, the set of allowed downstream hosts a bearer may be injected into, the token audience, and the scope ceiling — is BOOT-DECLARED, config/file-only. This decision (D-300) implements the shipped-code half, as Phase 166, with NO new Protocol surface, folded into v1.14 in-band (§17.6 — no separate patch release):

  1. A boot-declared downstream-host allow-list (ToolOAuthProviderConfig.AllowedDownstreamHosts) bound to each provider; resolveOAuthBinding REFUSES a binding whose connection host is not on it — loud, typed, never a silent unauthenticated dial. Boot validation rejects an empty allow-list on any bindable provider (fail-closed: a bearer-injecting provider must declare its sinks). Covers EVERY provider driver, since the connection-URL exfil is reachable through the interactive oauth2 binding too (D-278), not only tokenexchange.
  2. A boot-declared audience/scope ceiling. The token audience is NOT derived from the caller-chosen provider name when a ceiling is declared; requested scopes are INTERSECTED against the boot ceiling (an out-of-ceiling scope is dropped).
  3. The token-exchange HTTP client is hardened to the discovery client's bar: a net.Dialer.Control hook refusing private-range / link-local / IP-literal destinations POST-DNS (discovery.go:260-281), Proxy: nil, and a CheckRedirect that REFUSES every redirect with a typed sentinel (the credsource/remote precedent — a client carrying a client_secret must never replay it). A caller-supplied client is shallow-copied and re-hardened, never mutated. Loopback is deliberately ALLOWED (a carve-out from the discovery client, which refuses it). The distinction is that this client only ever dials the boot-declared, config/file-only token_url / remote.url (resolved through BuildProviders, never a Protocol write and never the attacker-influenceable resource_metadata URL the discovery walker follows), so "trusted origin" genuinely holds — whereas the discovery client follows server-returned URLs and must refuse loopback. Allowing loopback is required by the legitimate localhost-sidecar broker deployment and mirrors the credsource/remote bearer client, which already carves out loopback for the same reason. The RFC1918 / link-local / metadata-service (169.254.169.254) backstop is retained, so the DNS-rebinding defence is unaffected. (Implementation deviation from this decision's first draft, which listed loopback among the refusals; recorded here per §4.3 because it is a permanent security carve-out.)
  4. The audit-ordering lie is fixed. handleSetRawHTMLTrust (internal/protocol/mcp.go:843-875) godoc claims "a failed audit emit fails the call closed", but it APPLIES the mutation then emits, and on emit failure its own error reads "trust toggle applied but audit emit failed." Both v1.14 write phases were about to cite this as the audit posture to copy, making their audit ACs unsatisfiable. It is corrected to a genuinely fail-closed ordering (emit-then-apply, or apply-then-emit-then-COMPENSATE) with the godoc rewritten to match — establishing the ONE audit posture Phases 168/169 reuse via a small shared helper.

Why. The env-var-name rule failed because it named one lever (an env var read into the request) and missed the others (the URL the request is sent TO, and the downstream URL the result is injected INTO). The generalized invariant names the PROPERTY — a credential sink — instead of one instance, so it closes the whole class: token_url, connection host, audience, and any future sink-shaped field are all boot-declared and none is admin-writable. The three shipped exfil paths are fixed in-band because the vulnerability exists in released Harbor and shipping the write phases on top of an unhardened edge would widen it; §17.6 requires fixing what the review finds where the bug lives. The credsource/remote redirect refusal and the discovery client's dial guard are cited as in-repo precedents so the hardening is a known-good posture, not a new invention.

Findings I'm departing from. None — this SUPERSEDES the earlier v1.14 "no env-var NAMES on the wire" framing (never released; it lived only in the first-cut plans PR, now revised on the same branch).

Protocol additions. None. Config-schema additions only (ToolOAuthProviderConfig.AllowedDownstreamHosts + the audience/scope ceiling), boot-declared and restart-required; examples/ updated; the mandatory-allow-list behaviour change is noted in the CHANGELOG migration note (a provider binding a downstream host it never listed now fails at boot, not at first call). The handleSetRawHTMLTrust fix changes ordering, not wire shape.

Cross-references. D-271 (the tokenexchange PULL driver hardened here), D-278 (the southbound binding whose sink is bounded), D-285 (the credential-source seam whose remote redirect-refusal is the precedent), D-297 (the discovery client whose dial guard is the precedent). CLAUDE.md §7 (no credential passthrough by default; no unbounded egress), §13 (no silent degradation), §17.6 (fix what the review finds, in-band). RFC §6.4, §6.15, §7. Plan: docs/plans/phase-166-credential-sink-hardening.md. Wave: docs/plans/wave-v114-coordination.md.


D-301 — Runtime-added MCP connections + Protocol-installed providers carry an OWNER tag used only for revision ownership + reconcile-view scoping; boot infra + the bare-name catalog stay process-global (extends D-287, does NOT reverse it)

Context. Harbor is a FRAMEWORK: a downstream team runs one runtime per tenant OR one runtime serving many. An earlier v1.14 draft (a superseded coordinator instruction) proposed keying the MCP connection registry and the OAuth-provider set by the (tenant, user, session) triple. Two delta reviews returned NO-GO and converged, and the objection is confirmed against code on three counts. (1) It BREAKS the common deployment: boot-declared MCP servers attach ONCE under a single deployment identity (mcpDefault, internal/runtime/assemble/assemble.go:929) but are read under many session triples, so full-triple keying fragments deployment-wide servers into per-session buckets and they vanish from the Console / posture for real sessions. (2) It does NOT even isolate: tool DISPATCH + bearer injection go through the process-global, BARE-NAME tool catalog (internal/tools/catalog.gobyName, Resolve(name), not identity-filtered), which Phase 169 explicitly declines to widen — so keying the registry METADATA leaves same-named runtime-added connections colliding (ErrToolDuplicateName) or cross-serving another tenant's OAuth bearer. (3) It silently REVERSES D-287 (settled + PR-464-hardened: "the catalog + MCP registry are shared across sessions … a refcount/drain protocol was considered and rejected") without a superseding decision. The reviewer was right that the two in-code NOTEs (projection.go:125-132, mcp_detacher.go:77-87) asked for a per-AGENT reconcile VIEW ("scope the attached set per agent"), NOT full-triple isolation keying — and §6 says agent_id is not an isolation key.

Decision. Boot-declared MCP servers and their catalog tools stay PROCESS-GLOBAL and deployment-shared; D-287's shared-catalog/registry/dispatch model is PRESERVED, not reversed. Do NOT key the boot registry or the tool catalog by identity. Runtime-ADDED connections and Protocol-installed providers (Phase 169) carry an OWNER tag (tenant, agent) — the same owner that owns their agent-config revision (ConfigScopeAgent) — used for EXACTLY two things: (a) the agent-config revision OWNERSHIP they already have, and (b) RECONCILE-VIEW SCOPING, so a run-start reconcile only ever touches ITS OWN owner's runtime-added entries — never boot servers, never another owner's adds. This is precisely what the two NOTEs asked for; the NOTEs are REWRITTEN (not deleted) to describe the deliberate process-global boot behaviour PLUS the new owner-scoped reconcile view. The owner tag is a reconcile-VIEW filter, never an isolation principal and never a dispatch or storage WHERE-clause key. Resolution stays by bare name (D-287). The wave claims NO hard cross-tenant isolation of runtime-added tool DISPATCH in a shared runtime — a false safety property was the prior draft's central defect (reviewer FAIL-A). The honest, bounded guarantee is stated plainly in the plan, this decision, and the Console runtime-add copy: in a shared runtime, runtime-added connection/provider NAMES share a deployment namespace and a collision fails loud (ErrToolDuplicateName); a shared runtime therefore TRUSTS its co-tenant admins for runtime-added connections, and a deployment needing hard isolation runs ONE-RUNTIME-PER-TENANT (which then gets full isolation for free — one tenant, everything in the global catalog is theirs).

This EXTENDS D-287; it does NOT supersede it. D-287's process-global catalog/registry/dispatch model is unchanged. This decision adds a NEW class (owner-tagged runtime-adds) with owner-scoped revision ownership + reconcile-view scoping on top of that model. Because nothing in D-287 changes, no RFC PR is needed — but this is stated explicitly so the next contributor does not read two contradictory rulings. The relationship is: D-287 governs the shared runtime-entity model; D-301 governs how a run-start reconcile scopes ITS VIEW over that shared model to one owner's runtime-adds.

Why. The three code facts above make full-triple keying both wrong and unsafe. The owner-scoped reconcile VIEW is the minimal fix for the real latent bug the NOTEs flag: in a multi-owner deployment the current reconcile enumerates the process-global registry and can detach boot servers or another owner's runtime-added connections. Scoping the reconcile enumeration by owner tag closes THAT without touching the shared catalog, the resolution path, or dispatch — and it is exactly the load-bearing dependency the write phases (168's allowance reconcile, 169's provider uninstall/rollback) need to be owner-safe. A false isolation claim would be worse than none: it would invite deployments to co-tenant a runtime believing dispatch is isolated when the bare-name catalog guarantees it is not.

Findings I'm departing from. The superseded v1.14 draft's "identity-key the registries by the triple" instruction (never released; it lived only in an earlier plans PR on this branch). The departure is recorded here per §16 (silent departure forbidden) and in the phase plan's "Findings I'm departing from" section.

Protocol additions. None. Internal owner-tag + reconcile-view scoping; no method, wire type, or event. The single-tenant deployment is behaviour-identical (one owner; the reconcile view is the whole registry, exactly as today).

Cross-references. D-287 (EXTENDED, not reversed — the shared-catalog/registry model this preserves; the two NOTEs this settles), D-297/D-302 (168's allowance reconcile that rides the owner-scoped view), D-303 (169's provider uninstall/rollback that rides it), D-025 (concurrent reuse). CLAUDE.md §1, §6 (multi-isolation; agent_id is not an isolation key; rule 10 concurrency tests), §7, §13 (one mechanism; no false safety property), §17.6. RFC §6.4, §6.16, §7. Plan: docs/plans/phase-167-owner-scoped-reconcile.md. Wave: docs/plans/wave-v114-coordination.md.


D-302 — The MCP OAuth discovery allowance is revisioned agent-config state with a LIVE write path (admin-only, server-derived, shared validator, symmetric revoke, live on rollback via an owner-scoped reconcile); the registry stays process-global bare-name (D-287); the "shipped bug" was a wiring gap, not a dropped field

Context. Phase 164 (D-297) shipped MCP OAuth-requirement discovery, whose RFC 8414 authorization-server hop is inherently cross-origin and requires a per-connection origin allowance — existing only as the restart-required yaml field mcp.servers[].oauth_discovery_allowed_origins (internal/config/config.go:1308), unreachable to a Protocol-driven consumer. This is the discovery-allowance write half of HA-15, re-homed from the original v1.14 Phase 166 after the wave was restructured (D-300/D-301). Two review corrections are folded in: (a) the earlier "SHIPPED BUG: MCPConnectionAttacher.Attach DROPS OAuthDiscoveryAllowedOrigins" statement is FALSE — nothing upstream CARRIES the field (AttachRequest, the descriptor, and the wire type all lack it); the consequence (discovery inert for every runtime-added connection) is real but the mechanism is a §17.1 cross-package WIRING GAP (164 shipped the walker, 92f shipped the add path, neither joined them). The discredited "regression test fails against the pre-fix attacher" discriminator was unachievable (no field exists pre-fix); the real discriminator is a within-phase round-trip guard. (b) the write's rollback/reconcile leg must ride the OWNER-SCOPED reconcile (D-301), because lockAgent alone gives zero cross-tenant protection — but the registry itself stays PROCESS-GLOBAL bare-name (D-287, NOT re-keyed; triple-keying was rejected by two reviews as breaking the mcpDefault deployment, not isolating the bare-name catalog, and reversing D-287).

Decision. As Phase 168: agentcfg.MCPConnectionDescriptor gains the NON-SECRET OAuthDiscoveryAllowedOrigins, inheriting the revision spine (versioned / diffable / rollback-able) for free; the wiring gap is closed end to end (descriptor → AttachRequestconfig.MCPServerConfig → the registry). ONE narrow admin verb agent_config.set_mcp_discovery_origins {agent_id, name, allowed_origins[]} (FULL REPLACE) writes the revision under lockAgent (siblings carried forward, D-283) AND applies it to the PROCESS-GLOBAL bare-name live registry (D-287 — NOT re-keyed) via a new Registry.SetOAuthDiscoveryOrigins mutator (identity-mandatory for auth, mutex-guarded, returns prior). Binding invariants, each a named test: (1) revoke is live and symmetric — dropping an origin refuses the next hop AND prunes the recorded oauth_requirement's AS entries fetched from the revoked origin, by building a FRESH requirement and swapping the stored pointer under r.mu.Lock() (the registry hands the requirement out by pointer, registry.go:605, so in-place mutation is a data race); (2) rollback / set_revision take effect live through a run-start allowance-reconcile leg beside ReconcileConnections (OWNER-scoped per D-301) that does a FULL IDEMPOTENT re-prune of each of the owner's connections' allowance against its current revision — a revisioned write with no live effect is the exact "changes the revision not the runtime" silent half-write this design rejects for a general patch verb, so it is not reintroduced for rollback; (3) allowance ≠ SSRF bypass — a granted origin resolving private/loopback is still refused at dial by the post-DNS net.Dialer.Control guard; (4) origins run the SHARED config.ValidateDiscoveryOrigin (exported; the boot validator becomes its caller — one implementation); (5) admin-gated by inheriting the handler's default: arm, authority server-derived (D-219), no new scope (D-284); (6) audit uses D-300's corrected fail-closed ordering. NO general update_mcp_connection patch verb (attach-time fields patched without re-attach are silent half-writes; the allowance is the one field re-read per Discover). NO allowance-generation counter — a revoke landing mid-walk yields at most one stale record (the walk holds a per-call snapshot), self-healed by the next run-start FULL idempotent re-prune (the bound is a matching write-or-run, not unbounded); documented rather than defended by a counter. Console (D-062): the write is single-homed on /agent-config (beside diff/rollback), and /mcp-connections's needs_allowance deep-links to it — resolving the connection→agent mapping from the agent-config registry first (the MCP page carries no agent context), rendering honesty copy + no link for a boot-declared or unowned connection; the connections card gives Phase 156's caller-less remove_mcp_connection its first Svelte caller.

Ruling on the 92m collision (§13). The parked 92m (docs/plans/README.md:1349) plans an optional agent-bound OAuth block on the same add_mcp_connection request — a second Protocol-writable auth affordance. §13 forbids two parallel implementations. The v1.14 provider surface (Phase 169's install + the existing oauth_provider binding) is the one home; an unparked 92m routes through it. Pointer notes are written into phase-92m and phase-92k in this PR.

Findings I'm departing from. None on design. One correction to a PRIOR phase's defect statement (the false "dropped field" framing), restated honestly above and in the plan.

Protocol additions. One canonical method (agent_config.set_mcp_discovery_origins, admin-gated) + its request/response types + an additive oauth_discovery_allowed_origins on AgentConfigMCPConnectionDescriptor. Registered in all canonical homes (the methods.go prose counts updated); make protocol-ts-gen + make protocol-docs-gen re-run, artifacts committed (D-223 / D-209). ProtocolVersion unbumped.

Cross-references. D-297 (the walker + the allowance), D-301 (the owner-scoped reconcile this rides for rollback; the registry stays process-global bare-name, D-287 — the shared-registry write is safe because a runtime-added name is deployment-global and the reconcile is owner-scoped — the FAIL-5 clobber concern is resolved by owner-scoping, not by keying), D-300 (the corrected audit ordering), D-287 (the reconcile seam + remove_mcp_connection), D-283, D-219, D-284, D-062, D-121, D-025. CLAUDE.md §6, §7, §13, §17.1 (the wiring-gap framing), §17.6, §17.8. RFC §6.4, §6.16, §5.2, §6.15, §7. Plan: docs/plans/phase-168-mcp-discovery-allowance-write.md. Wave: docs/plans/wave-v114-coordination.md.


D-303 — An OAuth provider descriptor is Protocol-installable ONLY in a ZERO-URL broker-pull shape; no field on the writable descriptor is a URL or an env-var name; installed providers live in a provider SET (bare-name resolution + owner-tagged reconcile; not the driver registry, not a §4.4 seam), and uninstall closes the provider and fails bound calls loud within the owning owner

Context. The provider-install half of HA-15, de-scoped and re-homed from the original v1.14 Phase 167 after the reviews. A runtime-added MCP connection can already NAME a provider (the non-secret oauth_provider binding rides the descriptor and AttachRequest end to end), but the provider must exist, and the provider list is a boot-built by-value map[string]auth.OAuthProvider. The earlier "non-secret broker-pull descriptor" still carried token_url — which D-300 proves is a credential sink (the org's client_id/client_secret are POSTed there).

Decision. As Phase 169, honouring D-300's generalized invariant (no admin-writable field may determine where a credential is sent): the Protocol-writable provider descriptor carries ZERO URLs{name, credential_broker, scopes?} (with driver / credential_source validated to exactly tokenexchange / remote). The token endpoint, the allowed downstream hosts, and the audience/scope ceiling are all pinned at boot on a NAMED credential broker (D-300's AllowedDownstreamHosts + the broker's token_url); the descriptor references the broker by non-secret NAME. The invariant is trivially testable: a reflective test asserts the wire struct exposes NO URL-typed or env-var-name field, and because the forbidden fields (token_url, auth_url, client_id_env, client_secret_env, remote) are simply NOT on the struct, DisallowUnknownFields() rejects any of them BY NAME (json: unknown field "client_secret_env") — a loud, field-naming reject with no decoy fields (the strongest resolution of the "how do you name the offending field" problem: it cannot exist). Empty credential_source is a LOUD reject (in config "" means the env source, which this shape forbids). Backing it: auth.ProviderSet — a NEW type in a NEW file internal/tools/auth/providers.go (NOT registry.go, which is the OAuth DRIVER registry; a third Install/Uninstall type there guarantees driver-vs-instance confusion), one interface + one internally-synchronised concrete (D-025), PROCESS-GLOBAL bare-name for RESOLUTION (D-287) with an OWNER tag on runtime-installed entries for reconcile scoping (D-301), seeded at boot from BuildProviders, consulted by the MCP attach path. It is NOT a §4.4 driver seam (§4.4 is interface + drivers/<name>/ + factory + internal/drivers/prod blank-import; a provider SET holds instances, so no drivers/ dir, no prod registration — said explicitly so nobody builds a factory with nothing to dispatch). The catalog builder keeps its boot map (tools.entries[] bindings are boot-declared / boot-ordered, D-292). INSTALL and UNINSTALL ship together: agent_config.set_oauth_provider (upsert + live Install) and agent_config.remove_oauth_provider (drop + live Uninstall, which CLOSES the provider — verified tokenexchange.go:360 → the bound connection's next call fails loud at mcp.go:1166-1182); rollback past an install runs the SAME uninstall through the run-start reconcile seam (one mechanism, N triggers). Uninstall is deliberately breaking and defensible ONLY because D-301 owner-scopes the reconcile: a tenant-B run reconciles only its own owner's installed providers, so B can never close A's provider (the process-global reconcile-uninstall cross-tenant outage is closed by the dep on D-301). A boot-declared name collision is refused; a shared-runtime cross-owner install-name collision fails loud (D-301's bounded guarantee — no silent shadow, no hard dispatch-isolation claim). The binding half is Console-only: the Add-connection card gains an oauth_provider SELECT from the installed list, fixing the silent drop at state.svelte.ts:912-923.

Findings I'm departing from. brief 09 is positive on RFC 7591 dynamic registration and on reducing operator config burden; this decision does NOT make the interactive oauth2 driver (or any env-named local-secret provider) Protocol-installable, and it carries ZERO URLs on the writable shape. The departure is the security judgement two reviews forced: a URL or env-var name on an admin-writable descriptor is a credential sink (D-300). The broker-pull provider IS installable, so the brief's operator-burden intent is served for the case that motivated HA-15 — just not in a shape that hands an admin caller a sink.

Protocol additions. Two canonical methods (agent_config.set_oauth_provider, agent_config.remove_oauth_provider; both admin-gated) + their request/response types + a zero-URL provider view type + an additive OAuthProviders section on the agent-config payload. The named-broker config surface lands in D-300; this phase references it. Registered in all canonical homes (prose counts updated); make protocol-ts-gen + make protocol-docs-gen re-run, artifacts committed (D-223 / D-209). ProtocolVersion unbumped.

Implementation deviation (§4.3) — the boot broker completes the boot-pinned broker-pull provider + gets the audience/scope ceiling. The plan's draft said "this phase adds NO config key, it only references a broker by name." Two things forced additive fields on the D-300 broker (ToolOAuthCredentialBrokerConfig, boot-declared, config/file-only, restart-required — NEVER wire-writable, so the invariant is unaffected): (a) a tokenexchange/remote broker-pull provider PULLS its org client credential from a coordinator endpoint (D-271) whose URL is a credential sink and must be boot-pinned — the shipped broker declared only the exchange token_url, so credential_url is ADDED (the boot-pinned remote pull URL, authenticated by the existing auth_token_env); (b) the 166 review WARN "the broker-pull provider needs a boot-declared audience + scope-ceiling" — audience (pinned server-side) + scope_ceiling (the installed descriptor's requested scopes are INTERSECTED against it, so an install can never widen scope past the boot ceiling) are ADDED. All three are boot-declared sinks/ceilings — no admin-writable field determines any of them, so the credential-plane invariant holds. The KEK env is now REQUIRED when a broker is declared (an installed provider shares the boot token store). examples/ shows a broker declaring these fields; the plan's "adds no config key" line is superseded by this note.

Cross-references. D-300 (the invariant + the named broker that pins every sink), D-301 (the owner-tagged provider set + owner-scoped reconcile — makes uninstall/rollback owner-safe; process-global bare-name resolution, D-287), D-302 (the sibling that lands the editor surface + the 92m ruling), D-271 (the only writable driver; PULL preserved), D-285 (the credential-source seam), D-278 (the binding), D-287 (the install/uninstall symmetry lesson), D-283, D-292 (the catalog-builder boundary), D-219, D-284, D-297 (report-don't-follow — no auto-install), D-062 + D-121, D-025. CLAUDE.md §4.4 (what a driver seam IS — and what this is not), §7, §13, §17.8. RFC §6.4, §6.16, §5.2, §6.15, §7. Plan: docs/plans/phase-169-oauth-provider-install-binding.md. Wave: docs/plans/wave-v114-coordination.md.



D-304 — The MCP OAuth-discovery dial backstop is relaxed for EXACTLY the same-origin protected-resource hop, pinned to the connection's operator-declared dial target (host:port + resolved-IP set); the closure of the two-guards-disagree defect (HA-19), with the DNS-rebinding vector closed by pinning to the resolved IP, not the origin string — and NO production private-network knob

Context. Phase 164 (D-297) shipped the report-only MCP OAuth-requirement discovery walker with SSRF guardrails "specified PER HOP." As built, the walker enforces SSRF policy in TWO layers of internal/tools/auth/discovery.go that DISAGREE. (1) The per-hop policy validateHop (:427) computes sameOrigin (:435) and, for the protected-resource hop (case StepProtectedResource: at :442), PERMITS same-origin and refuses only cross-origin-without-allowance (:444-446); its cross-origin IP-literal refusal fires only if !sameOrigin && !d.allowPrivate (:455), and it accommodates loopback in the https-off-loopback scheme check (:449,:451). So by its OWN policy, a same-origin protected-resource hop to a private-address MCP server is PERMITTED. (2) The dial-time backstop — net.Dialer.Control installed by NewDiscoverer (:260-281) — UNCONDITIONALLY refuses any resolved private/loopback IP via isPrivateIP (:276-278), with no same-origin exemption and no reference to which hop/step it serves. So the same-origin hop validateHop just approved dies at connect. The only relaxation, WithPrivateNetworkAccessForTest (:232), PANICS outside a test binary (:234-235), so there is NO production config/env/per-connection path to the working dial. Consequence (reproduced live against v1.13.0): discovery can NEVER complete against an MCP server on localhost / a container-compose network / a private VPC — the ORDINARY self-hosted posture (runtime + MCP servers on the same private network). It dies on the FIRST hop — the RFC 9728 protected-resource-metadata fetch back to the very server the runtime is ALREADY dialing successfully for tool calls — one hop EARLIER than the correct allowance boundary. The feature's whole point (reaching needs_allowance so an operator can grant the authorization-server origin, HA-15/Phase 168's surface) is unreachable; the runtime never learns the requirement; the consumer's consent gate has nothing to render. The green suite masked this because EVERY positive-path chain-walk test constructs the discoverer with WithPrivateNetworkAccessForTest (discovery_test.go :103/:204/:277/:307/:328/:350/:374/:504/:543; mcpconsole/oauth_discovery_test.go:63; test/integration/phase164_mcp_oauth_discovery_test.go:114) — the escape hatch that hides the exact dial the production path cannot make, so validateHop's same-origin branch was never exercised end-to-end against a real dial (§17.8: a fixture that can't tell right-dial from wrong-dial is a rubber stamp).

Decision. Align BOTH dial-time guards to the per-hop policy validateHop already computes, relaxing them for EXACTLY one hop, one origin, pinned to the connection's actual dial target IP:port. (1) ControlControlContext. NewDiscoverer swaps net.Dialer.Control for net.Dialer.ControlContext(ctx, network, address, RawConn) (Go 1.20+; the module is Go 1.26), which retains the load-bearing post-DNS-resolution property (it sees the resolved ip:port, the DNS-rebinding defence) AND receives the fetch ctx; it sees the resolved IP:port, NOT the hostname, so the gate is expressed on the resolved address. The discovery transport ALSO sets DisableKeepAlives: true so every dial — including each redirect hop — re-enters ControlContext (a pooled private connection reused on a later hop would otherwise skip the step-gate). (2) Resolve the pin ONCE per walk + carry it on ctx. Discover resolves the operator-declared boot-config DiscoveryInput.ServerURL host — a TRUSTED origin, NOT the attacker-influenceable ResourceMetadataURL — into its full resolved IP SET (pinnedIPs, all A/AAAA records) and records pinnedPort at walk start (a single resolution, threaded into every fetchHop — not re-resolved per hop). fetchHop attaches a dialPin (step + sameOrigin + pinnedIPs + pinnedPort) to the per-fetch ctx. The pin rides ctx (per-run state), never the shared Discoverer struct — D-025-clean. (3) The relaxation (IP:port pinned). ControlContext permits a private resolved address ONLY when the pin is the same-origin protected-resource hop AND the resolved IP is a member of pinnedIPs AND the resolved port equals pinnedPort; otherwise it refuses fail-closed. The PORT is part of the pin because IP-set membership alone would let a same-origin 302 → {pinnedIP}:22 (SSH) / :6379 (Redis) through — intra-host port SSRF newly reachable because the fix enables the private dial. d.allowPrivate (test-only) short-circuits to allow, UNCHANGED. Cross-origin and authorization-server hops keep the full private-IP refusal. (4) The SECOND guard — extend https-off-loopback for the pinned same-origin hop. validateHop's https-off-loopback refusal (discovery.go:449-452; isLoopbackHost true ONLY for localhost/127.x/::1) ALSO blocks the target posture — a plain-HTTP NON-loopback MCP server (compose service name http://mcp:8080, k8s http://mcp-svc.ns:8080), which Harbor config PERMITS (unlike A2A peers, MCP-server config imposes no https requirement). Two of HA-19's four named postures would otherwise stay inert while a loopback-bound suite goes green. The relaxation is EXTENDED to this check: plain-HTTP is permitted for the same-origin protected-resource hop to the pinned target; a non-pinned or cross-origin plain-HTTP target still returns ReasonNotHTTPS. The extend path (over narrowing to "loopback-HTTP + private-HTTPS") is chosen because Harbor is self-hosted-first and compose/k8s is THE deployment story, and the safety argument is identical to the dial relaxation: the connection already dials that plaintext host for tool calls unguarded, and discovery carries NO credentials — the https check guarded against plaintext credential leakage, of which a credential-free metadata hop has none. (5) The DNS-rebinding crux — pin to the resolved IP, not the string. "Same-origin" is computed on the ORIGIN STRING, but the SSRF defence is by RESOLVED IP; the ResourceMetadataURL is attacker-controlled. A naive "same-origin ⇒ allow private" reopens rebinding: a metadata URL / same-origin-by-string redirect resolving to a DIFFERENT private IP would slip through. The walk-start pinnedIPs set + pinnedPort gate refuses it on membership. A redirect is enforced by ControlContext on the redirect's dial (NOT a validateHop re-entry — validateHop runs once on the initial target; CheckRedirect only bounds count + strips credentials), and DisableKeepAlives guarantees the redirect dial actually re-enters the gate. (6) No production private-network knob. The production path becomes reachable through the same-origin-pinned mechanism ONLY; NO allowPrivate config/env/option is exposed, and WithPrivateNetworkAccessForTest stays test-only (still panics outside a test binary). (7) Documented residual boundary. Walk-start resolution closes the discovery-specific rebinding vector (the attacker-influenced metadata URL / in-walk redirect) but not a TOCTOU rebind of the operator's OWN declared ServerURL host between the tool-call connect and the discovery walk — that same rebind also compromises the connection's tool calls (which dial the same host with no SSRF backstop today), so it is outside the discovery-specific threat boundary. Connect-time resolved-IP capture (pinning discovery to the IP set the established MCP transport actually uses) is the stronger follow-up, not built here (it needs IP capture plumbed out of the MCP driver's dialer; larger blast radius, no additional protection for the self-hosted posture this closes). A granted oauth_discovery_allowed_origins allowance still does NOT let the AS-metadata hop reach a private-address authorization server (the AS hop is cross-origin → still private-refused); needs_allowance + the AS issuer read from the RFC 9728 document is what surfaces — the intended custody/SSRF posture, recorded so the operator UX is honest.

Preserved security properties (each a named test or an explicit AC). Cross-origin hops keep the private-IP refusal AND the DNS-rebinding defence; a redirect off the pinned origin re-validates and is refused exactly as today; the oauth_discovery_allowed_origins allowlist still gates EVERY authorization-server hop (needs_allowance stays the intended halt); bounded redirects (3), body-size cap (8 KiB), per-fetch timeout (5 s), no-proxy, and credential-stripping on redirect are unchanged; RFC 7591 dynamic registration stays REPORTED never INVOKED (D-297 report-don't-follow); the runtime still NEVER runs the OAuth flow and never holds a token (D-271); the relaxation applies to ONE hop (protected-resource), ONE origin (the connection's own operator-declared server), and NOTHING else.

Why. The per-hop SSRF specification D-297 authored was correct; the dial backstop was STRICTER than the specified policy and overrode it for the one hop the policy meant to allow. Aligning the backstop to the policy is the minimal, correct closure — it does not weaken the attacker-influenceable legs (cross-origin + authorization-server), which is where the real SSRF exposure lives. Pinning to the resolved-IP set rather than the origin string is what keeps the relaxation from reopening rebinding: the operator-declared ServerURL is trusted and already dialed for tool calls, so a metadata hop to the SAME resolved target is exactly as safe as the tool calls the runtime already makes. Refusing to expose an allowPrivate production knob keeps the fail-closed posture (§13: no identity/guardrail-downgrading knobs); the working path is reachable ONLY through the pinned mechanism, so a misconfiguration cannot silently disable the SSRF backstop. §17.8: the new positive-path tests construct the PRODUCTION discoverer (no test-only escape) against the Phase 164 spec-derived RFC 9728/8414 fixtures, and the rebind test must be shown to WRONGLY-PERMIT under a host:port-only pin before the resolved-IP membership check is added — the discriminator that would have caught the original gap.

Findings I'm departing from. None — this corrects an as-built defect in Phase 164 and STRENGTHENS D-297 point (5) by making the dial backstop obey the per-hop SSRF policy the decision already specified.

Protocol additions. None. No method, wire type, event, config field, or ProtocolVersion change; no D-223 / D-209 churn. The fix is a dial-policy correction internal to internal/tools/auth/discovery.go (NewDiscoverer + fetchHop); validateHop, NewDiscoverer/Discover/DiscoveryInput signatures, and WithPrivateNetworkAccessForTest are unchanged in surface.

Cross-references. D-297 (the discovery walker this fixes; the SSRF-per-hop specification this backstop now obeys), D-271 (tokenexchange PULL — the custody posture this preserves), D-246 (the parked 92p sibling that reuses the corrected walker), D-219 (authority/identity from verified ctx, never request data — the same discipline read into the dialer: the pin derives from the operator-declared ServerURL, never the attacker-influenced metadata URL), D-025 (per-run state rides ctx — the dial pin, never the shared artifact), D-062 (the Console MCP-Connections consumer whose discovered-requirement rendering the fix makes reachable for the self-hosted posture). CLAUDE.md §7 (SSRF posture; no credentials on discovery fetches; fail-closed), §13 (no guardrail-downgrading knobs; fail-loudly), §17.1 (integration test on the self-hosted posture), §17.8 (spec-derived fixtures on the production construction path). RFC §6.4, §5.2, §7. Plan: docs/plans/phase-170-same-origin-discovery-dial.md.


D-305 — events.aggregate reads through the HistoryReplayer cross-session windowed fan-in (the same substrate events.list uses), not the per-session Replayer.Replay path — closing HA-18 (durable 500) AND, in the same phase, the events-driver conformance-matrix hole that let it ship (HA-20)

Date: 2026-07-13

Status: Shipped (Phase 171, v1.14 Track B)

Where it lives: docs/plans/phase-171-events-aggregate-durable-parity.md; internal/events/aggregate.go, internal/protocol/transports/stream/handlers.go, internal/events/conformancetest/.

Context (HA-18). events.aggregate returns HTTP 500 on the durable driver on every call. The aggregator replays through the per-session Replayer.Replay path with a session-less Filter{Admin:true} (aggregate.gobusFilter := Filter{Admin: true}, Replay(Cursor{Sequence: 0}, busFilter)); the durable driver correctly refuses a session-less admin replay (ErrIdentityScopeRequired, "durable replay requires a SessionID"), while the inmem driver honours Admin:true and fans in. So the method works in dev (inmem) and 500s in prod (durable). Meanwhile events.list ALREADY performs the session-less admin cross-session fan-in on durable successfully — listWindowDurable gathers every session head via the StateStore.ListKind maintenance scan and filters per event with MatchWire. The aggregator wants exactly that fan-in and already does its own per-event filtering.

Context (HA-20). The events-driver conformance matrix (self-registering registry + a shared conformancetest.Run suite both drivers pass, durable wiring a real StateStore) never exercised the session-less admin read the aggregator depends on, and events.aggregate appears ZERO times in the suite. ListWindow_Admin_FansInAcrossSessions exists and is covered — which is why events.list works in prod and events.aggregate did not.

Decision. (1) HA-18 fix: the aggregator sources its window snapshot from the HistoryReplayer cross-session windowed fan-in (the ListWindow family) — the SAME substrate events.list uses — instead of Replayer.Replay. It issues ONE windowed fan-in read over the effective [since, until) window, threading the effective bounds onto the substrate query (q.Filter.Since/Until, not only a post-loop guard, so an Until-clamped sub-window whose newest global matches are all newer than Until is not silently dropped): one read ⇒ exactly one audit.admin_scope_used on the widened path; a generous aggregation bound keeps memory equal to today's whole-ring materialization. A window too wide to count within the bound returns the PARTIAL buckets with an additive EventAggregateResponse.Truncated = true — UNIFORMLY on both drivers (inmem when its ring evicted below the requested window, durable when the scan hit the bound) — NOT a 400. An earlier draft used ErrAggregateWindowTooLarge → CodeInvalidRequest; that was wrong — because EventAggregateResponse carried no partial signal, the over-bound case fired on durable (unbounded log) but never on inmem (physically-bounded ring) for the SAME request, re-introducing the exact "a driver difference changes WHETHER it works" fork HA-20 exists to kill and breaking the four-method parity contract (200-vs-400 is neither the same answer nor the same named sentinel). So 171 is NOT zero-wire: it adds ONE additive field, EventAggregateResponse.Truncated bool (ProtocolVersion stays 0.1.0; full D-223/D-209 lockstep). The handler's widened decision (D-299 — cross-principal OR multi-value fan-in) is computed on the RAW, PRE-FOLD wire filter (widened := conv.RequiresAdminScope, byte-mirroring events_list_handler.go:167-206) and passed to the aggregator as a Go-level input (Admin: widened), NEVER a wire field (authority is never read from the request body, CLAUDE.md §6 item 5) — deriving it from the POST-fold filter would let a genuine cross-tenant read (TenantIDs:[T2], user/session folded to the caller) present a complete folded triple, evaluate widened=false, and run UN-audited AND un-fanned (the wrong intersection), worse than the over-audit this fixes. This also fixes a latent audit-integrity bug: the hardcoded Filter{Admin:true} emits admin_scope_used TODAY on inmem for EVERY aggregate (inmem.go:640), including non-admin own-session reads, so the "non-admin emits zero admin_scope_used" regression test guards a REAL current defect. A bus without HistoryReplayer yields ErrReplayUnavailable (loud), never a silent empty series. events.aggregate and events.list now agree by construction on what a session-less admin read means. Redaction, retention, bucket identity axes, and scope rules are unchanged. Bucket contents change in exactly ONE honest way (owned, not buried): the new substrate EXCLUDES bus-internal notice types (IsBusInternalNoticeadmin_scope_used, bus.dropped, audit.redaction_failed, bus.subscription_idle_closed; filter.go:301, durable.go:1129), whereas today's Replay(Filter{Admin:true}) + MatchWire path counts them into type buckets and self-pollutes (the admin Replay emits an admin_scope_used into the ring it then counts). Real-event-type counts are byte-identical old-vs-new; the ONLY delta is those four notice types — a latent-bug FIX adopted intentionally, pinned by a guarded old-vs-new-inmem test (a parity test alone masks it: both new-path drivers exclude and agree). Replayer.Replay is UNCHANGED (its per-session SSE-reconnect contract, including its session-less-admin refusal on durable, is correct and stays). (2) HA-20 closure: the conformance suite gains a driver-parametrized aggregate scenario (Aggregate_Admin_SessionLess_FansInAcrossSessions) over a MULTI-TENANT fixture (≥2 tenants × ≥2 users × ≥2 sessions — the harness already seeds tenant A/B/C) carrying an explicit ISOLATION assertion (parity ≠ isolation — two drivers sharing a cross-tenant leak agree on the leaky answer and would pass a parity-only check), and a method-parity leg running events.aggregate / events.list / events.subscribe / state.history against every registered driver for the same request, asserting the same answer OR the same named-sentinel difference; a registry gate that BLANK-IMPORTS internal/drivers/prod (so events.RegisteredDrivers() is actually complete) fails the build if any registered driver has no conformance run wired. The driver-parity thesis is made executable: a driver difference may change WHAT a method returns (retention depth surfaced via truncated, an observed horizon) but NEVER WHETHER it works — differences stay DATA / named sentinels (ErrReplayUnavailable, truncated), never a 500 and never a status-code fork, never normalized away. Do NOT make inmem durable and do NOT normalize retention depth.

Findings I'm departing from. The ask named the new scenario Replay_Admin_SessionLess_FansInAcrossSessions. After the fix the aggregator no longer depends on Replayer.Replay for the session-less read — it depends on the HistoryReplayer fan-in, whose session-less admin behaviour is already pinned (ListWindow_Admin_FansInAcrossSessions). Making Replay fan in session-less on durable would add a code path with no live consumer (Replay is the per-session reconnect path) — a §13 primitive-without-consumer smell. So the real matrix hole is one level up: events.aggregate is not parametrized by driver at all. The scenario lands as Aggregate_Admin_SessionLess_FansInAcrossSessions at the aggregate substrate plus the four-method parity leg; the Replay vs ListWindow session-less-admin divergence is additionally pinned as a uniform named contract so it is DATA, not a silent "whether it works" fork. Name + granularity change, not scope change.

Protocol additions. ONE additive field — EventAggregateResponse.Truncated bool (the DATA-not-500 partial signal; no method / error / event, no request-shape change). ProtocolVersion stays 0.1.0 (additive). Full D-223 lockstep (make protocol-ts-gen, wire-manifest.gen.json regenerated) + D-209 (make protocol-docs-gen, docs/site/protocol/types.md regenerated) in the same PR; §18 use-the-harbor-protocol SKILL.md updated (the aggregate response wire shape gained a field).

Cross-references. D-294 (events.list / the HistoryReplayer.ListWindow fan-in this reuses; the durable head-scan; the IsBusInternalNotice exclusion this adopts), D-283 (fix-the-instance-AND-close-the-class-same-PR precedent), D-299 (the widened decision is server-derived, computed on the raw pre-fold filter; single own-session read is not elevation), D-254 (state.history / the HistoryReplayer seam), D-025 (the aggregator stays a concurrent-reuse artifact), D-223/D-209 (the lockstep for the one additive field). CLAUDE.md §9/§11 (driver parity + shared conformance suite), §6 (multi-isolation; item 5 elevated scope server-derived), §13 (no silent degradation), §17.1/§17.8 (real-driver + real-StateStore integration test). RFC §6.13, §5.2, §6.5, §4, §7. Plan: docs/plans/phase-171-events-aggregate-durable-parity.md.


D-306 — events.aggregate gains an optional origin/epoch anchor so bucket boundaries fall on a fixed, addressable grid; absent ⇒ today's clock-anchored behaviour

Date: 2026-07-13

Status: Shipped (Phase 172, v1.14 Track B)

Where it lives: docs/plans/phase-172-events-aggregate-epoch-grid.md; internal/protocol/types/events.go, internal/events/aggregate.go.

Context. The aggregator lays its bucket grid from the wall-clock instant at handler entry (windowStart := now.Add(-req.Window)). Window % Bucket == 0 constrains bucket-series LENGTH but never ORIGIN, so alignment onto a fixed grid is arithmetically unreachable: two calls at two instants return two different bucket-boundary sets, a bucket_start is not addressable twice, and no consumer can legally cache an aggregate bucket.

Decision. Add a TRULY-OPTIONAL anchor field to EventAggregateRequest (Anchor *time.Time, json:"anchor,omitempty" — a pointer, not a time.Time struct value, because omitempty is a no-op on a struct and would force a non-optional TS mirror; nil ⇒ today's now-anchored grid). When set, bucket boundaries are floored onto the grid anchor + k·Bucket, so the response covers the Window's worth of buckets aligned to that grid; passing the Unix epoch yields a globally-shared grid. Two calls at two instants with the same anchor + window + bucket share boundary instants — a bucket is re-requestable and cacheable, and a cold N-bucket fill becomes ONE call whose buckets align to the next poll's grid. The chosen shape is the single additive Anchor field rather than explicit {since, until, bucket} boundaries because it is the smallest additive surface, composes with the existing Window/Bucket pair, and does not duplicate the request's Filter.Since/Until clamp. NOT part of the ask: no change to bucket contents, redaction, retention, or identity axes.

Findings I'm departing from. None.

Protocol additions. EventAggregateRequest.Anchor (additive optional field; no new method; response shape unchanged — EventBucket.Start/End become grid coordinates when the anchor is set). ProtocolVersion stays 0.1.0 (additive). Full D-223 lockstep (make protocol-ts-gen, wire-manifest.gen.json regenerated) + D-209 (make protocol-docs-gen, docs/site/protocol/types.md regenerated) in the same PR; §18 use-the-harbor-protocol SKILL.md updated.

Cross-references. D-305 (171 — the aggregate must work on the durable driver before its grid matters; 172 depends on it), D-223 (wire lockstep), D-209 (generated Protocol reference). CLAUDE.md §10 (additive/backward-compatible config discipline — no config field here), §18 (skill hygiene). RFC §6.13, §5.2, §7. Plan: docs/plans/phase-172-events-aggregate-epoch-grid.md.


D-307 — events.aggregate carries opt-in per-tenant attribution for admin-widened reads, making the tenant boundary independently verifiable on aggregates the way it already is on rows

Date: 2026-07-13

Status: Accepted (landed with Phase 173)

Where it lives: docs/plans/phase-173-events-aggregate-tenant-attribution.md; internal/protocol/types/events.go, internal/events/aggregate.go, internal/protocol/transports/stream/handlers.go.

Context. An aggregate bucket is a bag of scalars ({"tool.invoked": 7}) with NO tenant attribution. Unlike a row read (sessions.list / tasks.list / events.list), where every row carries its own tenant and a consumer post-filters the merged result against its entitled set, an aggregate consumer CANNOT verify an admin-widened count against the Filter.TenantIDs it asked for — for an aggregate the runtime's honouring of the filter IS the entire tenant boundary, a single point of enforcement with no downstream check.

Decision. Add opt-in per-tenant attribution: a request flag (ByTenant bool, json:"by_tenant,omitempty") and, on the response, EventBucket.CountsByTenant map[string]map[string]int64 (tenant → event_type → count, json:"counts_by_tenant,omitempty") alongside the existing Counts totals. Attribution is returned ONLY for admin-widened reads (the verified admin OR console:fleet scope set, derived server-side per D-299 — never the request body). Concrete bound: the attribution keys are a SUBSET of the authorized (named-or-folded) Filter.TenantIDs, and Counts and CountsByTenant are scoped to the IDENTICAL set BY CONSTRUCTION (both from the same MatchWire pass over the same authorized filter), so Σ CountsByTenant[*][type] == Counts[type] holds. There is NO per-tenant entitlement mechanism: ScopeAdmin / ScopeConsoleFleet are GLOBAL binary fan-in grants — the request body SELECTS the tenants and the scope AUTHORIZES the fan-in (no D-219 issue). An unelevated caller gains attribution for NOTHING it could not already read (a non-widened read yields at most the caller's own single tenant — no new information). The invariant proves attribution is a pure re-projection of the already-authorized counts, not a second looser read path. Per-bucket (not a response rollup) so it composes with the time series and D-306's grid; a rollup is derivable by summing buckets. Existing callers (no flag) see byte-identical responses. This makes the isolation boundary independently verifiable on aggregates the way it already is on rows (§6 defence-in-depth). NOT part of the ask: no payloads and no new identity axes — a count per (tenant, event_type) suffices.

Findings I'm departing from. None.

Protocol additions. EventAggregateRequest.ByTenant + EventBucket.CountsByTenant (additive optional fields; no new method). ProtocolVersion stays 0.1.0 (additive). Full D-223 lockstep + D-209 regen in the same PR; §18 use-the-harbor-protocol SKILL.md updated.

Cross-references. D-305 (171 — the aggregate must work on the durable driver and carry the server-derived widened decision before attribution can ride it; 173 depends on it), D-306 (composes with the anchored grid — same response), D-299 (the elevated-scope selector; server-derived widening), D-284 (the admin-widened read pattern). CLAUDE.md §6 (multi-isolation; defence-in-depth), §13 (opt-in, additive; no identity-downgrading knob), §18 (skill hygiene). RFC §6.13, §5.2, §6.5, §4, §7. Plan: docs/plans/phase-173-events-aggregate-tenant-attribution.md.


D-308 — A widened (cross-principal / multi-value), scope-gated, audited read does NOT fold elided identity axes; it fans in across them. Only a non-widened own-scope read folds to the caller's triple

Date: 2026-07-14

Status: Accepted (landed with Phase 172; fixes a latent defect in Phases 72a / 162)

Where it lives: internal/protocol/transports/stream/handlers.go (events.aggregate), internal/protocol/transports/stream/events_list_handler.go (events.list), web/console/src/lib/events/filters.ts (the Console sparkline consumer).

Context. Both the events.aggregate and events.list handlers folded EVERY elided identity axis onto the caller's own triple — UNCONDITIONALLY, even when the read was widened. The widened decision (conv.RequiresAdminScope, computed pre-fold, D-305/D-299) authorizes a cross-session fan-in and emits one audit.admin_scope_used. But the unconditional fold then narrowed a real admin/console:fleet call naming TenantIDs:[T2] with elided user/session down to {T2, caller-user, caller-session} → EMPTY (the caller is not a principal in T2). So the flag authorized+audited a fan-in the fold immediately defeated. The substrate always supported the fan-in correctly (empty user/session axes + Admin=true fan across sessions — the Aggregate_Admin_SessionLess_FansInAcrossSessions conformance case proves TenantIDs:[tenant-B] → perTenant=4 because it drives the aggregator directly, bypassing the handler fold). A Console fleet review returned NO-GO on the resulting silently-blank sparkline (a §13 silent-degradation FAIL); Phase 172's first cut papered over it in the Console by dropping the foreign tenant pin (an own-scope fallback), which was dishonest — the banner and table showed cross-tenant data the sparkline hid.

Decision. Make the fold asymmetric. On the WIDENED path, do NOT fold elided user/session axes — leave them wildcard so the substrate's MatchWire fans in across the named tenant scope; the pre-fold scope gate (admin/console:fleet, derived from the verified ctx, never the body) and the audit.admin_scope_used emission make that fan-in safe and accounted-for. On the NON-widened path, fold every elided axis to the caller's own component exactly as before (unchanged own-scope isolation). Concretely, the user and session folds are wrapped in if !widened { ... } in both handlers.

The tenant axis is a deliberate carve-out (name-to-widen, D-284 parity). The tenant fold stays UNCONDITIONAL: an elided tenant folds to the caller's own tenant even for an admin. Widening the tenant axis requires NAMING tenant(s) in Filter.TenantIDs — identical to the tasks.list / agents.list fleet selector (widened := len(TenantIDs) > 0; the widened branch reads ONLY the named tenants; an empty tenant axis is own-scope). Gating the tenant fold too would let a request widened by a foreign/multi USER (tenant elided) silently fan across EVERY tenant — a broader read than the wire expressed and a divergence from the sibling fleet surfaces. So events' broader widened trigger (foreign-single-user, multi-user, multi-session — not just tenant) never wildcards the tenant axis: a foreign-user read with an elided tenant returns that user's events within the CALLER's own tenant only.

Why isolation is unchanged. A non-admin naming a foreign principal (tenant OR user, or a multi-value set) is widened and hits the 403 CodeIdentityScopeRequired gate BEFORE the fold, so the un-folded wildcard path is reachable ONLY by a verified admin/console:fleet caller, and every such read emits exactly one audit.admin_scope_used. ScopeAdmin/ScopeConsoleFleet are global binary fan-in grants (D-307), so an admin fanning across a named tenant's users/sessions gains nothing it was not already authorized to read. A single own-other-session read stays un-gated (FilterFromWire does not elevate it), so the everyday Console Sessions / Playground history flow is unaffected.

HA-21 (session-less fan-in) closed by the same fix. HA-21 is this bug seen on the SESSION axis: a widened admin events.list / events.aggregate with an elided SessionIDs (empty ⇒ "any session") was folded to the caller's own session and returned nothing. The if !widened guard leaves the session axis wildcard on the widened path, so a session-less admin read fans across ALL of the named tenant's sessions — the bus-level ListWindow_Admin_FansInAcrossSessions conformance row (green with Filter{} + Admin:true) is now reachable THROUGH the handler, which previously overwrote the empty session set before the driver call (HA-20 "leg 2": cover the Protocol METHOD, not just the bus interface — pinned by a handler-level fan-in-across-≥2-sessions test on both surfaces). There is no longer any "unservable scope returns empty success" case on the happy path (HA-21 defect 2, resolved by CONSTRUCTION, not by a new error): a widened read returns the fanned-in rows, and a non-admin cross-session / cross-tenant read is 403 at the gate BEFORE the fold. A genuinely-empty tenant simply yields zero rows — honest, not a masked narrowing.

Console consequence. The dishonest own-scope fallback in aggregateFilter (dropping the foreign tenant_ids) is REMOVED. With the root fixed, a widened sparkline aggregate returns the real foreign-tenant rate that matches the fleet banner + the table; a genuinely-empty result renders an honest empty grid (the runtime decides emptiness, not the client).

Tests. Handler-level, both surfaces: a widened admin TenantIDs:[foreignTenant] read with DISTINCT foreign principals returns that tenant's events (a caller-fold would return 0 — the prior fixtures masked the bug by reusing the caller's own user/session id in the foreign tenant); a non-admin naming a foreign tenant is 403; a non-admin own read folds to own scope; an elided-tenant widened read (foreign user) stays scoped to the caller's own tenant (the tenant carve-out); one audit.admin_scope_used per widened request.

Cross-references. D-299 (elevation triggers; server-derived widening — authority from the verified ctx, never the body), D-284 (the name-to-widen admin selector the tenant carve-out mirrors), D-294 (events.list), D-305 (the aggregate fan-in substrate + pre-fold widened), D-306 (Phase 172 — this fix lands in its PR; the anchored grid). CLAUDE.md §6 (multi-isolation), §13 (silent-degradation forbidden). RFC §6.13, §5.2, §7.


D-309 — sessions.list / sessions.inspect populate the false-absence counters via a read-time Enricher seam (HA-22)

Date: 2026-07-14

Context. SessionRow (internal/protocol/types/sessions.go) declares 18 fields; the sole non-test producer projectRow (internal/sessions/protocol/lister_projector.go:154 — verified the ONLY non-test SessionRow{} literal) assigns 10. EIGHT — agent_id, agent_name, tasks_count, events_count, total_cost_cents, total_tokens, has_pending_intervention, has_failed_task — are declared, typed, and shipped on the wire, but NEVER assigned, so they are permanently zero on every row. This is not cosmetic: the Service ships facets, a sort, and a keyset cursor over those zeros, returning FALSE ABSENCE. filter.cost_above_cents (filter.go:38) excludes every row for any non-negative threshold, so "show sessions over $5" returns EMPTY on a fleet full of them; filter.agent_ids, filter.has_failed_task, and filter.has_intervention (filter.go:17,32,35) return empty-or-match-all; sort=cost_desc (lessForSort, protocol.go:548) has all-equal keys and silently degrades to the session-id tiebreak ("most expensive first" becomes "id ascending"); the free-text query axis matches over agent_name/agent_id that are always "" (filter.go:55); and the keyset cursor encodes TotalCostCents (cursor.go:46) — it degrades consistently (no page corruption, noted honestly). The first-party Console already renders the "Most expensive" sort option (sessions/+page.svelte:438) and the cost_above_cents facet chip (SessionFacetChips.svelte:169) over this zero data, so the harm reaches operators today, not just third-party clients. The unit suite is green because protocol_test.go's sampleRows/mk fixture assigns exactly the four fields (AgentID, AgentName, TotalCostCents, HasFailedTask) the production projector never does — a fixture RICHER than the runtime (HA-20's class, polarity flipped; §17.8). projectRow's own doc comment (:123-133) frames the zeros as a "documented deviation" enriched Console-side (D-179 / D-122), but a server-side facet/sort runs on the runtime BEFORE any client enrichment, so client-side enrichment can never make the facet truthful.

Decision (Phase 174 — option a, operator-preferred). Give sessions.Projector the SAME optional read-time Enricher SEAM tasks.Projector ships (internal/tasks/protocol/registry_projector.go:52-71; the honest-zero doctrine at :39-40 — "the zeros are honest ('we don't have this data'), not silent degradation of a known value"). Only the seam is inherited — the aggregation is net-new code. The tasks PRODUCTION enricher does NOT do read-time cost aggregation: internal/runtime/serve/enricher.go:36-40 returns a ZERO TaskCostRollup and defers cost to the llm.cost.recorded stream. So Enricher / WithEnricher are a precedented shape, but the per-session summing below is new code with its own correctness surface (a truthful sum + truncation honesty). A new Enricher interface (Counters(ctx, id, sessionID) SessionCounters) wired via WithEnricher(...); a production implementation reads raw data owned by subsystems one package over and SUMS it per session: total_cost_cents/total_tokens from llm.cost.recorded scoped to the session, tasks_count/has_failed_task from the task registry scoped to the session, events_count from the durable event substrate (via HistoryReplayer.ListWindow), has_pending_intervention from the pause registry — all identity-scoped, no shadow aggregation store. With the enricher wired, cost_above_cents narrows real data, cost_desc orders by real cost, and the facet/sort/cursor become truthful BY CONSTRUCTION. This DELIVERS D-179's explicitly deferred "V1.3 evolution" (a per-row cost aggregate wire) — it EXTENDS D-179, it does not supersede it (D-179's Console-side Cost-History detail tab stays a valid live-event projection; only the always-zero list columns / facet / sort change).

Truncation honesty — the fix must not recreate the class one level down (WARN-1; a D-311 instance). The cost/token/event rollup reads via HistoryReplayer.ListWindow, a BOUNDED scan returning HasMore/Truncated at a scanBound (internal/events/aggregate.go:230). A truncated per-session scan would yield a total_cost_cents/total_tokens/events_count SILENTLY LOWER than reality — a believable-but-false value, i.e. exactly the D-311 silent-absence class this phase closes, recursing; and cost_desc/the keyset cursor over an undercounted key mis-orders. FIX: (a) the per-session scan bound is named explicitly; (b) on truncation the enricher sets SessionCounters.Partial, surfaced as the additive SessionRow.CountersPartial — an HONEST LOWER BOUND, never a plausible exact number; (c) a cost_desc/cost_above_cents operation over a partial-key row is treated as non-authoritative (honest-partial), never silently mis-ordered/excluded. This is a first-class Acceptance criterion, not a "perf at scale" footnote. A single session's event count is bounded by its own lifetime (unlike a fleet-wide aggregate), so truncation is practically rare — but rare-and-honest, never silent.

Unwired-build honesty (WARN-3 — sessions differs from tasks). Honest-zeros-when-unwired is SUFFICIENT for tasks (which does not facet/sort over the rollup) but INSUFFICIENT for sessions, which runs SERVER-SIDE facets/sort over the counters — an unwired build would reproduce the ORIGINAL defect (cost_above_cents excludes every row), violating D-311's own "no facet-over-unpopulated" rule. FIX: EITHER gate the numeric-counter facets/sort (cost_above_cents/has_failed_task/has_intervention/cost_desc) behind an "enricher-wired" capability (loud-reject/honest-partial when unwired), OR expose them only when wired AND prove production ALWAYS wires the enricher — the assembly seam that constructs the projector today with no WithEnricher is internal/runtime/serve/mux.go:371, and the aggregation deps are present there. The chosen path is recorded at implementation; a test asserts an unwired Service never returns a false-empty counter-facet page.

The two agent fields take the class rule, not the rollup. agent_id/agent_name have NO single-valued session→agent binding today: the agent registry (internal/runtime/registry) keys agents by the (tenant, user, session) triple (agent_id is NOT an isolation principal — CLAUDE.md §6 clarifying note), and a session may run multiple agents over its life, so there is no authoritative single agent to name. Rather than fabricate one, this phase applies D-311 (the class rule) to exactly these fields, with the reject scoped PRECISELY (the two corrections below matter):

  • filter.agent_ids ONLY fails loud (WARN-4). agent_ids is the only facet that keys SOLELY on an unpopulated agent field, so an agent_ids filter over an unpopulated binding returns CodeInvalidRequest (loud), never a silent empty page. The multi-field query axis is a substring OR over session_id + agent_name + agent_id + user_id (filter.go:52-59) — TWO of the four sub-fields ARE populated — and it feeds the Console live search box (+page.svelte:81). Failing the WHOLE query loud because it touches an unpopulated agent sub-field would break working session-id/user search — an over-rejection is the inverse of a lying control, equally wrong. So query matches its populated sub-fields and honestly never-matches the agent sub-terms (or gates agent-substring behind the capability bit); it NEVER returns a whole-query invalid_request.
  • There is NO agent sort to reject (WARN-5). SessionSort (sessions.go:87-98) has only started_desc/started_asc/last_activity_desc/cost_desc — no agent axis. "Reject an agent sort" is N/A; no agent sort is defined, invented, or rejected.

A first-class "last agent bound to this session" read is a named follow-up.

Absence mechanism (pick at implementation; recorded here). The representable-absence mechanism for the agent fields is EITHER nullable *string with omitempty (null ≠ "" in-band) OR a session_agent_binding capability bit on runtime.info (the mechanism the runtime already uses to gate topology_snapshot). Separately, the WARN-1 truncation-honesty marker is the additive SessionRow.CountersPartial bool (json:"counters_partial,omitempty") — always added. Both are additive; ProtocolVersion stays 0.1.0. Because the SessionRow wire shape gains counters_partial (and possibly the agent nullable / a capability string), full D-223 lockstep + D-209 regen fire in the same PR: the hand-maintained per-page TS client + wire-manifest.gen.json are mirrored, and the GENERATED docs/site/protocol/types.md is regenerated via make protocol-docs-gen (hand-editing it is rejection-on-sight).

Fallback ladder (if option a proves infeasible at scale). (b) Remove the eight fields from the wire (an always-zero field is worse than an absent one — absent fails loud at parse/compile, present-zero is silently believable); (c minimum) make "not computed" representable (nullable / capability bit) AND a facet/sort over an unpopulated field returns a loud invalid_request. The rollup runs per visible row (≤ page limit, default 50 / max 200) and the runtime owns the data one package over, so (a) is expected feasible; the ladder is recorded for completeness.

Findings I'm departing from. D-179 / D-122 pinned "the registry projection is pure; the Console enriches." This EXTENDS that: lifecycle fields stay a pure registry read; the derived counters move to an explicit Enricher seam and are aggregated server-side at the source. Justified because a server-side facet/sort over zeros is invisible to any client-side enrichment strategy. Recorded per CLAUDE.md §15; D-179 is cross-referenced, not overturned.

Tests. The sampleRows/mk fixture is de-enriched (it no longer assigns fields the production projector cannot produce) + a projector-field-set pin test fails if a fixture is richer than the real producer (§17.8). Truthful cost_above_cents/has_failed_task/has_intervention narrowing, cost_desc ordering + cursor paging over real cost, and loud rejection of filter.agent_ids over an unpopulated binding — while query still matches session_id/user_id (WARN-4, never a whole-query reject). A forced-truncation test asserting SessionCounters.Partial/CountersPartial on a bounded scan that hits its limit (WARN-1) — not a silent undercount. An unwired-Service test asserting the numeric-counter facets never return a false-empty page (WARN-3). A concurrent-reuse test (projector + enricher, N≥100 under -race, §5/D-025). §17.1 integration test (test/integration/sessions_enrichment_test.go): real drivers, a populated counter reaches the wire, cross-session isolation (session A cost never bleeds into session B), ≥1 failure mode.

Cross-references. EXTENDS D-179 (Console Sessions page — the "dedicated cost aggregate wire is the V1.3 evolution" it deferred) and D-122 (registry-projection-is-pure). References D-311 (the shared silent-absence class rule this instance closes — the WARN-1 truncation recursion is itself a D-311 instance). Mirrors the tasks.Projector enricher SEAM (D-062 consumer-with-primitive shape; §4.4) — NOT its aggregation, which is zero-valued (internal/runtime/serve/enricher.go:36-40). D-223 (hand-maintained TS client lockstep) + D-209 (generated docs/site/protocol/types.md) fire for the additive counters_partial wire field. CLAUDE.md §5 (fail loudly; honest errors), §8 (wire single source), §13 (silent-degradation forbidden; honest zeros), §4.4 (Enricher seam), §17.1 / §17.6 (fix what the test finds — don't let the fix recreate the class) / §17.8 (integration + fixture-not-richer-than-runtime), §18 (observe-with-the-console + use-the-harbor-protocol skills). RFC §6.9, §5.1, §7. Plan: docs/plans/phase-174-session-projection-enrichment.md.


D-310 — The runtime.health retention block is fleet-observable: a verified admin/console:fleet caller reads the tasks+sessions horizons at runtime-wide scope (server-derived, D-299), and a per-surface scope marker makes an unobservable scope distinguishable from an empty surface (HA-23)

Date: 2026-07-14

Context. HA-14/D-296 shipped runtime.health.retention[] — the OBSERVED oldest-retained timestamp per durable surface — as the forward-looking window-edge honesty signal a fleet consumer reads before a windowed read. But the three horizons are built at THREE DIFFERENT scopes and only one is identity-free: events is runtime-wide (events.RetentionReporter.OldestRetainedAt(ctx) takes only ctx — CORRECT, unchanged); tasks is scoped to the caller's full TRIPLE (TaskRegistry.List(ctx, id, …) filters tenant+user+session — oldest CreatedAt across only the caller's session); sessions is scoped to the caller's TENANT (SessionLister.ListSnapshots(TenantIDs:[id.TenantID], IncludeClosed:true) — oldest OpenedAt across the tenant's sessions). The provider OMITS an absent surface entirely (internal/runtime/posture/posture.go godoc — len(out)==0 → nil), so the SAME wire shape (no entry) means BOTH "surface retains nothing" AND "caller has nothing in scope," indistinguishable on the wire. This bites the one caller that exists to observe the fleet: a coordinator's fleet-observe connection polls runtime.health under a dedicated SERVICE identity (a svc: principal that is not a real tenant, owns no sessions/tasks), so it receives ONLY the events horizon; the tasks+sessions horizons are structurally empty — not because the runtime retains nothing, but because nothing is in the svc: scope. The concrete consequence is a silent undercount: a coordinator's cross-session windowed view must enumerate sessions then read their events, and trustworthy depth is the SHORTER of the events and sessions horizons; if the sessions store is trimmed harder than events, a session aged out of the sessions surface (events remain) is missed by the enumeration and possibly cached as settled history. The correct guard — mark buckets incomplete when the sessions horizon < window — is INERT because the consumer cannot observe the sessions horizon at fleet scope.

Decision. Both halves, riding runtime.health (NO new method, NO new capability bit, ProtocolVersion stays 0.1.0 — additive): (1) Fleet widening. A runtime.health read by a caller carrying a SERVER-DERIVED verified auth.ScopeAdmin OR auth.ScopeConsoleFleet claim (from identity.From(ctx) + the scope set, NEVER the request body — D-299) reports the tasks and sessions horizons at RUNTIME-WIDE scope, the same identity-free scope the events horizon already uses. The runtime-wide read goes through an OPTIONAL identity-free reader — OldestRetainedAt(ctx) (time.Time, bool, error) — on the tasks registry + the session lister, the runtime-wide analogue of events.RetentionReporter, discovered by TYPE ASSERTION at the posture wiring seam (the D-296 as-built precedent; CLAUDE.md §4.4 no-Supports* ceremony); a store that omits it contributes no runtime-wide entry (honest absence, never a fabricated value). The widened decision is a Go input the surface computes and threads into the retention seam, never a wire field. The widened path (a runtime-wide fan-in crossing the tenant boundary) emits EXACTLY ONE audit event, and that event is audit.admin_scope_used (events.EventTypeAdminScopeUsed, the D-284/D-305 events-widened-fan-in pattern), NOT the posture_read_admin name the governance/llm PostureSurface reads use — a deliberate divergence from the sibling posture reads, because a runtime-wide horizon fan-in is a widened READ across tenants (the admin_scope_used shape) rather than a cross-tenant CONFIG read (the posture_read_admin shape); the implementer emits one, never both. It flows through the wired Redactor+Bus, redactor-before-publish, anchored on the actor's verified identity; an emit failure FAILS LOUD (CodeRuntimeError) — the fail-loud-audit-on-the-fan-in property is the load-bearing bit, borrowed from the PostureSurface cross-tenant read (which uses posture_read_admin). A non-widened read emits none. The ordinary caller's per-session/per-tenant fold is UNCHANGED — a fail-closed control, no widening, no downgrade knob (CLAUDE.md §13). (2) Absence made representable. RetentionHorizon gains an additive scope field (json:"scope,omitempty"; one of "runtime"/"tenant"/"session") naming the scope the horizon was measured at, and the three known surfaces (events/tasks/sessions) each emit an entry when the seam is wired (with oldest_retained_at omitted when the surface holds no rows AT THAT SCOPE) — so a consumer distinguishes scope:"runtime"+no-timestamp ("runtime retains nothing — trustworthy empty") from scope:"session"/"tenant"+no-timestamp ("nothing at your scope — runtime-wide truth NOT observable here") and degrades HONESTLY (marks the fleet window's completeness unverifiable) instead of silently trusting a shorter/absent horizon as runtime-wide truth. A nil retention seam still omits the whole block (older/headless wiring unaffected). The exact absence encoding (scope + omitted-timestamp vs an explicit observable bool) is the implementer's call in review; the load-bearing property is distinguishability.

Why. This is the §13 no-silent-degradation rule applied to a fleet view, and it completes what HA-14 shipped: the honesty signal HA-14 delivered reaches the ONE consumer it was designed for (the fleet coordinator) only if the two identity-scoped horizons are observable at that consumer's scope. It threads the HA-18/20/21/22/23 through-line — make the absence of a value representable; never let an unobservable scope masquerade as an empty result (the shared class rule recorded at D-311 by the sibling HA-22 plan if present; stated inline here otherwise, cross-referenced not authored to avoid a parallel-worktree collision). Elevation is server-derived per D-299 (the settled elevated-scope discipline — authority from the verified session, never the body), and console:fleet (RFC §7 fleet-observation) is accepted alongside admin so a read-only fleet token reaches the horizon without the higher control scope. Composes with the D-308 events-fold work (Phase 172 — the if !widened guard on events.list + events.aggregate, whose PR closes HA-16 + HA-21): that work makes the session-less cross-session enumeration REACHABLE, this makes its completeness VERIFIABLE — without either, a fleet window is silently incomplete. Orthogonal composition partner, not a hard build dependency (the real deps are D-296/163 + D-223/118).

Not part of this decision. No change to the events horizon (already runtime-wide/correct — it gains only the scope:"runtime" label), to the retention MECHANISM (Harbor has no retention/pruning knob — the durable log is gap-free and untrimmed, internal/events/drivers/durable/durable.go godoc; the horizon stays OBSERVED, D-296), to redaction, or to the D-296 decided-NO counters/metrics TSDB. No cross-RUNTIME federation (single-runtime runtime-wide horizons; merging stays coordinator-side, D-284). No relaxation of the ordinary caller's scope.

Findings I'm departing from. None — this ratifies D-296's observed-not-configured horizon model and D-299's server-derived elevation, extending the READ SCOPE of two horizons for an elevated caller and adding an absence-representable marker.

Protocol additions. One additive wire field: RetentionHorizon.Scope (json:"scope,omitempty"). No new method, no new capability, no error-code change. ProtocolVersion stays 0.1.0 (additive). Full D-223 lockstep (all canonical homes + make protocol-ts-gen) + D-209 regen (make protocol-docs-gen) + §18 use-the-harbor-protocol SKILL.md + the docs-site protocol stub, all in the same PR.

Cross-references. D-296 (HA-14 — the retention block + RetentionProvider this extends), D-299 (server-derived elevated-scope discipline — the admin/console:fleet selector), D-284/D-305 (the admin-widened read + its admin_scope_used audit pattern), D-311 (the shared silent-absence class rule, cross-referenced if the sibling HA-22 plan authored it). CLAUDE.md §5 (fail loudly), §6 (multi-isolation; agent_id/svc: is not an isolation-widening knob; no cross-tenant enumeration — only a content-free runtime-wide roll-up), §13 (additive; no identity-downgrading knob), §4.4 (no-Supports* ceremony), §18 (skill hygiene). RFC §5.2, §5.5, §6.1, §6.13, §6.14, §6.16, §7. Plan: docs/plans/phase-175-fleet-retention-horizons.md.


D-311 — The silent-absence class rule: make absence representable, and make an operation over an absent value fail loudly rather than succeed emptily

Date: 2026-07-14

Context. A recurring defect class surfaced across the v1.13/v1.14 Protocol-edge audits (HA-18, HA-20, HA-21, HA-22, HA-23): a read surface that fails SILENTLY rather than LOUDLY. The shapes differ — an aggregate that 500s on one driver and 200s on another (HA-18), a facet that runs over a permanently-zero field and returns an empty page on a full fleet (HA-22), a projection whose declared field is never populated so a sort over it degrades to a tiebreak (HA-22) — but the through-line is identical: a value is ABSENT (never computed, unpopulated, unavailable on this driver), the absence is NOT representable in-band (a zero is indistinguishable from a true zero; an empty page is indistinguishable from "no matches"), and an operation over the absent value SUCCEEDS EMPTILY (returns a believable-but-false result) instead of failing loudly. The runtime already has the correct precedent: the tasks.Projector enricher documents its zeros as honest ("we don't have this data") not silent degradation (internal/tasks/protocol/registry_projector.go:39-40), and events driver conformance makes a driver difference DATA / a named sentinel (truncated, ErrReplayUnavailable), never a 500 or a status fork (D-305, the events-driver-conformance-parity thesis).

Decision (class rule). For any read surface where a value may be absent: (1) make the absence REPRESENTABLE in-band — a nullable field, an omitempty distinguishing null from zero, a truncated / horizon signal, or a runtime.info capability bit gating the surface — so a consumer can tell "we don't have this" from "the value is zero / the set is empty"; and (2) make an operation over an ABSENT value FAIL LOUDLY (invalid_request, a named sentinel, or a truncated=true honest-partial) rather than SUCCEED EMPTILY (a silent empty page, a mis-ordered result, a fabricated zero). Honest zeros are acceptable ONLY when they are documented as "we don't have this data" AND no facet / sort / filter silently operates over them as if they were known values. This is the §17.6 "fix what the test finds" rule read upstream and the §13 "silent degradation is forbidden" / "honest zeros" bar made into a reusable pattern: the absence of a value is itself data, and an operation that cannot honour a filter must say so, not return a plausible lie.

Scope. This decision is a SHARED reference for the class, not a single implementation. HA-22 / Phase 174 (D-309) is its first named instance on the sessions projection: the six numeric counters are populated at the source (option a), and the two agent fields — for which no populated value can be produced yet — take the rule directly (representable absence + loud rejection of a facet over the unpopulated field). The HA-23 / Phase 175 plan references this rule for its own leg. When option (a)-style population fully fills a field, the rule still governs the degenerate "enricher not wired" case (honest zeros + no facet-over-unpopulated).

Cross-references. First instance: D-309 (Phase 174, HA-22 — the sessions-projection leg). Sibling class members: D-305 (events-driver conformance parity — a difference is DATA/a named sentinel, never a 500/status fork; HA-18/HA-20). Extends the honest-zero doctrine of the tasks.Projector enricher. Referenced-forward by the HA-23 / Phase 175 plan. CLAUDE.md §5 (fail loudly), §13 (silent degradation forbidden; honest zeros), §17.6 (fix what the test finds), §17.8 (a fixture that can't tell right-field from wrong-field is a rubber stamp).


D-312 — A closed session (explicitly closed OR GC-reaped) MAY be reopened, re-activating the existing record in place with its durable history intact; an ERASED session is the one terminal exception (reopen fails loud). Supersedes RFC §6.9's original "reopen-after-close is forbidden — clients open a new session"

Date: 2026-07-14

Status: Pending (Phase 176, v1.14)

Where it lives: RFC-001-Harbor.md §6.9 (amended); docs/plans/phase-176-session-reopen.md; internal/sessions/registry.go, internal/sessions/sessions.go, internal/sessions/events.go, internal/sessions/erasure.go, internal/protocol/errors.go, internal/runtime/serve/session_ensurer.go.

Context. RFC §6.9 was Settled at "A session is open until explicitly closed or GC'd. Reopen-after-close is forbidden. Clients open a new session." The consumer-chat / white-label product model breaks that assumption: a conversation must be always resumable — a user returns days later and sends a new message on the SAME conversation. Reopen is CLEAN in Harbor's current code because close/GC reap the session RECORD, not the DATA: Registry.Close (registry.go) and the GC sweep (gc.go) mark the record Closed=true and drop it from the live openSessions map but do NOT delete the session's durable events / state / memory (the GC code explicitly guards against resurrecting the record, i.e. the stored data is deliberately left intact); the durable event log is "gap-free and untrimmed in V1" (internal/events/drivers/durable/durable.go — no retention knob, no TTL, no cap) and the StateStore has no retention sweep. The ONLY hard-delete is explicit erasure — Phase 130 session.erase (internal/sessions/erasure.go), own-session-only, audited, cascading to StateStore.DeleteScope + a durable erasure ledger. The four load-bearing session invariants (sessions.go): (1) identity captured IMMUTABLY on Open; (2) reopen-after-close FORBIDDEN — THIS is what changes; (3) cross-tenant SessionID reuse REJECTED (ErrSessionIDReuse); (4) GC never reaps a RUNNING task.

Decision. Amend §6.9 invariant (2): Open / EnsureOpen / the start create-on-first-use seam on a session whose stored record is Closed=true (explicit close OR GC reap) re-activates it in place — clears Closed / ClosedAt / ClosedReason, preserves the IMMUTABLE identity AND OpenedAt (invariant 1; OpenedAt is also the erasure cascade's lifecycle discriminator and MUST NOT be mutated), stamps a new LastReopenedAt, refreshes LastSeen, re-adds the record to openSessions + the (tenant, user) discovery catalog, lifts the erasure fence, and emits a new content-free session.reopened lifecycle event — and because the durable events / state / memory were never reaped, the conversation resumes with its history INTACT. GC hard-cap fix (FAIL-1, IN THIS PHASE not a follow-up): the GC hard cap was measured from OpenedAt alone (gc.go), so a session opened >HardCap ago and reopened today would be re-reaped within one SweepInterval on every deployment (HardCap<=0 restores the 720h default; no disable) — making "resume an old conversation forever" false. The hard cap is now measured from max(OpenedAt, LastReopenedAt); the separate stamp is REQUIRED precisely because OpenedAt cannot be refreshed without corrupting erasure discrimination. The idle-TTL already resets via LastSeen. The one terminal exception: a session that went through session.erase MUST NOT be reopened — reopen fails loud with a new ErrReopenAfterErase sentinel, NEVER a silent empty-start (CLAUDE.md §5 fail-loud; §7 right-to-erasure — you do not resurrect data a user asked to be deleted). This gate MUST fire on the not-found / fresh-create path, not only the closed-record branch (FAIL-2): a fully-CONVERGED erase runs deleteScopeSerialized → DeleteScope which removes the session.lifecycle record entirely, so a subsequent reopen hits state.ErrNotFound and falls through to fresh-create — a naive implementation would mint a fresh empty session (a silent resurrection, failing the primary AC in the plain sequential case, not just under a race). isErased(ctx, id) therefore gates BOTH branches under the same r.mu hold, BEFORE minting. Terminality is detected against the erasure's observability-scope records via an O(1) point-Load: a pending erasure ledger (erasureLedgerKindPrefix, in-flight / interrupted erasure) OR a new durable, content-free erasure tombstone (erasureTombstoneKindPrefix, RETAINED — the sibling of the durable session.erased record-of-fact, carrying the SAME content-free shape so it adds no retained information about deleted data) — either blocks reopen. The tombstone is REQUIRED because the pending ledger is deleted on erasure success, so it alone cannot make a fully-converged erasure terminal. Why a dedicated tombstone and not a scan of the existing session.erased event (WARN-2): a terminality guard must FAIL CLOSED, but the event scan (recordAlreadyEmitted) cannot — it is capability-gated (optional events.HistoryReplayer, returns false when absent → fail-open), bounded (erasureDedupeScanLimit=512, a dedupe window not a guarantee), and future-retention-prunable (Phase 163 track); a StateStore point-Load is mandatory on all three drivers, fail-closed O(1), never pruned. The rejected alternative (a converged erasure frees the id and a reopen starts a FRESH empty session) makes the "erase → reopen fails loud" property unrepresentable and reads as a silent create where a loud terminal is expected. Race-safety — the real ordering invariant (WARN-1): reopen's load-or-not-found → isErased → re-activate-or-mint → save runs in one registry r.mu critical section, serializing against the erasure's r.mu-held deleteScopeSerialized/clearErased. The tombstone is NOT atomic with DeleteScope (it rides a DIFFERENT — observability — scope and is written in completeErasure) and does not need to be; the binding invariant is write-happens-before-delete: the tombstone Save completes BEFORE the pending ledger is deleted (deleteLedger), so at every instant isErased == (ledger present) ∨ (tombstone present) with NO gap, and a reopen at any interleave sees at least one. The tombstone write is SUCCESS-CRITICAL — a failed Save fails the erasure loud (wrapped like ErrErasureRecordFailed) and MUST NOT proceed to deleteLedger, else a tombstone-fails + ledger-deleted interleave opens a converged-erasure gap and permits resurrection. The tombstone Save is also UNCONDITIONAL per terminal completeErasure, OUTSIDE the recordAlreadyEmitted emit-skip guard (WARN-B): completeErasure skips emitErased when a prior attempt already published the record-of-fact, but the tombstone write must run regardless and be idempotent — a converging retry that emitted the event but died before the tombstone write would otherwise (if the Save were co-located inside the !recordAlreadyEmitted block) skip the tombstone, run deleteLedger, and leave neither marker → a later reopen silently resurrects. isErased fails CLOSED (WARN-C): it returns (false, nil) ONLY when BOTH the ledger and tombstone Loads return state.ErrNotFound; any other Load error propagates and reopen fails loud (mints nothing, re-activates nothing) — a fail-open collapse to "not erased" on a transient StateStore fault is the exact seam WARN-2 rejects the history-scan for, and the point-Load must not reintroduce it (mirrors loadLedger, erasure.go:665-678). All §6 multi-isolation preserved: reopen is identity-mandatory and identity-immutable (the caller's verified (tenant, user) must equal the stored record's captured identity, ErrIdentityMismatch otherwise); cross-tenant reuse of a session id is STILL ErrSessionIDReuse (invariant 3 unchanged — a reopen under a DIFFERENT tenant is reuse, not a reopen); no new identity-downgrade knob. The old blanket ErrReopenAfterClose (sessions pkg) is retired from the reopen path; Touch on a still-closed session keeps a loud read-only guard (renamed ErrSessionClosed — Touch is not a reopen entry). The protocol-side ErrSessionReopenAfterClose mapping is retired; the ensurer adapter maps sessions.ErrReopenAfterEraseprotocol.ErrSessionReopenAfterErase → a NEW dedicated, MACHINE-BRANCHABLE wire code CodeSessionErased ("session_erased", HTTP 409, added in internal/protocol/errors/errors.go — §8: codes added there and only there; NOT a ProtocolVersion break) rather than the retired reopen-after-close's CodeInvalidRequest (WARN-A): the originating pain is a consumer-chat product that must distinguish "this conversation was deleted — start a new one" from a genuinely-malformed start, and errors/errors.go forbids clients branching on the advisory Message, so a dedicated code is the only clean programmatic trigger. Documented-surface prose fix (the re-review FAIL): adding the code genuinely adds an errors.md row, so the D-209 regen runs and the SAME pass corrects now-false prose the lockstep gate could never catch (it keys rows on errors.Codes() presence, never on When prose) — the CodeInvalidRequest generator join (cmd/harbor-gen-protocol-docs/errors.go) no longer cites "a start on a closed session (reopen-after-close is forbidden)", and the hand-written choreography page docs/site/protocol/auth-and-identity.md is rewritten so a start on a closed session reopens and only an ERASED session is rejected session_erased. §13 same-wave consumer: the Console Playground / Sessions page resume a closed session (a start on a closed id now succeeds), refresh on session.reopened, and branch on code == "session_erased" for the deleted-conversation path.

Findings I'm departing from. This decision is itself a departure from a prior Settled RFC decision (§6.9 invariant 2), which is why it is an RFC amendment + this decisions entry rather than a silent phase-plan choice (AGENTS.md §2 priority chain, §15). The other three §6.9 invariants are kept verbatim. The GC hard-cap fix (FAIL-1) and the not-found-path erased gate (FAIL-2) are IN-PHASE, not follow-ups — an earlier draft mis-scoped the hard cap as an out-of-scope open question and gated the erased check only on the closed-record branch; both were adversarial-review NO-GO FAILs and are corrected above. Two lesser limitations are acknowledged, not open questions: (a) the in-mem dev driver does not persist across restarts, so reopen on it re-activates the record shape without the durable history the durable driver preserves (the D-074-style honesty note) — durable is the real reopen target and the integration test proves the guarantee there; (b) the cross-tenant ErrSessionIDReuse rejection is enforced by the in-memory idIndex, which hydrates lazily per process, so the loud rejection is best-effort cross-process — but the underlying isolation is not (the StateStore record is full-triple-keyed, so a post-restart cross-tenant reuse of the same id string reads a different key, never leaks, never reopens the other tenant's session). Reopen adds no new exposure. The tombstone rides the pre-existing <erasure-audit> reserved-session sentinel (erasure.go:31-38), now slightly widened to a reopen terminality surface; the standing identity-charset-guard follow-up the erasure code already names covers it.

Protocol additions. TWO additive wire surfaces, no method change, no request-shape change, ProtocolVersion stays 0.1.0: (1) a new canonical event session.reopened + SessionReopenedPayload{SessionID, ReopenedAt, PriorClosedReason} (SafePayload, mirroring session.opened / .closed / .gc_reaped); (2) a new canonical error code CodeSessionErased ("session_erased", HTTP 409 — errors/errors.go constant + canonicalCodes, the control.HTTPStatus binding, the conformance expectedHTTPStatus + errorCodeMatrix (deferred live scenario, mirroring CodeSessionRunning) + the size-count 12 → 13). The wire-surface digest covers error codes, so it changes (runtime.info.wire_surface_digest). Full D-223 lockstep — make protocol-ts-gen (wire-manifest.gen.json + digest), make protocol-ts-types-gen (the vendorable examples/protocol-clients/event-viewer-ts/harbor-protocol.gen.ts), and the event/error join rows in cmd/harbor-gen-protocol-docs — + D-209 — make protocol-docs-gen regenerates BOTH docs/site/protocol/events.md and errors.md — all committed in the same PR. §18: the session.reopened event key + the session_erased code are added surfaces — any skill demonstrating the session lifecycle events or error handling refreshes in the same PR.

Cross-references. D-130-era erasure (session.erase own-session-only cascade — the terminal exception + the ledger/tombstone scope reopen checks against), D-287 (the process-global session-catalog + registry serialization this reopen path shares r.mu with), D-254 (state.history windowed read — the read path a reopened conversation is reduced from, and the integration-test probe that proves history intact), D-288/D-289 (session title + auto-naming — reopen leaves the title untouched; a reopened session keeps its name), D-223 / D-209 (the lockstep for the new event), D-025 (the registry stays a concurrent-reuse artifact; the reopen-vs-erase race test). CLAUDE.md §6 (multi-isolation; identity mandatory + immutable), §5/§13 (fail-loud, no silent degradation — reopen of an erased session fails loud), §9 (persistence; the untrimmed durable log), §17.1 (real-driver integration test), §17.6 (fix across phase boundaries — the tombstone touches the Phase 155 cascade). RFC §6.9 (amended), §5.2, §6.13, §7, §4. Plan: docs/plans/phase-176-session-reopen.md.


D-313 — The silent-absence class rule made mechanical: a registry-gated projection-completeness gate that fails the build when a filtered/sorted/aggregated wire field is never assigned by its projector (HA-24, extends D-311)

Date: 2026-07-14

Context. D-311 (Phase 174, HA-22) named the silent-absence class — a read surface declares a typed wire field, ships a facet/sort/aggregate over it, never populates it, and returns FALSE ABSENCE (an empty page / a fabricated zero) on a fleet full of matching data — and fixed its first instance (the sessions projection). A follow-up audit found the SAME shape on four more Protocol read surfaces, in two variants: never-assigned (the producer omits the field — the HA-22 shape) and never-wired (the populate path exists and is correct, but the production constructor never installs it, so a structural default ships in prod while a test double exercises the populated path). The four (verified against v1.13.1 source):

  • tasks (tasks.list): TaskRow.HasPendingApproval is READ by the list filter (internal/tasks/protocol/list.go) but NEVER assigned by the sole producer projectRow (registry_projector.go) → has_pending_approval:true returns an empty page on a fleet with open gates (the sharp false-absence). TaskRow.BackgroundAcknowledged is never assigned and no filter reads it (fabricated-false only). ALSO the WIRED tasks serve.Enricher (internal/runtime/serve/enricher.go) is a STUB — ParentSession returns a zero TaskParentSessionRef{} and Cost a zero TaskCostRollup — so even the surface Harbor got structurally right (the read-time seam D-311 held up as the exemplar) ships zeros because its concrete is a stub.
  • tools (tools.list/describe/metrics/content_stats) — the never-wired variant, CONFIRMED: the CatalogProjector reads OAuth/approval/last-used/ metrics/content-stats/display-modes through an optional Annotator seam (WithAnnotator), but the production constructor (internal/runtime/serve/mux.goNewCatalogProjector) supplies ONLY WithLoadingResolver; the sole Annotator implementer is a fakeAnnotator test double (§17.8). So in prod filter.oauth_statuses / filter.approval_policies / the Name+" "+Version search axis / the catalog aggregates all operate over structural defaults.
  • flows (flows.list/get): budgetConsumption (internal/runtime/flow/protocol/catalog.go) sums RequestsUsed + CostUSDUsed but NEVER TokensUsed (a non-omitempty field) → the Budget meter renders a fabricated "0 tokens used."
  • memory (memory.list + health): the producer (internal/memory/protocol/protocol.go) never sets AgentID or ExpiresAt; the ROW fields are honest-by-omission (omitempty), but filter.agent_ids, filter.has_ttl_expiring, and the expiring_in_1h aggregate operate over them. Key nuance: V1 memory has NO TTL (ExpiresAt "zero = no TTL," never populated), so the TTL facet + aggregate are structurally dead.

Decision. Close BOTH variants of the class AND make each mechanically catchable. (1) The gate (the primitive) — TWO coverage halves, because the class has two variants and a single reflection probe closes only one. A registry-gated projection-completeness check (internal/protocol/projectioncheck) where every projection surface self-registers a ProjectionContract — a probe that runs the PRODUCTION projector over a fully-populated record, the set of wire json-tags its filter/sort/aggregate layer reads, a reason-carrying honest-omission allow-list, and the name of a prod-wiring test. Half A (never-assigned): a build-time test reflects each probe and FAILS when a filtered/sorted/aggregated field is left at its zero value and not allow-listed; an empty-string allow-list reason is itself a FAILURE (anti-theater). Half B (never-wired — the variant that motivated this band): Half A cannot catch the tools bug — for a read-time-enriched field the probe wires its own populated fake and passes while production mux.go can omit the WithX wiring and ship false absence — so each ProjectionContract MUST register a prod-wiring test that exercises the projector as assembled through real mux wiring; a registered surface with no prod-wiring test, or a production assembly that omits its WithX, FAILS the build. A surface-coverage check asserts every known surface is registered (an unregistered surface fails the build), mirroring the events RegisteredDrivers()/conformance-parity gate (D-305). The "mechanically impossible to reintroduce" claim is scoped precisely: Half A closes never-assigned mechanically; Half B closes never-wired mechanically for any surface that registers its mandatory prod-wiring test (the coverage check forces registration). (2) The consumers (§13): tasks — populate HasPendingApproval at projection time from the approval/pause registry (option a), represent/populate BackgroundAcknowledged, un-stub the serve.Enricher parent-session card + cost rollup (cost coordinated with 174); flows — add RunRecord.Tokens (symmetric with CostUSD) and sum it into TokensUsed; memory — REMOVE the structurally-dead has_ttl_expiring facet plus BOTH expiring_in_1h fields (MemoryAggregates + MemoryHealthAggregate, neither omitempty) — a breaking wire-shape change whose RFC §8 deprecation-window requirement is met by explicit exemption (always-empty fields → no live consumer + the pre-GA 0.1.0 within-version-removal precedent, Phase 171), NOT by silence — and for filter.agent_ids the V1 mechanism is loud-reject (CodeInvalidRequest): ConversationTurn carries no producer identity, so V1 has no agent to populate from; populate is DEFERRED to a follow-up that adds producer identity to the turn record (the facet keeps its wire slot as a not-yet-wired field, unlike the removed TTL design absence); sessions — this phase ADDS the sessions registration (174 predates projectioncheck and cannot register into it), serialized after 174 since it edits 174's lister_projector.go; tools — because assembling a production Annotator is substantial net-new work (no impl exists) it is split to D-314/Phase 178, so Phase 177 honestly GATES the annotator-backed surface behind ONE "annotator-wired" capability toggle: facet filters loud-reject when unwired, and the response-riding catalog aggregates carry an explicit aggregates_partial marker (Console renders "unavailable," NEVER a silent 0 — the fabricated zero the class kills), registered in the gate with a "178 pending" allow-list reason. The gate lands GREEN because every surface is populated, removed, or honestly gated (§13 primitive-with-consumer).

Scope. HA-24. This is a BAND (D-313 + D-314): D-313 = the gate + tasks + flows + memory + tools-interim-gating (Phase 177); D-314 = the tools production Annotator (Phase 178) that flips the gated facets to real data + lights up the inert admin write path. The honest exclusions (NOT fixed — honest-by-omission): FlowBudget.TokenCap ("zero = no cap," omitempty), MemoryItem.AgentID/ .ExpiresAt ROW fields (omitempty; the harm was only in the filters/aggregate over them), TaskParentSessionRef.SessionID (populated). The gate catches "declared-but-never-assigned filtered field," not "assigned with a wrong value" — deeper correctness stays the per-surface truthful-data tests.

Cross-references. Extends D-311 (the class rule) and D-309 (Phase 174, the sessions instance the gate must also cover). Sibling class-closer shape: D-305 (the events driver-registry conformance gate — a registered member without a conformance run fails the build; HA-20). Split consumer: D-314 (Phase 178, the tools annotator). CLAUDE.md §5 (fail loudly), §8 (wire single-source), §13 (silent degradation forbidden; honest zeros; primitive-with-consumer), §17.1/§17.8 (integration + the fixture-richer-than-runtime double), §18 (skill hygiene). RFC §5.2, §6.1, §6.4, §6.6, §6.8, §7. Plan: docs/plans/phase-177-projection-completeness-gate.md.


D-314 — The tools production Annotator: supply the missing concrete behind the shipped Annotator seam, flipping D-313's honestly-gated tools facets to real data and lighting up the inert admin write path

Date: 2026-07-14

Context. D-313 established that the tools catalog projector reads every per-tool annotation (OAuth/approval/last-used/metrics/content-stats/display-modes) through the optional Annotator seam, but no production Annotator is ever wired — mux.go supplies only WithLoadingResolver, and the sole implementer is the fakeAnnotator test double (§17.8). The seam shipped correctly; the concrete is missing. Assembling it is substantial net-new work (it aggregates from tools/auth, tools/approval, the events stream, and MCP DisplayMode negotiation), so D-313/Phase 177 honestly GATES the annotator-backed facets/search/aggregates pending this phase rather than shipping false absence.

Decision. Assemble a production Annotator (a §4.4-shaped concrete behind the existing seam — no new interface) implementing the full interface plus the ApprovalPolicySetter / OAuthRevoker admin seams the projector already delegates to; wire it at internal/runtime/serve/mux.go via WithAnnotator(...) exactly as WithLoadingResolver is wired; flip the D-313 annotator-wired capability on so filter.oauth_statuses / filter.approval_policies / the version search axis / the catalog aggregates operate over real data; populate Tool.Version (or keep it honestly empty with a name-only search axis where a transport carries no version — representable absence, never a fabricated version); and light up the previously-inert admin write path (tools.set_approval_policy / tools.revoke_oauth, which returned ErrAdminUnsupported because no annotator implemented the setter/revoker), routing writes back through tools/approval / tools/auth with audit — never a Console shadow store (D-061). With the annotator wired, the D-313 gate now ENFORCES the tools annotator-backed fields (their honest-omission allow-list entries are removed).

Scope. The tools leg of HA-24; the second member of the D-313 band. No new Protocol method, no ProtocolVersion bump — the tools wire fields are already declared (D-313 gated them, not removed them); the capability flips from unwired to wired (an advertised-set change). No new metrics store — metrics/last-used/ content-stats derive read-time from the existing events stream.

Cross-references. Consumer of D-313 (the gate + the annotator-wired capability it flips) and D-311 (the class rule). D-061 (no Console shadow store for runtime entities). CLAUDE.md §4.4 (interface + factory + registry; concrete behind a seam), §5 (fail loudly; no test-grade default on an operator seam), §7 (admin writes audited + scope-gated), §13 (no stub as production default), §18 (skill hygiene). RFC §5.2, §6.4, §6.15, §7. Plan: docs/plans/phase-178-tools-annotator.md.


D-315 — Harbor has one reusable authenticated Go Protocol client; shipped inspect commands are its first consumers before the TUI

Date: 2026-07-14

Context. The stock inspect-* commands each depend on command-local REST/ SSE machinery in cmd/harbor/inspect_common.go, while the native TUI needs the same authentication, typed errors, event framing, cursor, cancellation, and session-cloning behavior. Copying that code would create two Protocol clients inside one binary and guarantee reconnect/auth drift before scaffolded runtimes can consume either implementation.

Decision. Phase 179 promotes one concurrent-safe implementation to internal/protocol/client and exposes a curated sdk/protocolclient facade. The client accepts an injected token source; environment/file discovery remains CLI policy. It implements a narrow typed method set for current consumers plus the generic JSON call core, not convenience wrappers for all canonical methods. inspect-events, inspect-runs, and inspect-topology convert in the same phase and their golden output remains stable. The conversion is the first consumer required by §13; Phase 180 is the second.

Cross-references. D-075/D-079 (Protocol errors/auth), D-204 (curated SDK facade), D-223 (wire lockstep), D-025 (concurrent reuse). RFC §3.6, §5.1, §5.3–§5.5, §8. CLAUDE.md §5, §6, §8, §13, §17. Plan: docs/plans/phase-179-go-protocol-client.md.


D-316 — The TUI conversation projection is a pure deterministic reducer over canonical snapshots and events, shared-fixture-gated against the Console

Date: 2026-07-14

Context. Harbor has no canonical ordered turn/part read model. The Console currently joins state history, task rows, pauses, and live events. A native TUI must not solve this with a private endpoint, but independently reimplementing the join without a shared gate would create cross-client ordering and replay drift.

Decision. Phase 180 builds a rendering-independent pure Go reducer over the D-315 client shapes. It owns generation fences, sequence dedupe, snapshot reconciliation, missing-start lifecycle repair, tombstones, and explicit partiality. A language-neutral fixture corpus runs through both the Go reducer and Console reducer tests and compares normalized output. No Bubble Tea import, public SDK projection, or new Protocol method lands. Measured fixture drift is the trigger for a future transcript RFC, never permission for a private wire.

Cross-references. D-002, D-025, D-061, D-209/D-223, D-310–D-314, D-315. RFC §3.1, §3.3, §4, §5. CLAUDE.md §1, §6, §8, §11, §13, §17. Plan: docs/plans/phase-180-tui-projection-core.md.


D-317 — OpenCode-level or better perceived quality is the binding TUI floor; Bubble Tea v2 is the terminal stack and visual polish is not deferred

Date: 2026-07-14

Context. A three-phase decomposition hid the entire visual system, interaction framework, conversation workflow, and control plane inside one oversized implementation phase. That shape could pass functional tests while shipping a generic log viewer materially below the market reference.

Decision. docs/design/tui/CONVENTIONS.md is binding. The minimum is OpenCode-level or better perceived execution in hierarchy, spacing, responsive behavior, composer editing, dialogs, commands, sticky streaming, themes, accessibility, feedback, and terminal lifecycle. Exact geometry includes the 79/80 and 120/121 transitions and the ten-size golden matrix. Bubble Tea v2, Lip Gloss v2, selected Bubbles v2, and charmbracelet/x/ansi are the settled CGo-free stack; Glamour/Uniseg require measured need. Phase 181 ships the whole terminal foundation and PTY harness before feature breadth. A functional but visibly weaker result is release-blocking; “polish later” is not a deviation.

Cross-references. D-002, D-025, D-091, D-315/D-316. RFC §3.1, §5.1, §5.4, §8, §10. CLAUDE.md §5, §11, §13, §17. Plan: docs/plans/phase-181-tui-terminal-foundation.md. Design authority: docs/design/tui/CONVENTIONS.md.


D-318 — The first user-operable TUI phase ships the complete attach conversation/session experience, not a thin textarea-plus-log MVP

Date: 2026-07-14

Context. The quality reference derives much of its perceived depth from the composer, session navigation, scroll stability, commands, drafts, autocomplete, and reconnect behavior. Splitting these into optional later polish would leave the first usable Harbor TUI below the accepted floor.

Decision. Phase 182 joins D-316 and D-317 into harbor tui --attach with full editor-quality composition, local history/stash, structured references, session search/switch/rename/resume/erase remediation, sticky streaming, semantic navigation, compact/native-scrollback mode, export, retry, and visible reconnect/partiality. It is deliberately a single-operator, one-active-session terminal, not a multi-user or fleet view. Explicit session switching reacquires a JWT for the target triple. The lifetime-scoped token source is consulted for every request/reconnect, reloads rotated token-file credentials, and accepts a replacement in memory after visible auth expiry; it never extends or persists a signed token. Local persistence contains interaction state and the last durable session reference only, never Runtime rows or plaintext credentials. Restart reattaches that session; a closed durable session reopens on its next canonical start, while erased remains terminal. The phase is not complete without the applicable visual matrix and PTY keyboard walkthrough.

Cross-references. D-061, D-312, D-315–D-317. RFC §3.1, §4, §5, §8. CLAUDE.md §6, §8, §13, §17, §18. Plan: docs/plans/phase-182-tui-conversation-experience.md.


D-319 — Runtime control and inspection deepen the proven conversation TUI through generic renderers and canonical controls, without coding-agent scope

Date: 2026-07-14

Context. Harbor's terminal product must test and control more than a text turn, but adding every screen before conversation ergonomics are proven would mix visual foundation, reducer correctness, and control semantics into one unreviewable change.

Decision. Phase 183 adds tasks, tools, artifacts, events, posture, interventions, controls, diagnostics, attention, and typed part/tool renderer registries to the proven attach TUI. Unknown shapes always have a generic safe fallback. Pause/approval/OAuth/input flows use the unified primitive; controls use the canonical taxonomy and verified scopes. Partial/bounded/unavailable data is labelled honestly. Git, repository, shell, editing, patch, LSP, worktree, and coding-specific renderer scope remains excluded.

Cross-references. D-002, D-026, D-061, D-097, D-310–D-314, D-315–D-318. RFC §3.3, §4, §5, §6.3, §8. CLAUDE.md §6–§8, §13, §17. Plan: docs/plans/phase-183-tui-runtime-control.md.


D-320 — Stock and scaffolded TUI co-launch waits on explicit readiness, preserves the full UI, and attaches through authenticated REST/SSE

Date: 2026-07-14

Context. sdk/server.Handle exposes Serve, Close, and BindAddr, but a co-launched terminal client needs a race-free bound-listener signal. Polling is not a lifecycle contract, and passing Runtime handles to the TUI would violate the client boundary. Distribution must also not substitute a reduced shell for the quality-gated attach application.

Decision. Phase 184 adds explicit readiness to the served handle and sdk/server, promotes the complete runner through connection-only sdk/tui, and consumes both from harbor serve --tui and generated serving binaries' --tui. Every mode dials normal REST/SSE with an operator JWT; no anonymous loopback, signer, mock, private endpoint, or internal state adapter exists. Attach quit leaves the remote Runtime alive; explicit co-launch owns and drains its server. The wave-end PTY E2E asserts cross-mode frame equivalence as well as auth, identity, reconnect, controls, shutdown, and cleanup.

Cross-references. D-291/D-292, D-315–D-319, D-025, D-002/D-061. RFC §3.1, §3.6, §5.1, §5.4–§5.6, §8. CLAUDE.md §1, §5, §7, §8, §13, §17. Plan: docs/plans/phase-184-tui-runtime-distribution.md. Wave: docs/plans/wave-v115-tui-coordination.md.


D-321 — harbor dev loads ./.env (dev-only, loud, environment-wins)

Date: 2026-07-17

Context. Dev-loop configs reference secrets as env.NAME (resolved via os.Getenv, fail-closed), so operators keep keys in a local .env next to harbor.yaml and had to source .env manually before every harbor dev. Auto-loading a dotenv file risks the exact postures §13 forbids — silent degradation, implicit production behaviour, secret-leaking logs — unless the load is scoped and loud by construction.

Decision. harbor dev — and ONLY harbor dev — loads ./.env (working directory only, no search-up-parents) into the process environment before config load and boot, on both the plain and --tui paths. The posture is pinned: a successful load prints one stderr line naming the file and the variable NAMES loaded — never values; a missing file is a silent no-op; a malformed file fails the boot loudly with file:line of the bad entry (surfaced as a boot_config_invalid CLI error — bad lines are never skipped); a variable already present in the process environment is NEVER overridden, and skips are counted and named in the same line. The parser is minimal and dependency-free: KEY=value, optional export prefix, blank lines, # comments (full-line, and trailing after both quoted and unquoted values), single/double-quoted values with quote stripping only — no escapes, no multiline, no interpolation; keys must match [A-Za-z_][A-Za-z0-9_]*. Operators opt out with --no-env-file (registered like --no-hot-reload). harbor serve and every other subcommand never touch a dotenv file — production environments are provisioned by real secret machinery.

Cross-references. D-089 (the explicit, banner'd dev-only escape-hatch posture this extends), D-196. CLAUDE.md §5 (fail loudly), §7 (never log secrets), §13 (dev-only escape hatches — explicit, never silent). Files: cmd/harbor/devenv.go, cmd/harbor/cmd_dev.go, docs/skills/run-the-dev-loop/SKILL.md, examples/dev.yaml.

KEY= assigns an empty value — meaningful for os.LookupEnv-style HARBOR_* overrides, where set-but-empty differs from unset. Malformed-line errors carry path:line and a reason only; no fragment of the file's content is ever echoed into stderr or the JSON error surface, since a malformed line is exactly where a wrapped secret lands. A .env can also supply HARBOR_* knobs (including HARBOR_DEV_ALLOW_MOCK); the mock banner still prints unconditionally, and the environment always wins.


D-322 — The Batch decision: one native response carries heterogeneous intent; the standalone rule shrinks to _finish and _await_task

Date: 2026-07-17

Context. The reserved planner-control guard (the AC-21 co-occurrence rule) rejects any _finish / _spawn_task / _await_task call that co-occurs with another tool call in one native response. The rule closed a real silent-tail- drop defect in the one-JSON-action-per-step era, but native tool calling made a response a set of calls — and models trained on "send a single message with multiple tool calls" batch a spawn with catalog tools in production, failing runs at step 0 with ErrInvalidDecision. The mined evidence (brief 16 §2) is unambiguous: mature agents allow and even prompt for spawn-plus-tools co-occurrence; none carries an AC-21 equivalent. Harbor's own _spawn_task description invites the parallelism the guard rejects.

Decision. Phase 185 adds planner.Batch{Tools []CallTool; Spawns []SpawnTask; Join *JoinSpec} as a new sealed Decision shape — a fourth shape, never a widening of CallParallel (whose branch count is load-bearing for tool-invocation accounting; spawns count zero). The projector partitions a native response by reserved name: _finish and _await_task keep the standalone guard verbatim (a terminal decision and a single-target block have no coherent multi-call semantics; a batched await would create a same-step dependency on a sibling's not-yet-existing task id); _spawn_task becomes batchable with catalog tools and with other spawns — the narrowed guard is named AC-21′ across the wave's plans. Every Batch spawn is RetainTurn=false (a blocking spawn inside a non-blocking multi-dispatch is a contradiction — construction fails loud). The projector never constructs a degenerate one-branch Batch — single-call responses keep their plain shapes, one representation per semantic, pinned by conformance. The reserved-control prompt descriptions are rewritten to teach the new contract, closing the prompt-vs-validator disagreement. RFC §6.2 amended in the same planning PR; the executor consumer lands in the same wave (Phase 186), satisfying §13.

Supersession, named. D-169 item 5's closing note settled a different future shape ("a one-turn batch-spawn, if wanted, is a dedicated _spawn_tasks meta-tool taking an array — never reserved names as CallParallel branches"). This decision supersedes that note's array-meta- tool direction while honoring its actual constraint: spawns still never become CallParallel branches — they become Batch.Spawns, a distinct typed half with its own dispatch and accounting. The singular _spawn_task name and args schema stay; models batch by emitting N native calls, the convention they are trained on, not a bespoke array envelope.


D-323 — Batch execution: flat concurrent dispatch, auto-grouped spawns, call-id-keyed ordered observations, cascade-by-default cancellation

Date: 2026-07-17

Context. A heterogeneous batch has two halves with different completion semantics: tool branches join within the turn; spawn branches escape it (the task registry and WatchGroup own their resolution). The mined agents (brief 16 §2d-e) agree on error-as-value per branch and disagree on caps (one caps spawns hard, one relies on prompt hope). Harbor's spawn depth cap bounds depth only — sibling spawns in one response are unlimited today (brief 16 §6). Provider tool-calling protocols require exactly one result per call_id, some order-sensitive; Go map iteration must never decide reply order.

Decision. Phase 186 dispatches Batch as ONE flat concurrent dispatch: tool branches through the existing JoinSpec executor, spawn branches through the existing registry spawn path. NATIVE-PATH parity with D-169 governs both halves: Join is always nil → JoinAll (partial joins would orphan unanswered tool_call_ids — D-169 item 2; the other kinds stay programmatic-planner surface), and dispatch is NON-ATOMIC per branch (a tool branch's resolve/validate failure becomes that branch's error result while valid branches fan out; a spawn branch's registry reject becomes that branch's error result — D-169 item 3; every call_id is always answered). Whole-batch loud rejection is reserved for STRUCTURAL invariants, not branch args: the planner.max_batch_spawns breadth cap (operator-configurable; never silent truncation), FailFast disagreement across auto-grouped spawns, and a RetainTurn=true spawn inside a batch. Auto-grouping: ≥2 spawns without an explicit GroupID join one ResolveOrCreateGroup group; an explicit GroupID is never overwritten. The observation keys by call_id; spawn results carry {task_id, group_id} at registration (never "not done yet"); RoleTool replies reconstruct in original resp.ToolCalls order — a binding invariant with its own test. Cancellation: a run-level cancel aborts in-flight tool branches AND cascade-walks the batch's auto-created group — interrupt kills everything — except spawns explicitly marked isolate (unreachable from the model until D-324 lands the brake). Spawns never fire the tool-dispatch accounting hook.


D-324 — Task-management meta-tools _task_status / _cancel_task, descendant-scoped; model-expressible isolate lands only with them

Date: 2026-07-17

Context. The planner's task-management surface is fire (SpawnTask), block (AwaitTask), and typed outcome at resolution (BackgroundResult) — no mid-flight observation, no cancel. A model that fans out four explorations and gets its answer from the first cannot cancel the other three. Separately, Task.PropagateOnCancel (cascade/isolate) exists in the registry but is deliberately absent from SpawnSpec (D-047): the model cannot detach work from its own cancellation — and it must not gain that power without tools to observe and stop what it detached, while the operator must keep the last word.

Decision. Phase 187 ships two reserved planner-control meta-tools following the _spawn_task translation pattern into new sealed shapes (TaskStatusQuery, CancelTask — the query shape is deliberately NOT named TaskStatus, the tasks-package lifecycle enum): both resolve ONLY tasks whose parent chain reaches the calling run's task — descendant scope, enforced with an isolation test (a run can never status or cancel a sibling run's tasks). In the same phase — never earlier — propagate_on_cancel: isolate becomes expressible in the _spawn_task args and SpawnSpec (amending D-047's field set). The cancel hierarchy is invariant and tested end-to-end: the operator reaches any task directly regardless of propagation mode, and a session-scoped operator cancel sweeps isolate-marked tasks too — there is no uncancellable task; the agent reaches only its own descendants; cascade stays the default. The two meta-tools are NOT batchable in this wave (conservative grammar; widening is cheap, retracting is not).

Descendant scoping is IN ADDITION to identity scoping, not instead of it.dispatch.isOwnDescendant walks the target's ParentTaskID chain upward (bounded like spawnChainDepth) and rejects any target that does not reach the calling run's own task with the new dispatch.ErrTaskNotOwnDescendant sentinel — a sibling run's own tasks in the SAME session are out of scope. This runs on top of the registry's existing (tenant, user, session)identityVisible check (CLAUDE.md §6), never as a replacement. _task_status with explicit ids validates EVERY id before any Get (atomic — one out-of-scope id fails the whole call, never a silent partial result); list-all walks the caller's own subtree, so those ids are in-scope by construction.

Cascade-walk fix. isolate was structurally unreachable from the model before this phase, so the shipped engine cascade left a dormant gap: internal/tasks/engine/engine.go's Cancel and groups.go's cancelTaskLocked cancelled every reachable descendant "regardless of their own PropagateOnCancel" (the code's own comment at engine.go:620-625), consulting only the CANCEL TARGET's flag to decide whether to start the walk. That contradicts the now-load-bearing invariant "isolate detaches a task from its parent's cascade." This phase extracts one shared cascadeCancelDescendantsLocked helper both call sites use, which checks EACH descendant's own PropagateOnCancel mid-walk: an isolate-marked descendant is skipped AND its whole subtree detaches (children never enqueued). The direct-target semantics are byte-for-byte unchanged — a direct Cancel on an isolate task still transitions it (the operator's last word; the spawning run's own _cancel_task); only the treatment of nodes reached mid-cascade changed. Covered by the Cancel_Cascade_SkipsIsolateDescendant conformance scenario (both drivers) plus the engine cancel-hierarchy end-to-end test.


D-325 — Background-task resolution wakes the conversation: a notification-class mirror on top of the typed completion, plus foreground turn-failure honesty

Date: 2026-07-17

Context. Group resolution reaches the planner as typed MemberOutcomes (the wake contract) — but nothing tells the OPERATOR conversationally that background work finished, and a foreground turn that FAILS goes idle with no on-chat indication (task.failed is deliberately filtered off the chat surface as runtime internals), so a planner rejection reads as a silent hang. The mined background-mode precedent (brief 16 §2c) wakes the parent by injecting a message — a side effect Harbor should not copy wholesale, but the insight stands: the typed channel and the conversational channel compose.

Decision. Phase 188 emits a notification-class event through the existing notifications subsystem on group resolution (and single background terminal transitions with NotifyOnComplete), carrying ref-shaped member-outcome summaries under the owning identity. The planner-facing WatchGroup path is untouched. The TUI renders these as muted conversational lifecycle lines and renders an explicit failure line for a foreground run's terminal failure (detail stays on diagnostics); the Console's run view renders the same family. Any new event type rides the canonical stream (no new method) with generated-docs lockstep, and every hand-decoded payload consumer is enumerated and updated in the same PR.


D-326 — Stop dropping provider cache accounting: typed cache read/write tokens on Usage, mirrored on the cost event

Date: 2026-07-17

Context. The LLM gateway dependency already returns per-response cache accounting (PromptTokensDetails.CachedReadTokens / CachedWriteTokens); Harbor's driver translator never reads the field — provider-computed cache data is silently discarded (brief 17 §1). Governance consumes CompleteResponse.Usage synchronously in-band; the cost event is a best-effort mirror whose payload three consumers hand-decode with no compile check (brief 17 §2, §4). No cache metric exists anywhere, which also blocks any future cache-aware policy from being designed against data.

Decision. Phase 189 is telemetry-only, zero behavior change: the driver translator reads the cache token details; llm.Usage gains additive CacheReadTokens / CacheWriteTokens; the cost-recorded payload mirrors them; and ALL hand-decoded consumers update in the same PR (TUI reducer and Console run-events reader/cost components functionally; the sessions enricher as a documented intentional non-extraction — no SessionRow field carries cache counts) — a missed consumer silently reads zero, so the consumer list is acceptance criteria, not a footnote. Generated protocol-docs regenerate. Governance ceiling math is deliberately untouched (cache tokens are informational first). The request-side cache-intent surface (CachePolicy on CompleteRequest, config lowering into the gateway's existing cache wire vocabulary) is a named mid-wave decision point, not part of this phase.


D-327 — agents.list surfaces the runtime's synthetic default agent as a first-class, marked row

Date: 2026-07-17

Context. A second consumer's fleet Agents catalog composes agents.list across runtimes. The registry scopes over agents explicitly registered by session orchestration — a runtime serving only its synthetic default agent (which its own live view already reports as active) has never produced a row, so the catalog renders empty for a runtime actively serving traffic. "No rows" reads as "no agents" when the truth is "one agent, not enumerable this way" — the absence-must-be-representable class (D-311), tracked externally as HA-25.

Decision. Phase 190 emits the synthetic default agent as a first-class agents.list row: well-known id/name plus an additive is_default: true marker distinguishing it from registered sub-agents. Authority stays server-derived from the verified session (D-299) — one more row, no scope change, no new identity axis, no registration-semantics change. Full wire lockstep (TS manifest, generated docs, Console catalog rendering) in the implementing PR; the admin-widened fleet fan-in picks the row up and an integration test asserts it.


D-328 — OAuth broker legs: structured step-up visibility, resource-bound exchange with per-tool provider binding, and the actor leg of the delegation chain

Date: 2026-07-17

Context. The broker-pull spine is shipped: discovery surfaces the OAuth requirement as inert data, operator-confirmed writes bind a provider, the runtime PULLs per-identity downstream bearers via RFC 8693 token exchange (D-271) and never runs the flow or holds a token. Three runtime-side legs remain, tracked externally as HA-26/HA-27/HA-28: a mid-run insufficient_scope step-up challenge dies as an opaque tool error (the required-vs-granted scope delta is present at the MCP edge and lost before the Protocol); the exchange cannot assert WHICH downstream resource a token is for (the discovered RFC 8707 resource indicator is not carried, and the oauth_provider binding is per-connection, forcing one audience across a multi-resource server's tools); and the exchange carries no RFC 8693 actor_token, so the broker's authorization server cannot bind or audit the delegation chain.

Decision. Phase 191 ships the three additive legs, posture invariant (report-not-act; no custody change; no auto-escalation; discovered values stay operator-confirmed proposals; composes with the D-300 credential-sink allow-lists, never weakens them): (a) a structured step-up surface — the downstream resource id, required scopes from the challenge, granted scopes the binding carried, and the verbatim challenge with origin — on the tool-result/error envelope and connection view the coordinator already reads; (b) an optional RFC 8707 resource indicator on the exchange request with returned-token audience validation, and the non-secret oauth_provider binding resolvable at tool granularity with connection-level fallback; (c) an optional RFC 8693 actor_token carrying the run's VALIDATED inbound principal — never a client-named field — for subject/actor cross-checking and delegation-chain audit. Absent fields preserve today's behavior exactly. The phase also carries the wave-end E2E per §17.7.

D-329 — The unprompted group-cancel wakes the conversation too: a notification.task_group_cancelled mirror keyed on a typed cancel origin

Date: 2026-07-20

Context. D-325 shipped the background-wake notification family: notification.task_group_resolved / notification.task_completed mirror a group's successful resolution (and opted-in background completions) onto the conversation surface while the typed WatchGroup planner path stays untouched. D-325 deliberately scoped the wake to the success path and filed a cancelled-group mirror as an explicit non-goal — "a reasonable follow-up ... left out to keep the blast radius matched to what D-325 authorizes." But a batch-spawned group cancelled by fail-fast or by an inherited cascade is then SILENT while its successful siblings wake: spawnOne marks every batch spawn NotifyOnComplete=true, so an operator watching the conversation sees the winners announce themselves and the unprompted-cancelled losers vanish without a word. Tracked externally as the v1.16 checkpoint audit WARN W2 (GitHub issue #532).

Decision. Phase 192 adds the authorized D-325 follow-up — it EXTENDS D-325's design to the sibling transition D-325 parked, it does not re-litigate it. A new V1 notification class notification.task_group_cancelled (trigger task.group_cancelled) is synthesised by the same internal/runtime/notifications mapper, reusing D-325's ref-shaped member-outcome summarisation verbatim (MemberOutcomeSummary capped at MaxMemberSummaries, MembersTruncated on overflow, true full-membership Member{Succeeded,Failed,Cancelled} totals; member Result/Error bytes never cross onto the payload). The planner-facing WatchGroup / GroupCompletion typed path and the cancel hierarchy (D-324) are unchanged — this is a mirror, not a new decision point or a new cancel mechanism.

The suppression rule keys on a typed cancel origin, never on downstream guesswork. A new tasks.CancelOrigin (operator / cascade / failfast) is stamped on TaskGroupCancelledPayload at the engine's group-cancel call site from that site's own provenance: a direct CancelGroup is CancelOriginOperator, the fail-fast gate (live and crash-recovery recompute) is CancelOriginFailFast, and an inherited ancestor cascade is CancelOriginCascade. mapTaskGroupCancelled MIRRORS an unprompted cancel (cascade / fail-fast — narrative the operator did not ask for) and SUPPRESSES a directly-operator-initiated cancel (return nil, nil — the actor already knows), exactly analogous to how D-325's notification.task_failed suppresses a foreground turn's own failure. An unknown or empty origin FAILS LOUD by being surfaced, never silently swallowed (CLAUDE.md §13): an unclassified cancel synthesises a notification (the failure mode is an extra line, never a hidden one). Rendered on the SAME surfaces D-325 wired — the TUI's muted notification block kind (no new kind) and the Console Sessions / Tasks docks — with no per-member fan-out.

Additive wire, no version bump. The new event class rides the canonical events.subscribe stream (no new Protocol method); the additive TaskGroupCancelledPayload.Origin field and the new class regenerate the wire manifest (D-223) and generated Protocol docs (D-209) in the same PR; ProtocolVersion stays 0.1.0 (additive event class + additive payload field, same posture as D-325). Deliberately out of scope: a solo notification.task_cancelled for a non-group cancelled task (no sibling-asymmetry driver — a second additive class with its own suppression question, filed not folded in) and any Console notification-center / toast / bell surface (D-325's non-goal, unchanged).


D-330 — Planner-facing steer / pause / resume of a spawned child: three sealed decisions producing the existing steering inbox + unified pause/resume primitive, descendant-scoped, human-superseded

Date: 2026-07-20

Context. D-324 gave the model observation (_task_status) and control (_cancel_task) over the background tasks its own run spawned, and parked one verb as a named future extension: steering or pausing a spawned child from the parent's model turn. The per-sub-run steering inbox (internal/runtime/steering/inbox.go) already exists for every background sub-run and the unified pause/resume primitive (RFC §3.3) already coordinates pause — but neither was exposed as a planner-facing verb. A model that fans out several explorations can cancel the losing branches (D-324) but cannot nudge a promising one mid-flight, nor park one and resume it later. This completes the operator↔agent control taxonomy on the AGENT side.

Decision. Three reserved planner-control meta-tools — _steer_task, _pause_task, _resume_task — become three new sealed planner.Decision shapes (SteerTask{TaskID; Directive}, PauseTask{TaskID; Reason}, ResumeTask{TaskID; Directive}, RFC §6.2), projected in the React projector through the same reserved-name translation → executor-dispatch seam _task_status / _cancel_task use. Following brief 02 §5 (sharp edge 4, "magic strings as opcodes") they extend the Decision sum, never a string-typed control channel — the same choice D-324 made.

Not a widening of Batch (structural). Batch's shape carries only Tools []CallTool and Spawns []SpawnTask — it has no slot for these three types, so they are structurally non-batchable. The projector's standalone-name guard (isStandaloneControlName) gains all three: a steer/pause/resume control co-occurring with ANY other tool-call in one response is rejected loud with planner.ErrInvalidDecision naming the offending control. Widening to batchable later is additive; retracting a shipped batchable surface is not — the conservative non-batchable grammar is the first-wave choice, exactly as D-324 chose for _task_status / _cancel_task.

One guard, one sentinel (reuses D-324). All three executor methods call D-324's EXISTING dispatch.isOwnDescendant(ctx, targetID, callerRunTaskID) BEFORE touching the inbox; a target outside the caller's ParentTaskID lineage — including a sibling run's tasks in the SAME session, and the run's own task (self is not a descendant) — is rejected with D-324's EXISTING dispatch.ErrTaskNotOwnDescendant sentinel. No new scope sentinel is minted; the steer/pause/resume taxonomy stays coherent with the cancel taxonomy. This is IN ADDITION to the registry's (tenant, user, session) identity-visibility check, never instead of it (CLAUDE.md §6).

No new mechanism (§13, RFC §3.3). All three verbs enqueue a canonical steering.ControlEvent onto the descendant sub-run's EXISTING per-sub-run steering inbox — the SAME inbox the operator's steering targets (the descendant task id doubles as its run id at this layer, RFC §6.8, so the inbox is keyed by the run triple with RunID == taskID). _steer_task enqueues an INJECT_CONTEXT control carrying the directive; _pause_task enqueues a PAUSE control and _resume_task a RESUME control — the operator's exact entry points to the unified pause/resume primitive, which the descendant's own RunLoop drives through pauseresume.Coordinator. A resume directive rides the RESUME control's payload, which the RunLoop forwards to the primitive's EXISTING resume-payload seam (pauseresume.Coordinator.Resume's payload parameter, merged into the pause record via mergeStringMap) — no new resume mechanism. Pausing a descendant NEVER pauses the run issuing the verb: only the resolved descendant's inbox receives the control (a test asserts the parent inbox stays empty).

Human supremacy preserved. The agent presents ScopeOwnerUser under its own tenant — the run-owner's authority over its OWN descendants, never admin, never cross-tenant. The operator's EXISTING control surface reaches ANY task at ScopeAdmin through the same inbox and always supersedes; a companion test proves an admin-scoped operator PAUSE reaches a descendant that run B (a sibling) is rejected from touching.

Fail-loud serialization — the honest scope (§5). _pause_task / _resume_task validate the AGENT-supplied control payload (the {source, issuer_run, reason|directive} map this dispatch edge builds) against trajectory.ValidateEncodable BEFORE enqueue, surfacing trajectory.ErrUnserializable loud rather than a silent drop. This is a real guard on the agent payload — but it is NOT the descendant-run-state serialization contract §5/AC-10 primarily names, and it CANNOT trip through the real projector→dispatch path (every field it emits is an agent-supplied string/literal). The descendant's own run-state serialization (its checkpointed trajectory) is enforced fail-loud DOWNSTREAM, unchanged, by the unified primitive inside the descendant's RunLoop (Coordinator.Request) — the parent verb neither sees nor re-enforces it, so there is no parent-observable, end-to-end ErrUnserializable from _pause_task itself. The AC-10 test is scoped honestly to the agent-payload guard; the descendant-run-state contract is covered by the pauseresume package's own contract tests.

Return-value semantics — "control enqueued", not "state transitioned" (§4.3 deviation, noted). Each verb returns {task_id, steered|paused|resumed: bool} where the bool means the control was ENQUEUED onto a live descendant; it is false ONLY when the descendant has already finished (its inbox retired), mirroring D-324's _cancel_task terminal contract. The dispatch edge does NOT inspect the descendant's pause state, so there is no parent-observable "no transition" signal: a redundant _pause_task on an already-paused descendant still reports paused:true (harmless — the descendant's RunLoop parks once via the re-emit guard), and — the sharp edge — a _resume_task on a live descendant that was never paused still reports resumed:true but is NOT harmless: the descendant's RunLoop applies that RESUME exactly as an operator's mistaken resume, surfacing ErrNoOutstandingPause that ends that descendant's run loud. This is INHERITED pause/resume-primitive behavior (an operator's spurious RESUME does the same), not a new mechanism; the model-facing description and godoc state it truthfully ("resume only a task you actually paused"). Resolving fine-grained already-paused / not-paused idempotency at the dispatch edge would require the parent to query/drive the descendant's pause token directly — a second coordination path §13 forbids, and making agent-issued spurious resumes non-fatal would be a steering-RunLoop semantics change (a separate decision), out of scope here. Routing through the inbox keeps "no new mechanism" exact.

Consumers land in the same phase (§13 primitive-with-consumer). Each of the three controls ships its real dispatch executor method + tests (descendant-scope rejection, idempotent-terminal, the human-supremacy + cross-sibling isolation integration test against the real inprocess TaskRegistry + real steering Registry, the fail-loud serialization test, and the N≥100 D-025 concurrent-reuse test) in THIS phase — never a primitive without its consumer. No new Protocol method; ProtocolVersion unchanged. RFC §6.2's standalone-control sentence is extended to name the three tools in the same PR (keeping RFC ↔ projector guard in lockstep, mirroring D-324's AC-19).

D-331 — Per-tool OAuth binding reaches the resource/prompt RPC paths, and the credential-sink uninstall is owner-scoped at the store boundary

Date: 2026-07-20

Context. Two loose ends on the credential plane D-328 (Phase 191, HA-27b) established. (a) HA-27b shipped the per-tool oauth_provider binding — MCPServerConfig.ToolOAuthProviders, keyed by MCP-side name — but wired it to callTool ONLY. ReadResource / SubscribeResource / GetPrompt (the other identity-stamped MCP RPC paths D-278 enumerates) still resolved only the connection-level binding, so a shared MCP server fronting N downstream resources could bind a per-tool audience to a tool call but not to a resource read or a prompt get — the one-audience-per-server gap re-opened on the resource/prompt surfaces. (b) GitHub issue #507: ProviderSet.Uninstall(ctx, name) refused a boot-seeded provider but did NOT verify the caller's owner matched the installed entry's owner before dropping + closing it; owner-scoping was enforced entirely caller-side (the remove_oauth_provider handler resolving the name within the caller's own agent-config revision), so the store trusted every present and future caller to have done so — brief 09's "fail closed on missing components" read against the store boundary itself.

Decision. Phase 194 closes both, posture invariant (report-not-act; no custody change; no auto-escalation; composes with D-278/D-300/D-303 and NEVER weakens D-300's credential-sink invariant — every knob stays boot-declared / server-derived, no URL/secret on the wire, ProtocolVersion unchanged):

  • (a) Resource/prompt reach. resolveBearerCtx(ctx, key) (191's per-tool resolver) is now called with the RPC's addressing key on EVERY previously connection-level bearer-injection site in the MCP driver — ReadResource and SubscribeResource (key = resource URI), the resource-read descriptor invoke (resource URI), and the prompt-get descriptor invoke (prompt name) — so the SAME ToolOAuthProviders map (no shape change, no new config field, not on the Protocol-writable ToolExposure layer) resolves a per-entry binding for those paths, falling back to the connection-level oauth_provider when unbound. Every per-entry binding rule HA-27b enforces for callTool (unknown name / stdio transport / static-Authorization conflict / downstream-host allow-list) is re-enforced identically at boot / attach validation. The per-entry map is a SINGLE namespace keyed by MCP-side name: a key that would address more than one discovered surface at once (a tool AND a prompt of the same name, or a resource URI equal to a tool name) is rejected LOUD at discovery (ErrAmbiguousOAuthBinding) — never a silent documented-precedence resolve. The binding is a property of the RPC, not of the callTool dispatch site.

  • (b) Owner-scoped uninstall. ProviderSet.Uninstall gains an Owner parameter — Uninstall(ctx, owner, name) — and refuses a cross-owner drop with ErrProviderOwnerCollision (mirroring Install's owner-collision refusal) when existing.owner != owner, at the STORE boundary, independently of caller-side owner resolution (defense in depth). The boot-protected (zero-owner) refusal via ErrProviderBootProtected stays; a matching-owner drop closes + removes the entry exactly as before, so a subsequently-bound call fails LOUD (never a silent fall-through to the old key, an unauthenticated dial, or another owner's provider). The one caller — the agent_config.remove_oauth_provider handler — threads the caller's resolved owner (the active agent-config revision owner, under lockAgent); its existing caller-side owner resolution stays, the store check is the second, independent gate. The owner remains a reconcile-view tag, never an isolation principal and never a credential sink.

Both are the sanctioned extension of D-328, not a re-litigation; the §17.8 conformance fixture is the official go-sdk MCP server over the real streamable HTTP wire (never a hand-authored RPC shape).

D-332 — A governance-WRITE Protocol surface makes the identity-tier policy table administrable over the wire (the write sibling of governance.posture)

Date: 2026-07-20

Context. A second consumer renders a Governance page from governance.posture — the default tier, the caller's resolved tier, and the per-tier table of budget (USD) ceiling / max-tokens cap / rate-limit capacity the runtime enforces. The read is faithful and complete, but it is read-only: an operator can inspect the identity-tier policy and cannot change it over the Protocol. governance.posture's own consumer note already says editing identity tiers is a runtime-config concern (post-V1), so the enforced policy is Protocol-visible but Protocol-immutable, and a coordinator whose whole job is policy + composition has a Governance page that shows the enforced policy and offers no honest control to change it — short of reaching around the Protocol into runtime config, which a pure Protocol client must not do. The already-writable governance.set_tenant_overrides covers per-tenant LLM defaults (model / temperature / max-tokens / reasoning-effort) but NOT the identity-tier policy table itself. Tracked externally as HA-29.

Decision. Add governance.set_posture (the pinned method name; a plan-time rename must be settled before D-209/D-223 lockstep, not left floating) — the write sibling of the existing governance.posture read. Full-replace semantics, never a partial merge: the write validates and replaces the whole identity-tier policy table (per tier its budget ceiling / max-tokens cap / rate-limit capacity, plus the default-tier assignment) through the same shared validator the read projects (the D-302 FULL-REPLACE pattern) — a write that OMITS or zeroes an enforced ceiling is rejected fail-closed, never silently widened to unbounded, and never a budget-widening default; a §9 conformance test covers the partial/empty write case across all three drivers. Authority is admin-only: derived server-side from the verified session, never the request body (D-219), and gated on the auth.ScopeAdmin claim ONLY — explicitly NOT the two-scope set (admin OR console:fleet) that gates the read, so a leaked read-only fleet token cannot widen a budget (D-066 — control is a strictly more elevated tier than observation; D-079). The runtime is the sole owner and enforcer of the policy record — the consumer drives the change in as an authenticated admin and keeps only Console-local state (saved-views / annotations class, D-061), never a projected copy or shadow of the tier values. Round-trips faithfully with the read (what you set is what the next posture returns). The tier policy graduates from hot-reloadable boot config to a StateStore-backed record layered over the config-declared defaults (in-mem / SQLite / Postgres conformance, forward-only migrations per §9); a runtime with no written override enforces its config defaults, so the write is additive and backward-compatible. Explicitly not part of this: no consumer-side policy engine or re-enforcement, no new identity axis, no change to how a tier is resolved for a caller, no scope-gate relaxation. Priority MEDIUM (an operator can still edit runtime config out-of-band; this closes the honest gap between a policy an operator can see and one they can administer). RFC §6.15 amended (Gate 0). Framework-framed only — no consumer product / white-label / resale intent in any committed artifact (§13). Full D-209/D-223 wire lockstep and a governance.set_posture smoke land with the implementing phase.

D-333 — The inference plane gains a broker-pull credential source, mirroring the tool-plane token-exchange PULL (D-271) onto the LLM client

Date: 2026-07-20

Context. A coordinator that centrally custodies downstream credentials — mint / rotate / revoke, pulled per-use, never persisted per-runtime (the Plane-B posture D-271 established for the tool/OAuth plane) — wants the same custody model for the one plane that lacks it: the runtime's LLM provider key. Today the inference client sources its key from local boot config (an env var / config-file key Harbor's Account impl hands to bifrost via Account.GetKeysForProvider); there is no broker-pull credential source for it — no analogue of the tokenexchange credential_source: remote driver, which is bound to the MCP oauth_provider plane and never reaches the LLM client. So the LLM plane is the only credential plane with no pull path: provider keys live per-runtime as boot config, and a central custodian cannot rotate them in without touching each runtime's environment out-of-band. Tracked externally as HA-30 (leg 1).

Decision. Add an inference-plane analogue of the token-exchange PULL: an Account credential source that, at connect + refresh (NOT per hot-path call — the pulled key is cached and refreshed like the tool plane; the inference critical path must not eat a per-call KEK decrypt), pulls the provider key from the coordinator's broker instead of reading local config. Same custody posture as D-271: the runtime never persists the key, the coordinator remains sole custodian, the pull is per-runtime-authenticated. The pulled key rides the existing atomic key-swap (D-019), so a broker-side rotation lands on the next call with no ReloadConfig race. Fail-loud, no dual path (D-271 item 2, carried verbatim — not just its custody half). A broker unreachable at connect OR a failed refresh raises a typed sentinel (ErrProviderKeyUnavailable); the runtime NEVER silently falls back to a local/boot key, and NEVER continues serving a stale cached key past its refresh contract (a broker-side revocation that fails to propagate must surface, not be masked by the cache). A provider source is brokered XOR local, config-declared — no dual path, no silent degradation (§13). The sink is boot-pinned (D-300 preserved). The pull URL / audience / scope ceiling are pinned by a boot-declared, config/file-only inference-broker config (the D-300 analogue of ToolOAuthCredentialBrokerConfig), referenced by non-secret NAME — no URL or secret ever crosses the wire (the install write that binds it is D-334). Granularity (deliberate): unlike a tool bearer, a provider key is a runtime-level credential, not an isolation-tuple (tenant, user, session) one — the pull is per-runtime, not per-identity, and the key is infrastructure custody, never identity-scoped data; it does not widen the isolation tuple (§4). Attribution (extends D-285, does not silently contradict it). This lives behind the §4.4 credential-source seam, but D-285 restricted its remote source to per-identity, LAZY pulls precisely because a connect-time pull has "no run identity to attribute" the mandatory fetch event to. This runtime-scoped variant is defined WITH its attribution: every connect/refresh pull emits a runtime-scoped SafePayload llm.provider_credential_fetched audit event keyed by the runtime identity (not a run/session) — superseding D-285 note (2)'s tokenexchange-only-because-no-identity reasoning for this one defined, audited case. Open item for the implementing phase (flagged, not hand-waved): the emission ctx for a runtime-scoped audit event outside a (tenant, user, session) request — the exact seam D-285 note (2) named as hard — must be specified by the phase, keyed to the per-runtime service principal that already authenticates the pull (D-271's runtime service token), NOT a synthesized session. This is a specification gap, not a contradiction; the runtime principal is infrastructure identity and does not widen the isolation tuple (§4). Priority MEDIUM. RFC §6.5 amended (Gate 0). Framework-framed only (§13).

D-334 — An inference-plane provider install / rotate write binds a runtime to a NAMED broker-pull provider in the D-303 zero-URL shape

Date: 2026-07-20

Context. D-333 gives the inference client a broker-pull source; it needs a Protocol write to install / rotate that binding. Today agent_config.set_oauth_provider (D-303) is validated to exactly driver: "tokenexchange" and hard-rejects anything else (it serves the OAuth plane); runs.set_overrides selects a model name against the runtime's own key, never a provider key. So the LLM plane has no rotate write: provider keys live per-runtime as boot config, and a central custodian cannot rotate them in over the Protocol. Tracked externally as HA-30 (leg 2).

Decision. Add agent_config.set_llm_provider — a SEPARATE inference-plane method, NOT a relaxation of set_oauth_provider's hard tokenexchange-only allowlist (that allowlist and its reflective no-URL decode test stay intact — widening it would reopen exactly the surface D-303 hardened). The new method ships its OWN reflective zero-URL / zero-secret decode test asserting the writable descriptor carries no URL and no env-var name. The written descriptor is the D-303 shape exactly: zero-URL, zero-secret — it references a boot-declared, config/file-only inference-broker config (D-333, the D-300 analogue of ToolOAuthCredentialBrokerConfig) by non-secret NAME; that config — never the wire descriptor — pins the pull endpoint / audience / scope ceiling, so no admin-writable field determines where the credential is sourced, preserving the D-300 credential-plane invariant. Authority is derived server-side from the verified session (D-219) and gated on the auth.ScopeAdmin claim ONLY — explicitly NOT the admin OR console:fleet two-scope set that can reach a read (D-066 — a control write is a strictly more elevated tier than any read; D-079), so a leaked read-only fleet token cannot rebind a runtime's provider. Installed providers follow D-303's provider-SET model (bare-name resolution, owner-tagged reconcile, uninstall closes the binding and fails bound calls loud). This closes the honest gap that keeps the LLM plane outside the central mint / rotate / revoke custody every other credential plane already enjoys. Explicitly not part of this: no coordinator-side inference and no provider-key mirror beyond the single central custody the broker already holds (the runtime does all inference; the coordinator never calls an LLM); no change to model selection; discovered / confirmed values stay operator-gated, never auto-applied. Priority MEDIUM. RFC §6.5 amended (Gate 0). Framework-framed only (§13).

D-335 — Broker-pulled provider failover stays Harbor-orchestrated at the Governance layer; bifrost's native Fallbacks array is NOT used (extends, does not reverse, D-018)

Date: 2026-07-20

Status: Shipped (Phase 197, v1.17). The FailoverPolicy seam + the chain driver + the governance.failover hop event + the re-run-PreCall budget gate landed; see D-337 for the two implementation-shape refinements (the event rides the events registry, not a wire type; the chain is boot/install-declared, not a new Protocol write).

Context. A consumer that centrally custodies provider credentials (D-333/D-334) wants a runtime's inference client to fall back from a primary provider key to one or more following keys on a retryable error — the following keys potentially naming a different provider altogether. bifrost exposes a native per-request Fallbacks array (schemas.Fallback, core v1.5.x) that would perform this inside the SDK. Doing so, however, would hide every fallback hop from Harbor's audit redactor, event bus, and per-identity cost accumulator — exactly the coupling D-018 rejected when it settled that Harbor orchestrates failover at the Governance layer, not by pushing a Fallbacks array into bifrost.

Decision. D-018 stands. Broker-pulled failover is expressed through Harbor's own FailoverPolicy seam (§6.15; post-V1 phase 93), NOT bifrost's Fallbacks. The broker-pull source (D-333) supplies the ordered chain of keys/providers the consumer configured (each a named, zero-URL/zero-secret broker descriptor); FailoverPolicy walks it — on a retryable provider error it advances to the next key/provider, emits a governance.failover hop event (cost + identity attached), re-runs Governance PreCall (budget / rate-limit / MaxTokens) BEFORE re-issuing — not merely PostCall accounting after the fact — so a chain walking N providers cannot push a run past its per-identity ceiling across hops (the exact cost-control hole D-018 exists to prevent), and re-issues through the same one-method LLMClient, the fallback provider's key itself broker-pulled and never persisted. A hop that trips PreCall fails loud (ErrBudgetExceeded / ErrRateLimited), it does not silently continue down the chain. Cross-provider fallback is fully expressible (a heterogeneous chain) without delegating orchestration to the SDK — every hop stays a Harbor event that passes the full Governance gate + audit + bus + cost accounting. This is the DNA-aligned realization of the "fallback on error, possibly cross-provider" capability: the mechanism is Harbor-orchestrated even though the capability mirrors what the SDK offers. Priority MEDIUM; composes with D-333/D-334 and the shipped governance.rotate_key (D-019). RFC §6.15 amended (Gate 0). Framework-framed only (§13).


D-336 — The Protocol-installed inference-provider binding (set_llm_provider) is a LIVE runtime-level rebind, NOT persisted through the per-agent agent-config revision spine (FLAGGED for coordinator confirmation)

Date: 2026-07-20

Status: Shipped ephemeral (Phase 196, v1.17) — the durability posture is FLAGGED for coordinator sign-off; see "Open item" below.

Context. D-334's agent_config.set_llm_provider installs a broker-pull inference-provider binding. The v1.17 security review asked whether that binding should be DURABLE the way the OAuth set_oauth_provider binding is — persisted through the agent-config revision ConfigPayload (giving rollback, run-start reconcile, restart-survival). The OAuth binding rides that spine because an OAuth provider is a per-agent entity (a connection's oauth_provider references it, owner-tagged). The question is whether the LLM-provider binding should mirror it.

Decision. The set_llm_provider binding is a LIVE, runtime-level rebind of the runtime's shared provider-key holder (llm.LiveKey) via the LLMProviderInstaller seam + a fail-closed admin audit (agent_config.llm_provider.installed); it is NOT threaded through the per-agent agent-config revision ConfigPayload. The install builds an InferenceKeySource over the shared LiveKey, starts its refresh scheduler, and the pull happens at connect + refresh (D-333). It is LIVE for the process lifetime; a restart drops it (the config-declared brokered primary llm.credential_source: remote IS durable — it lives in yaml).

Rationale (a semantic mismatch, not merely scope). D-333 settled that a provider KEY is a runtime-level credential — "the pull is per-runtime, not per-identity … it does not widen the isolation tuple." There is exactly ONE primary provider key per runtime. The per-agent agent-config revision payload is keyed by agent_id; recording a runtime-level key-source binding inside one agent's revision mis-models it (no single agent "owns" the runtime's provider key). The honest durability mechanism for a runtime-level credential-source binding is a runtime-scoped persistent store + a run-start reconcile — a NEW persistence surface that, per §9, must ship with in-mem / SQLite / Postgres driver parity + a conformance suite. That is a disproportionate multi-file domain surface for this phase, AND it is a genuinely different shape from the per-agent OAuth spine (so "parity with OAuth" would be the wrong parity). The provider KEY itself is NEVER persisted either way (D-333 custody model), so the only thing at stake is the durability of the which-broker binding, which a coordinator that rotates keys in can re-apply on reconnect (the coordinator remains the source of truth).

Consequence. The plan's "owner-tagged reconcile at run start" acceptance line is NOT met for the Protocol-installed binding and is dropped for this phase (the config-declared brokered primary needs no reconcile — it is re-established at boot). The install verb's provider-SET semantics that ARE met: bare-name resolution, live install/rebind, and uninstall closes the binding + fails subsequently-bound calls loud (never a silent no-op serving the old key).

Open item (FLAGGED — coordinator to confirm). Whether the runtime-level binding should gain durable, restart-surviving persistence via a runtime-scoped store (NOT the per-agent revision spine) is deferred to a follow-up decision. If the coordinator wants restart-survival, it is a dedicated runtime-scoped binding store (inmem/SQLite/Postgres + conformance) plus a run-start reconcile — tracked as the D-336 follow-up. Until then the Protocol-installed rebind is live-for-process by design, documented here so it is not a silent gap. Priority MEDIUM. Framework-framed only (§13).


D-337 — The FailoverPolicy seam shape: a governance.failover canonical EVENT (not a Protocol wire type), a boot/install-declared chain (no new Protocol write), and a KeyActivator seam over the broker-pulled LiveKey

Date: 2026-07-20

Status: Shipped (Phase 197, v1.17). Refines D-335's implementation shape.

Context. D-335 settled the POSTURE (Harbor orchestrates failover at the Governance layer; bifrost's Fallbacks array is unused). Realizing it raised three shape questions the decision left open, two of which the Phase 197 plan flagged for confirmation.

Decision.

  1. governance.failover is a canonical EVENT, registered in the internal/events registry (via internal/governance/events.go) and rendered by the protocol docs generator (cmd/harbor-gen-protocol-docs) — NOT a request/response wire type in internal/protocol/types. The Phase 197 plan speculatively listed a GovernanceFailoverEvent wire type in internal/protocol/types + a methods.go registration + a singlesource CanonicalWireTypes entry + a ts-types index entry. That is the wrong mechanism: Harbor events are not request/response wire types — they ride the existing events.subscribe / StateEvent surface and their payloads live beside their owning subsystem (exactly like every prior governance.* event: budget_exceeded, posture_set, key_rotated). The GovernanceFailoverPayload therefore lives in internal/governance, registers via RegisterEventType, and joins the docs generator's eventPayloadIndex. Consequences: NO new internal/protocol/types wire STRUCT, NO methods.go method, NO singlesource.CanonicalWireTypes entry, NO ts-types typeInstanceIndex entry. The one net effect on generated artifacts is the new event-type STRING joining the canonical event-type enumeration — so make protocol-docs-gen (events.md), make protocol-ts-types-gen (the HarborEventType union in the external client), and make protocol-ts-gen (the same enum in wire-manifest.gen.json) each add exactly the governance.failover line and are committed. ProtocolVersion stays 0.1.0. (§4.3 plan deviation — a speculative "new wire type" interface corrected to the real "new canonical event" mechanism once code landed.)

  2. The ordered failover chain is boot/install-declared (an ordered set of installed, zero-URL provider-binding names), NOT a new Protocol write. The Phase 197 plan's risk section flagged this for coordinator confirmation. D-335 mandates no new write; this phase adds none. The chain references installed bindings by bare name (D-303/D-334), so the WALK ORDER is never a wire-writable credential-sink lever. An admin-writable chain order (a set_failover_chain write) remains a scoped follow-up with its own zero-sink-field analysis if a coordinator wants it.

  3. A KeyActivator seam decouples the walk from the credential source. The FailoverPolicy calls KeyActivator.Activate(ctx, ProviderRef) to make a hop's broker-pulled key live before re-issuing; production wires it to the inference broker-pull source over the shared llm.LiveKey (D-333/D-334), a test wires a fixture. This keeps the policy a pure governance-layer artifact (immutable, D-025) and makes cross-provider chains expressible at the seam without the policy knowing broker mechanics. Because V1 is single-provider at the runtime level (D-333), production true multi-provider key custody is beyond this phase; the seam and the governance.failover cross-provider event surface ship so the capability is expressible and observable.

    Inert-until-wired (tracked follow-up). As shipped, FailoverPolicy / WrapWithFailover / KeyActivator have NO production consumer: no LLM assembly or harbor command constructs a failover-wrapped client, and no production KeyActivator exists (only test fixtures). This is a deliberate, acceptable carve-out — the primitive-with-consumer rule (§13) is satisfied by WrapWithFailover + the unit/wave-E2E tests that exercise the walk end-to-end — but the production wiring (a real KeyActivator over the broker-pull LiveKey + a boot/install-declared chain threaded into the LLM assembly) is a TRACKED FOLLOW-UP, bounded by D-333's V1 single-provider custody and post-V1 per D-335. Until that wiring lands the seam is inert in the shipped binary by design, documented here so the status is unmistakable.

Consequence. The FailoverPolicy interface + the chain §4.4 driver + NewFailoverPolicy/WrapWithFailover (the consumer) + GovernanceFailoverPayload land in internal/governance; the retryable-vs-permanent classifier reuses the existing LLM + governance sentinels (a permanent structural error or a governance gate stops the walk; a transient provider error advances it). The wave-end E2E (test/integration/wave_v117_test.go) exercises the budget-trip failure mode end-to-end. Priority MEDIUM; composes with D-335/D-333/D-334/D-019. Framework-framed only (§13).


D-338 — A dev-only, fail-closed, boot-only opt-in permits the tokenexchange credential POST to dial a private-IP token_url (config flag OR boot env); the D-300 DNS-rebinding backstop stays default-armed

Date: 2026-07-23

Status: Shipped (v1.17.3 hotfix). Reconciles a dev-topology gap in D-300 without reopening it.

Context. D-300 hardened the tokenexchange credential-bearing RFC-8693 exchange POST: its dialer installs a post-DNS net.Dialer.Control backstop that refuses any resolved address in the link-local (unicast+multicast), RFC 1918 private, IPv6 ULA, or unspecified ranges — the DNS-rebinding defence for a client that carries the runtime's client_id/client_secret — while deliberately carving out loopback (a boot-declared localhost-sidecar broker is legitimate). The guard is load-bearing and correct against the wire-derived / attacker-influenceable case. But it also refuses the ORDINARY containerized local-dev topology: a coordinator reachable only behind a private-IP TLS sidecar (a Docker-compose service address, a k8s cluster IP), where the operator's OWN boot-declared token_url resolves into RFC 1918 space. That endpoint is not attacker-influenceable — it is config/file-only, the single endpoint this hardened client ever dials — yet the backstop dies on it with ErrPrivateDialRefused, so brokered credential exchange cannot run in the standard self-hosted dev deployment. This is the same shape D-304 closed for the MCP OAuth-discovery dial (a same-origin, operator-declared private target the backstop refused one hop too early), read onto the credential plane.

Decision. Add a per-provider, fail-closed opt-in that relaxes ONLY the private / link-local / ULA branch of the dial guard, scoped to that provider's own boot-declared token_url. Two surfaces feed one effective posture, allowPrivate = (per-provider config flag) OR (global boot env), both defaulting false:

  1. Per-provider config flagtools.oauth_providers[].allow_private_token_url (bool, default false), threaded through BuildProviders into the driver ProviderConfig and consulted at provider construction. Meaningful only for the tokenexchange driver (rejected fail-loud on any other driver, mirroring resource_indicator / include_actor_token). Boot-declared, config/file-only — never Protocol-writable and never derived from a discovered / wire descriptor.
  2. Global boot envHARBOR_DEV_ALLOW_PRIVATE_EXCHANGE=1, captured ONCE at boot into a package-level write-once atomic (the HARBOR_DEV_ALLOW_MOCK posture: capture + a [DEV-ONLY PRIVATE-IP TOKEN EXCHANGE — DO NOT USE IN PRODUCTION] stderr banner are reciprocal at one call site, reachable from harbor dev, harbor console, and harbor serve).

The relaxation is narrow and explicit: the unspecified address (0.0.0.0 / ::) stays refused UNCONDITIONALLY (never a valid coordinator, not relaxed even under the opt-in); the loopback carve-out is unchanged (still allowed); the redirect refusal (ErrTokenEndpointRedirect, the credential-form-replay defence), the Proxy: nil posture, and the caller-supplied-client path are all untouched. The opt-in is inert for every provider that does not set it; a sibling provider without the flag still refuses a private dial even when another provider in the same runtime has it.

Why this does not reopen D-300. The guarded property is unchanged for the case it defends: an attacker who can influence WHERE the credential is sent. The token_url is the operator's boot-declared, config/file-only sink — the credential-plane invariant (no admin-writable field determines a credential sink) holds because the opt-in is a boot-declared knob keyed to that same trusted sink, not a wire value. The relaxation is default-off and fail-closed (§13: no silent degradation; the guard stays armed unless the operator explicitly opts in via config or a banner'd env), and it is never Protocol-writable or attacker-influenceable. It leaves the redirect refusal, the loopback carve-out, and the unspecified-address block intact.

Relationship to D-304 (a knowing DEPARTURE, not a mirror). D-304 closed the analogous over-refusal on the MCP OAuth-discovery dial, but it explicitly REFUSED an allowPrivate config/env knob — it relaxed the guard automatically via a same-origin resolved-IP pin (resolve the operator-declared ServerURL once at walk start, permit a private dial ONLY to that pinned IP:port), precisely because the discovery walk follows attacker-influenceable URLs (the resource_metadata pointer, in-walk redirects) where an operator-set "allow private" switch could be steered onto an unintended private target. D-338 deliberately takes the OTHER mechanism — an explicit, operator-set opt-in knob — because THIS plane has no attacker-influenceable target to steer onto: the hardened client dials ONLY the provider's boot-declared token_url (never a discovered or wire-derived address), so there is nothing an automatic pin would protect that the boot-declared sink does not already pin. The knob is therefore acceptable here where D-304 rejected it: it is the §13 dev-escape-hatch shape (default-off, config/boot-env only, a stderr banner on every boot when the env fires), keyed to an already-trusted sink, not a lever over where a credential is sent. This is a documented departure from D-304's mechanism, not an application of it.

Consequence. internal/tools/auth/drivers/tokenexchange gains the boot-capture atomic + isBlockedDialIP(ip, allowPrivate) decision + the threaded hardenTokenExchangeClient(..., allowPrivate); internal/tools/auth ProviderConfig and internal/config ToolOAuthProviderConfig gain the field (validated fail-loud off-driver); cmd/harbor gains EnvDevAllowPrivateExchange + the reciprocal banner/capture at the dev/console/serve boot sites. Tests pin the decision matrix (default refuses private + link-local + ULA, still refuses unspecified, still allows loopback; opt-in permits private but still refuses unspecified and still refuses redirects), the config-OR-env effective flag, and the per-provider scoping. Framework-framed only (§13).


D-339 — agent_config.add_mcp_connection is idempotent at the live layer: a same-name re-attach synchronously REPLACES the existing registration (deregister tools + close transport, then register) rather than failing on a duplicate-tool-name collision

Date: 2026-07-23

Status: Planned (v1.18). Completes the runtime MCP add-connection lifecycle (issue #375).

Context. The runtime-add MCP attach is SYNCHRONOUS: agent_config.add_mcp_connection dials, handshakes, discovers, and registers the server's tools into the live catalog before returning the resolved state (online / failed / auth_required). Its mirror agent_config.remove_mcp_connection is ASYMMETRIC — it removes the durable descriptor synchronously but DEFERS the physical tool deregistration + transport teardown to the runtime's next run-start reconcile (the DETACH-only leg shipped in v1.11.0, issue #375). A long-lived coordinator that re-establishes a connection it previously removed — same name, same still-running runtime process, before any new run has triggered the deferred detach — calls add_mcp_connection for a name whose tools are STILL LIVE in the catalog. The attach path blind-registers (it does not check for, or replace, an existing same-name registration), so catalog.Register fails with ErrToolDuplicateName (name=<server>_<tool>) and the attach returns state: failed. Net effect: the agent ends up with ZERO tools for that server and stays that way until the runtime process is restarted (or a run start finally reconciles the stale detach). Nothing the caller does at the Protocol layer can force the pending teardown first. A second latent bug compounds it: even past the catalog's loud reject, Registry.Register OVERWRITES a same-name serverEntry in its map without closing the prior provider's transport — a silently leaked transport/session.

Decision. Make the attach path an atomic same-name UPSERT at the live layer. When the connection name being attached already has a live in-memory registration, the attacher — inside the coarse serialise-the-whole-attach lock it already holds — first performs BOTH detach legs the deferred reconcile would eventually run: (1) the catalog source-deregister (DeregisterSource(ToolSourceID(name)), removing every <name>_<tool> descriptor), and (2) the registry deregister (Registry.Deregister(ctx, name), which closes the OLD provider's transport OUTSIDE the registry lock), then proceeds with the normal Connect → Discover → Register flow for the new connection. The replace is idempotent by construction (a first attach with no prior registration simply skips the deregister legs — Deregister on an unknown name is already a no-op returning ErrServerNotFound, swallowed). No new verb (teardown_now was considered and rejected), no forced-reconcile round-trip, no window where the caller must sequence a remove before an add. The replace closes the OLD transport rather than leaking it, fixing the Registry.Register-overwrite bug in the same change.

Owner-scoped replace (identity-critical). The upsert is scoped to the caller's owner: the attacher reads the prior same-name registration's owner (Registry.OwnerOf) and performs the deregister-then-register replace ONLY when it matches the caller's owner. A same-name registration owned by a DIFFERENT owner (a different tenant/agent) is NEVER torn down — doing so would silently evict another tenant's live tools + transport (a cross-owner eviction / DoS) — it is rejected LOUD with ErrConnectionNameOwnerConflict BEFORE any teardown or dial. This keeps the replace consistent with the deliberately owner-scoped run-start reconcile (RuntimeAddedSources(owner)) and preserves multi-isolation (§6). Because the teardown-first supersede is therefore same-owner-only, the one destructive window (a Connect/Discover failure after the old tools were deregistered) only affects a caller replacing THEIR OWN connection — the operator asked for it.

Why this needs no new isolation or lock surface. The production attacher already serialises the entire attach under a single mutex specifically so two concurrent same-name adds cannot race the catalog/registry; the deregister-then-register replace fits INSIDE that existing critical section with no new lock. The catalog write-lock and the registry write-lock are independent inner locks, and provider.Close runs outside the registry lock (its documented "a transport close can block on session teardown" carve-out), so the replace introduces no lock-ordering hazard and no deadlock. Identity is unchanged: attach is an admin-gated control-plane action keyed to the agent registration, not an isolation-tuple widening (agent_id is not an isolation principal — CLAUDE.md §6).

Relationship to issue #375 (completes, does not reopen). #375 shipped the deferred DETACH-only reconcile leg — a declared-but-absent connection is torn down at the next run start. D-339 does NOT move detach earlier or make it synchronous in the general case; the deferred reconcile remains the authority for a removed connection. It only makes RE-ATTACH self-healing: an add that collides with a still-live same-name registration performs the equivalent teardown inline as part of its own atomic upsert. The two are consistent — the reconcile handles "declared, then undeclared"; the upsert handles "undeclared-but-still-live, then re-declared."

Consequence. internal/tools/drivers/mcp (the Attach flow) gains the pre-register same-name replace; internal/runtime/serve/mcp_attacher.go needs no new lock (the replace lives under the existing attach mutex); internal/tools/drivers/mcp/registry.go's Register-overwrite path is fixed to route same-name replacement through Deregister (transport close) rather than a bare map assignment. Tests: a re-attach of a still-live same-name connection returns online with the NEW tool set live and the OLD transport closed (no leak — goroutine-baseline assertion); a first attach with no prior registration is unaffected; a concurrent-reuse test drives N≥100 interleaved same-name attach/re-attach against a single attacher under -race asserting no duplicate-registration, no leaked transport, no cross-talk. Smoke: add_mcp_connectionadd_mcp_connection (same name) → assert the second returns success and the tool set is live. Framework-framed only (§13).


D-340 — A dev-only, fail-closed, boot-only opt-in permits agent_config.set_oauth_provider / add_mcp_connection to carry the NEW server's OAuth params over the wire (token_url/audience/scopes, still naming a boot-declared credential_broker); the D-303 name-only binding stays the default and the derived-allow-list + SSRF backstop keep the credential-plane invariant honest

Date: 2026-07-23

Status: Planned (v1.18). Revisits D-303 (the name-only default stands; the wire descriptor is a gated extension). Composes with D-300/D-338/D-304.

Context. D-303 settled the Protocol-installed OAuth provider as a ZERO-URL, name-only binding: AgentConfigOAuthProviderDescriptor carries {name, driver, credential_source, credential_broker, scopes?} and NOTHING that names a credential sink — no token_url, auth_url, client_id_env, client_secret_env, or remote — precisely so a DisallowUnknownFields decode rejects any sink field BY NAME. The credential-plane invariant behind it (D-300): no admin-writable field may determine WHERE a credential is sent; every sink is boot-declared. That shape is safe but cannot connect a NEW OAuth-fronted MCP server without a static tools.oauth_providers[] block and a runtime redeploy — a long-lived coordinator standing up a new provider at runtime has no path. Carrying token_url over an (admin-gated) Protocol verb is, by construction, an admin-writable field that determines a credential sink — it directly touches the D-300 invariant, so it cannot simply be "allowed."

Decision. The descriptor MAY carry the NEW server's OAuth params over the wire (token_url, audience, scopes) — but ONLY behind a fail-closed, boot-only opt-in, allowWireOAuthDescriptor = (tools.allow_wire_oauth_descriptor config flag) OR (HARBOR_ALLOW_WIRE_OAUTH_DESCRIPTOR boot env), both defaulting false (the D-338 shape: config-OR-env, boot-captured into a write-once atomic, a [DEV-ONLY WIRE OAUTH DESCRIPTOR — DO NOT USE IN PRODUCTION] stderr banner when the env fires). The runtime's OWN credential custody stays 100% boot-declared: the wire descriptor STILL names a boot-declared credential_broker (a tools.oauth_credential_brokers[] entry) that supplies the coordinator credential-pull endpoint, the service-token env-var NAME, and the org client credential — NO credential-source URL, env-var name, or secret rides the wire (an earlier draft that listed a wire remote{} credential-pull block was removed: a self-contained wire credential source is exactly the exfil primitive D-300 forbids — an opted-in admin could point remote.url at an attacker host + name any process env var and the runtime would POST that env var's value there; the SSRF backstop only blocks private ranges, so a public attacker host sails through). Only the NEW server's public token endpoint (token_url), its audience, and its scopes are wire-carried. With the opt-in OFF (the default, and all of production), a wire descriptor carrying token_url or audience is rejected exactly as D-303 rejects a sink field today — the name-only binding is unchanged. When the operator opts in, two constraints keep the relaxation honest: (1) allowed_downstream_hosts is NEVER a free-form wire field — it is DERIVED from the connected server's own URL via NormalizeDownstreamHost(connection.url), so the exchanged token can only ever be presented to the one endpoint the connection actually dials (a wire-supplied downstream-host list has no field on the wire struct — a DisallowUnknownFields decode rejects one by name); (2) the wire token_url is hardened by the IDENTICAL tokenexchange SSRF backstop the boot path uses — post-DNS dial refuses resolved private / link-local / ULA / unspecified, Proxy: nil, every redirect refused, loopback the one carve-out (D-300/D-338). The opt-in is ALSO a kill-switch: the run-start provider reconcile refuses to rebuild a wire provider (a descriptor with a token_url) when the opt-in is off, so a wire provider installed while it was on is not silently rebuilt after a restart with it off (loud log, provider stays absent, a bound connection fails loud).

Why this does not reopen D-300 by default. With the opt-in off (production posture), the invariant is untouched: no wire field reaches a sink; D-303's DisallowUnknownFields reject stands. The opt-in is the §13 dev-escape-hatch shape — default-off, fail-closed (no silent degradation: a sink-bearing descriptor is REJECTED, never silently downgraded to name-only), config/boot-env only, banner'd. When an operator DOES opt in, they have made an explicit, boot-time, deployment-wide choice to trust admin-scoped callers to declare a sink at runtime — and even then the sink is doubly bounded: the downstream host is DERIVED (not wire-chosen) so a stolen/malicious descriptor cannot redirect an exchanged token to an attacker host, and the token_url cannot reach an internal service (SSRF backstop) nor replay the credential form through a redirect. The relaxation is therefore "an opted-in operator lets admins name a PUBLIC token endpoint at runtime," not "any admin can send a credential anywhere."

Relationship to D-303 / D-338 / D-304. D-303: the name-only binding remains THE default and the only production-safe shape; D-340 is a gated extension, not a replacement (the two-parallel-implementations concern of §13 is avoided because the wire descriptor routes through the SAME provider-install surface, just carrying more fields when the gate is open). D-338: same fail-closed config-OR-env boot-capture + banner mechanism, one plane further out (the token_url now MAY be wire-carried, so the private-dial relaxation of D-338 and the wire-descriptor gate of D-340 are independent opt-ins that compose — a wire token_url still faces the default private-dial refusal unless D-338 is ALSO opted in). D-304 (same-origin resolved-IP pin for the discovery walk): D-340 follows the D-338 mechanism (an explicit knob) not the D-304 mechanism (an automatic pin), because the derived-from-connection-URL allow-list already pins the downstream sink to the one endpoint the connection dials — there is no attacker-influenceable target an automatic pin would additionally protect.

Consequence. internal/protocol/types/agentconfig.go gains the wire fields token_url / audience on AgentConfigOAuthProviderDescriptor (+ an inline oauth binding on the add_mcp_connection connection descriptor), both omitempty, gated at decode/validate; credential_broker is required in both the name-only and the wire shape; there is deliberately NO wire remote / credential-source URL / env-var-name field (a DisallowUnknownFields decode rejects one by name). internal/protocol/singlesource + the Console typed client + wire-manifest.gen.json mirror them (D-093/D-223, make protocol-ts-gen), and the generated protocol docs regenerate (D-209, make protocol-docs-gen); ProtocolVersion stays 0.1.0 (additive, omitempty). internal/runtime/agentcfg validates the gate (opt-in off → token_url/audience present → reject; on → derive allowed_downstream_hosts from connection.url); internal/tools/auth ProviderBuilder.BuildWire reads the runtime's credential custody from the named boot broker and overrides only the wire token endpoint / audience / derived downstream sink (sharing the boot broker-pull construction); the serve installer honours the opt-in as a KILL-SWITCH on every build (a wire provider is not rebuilt by the reconcile when the opt-in is off — loud skip); internal/config gains tools.allow_wire_oauth_descriptor; cmd/harbor gains HARBOR_ALLOW_WIRE_OAUTH_DESCRIPTOR capture + banner at the dev/console/serve boot sites. Tests pin: opt-in-off rejects each wire field (the D-303 posture, unchanged, mutation-verified); opt-in-on installs a wire provider whose allowed_downstream_hosts equals NormalizeDownstreamHost(connection.url); a wire token_url resolving private/redirecting is refused by the SSRF backstop; the reconcile kill-switch skips a persisted wire provider when the opt-in is off; the boot banner fires. Fixtures for the wire round-trip derive from the real add-connection / RFC-8693 transcript (§17.8). Framework-framed only (§13).


D-341 — Per-user credential INJECTION for receiver-style MCP servers: the southbound driver sources the acting principal's credential from the broker per outbound call and injects it in the server's declared form (headers / Authorization: Basic / _meta), a controlled pull-then-inject exception to the pull-only posture (D-271) that extends the shipped injection seam (D-278)

Date: 2026-07-23

Status: Planned (v1.18). A controlled exception to D-271; extends D-278. Composes with D-285 (credential-source seam).

Context. The southbound MCP driver's per-user credential plane is PULL-based (D-271): a connection bound to a tokenexchange provider mints a per-user identity token (the verified ctx identity triple as the RFC 8693 subject_token), the downstream server presents it back and PULLS the acting user's credential. That covers every MCP server built to fetch its own credential. It does NOT cover a server that expects the credential handed to it DIRECTLY on the request — arbitrary headers (x-<vendor>-api-key), Authorization: Basic base64(key:x), or MCP _meta.<vendor>.{key,...} — because nothing on the outbound path SOURCES and INJECTS that credential PER ACTING USER. The only direct-feed today is a static per-connection secret header (MCPServerConfig.Headers, secret:"true") — but that is PER CONNECTION, shared by every user of the agent, so it cannot be per-user; and rebuilding the third-party server to PULL via tokenexchange is impossible for a server the operator does not control. So a per-user credential has no path to a receiver-style server.

Decision. Add a per-user credential-INJECTION mode to the southbound MCP driver. On an outbound tool call for a connection configured with it, the driver SOURCES the acting principal's credential from the configured broker — the SAME broker-pull the tokenexchange provider already performs (OAuthProvider.Token(ctx, source) reading identity.From(ctx); the credential still originates from the broker, is still per-user, is fetched-not-held, memory-only with TTL) — and INJECTS it into the outbound request per a declared INJECTION MAPPING: which credential field(s) map to which request header(s) / to an Authorization: Basic value / to _meta.<vendor> keys. The mapping is discoverable-or-declared: absent a server capability advertisement, the operator supplies the mapping once at connection config. It is NON-SECRET connection config (like allowed_downstream_hosts); only the pulled value is secret. Injection is mutually exclusive with the existing bearer/oauth mode — the attach-time "one auth mode per connection" guard extends to reject an injection mapping alongside an oauth_provider bearer binding or a static Authorization header. The injection point is the existing per-identity outbound seam (the bearerInjectingTransport / headerInjectingTransport layer for HTTP forms; the buildIdentityMeta _meta stamp for the _meta form), so this is a SECOND declared form on the ONE injection mechanism, not a parallel implementation (§13).

Why this is a faithful exception to the pull-only posture (D-271), not credential passthrough. D-271 rejects a downstream credential arriving IN-BAND from a northbound Protocol client (the coordinator pushing a secret ahead of need). D-341 does the opposite: the runtime PULLS the credential from the broker per acting user at call time (never held, never pushed by a client), and only the LAST HOP differs — the runtime delivers the pulled value into the request because the receiver server cannot pull it itself. The per-user isolation, the ctx-identity-derived broker exchange, the memory-only TTL cache, the fail-loud-on-broker-error posture, and the tool.credential_exchanged audit (zero token bytes) all carry over unchanged from the pull path. No secret enters a wire descriptor or a northbound request body; the injection MAPPING (non-secret) is the only new config surface.

Why the audit redactor must be extended in the same change. The existing redactor matches Authorization: Bearer <...> (a Bearer-only value regex) and a fixed alias set of secret keys. HA-341's forms are NOT covered: Authorization: Basic <base64> is not matched by the Bearer regex, and an arbitrary vendor header key (x-<vendor>-api-key) / a _meta credential value are not in the alias lists. Shipping injection without extending redaction would leak the injected value into any audit payload that captured the outbound request — a §13 silent-degradation / secret-logging violation. So the redactor's coverage is extended to the Basic scheme, the operator-declared injection header keys, and the declared _meta credential keys, redacted to the same *** bar as the Bearer path, in the same phase.

Consequence. internal/tools/drivers/mcp gains the injection-mode config (the non-secret mapping) + the per-call source-and-inject at the existing outbound seam; internal/config gains the connection-descriptor injection-mapping field (non-secret) + the extended one-auth-mode validation; internal/audit/rules.go gains the Basic-scheme + declared-key + _meta-credential redaction rules; the broker-pull reuses internal/tools/auth OAuthProvider.Token unchanged. Tests: a per-user injection call sources a per-user value and injects it in each declared form (header / Basic / _meta), with two different acting users getting two different injected values (isolation); a forced broker error fails the call loudly (no silent skip); the extended redactor holds every new form to *** in the audit payload (asserted against a captured outbound request); the one-auth-mode guard rejects injection + bearer on one connection. Concurrent-reuse: N≥100 interleaved per-user injection calls against one shared driver under -race asserting no cross-user value bleed. Fixtures derive from a real receiver-style server's declared credential forms (§17.8). Framework-framed only (§13).


D-342 — The MCP Apps host renderer emits live theme + styles.variables into ui/initialize host-context (patched via host-context-changed) and delivers the tool input/result into the rendered app; re-lands the reverted Console halves of D-226/D-227 with a handshake-safe lifecycle + a live-render gate

Date: 2026-07-24

Status: Accepted (v1.20 — split out of the v1.19 line so the skills driver could ship independently; design settled). Completes + supersedes the reverted Console halves of D-226 (Data Delivery) and D-227 (live theme / host-context-changed).

Context. The MCP Apps host renderer (web/console/src/lib/chat/renderers/app-bridge-host.ts + mcp-app.svelte) mounts a sandboxed ui:// iframe and completes the ui/initialize postMessage handshake, but a rendered app cannot ADAPT to the host: the host-context is baked to a fixed dark theme (no styles.variables, no host-context-changed), and the originating tool call's input/result is never delivered — so a spec-conformant app boots empty and pasted-on. The App-side ext-apps SDK already CONSUMES both (applyDocumentTheme/onhostcontextchanged; ontoolinput/ontoolresult), and the vendored @modelcontextprotocol/ext-apps@1.7.4 AppBridge already exposes the sender methods (setHostContext, sendToolInput, sendToolResult) — the consumption is inert only because the host never PRODUCES the values. This work WAS built once (D-226 Console push = 109j; D-227 live theme = 109k) and reverted (commit b37ca533) because it broke the ui/initialize handshake ("timed out after 30000ms"; the iframe received zero messages). The exact break was never isolated; the backend halves (D-225 mcp.apps.tool_context, D-227 mimeTypes capability + RuntimeInfo.MCPAppDisplayModes) survived and are on main. Known-good v1.4 baseline: commit 5d6c2b1.

Root cause of the handshake break (isolated by this wave's Stage-0 spike). The reverted live-theme wiring threaded a REACTIVE theme into the Svelte $effect that owns the bridge lifecycle in mcp-app.svelte. That effect's cleanup re-runs on any tracked-dependency change — so when the theme store hydrated a microtask after mount, it called host.close(), tearing down the transport (and its postMessage listener) MID-HANDSHAKE and constructing a NEW bridge on the same iframe window. The App had already posted ui/initialize to the now-closed transport, so it never received a response it would accept → 30s timeout. The host's own instrumentation showed a valid response posted to the right window because the bridge around the window was swapped, not the window.

Decision. Re-land both Console halves as one coherent slice, handshake-safe:

  1. Construct the bridge ONCE with the FINAL host-context. AppBridgeHost is built with the resolved theme + styles.variables (typed as the vendored McpUiHostContext.styles = McpUiHostStyles{variables: McpUiStyles}) at construction — never patched mid-handshake. Theme source: OS prefers-color-scheme (the D-227 precedent; the Console has no applied light/dark theme store yet), mapped from the Console tokens.css custom-property names onto the ext-apps McpUiStyleVariableKey namespace (a closed union — a wrong key fails svelte-check, the §17.8 mechanical guard).
  2. Isolate the host lifecycle from theme reactivity. The $effect owning connectBridge/host.close depends ONLY on loadState + iframeEl; theme is read UNTRACKED at construction. A theme change routes through a SEPARATE effect that calls setHostContext on the LIVE bridge (→ host-context-changed) and no-ops until the bridge reports initialized — it NEVER tears down + rebuilds the bridge.
  3. Gate every host→app send behind oninitialized. setHostContext (theme patch) and the D-226 sendToolInputsendToolResult delivery both fire only after the app reports ui/notifications/initialized, on the existing oninitialized callback; delivery reads mcp.apps.tool_context through the injected MCPAppHostClient (D-173 no-direct-transport preserved), heavy-aware + best-effort (D-226 §2/§3 unchanged).

Test gate (why it slipped before, and the binding done-definition). The existing Playwright spec is a security-primitive harness that never drives a real ui/initialize through the vendored App client (a hand-rolled fixture posting raw JSON-RPC) — the D-216/§17.8 rubber-stamp shape. This wave's gate is TWO layers: (a) a real-iframe Playwright test that boots the vendored ext-apps App client in the sandboxed iframe, completes ui/initialize end-to-end, toggles theme (asserting a host-context-changed arrives WITHOUT breaking the handshake), and asserts the delivered tool input/result renders — the CI gate; and (b) a binding live-render check: the real Dockyard analytics-widgets MCP app, driven by a REAL LLM agent (openai/gpt-5.6-luna via OpenRouter), must RENDER correctly (themed, with data) in the Console/playground, browser-verified with a screenshot — the DONE gate. Not done until (b) passes; this targets the exact "green in fixture, inert for real" failure that previously cost days.

Consequence. web/console/src/lib/chat/renderers/app-bridge-host.ts gains the styles.variables host-context construction + a post-init setHostContext theme relay + the re-landed oninitialized tool-input/result delivery; mcp-app.svelte threads the resolved theme/styles (untracked) and isolates the lifecycle effect; mcp-app-host-client.ts re-adds the toolContext adapter method. NO Go/Protocol change (the ext-apps dialect is vendored, outside CanonicalWireTypes; no wire lockstep). Tests: the two-layer gate above + vitest units typed against the vendored ext-apps schema. Skill drive-the-playground (surface: playground) updated same-PR (§18). Framework-framed only (§13).


D-343 — RESERVED: progressive tool-input-partial streaming into a rendered MCP App (deferred; needs a runtime llm.toolcall.partial companion event)

Date: 2026-07-24

Status: Reserved / deferred (post-v1.19). Not yet designed in full.

Context. Rendering an app that assembles LIVE as a tool streams — relaying tool-input-partial (and progressive tool-result deltas) across the postMessage bridge onto the vendored AppBridge.sendToolInputPartial the app already subscribes to — has NO source in the runtime today. Partial tool-call arguments arrive at the LLM driver across streaming SSE chunks and are merged there by Index; only the COMPLETE ToolCallStructured leaves the driver. llm.completion.chunk carries content/reasoning deltas only; tool.invoked carries no arguments; mcp.apps.tool_context returns the completed {input,result} once. So the "assembles as it streams" experience is not Console-only — it requires a new canonical streaming Protocol event.

Decision (reserved). A future wave adds a llm.toolcall.partial streaming event (a Go/Protocol-additive event emitted at the LLM driver's fragment-assembly site — the same callback that feeds the chunk publisher — carrying {identity quadruple, run/task id, tool-call index, accumulated-or-fragment args, done}), with wire-type + make protocol-ts-gen lockstep + a smoke assertion; then a thin Console relay onto sendToolInputPartial. Protocol-additive, NOT a ProtocolVersion bump. This number is reserved so the deferred design is not re-litigated as new and does not collide with a parallel decision. (Operator deferred the progressive half of the MCP Apps asks at v1.19 scoping.)


D-344 — The skills subsystem gains a Postgres driver for durable/shared storage, behind the existing SkillStore seam, passing the existing conformance suite (§9 three-driver parity)

Date: 2026-07-24

Status: Accepted (v1.19).

Context. The skills subsystem (internal/skills) defines the SkillStore interface + a conformancetest.Run(t, factory) suite + a self-registering driver seam (drivers blank-imported in the internal/drivers/prod aggregator, D-196), but ships ONLY the localdb (SQLite / modernc.org/sqlite, CGo-free) driver. §9 mandates three-driver parity (in-memory / SQLite / Postgres) for every persistence-shaped subsystem; skills is under-parity. An operator running a multi-instance / durable deployment needs skills persisted in shared Postgres, not a per-instance SQLite file.

Decision. Add a postgres skills driver (internal/skills/drivers/postgres/, pgx) behind the existing SkillStore interface — no interface change, no Supports* capability ceremony (§4.4: every driver implements everything). It self-registers from init() and is blank-imported in the internal/drivers/prod aggregator (D-196); nothing else imports it. It carries its own per-driver, forward-only migrations (§9; each ending INSERT ... ON CONFLICT DO NOTHING into schema_migrations), all queries parameterized, identity-triple-scoped WHERE filters (§6), and it PASSES the existing internal/skills/conformancetest suite unchanged (parity is proven by the shared suite, not a new one). Semantic-search parity follows the localdb driver's approach on Postgres. Config selects the driver by name via the existing skills backend seam (backward-compatible: localdb stays the default). D-025 concurrent-reuse test (N≥100 shared-instance under -race) as for any store driver.

Consequence. internal/skills/drivers/postgres/ (driver + migrations + tests through conformancetest.Run); the driver's blank import added to internal/drivers/prod/prod.go (the single sanctioned home, next to the localdb line); examples/harbor.yaml documents the postgres skills backend option; the configure-memory-and-skills skill (surface: memory) notes the durable Postgres option same-PR (§18). No Protocol / wire change. Framework-framed only (§13).


D-345 — Durable-by-default per-user skills: a CLAIM-FREE agent_config.user.skills.* family + a skills.ScopeUser rung, safe because a personal skill cannot widen capability

Date: 2026-07-24

Status: Accepted (v1.21).

Context. The skills subsystem already had an ephemeral session rung (agent_config.session.skills.*, forced Scope=session) and the durable user-config variant carried an inert PersonalSkills []string membership field (names only) — but there was NO way for a plain authenticated user to author a durable skill BODY that persists across their conversations. The seam was half-wired: membership names could be pinned, but the bodies they referenced could never exist at user scope. Operators asked for personal skills that survive across ALL of a user's conversations, not just one session.

Decision. Add a new skills.ScopeUser visibility (between ScopeSession and ScopeProject) and a CLAIM-FREE agent_config.user.skills.{list,upsert,delete} verb family that force Scope=user.

  1. Storage. ScopeUser rows are persisted session-zeroed (skills.StorageSessionID(id, scope) returns "" for user scope; identity is still validated at every driver boundary FIRST — the fail-closed contract from brief 04 holds, no require_explicit_key-style knob). All three drivers (localdb, postgres, the in-memory test fixture) resolve reads/delete with (session = ? OR scope = 'user') so a user-scope row is visible from every session of the same (tenant, user); every non-user scope stays session-pinned exactly as before. No PK/schema change is needed — a (session='', scope='user') PK tuple is new and never collides with a real (non-empty) session. The isolation principal stays (tenant, user): a different user or a different tenant never sees the row.

  2. Durability = scope + driver, no knob. Physical durability rides the driver — in-memory is ephemeral (dev/embedded), sqlite (localdb) and postgres survive restart. There is deliberately no separate durability flag (a toggle would be the wrong shape — brief 04).

  3. Composition with the user config variant. Upsert/delete write the BODY to the SkillStore at user scope AND record the name into the caller's ConfigScopeUser config revision's skills membership — the same durable membership the AgentConfigUserPayload.PersonalSkills field addresses. They COMPOSE: set_revision sets that membership declaratively; the skills verbs write bodies and mutate it incrementally, giving the durable rung diff/rollback parity. The run-start projection (projection.ActiveSkillViews) unions that durable membership (via activeDurableUserSkillNames, mirroring the ConfigScopeUser reads the tool-exposure and user-prompt projections already do) so a durable user skill survives an admin membership pin, exactly like the session's ephemeral personal skills.

  4. Why CLAIM-FREE (the safety argument). The verbs require only a valid identity — NO auth.ScopeAdmin and NO auth.ScopeAgentConfigUser (unlike the rest of the agent_config.user.* family). This is SAFE because a personal skill cannot widen capability: internal/skills/capfilter is default-deny, and internal/skills/tools/redactor.go SCRUBS any tool name a skill references that is not in the run's allowed set (rewriting it to "a suitable tool (use tool_search)"). RequiredTools is provenance/filter metadata, never a grant. So authoring a personal skill is exactly as safe as the already-claim-free session rung. In the wire handler the routes live in the CLAIM-FREE session-safe route set, NOT the admin/user-claim sets. (user in the verb name denotes the durable STORAGE scope, not an auth tier.)

  5. Claim-free write of the user-config revision's Skills section (a deliberate, narrow expansion). The user.skills.upsert/delete verbs record the durable membership by writing a NEW ConfigScopeUser revision — whereas agent_config.user.set_revision gates that same revision behind the ScopeAgentConfigUser claim. This asymmetry is INTENTIONAL and safe: the skills verbs mutate ONLY the Skills membership section of the user-config revision (via recordUserSkillsMembership, which preserves the active revision's PromptLayers + ToolExposure sections and only replaces Skills.Names) — never the prompt or tool-exposure sections the claim protects. Because a personal skill provably cannot widen capability (point 4), a claim-free write of the Skills membership is a bounded expansion of the claim-free mutation surface, not a hole in the claim. The claim still fully guards the prompt/tool-exposure sections (only the claimed set_revision can touch those). The write is own-principal only (keyed by the caller's real (tenant, user)), so a caller can only ever edit their own durable membership.

  6. Rung-precise delete (a destructive op must not cross the durability boundary). The read filter unions the session rung and the durable user rung (session = ? OR scope = 'user') so a user sees their durable skills from any session — but a DELETE must not use that union, or an ephemeral session.skills.delete("foo") would destroy a durable user skill named foo for every session (a cross-durability data-loss bug). SkillStore.Delete therefore takes a target Scope: ScopeUser deletes ONLY the durable row (keyed (tenant, user), session-independent — the user verb's cross-session delete); any other scope deletes ONLY the caller's session-local scope != 'user' rows (the session/admin/generator/CLI callers). The session verb passes ScopeSession, the user verb ScopeUser; the generator rolls back with the inserted skill's own scope. localdb + postgres + the memStore test fixture enforce this, and the conformance suite's delete_rung_independence subtest pins both directions (a session delete leaves a same-named durable user skill intact; a user delete leaves a same-named session row intact; a session delete of a durable-only name deletes nothing durable).

Departure from brief 04. Brief 04's Skill.Scope enum sketch is Project | Tenant | Global. The shipped code already added ScopeSession; this adds ScopeUser. The brief predates the session/user personal rungs. A user rung keyed (tenant, user) does NOT violate brief 04's "cross-session reads require an admin scope" note — a user reads only their OWN skills across their OWN sessions, never another user's or another tenant's.

Consequence. skills.ScopeUser + StorageSessionID (internal/skills/skills.go); relaxed read/delete filters in localdb + postgres + the memStore test fixture; a user_scope_cross_session conformance subtest all drivers pass; three methods + wire types + claim-free routes across internal/protocol/{methods,types,singlesource,transports/stream}; (*Service).UserSkills{List,Upsert,Delete} + recordUserSkillsMembership (internal/runtime/agentcfg/protocol/userskills.go); the projection union; the three code generators' type-index + method-table rows (make protocol-ts-gen + make protocol-docs-gen regenerated same-PR); the use-the-harbor-protocol skill (surface: protocol) documents the two non-admin skill rungs same-PR (§18). Tests: D-025 concurrent-reuse (N≥128), cross-user isolation, capability-safety scrub, and a real-driver integration test across the projection + drivers seam with a fail-loud missing-identity mode. ProtocolVersion unchanged (additive methods).


D-346 — A dev-only, fail-closed, boot-only opt-in (independent of D-340) permits agent_config.add_mcp_connection to carry a per-user credential-INJECTION mapping (the injection object) for a receiver-style MCP server over the wire, persisted in the revision; the sink-derivation + redaction-coverage guards keep the credential-plane invariant honest

Date: 2026-07-24

Status: Accepted (v1.21). The wire-plumbing sibling of D-341 (the injection engine), mirroring the D-340 wire-OAuth-descriptor posture. Composes with D-271/D-278/D-300/D-303/D-338/D-340/D-341.

Context. D-341 (HA-34) gave the southbound MCP driver per-user credential INJECTION for RECEIVER-STYLE servers — a server that authenticates by RECEIVING its credential directly on each request (an arbitrary header, Authorization: Basic, or a _meta key) rather than PULLING it via RFC 8693. But that injection mapping is BOOT-declared agent config ONLY (config.MCPServerConfig.Injection / config.MCPCredentialInjectionConfig). It is NOT a field on the add_mcp_connection wire descriptor, so a long-lived coordinator that ATTACHES a connection at runtime (self-serve, no redeploy) has no path to deliver a per-user credential to a receiver-style server. D-340 (HA-32) solved the identical wire-parity gap for the OAuth-provider descriptor; this is its injection sibling. Carrying the injection mapping over an (admin-gated) Protocol verb is, by construction, an admin-writable field that wires a credential to a receiver — it touches the credential-plane invariant (D-300: no admin-writable field determines WHERE a credential is sent), so it cannot simply be "allowed."

Decision. The add_mcp_connection connection descriptor MAY carry an optional injection object (AgentConfigMCPCredentialInjectionDescriptor: provider, form header/basic/meta, header, basic_username, meta_key — mirroring MCPCredentialInjectionConfig) — but ONLY behind a fail-closed, boot-only opt-in, allowWireInjection = (tools.allow_wire_injection config flag) OR (HARBOR_ALLOW_WIRE_INJECTION boot env), both defaulting false (the D-340 shape: config-OR-env, boot-captured into a write-once atomic in internal/tools/auth, a [DEV-ONLY WIRE INJECTION — DO NOT USE IN PRODUCTION] stderr banner when the env fires). It is a NEW, PARALLEL opt-in — NOT a reuse of allow_wire_oauth_descriptor: an operator may enable wire-OAuth without wire-injection or vice versa (the two relaxations are independent postures). With the opt-in OFF (the default, and all of production) a connection carrying ANY injection field is REJECTED with a distinct typed error (ErrWireInjectionNotAllowed, → 400), fail-loud, exactly as the wire-OAuth descriptor's sink fields are rejected today. The credential-plane invariant stays honest even when opted in, exactly as in D-340/D-341: (1) the injection provider NAMES a boot-declared tools.oauth_providers[] broker — the per-user credential is still PULLED per outbound call from that broker via the acting ctx identity (fetched-not-held, memory-only TTL), so NO credential source or secret ever rides the wire, only the NON-SECRET mapping; (2) the pulled credential's reachable downstream sink is the host DERIVED from the connection's own URL and validated against the named broker's boot-declared allowed_downstream_hosts at attach time in the SHARED injection engine (resolveInjectionBinding) — there is no wire-supplied host field to smuggle; (3) every declared target key must be redaction-covered — the wire-descriptor validation rejects a header / _meta leaf the audit redactor would not hold to ***, using the SAME config.IsReceiverInjectionCredentialKey predicate the redactor (D-341's extension) and boot validation consult, so a wire-declared key an operator may set is exactly a key the redactor redacts (fail-closed). Injection is mutually exclusive with the bearer bindings (oauth_provider / inline oauth) — one auth mode per connection — and rejected on a stdio transport (no HTTP request to inject into). A meta form's meta_key is depth-capped (≤ 16 dot-segments, far below the audit redactor's audit.MaxDepth = 64 deep-walk cap) so a pathologically-nested credential can never sit BELOW the redactor's walk and ride an audit payload uncredacted. The gate + shape validation are applied at BOTH persistence doors — the add_mcp_connection door AND the full-payload agent_config.set_revision door (which likewise accepts a connections.servers[].injection mapping from the wire) — so the fail-closed invariant (opt-in off ⇒ rejected, nothing persisted) holds at every door, not just the add path. The mapping is PERSISTED in the config revision (it is part of the agent's versioned desired state — diff / rollback / list parity), so it round-trips through the revision spine like the sibling descriptor fields. The injection ENGINE is REUSED unchanged (D-341): this decision is wire-plumbing + the opt-in gate + persistence + validation, never a second implementation (§13).

Why this does not reopen D-300 by default. With the opt-in off (production posture), the invariant is untouched: an injection-bearing descriptor is REJECTED (fail-closed; never silently downgraded to a bearerless dial). The opt-in is the §13 dev-escape-hatch shape — default-off, config/boot-env only, banner'd. When an operator DOES opt in, they have made an explicit, boot-time, deployment-wide choice to trust admin-scoped callers to wire a per-user credential to a receiver at runtime — and even then the credential is doubly bounded: only the NON-SECRET mapping rides the wire (the value is broker-pulled per-user at call time, never held or pushed), and the reachable sink is DERIVED from the connection URL + gated by the named broker's boot-declared allow-list, so a stolen/malicious descriptor cannot redirect a pulled credential to an attacker host, and a target key the redactor cannot cover is rejected at validation. The relaxation is therefore "an opted-in operator lets admins wire a per-user broker-pull credential to the receiver the connection actually dials," not "any admin can send a credential anywhere."

Consequence. internal/protocol/types/agentconfig.go gains AgentConfigMCPCredentialInjectionDescriptor + an injection field on AgentConfigMCPConnectionDescriptor (additive omitempty; ProtocolVersion stays 0.1.0); internal/protocol/singlesource + both generator type-indexes + the Console typed client + wire-manifest.gen.json mirror it (D-093/D-223, make protocol-ts-gen), and the generated protocol docs regenerate (D-209, make protocol-docs-gen). internal/agentcfg gains the domain MCPCredentialInjectionDescriptor (carried through canonicalisation + the revision-view projection). internal/runtime/agentcfg/protocol gates + validates the wire mapping (wireinjectiondescriptor.go) and threads it into the attach + revision (addconnection.go). internal/runtime/serve projects it into the config.MCPServerConfig the production attacher builds (so the shared engine fires) and computes the effective opt-in (config OR captured env). internal/config gains tools.allow_wire_injection; cmd/harbor gains HARBOR_ALLOW_WIRE_INJECTION capture + banner at the dev/console/serve boot sites (internal/tools/auth/wire_injection_gate.go). The audit redactor already covers the wire-declared keys (D-341 extended it via the SAME predicate the wire validation now uses — verified, no gap). Tests pin: opt-in-off rejects an injection-bearing add (the fail-closed gate, mutation-verified); opt-in-on comes online, persists the mapping in the revision, and an invocation injects the per-user pulled value (two users → distinct values, isolation); the redactor holds every form to ***; a broker outage fails the call loud with no wire request; N≥100 concurrent injection adds under -race. The integration test wires the full protocol→agentcfg→serve-attacher→tools/mcp→audit path against a real receiver fixture + real broker (§17.3/§17.8). Framework-framed only (§13).


D-347 — Artifacts gain a read side: a byte-serving Protocol method, a read key reconciled onto the isolation triple, an operator-owned fetch ceiling, and in-process pass-by-reference routing — with tabular windowing dropped and third-party egress deferred on named blockers

Date: 2026-07-25

Status: Accepted (v1.23) — a DESIGN GATE. No code lands with this entry; it settles the contracts four subsequent phases build against, and names what is deliberately not built. Extends D-026 / D-022 (the heavy-content offload this gives a read side), D-133 (which reserved artifacts.get for its first consumer), D-167 (the artifact_fetch builtin + the <session_artifacts> manifest), and D-218 (the presign trap on the render path). Anchors its ownership rule on D-349 rather than restating one.

Context. Harbor's heavy-content safeguard is a one-way valve. The write half is complete and load-bearing: a tool result at or above artifacts.HeavyOutputThresholdBytes routes to the ArtifactStore (internal/runtime/dispatch/dispatch.go::projectForLLM), a multimodal DataURL above the same threshold auto-materialises (internal/llm/materialize.go), and the LLM edge fails loud on anything that slipped through (internal/llm/safety.go::findContextLeak). What an agent can then DO with the resulting reference is one head-only builtin — artifact_fetch, whose own godoc calls it "the LLM-facing recovery path… pull the full bytes."

Four gaps sit behind that, and they are not equally ready to close. Reading them together is what this entry is for.

The Protocol has no byte path at all. internal/protocol/methods/methods.go declares a closed set of four artifact methods — list / put / get_ref / delete — pinned twice (canonicalMethods :1093–1096, canonicalArtifactsMethods :1235–1238). artifacts.get is named in RFC §5.2's Protocol table, in brief 05's surface list, in four wire-type godocs (types/memory.go:305, types/flows.go:401, types/search.go:165, types/pause.go:96), and in the glossary's memory.get entry — and it has never existed. D-133 is why: Wave 13 dissolved the bundling phase and deferred artifacts.get explicitly, correctly, under §13 — "no V1 Console consumer… lands additively in the same wave as the first Console surface that consumes it." That consumer has arrived. Meanwhile the one read path that does exist, artifacts.get_ref, type-asserts artifacts.Presigner (internal/protocol/artifacts.go:535–542) and answers CodePresignUnsupported when the assertion misses. internal/artifacts/drivers/s3 is the sole implementor of five drivers, and artifacts.DefaultDriver is "inmem". So the Protocol's byte surface is served by one driver and not by the default — the shape D-218 hit on the App-document path and closed there by raising an inline cap, leaving the general case open.

Discovery and read disagree about the key. ArtifactScope carries four fields, and the fourth is treated two ways. List reads an empty field as a WILDCARD (the identical matchesFilter body in inmem/fs/s3, and conditional WHERE-clause construction in sqlite/postgres); Get / GetRef / Exists / Delete are EXACT-key on all four (inmem's keyFor builds indexKey{Tenant,User,Session,Task,ID}; sqlite WHERE tenant=? AND user=? AND session=? AND task=? AND id=?). The three production call sites do not agree with each other: internal/llm/materialize.go:154–158 writes TaskID: id.RunID, internal/runtime/dispatch/dispatch.go:1278–1282 writes no TaskID, and internal/tools/builtin/artifact_fetch.go:130–139 reads with TaskID: "" under a comment explaining that it matches the dispatch shape. The <session_artifacts> manifest D-167 injects each turn lists on the triple, so it enumerates all three shapes and invites the model to artifact_fetch any of them. A discovery surface built on List therefore hands the model rows a read resolves as found-false. That is the shape any of these four facets would inherit, so it is settled first.

Tabular windowing is not buildable on the current identity. A heavy tool result is stamped MimeType: "application/json" (dispatch.go:1290), hardcoded, whatever the tool actually produced. MIME is not part of the content-addressed id ({namespace}_{sha256_hex[:12]}, derived identically and independently in all five drivers). A re-Put of the same bytes is first-writer-wins in every driver and DISCARDS the new MimeType / Filename / Source — the s3 driver's comment states the contract explicitly. The eight-method ArtifactStore interface has no metadata-update method. A stamped MIME is therefore permanent for the life of a (scope, namespace, content) tuple.

The ceiling is a compile-time constant and the offload threshold is not. defaultArtifactFetchMaxBytes = 64 KiB and hardArtifactFetchMaxBytes = 1 MiB are consts, and the clamp is silent in both directions (artifact_fetch.go:123–129), while HeavyOutputThresholdBytes is a validated ArtifactsConfig field. An operator can tune what goes out and not what comes back, and a caller cannot tell a clamp from an end-of-artifact.


Decision. Eight parts. Parts 1–4 and 6–8 are contracts; part 5 ships one arm of a facet and defers the other on named blockers.

1. The byte-serving surface is artifacts.get — a Protocol METHOD, not a URL. It lands as the fifth artifact method, on the existing control transport at POST /v1/control/artifacts.get, alongside its four siblings. It resolves through ArtifactStore.Get and is therefore served by every registered driver, the default included.

This is not a new surface invented for the occasion. It is the method RFC §5.2 already advertises and D-133 explicitly reserved for its first consumer, and landing it retires four godoc forward-references and one glossary claim that currently describe it as shipped.

  • Auth and posture. Exactly what its siblings carry: the transport-established identity, reconciled through internal/protocol/bodyscope. It takes the FLAT row, matching artifacts.get_ref and not artifacts.list — see part 8.
  • The base-URL question does not arise, and that is the point. A Protocol method is served on the connection the client already holds. Nothing is minted, so nothing needs to know the Runtime's externally-reachable address. internal/config has no public_url / external_url / advertised-address field and ServerConfig.BindAddr is a LISTEN address, so a URL-minting design would require a new config surface and a deployment-topology story (reverse proxies, container networks, TLS termination). That requirement belongs to third-party egress, which part 5 defers; it is not a prerequisite for serving bytes to the caller who already authenticated.
  • The response is truthful about its own bounds. It carries the ref's metadata plus the bytes actually returned, plus total_size_bytes, returned_bytes and truncated — so a bounded read is never mistakable for a complete one. Part 4 makes the ceiling that produces a bound an operator's, and part 3 adds the offset that makes a bound navigable; all three share ONE set of response fields rather than growing a signal each.
  • get_ref is not a parallel implementation of this, and the distinction is stated so it stays true (§13). They are not two ways to do one thing. artifacts.get is the CONTRACT — the universal, driver-independent read every client can rely on. get_ref is a driver-specific TRANSPORT OPTIMISATION: where the store can hand bytes off its own edge, a large media download need not transit the Runtime. Both resolve the same ref under the same verified identity and the same registry-row family; they differ in who serves the bytes, not in who may read them. A client that cannot presign has a path rather than a refusal, which is what makes keeping both defensible.
  • Smoke. The phase's own script asserts, against the default inmem driver on the preflight dev server: a putget round-trip returning the stored bytes; a bounded read reporting truncated: true with a total_size_bytes larger than returned_bytes; and a cross-tenant id answering not-found rather than revealing existence.
  • Lockstep. An additive method with an additive wire type: make protocol-ts-gen and make protocol-docs-gen regenerate (D-223, D-209), the Console typed client mirrors the shape by hand, and internal/protocol/singlesource carries it. ProtocolVersion does not move.

2. ArtifactScope.TaskID is a PROVENANCE ANNOTATION. The read key is the isolation triple. This is the one contract discovery, read and routing must share, so it is settled before any of them is built.

The isolation boundary is (tenant, user, session) — CLAUDE.md §6, and ArtifactScope.Validate already says exactly this by requiring the three and accepting an empty TaskID. A task is not an isolation principal, for the same reason CLAUDE.md §6's clarifying note gives for agent_id: it is a runtime entity that runs WITHIN the tuple and does not widen it. TaskID is the same class of fact as Source["source"] or Source["run_id"] — which is what dispatch.go:1294 already writes it as.

  • Reads resolve on the triple. Get / GetRef / Exists / Delete match (tenant, user, session, id). Discovery, read and routing then agree by construction, because all three key on the same three fields plus the id.

  • This narrows the KEY to the declared boundary; it does not widen the boundary. The honest consequence, stated plainly (D-351's bar): an artifact written under one run's scope becomes readable by a sibling run in the SAME session. That is correct — the session is the innermost isolation scope, the <session_artifacts> manifest already lists across runs within it, and artifact_fetch already reads with the empty-TaskID shape intending exactly this. Nothing crosses a session, a user or a tenant.

  • The content-addressed id is what makes the narrowing safe. ID = {namespace}_{sha256_hex[:12]} is content-derived, so within one session two artifacts sharing an id ARE the same bytes. Removing a field from the read key cannot merge distinct content; it can only stop an artifact from being hidden from itself.

  • List keeps TaskID as a FILTER, and the wildcard there is deliberate. "Show me this run's artifacts" is a legitimate query. What changes is that filter semantics and key semantics stop being accidentally different: one is a predicate over a result set, the other is an identity, and they are now distinguishable in the interface's own godoc.

  • AND THAT FILTER BECOMES LOSSY — the second honest consequence, held to the same bar as the first. Narrowing the read key narrows the WRITE/dedup key with it: inmem's indexKey includes Task (inmem.go:57–72), so today two runs storing identical bytes produce two entries, each carrying its own stamp. Under the reconciled key they collapse to one, and ArtifactRef.Scope.TaskID becomes first-writer-wins. Concretely: if run B writes bytes run A already wrote, a TaskID=B filter does not return that row — it carries A's stamp. So the filter stops fully answering the question it is defended for above.

    This is accepted, and the reason is a property of content-addressed storage rather than a convenience. Provenance on a content-addressed store is inherently first-writer: the id is derived from the bytes, so "which run produced these bytes" has no single answer once two runs produce them. The alternative — keeping Task in the write key to preserve per-run stamps — is what produces the enumerate-then-fail divergence this part exists to close, since it makes the read key either lossy or wrong. Between a filter that under-reports a duplicate and a manifest that lists refs no read resolves, this decision takes the former. Two obligations follow: the List godoc states the first-writer property at the filter rather than leaving it to be inferred, and a conformance row pins it (two scopes differing only in TaskID writing identical bytes yield ONE artifact whose stamp is the first writer's) so the behaviour is asserted rather than incidental.

  • List gains the identity precondition every sibling already has. It is the one ArtifactStore method no driver validates, so ArtifactScope{} is a legal all-tenants filter at the store boundary. Discovery is built on List, so the discovery surface must not inherit that: List validates (tenant, user, session) like its siblings. The admin fan-out that legitimately needs a wildcard tenant reaches it the way artifacts.list's registry row already declares — through identity.WithElevated, on D-349's minting list (part 8).

  • ScopedArtifacts needs a CODE change, not only a comment fix. GetRef compares ref.Scope.Equal(s.scope) (scoped.go:92–95) and Equal includes TaskID (artifacts.go:78–83), so under the reconciled key a facade scoped with an empty TaskID reading a materialize.go-stamped ref returns ErrScopeMismatch — precisely the case the narrowing is meant to enable. The comparison narrows to the triple. Its godoc is corrected alongside: it claims scope-checks "on every read (Get, GetRef, Exists, Delete, List)" while only GetRef performs one, and under the reconciled key the drivers are the enforcement point and the facade is a pass-through. Blast radius is low — ScopedArtifacts has no production consumers outside conformancetest — which is a reason to fix it cleanly here rather than to leave a facade that contradicts the store beneath it.

  • This is a five-driver conformance change (the key is per-driver) and lands with its conformance rows: a re-Put under a differing TaskID resolves to one artifact; a read with a populated TaskID and a read without it return the same bytes; a cross-session id answers not-found. The suite currently asserts the wildcard rule for List (List_NilFieldsAreWildcards) and nothing about the read key — that asymmetry is what let the two shapes drift.

  • fs is the outlier and "five-driver change" understates it there. The other four hold the task in an index key or a WHERE clause; fs encodes it in the ON-DISK PATH — <root>/<tenant>/<user>/<session>/<task>/<namespace>/<id>, with an emptyTaskSentinel directory standing in for the empty case (fs.go:372–380). A triple-keyed read there must resolve ACROSS task directories rather than tighten a predicate, so its implementation is a layout question (and, for artifacts already on disk under a run-stamped directory, a resolution-order question) rather than a one-line filter edit. The phase plan sizes fs separately from its siblings.

  • §17.6 fix, bundled not deferred. Making the read key the triple is what makes the D-167 manifest's invitation true: the manifest tells the model it may fetch any listed ref, and an LLM-materialised attachment is a listed ref. The fix lands with the key change.

3. Windowing is BYTE-OFFSET and MIME-agnostic. Tabular windowing is DROPPED from V1 — not deferred with a design.

artifact_fetch gains offset alongside max_bytes, answering {content, offset, returned_bytes, total_size_bytes, eof} — the same fields part 1 gives artifacts.get. A model paging a large CSV reads byte ranges and splits lines itself.

Row-addressed windowing keyed on stored MIME is dropped because the obstacles are structural, not tunable: heavy tool results are stamped application/json unconditionally; MIME is not part of the content-addressed id; a re-Put dedups and discards the new MIME; and no metadata-update method exists. A mis-stamped MIME is permanent for that content, so "fail loud on a wrong MIME" resolves to "fail forever" — which is not the §13 fail-loud posture but its inversion. Fail-loud is valuable because it routes an operator to a fix; an error with no available recourse is a permanent refusal wearing a diagnostic's clothes.

Reopening it requires one of two prerequisites, named here so the next author does not re-derive them: either (a) MIME joins the content-addressed identity — an id-shape change touching five drivers, every persisted ref, and the dedup contract, therefore an RFC change; or (b) the interface gains a metadata-revision method — a ninth method, five-driver conformance parity (§9), and a defined answer for what a revised MIME means for a ref already rendered into a model's context. Neither should be adopted to enable a windowing convenience.

4. The fetch ceiling becomes operator policy, and the clamp becomes observable. Two properties are settled independently.

ArtifactsConfig gains two additive fields with the current constants as defaults, so existing configs are unchanged (§10): fetch_default_max_bytes (64 KiB) and fetch_hard_max_bytes (1 MiB), validated in loader.go::Validate (each positive; default ≤ hard). The offload threshold is already an operator's; the ceiling governing the read-back now is too, and the two are tunable as the pair they are.

The clamp stops being silent (§13). A request above the effective ceiling is SERVED at the ceiling and reports it through the same truncated / total_size_bytes / eof fields — one signal, whether the bound came from the caller, the default, or the ceiling. Deliberately not a refusal: the model cannot know the ceiling before asking, and a refusal degrades its recovery. Truthful truncation is the correct posture; silent truncation is the one §13 names.

And the guarantee is bounded, stated as such (D-351's bar): the ceiling bounds ONE fetch. It is not a budget over repeated fetches, and the governance layer's cost ceilings and rate limits remain the mechanism for aggregate consumption. Saying otherwise would be claiming a property the knob does not have.

5. Pass-by-reference routing ships its IN-PROCESS arm. Third-party egress is DEFERRED on three named blockers.

The primitive: a tool declares an artifact-reference parameter in its input schema; the model supplies an id; the runtime resolves the ref at dispatch and hands the consumer the bytes. Bytes flow store → consumer; the model authors an id and never sees content. Because tool input schemas are reflection-derived from the Go type (internal/tools/drivers/inproc.RegisterFuncschema.Derive), a reference parameter is a declared field type rather than a hand-written schema convention.

Shipped: the in-process arm. The consumer runs inside the Runtime, so resolution is a store read and a struct field. No URL, no third party, no egress. This delivers the facet's actual claim for the consumer class Harbor controls.

Deferred: the third-party arm — an HTTP / MCP / A2A tool that must be handed something it can dereference. Deferred on blockers, not on effort, and each is a separate design question:

  • The address. A remote consumer needs one; the Runtime has no configured externally-reachable address to give (part 1). Inventing one is a deployment-topology decision with its own config surface, and when it lands it should take the D-338 / D-340 / D-346 shape: config-or-boot-env, default off, fail-closed, never wire-writable.
  • The grant's semantics. "Single-use" is not a property a presigned URL has — it is expiry-bounded and nothing more. Shipping one feature whose two mechanisms carry different security properties is the §13 parallel-implementation shape, and a grant cannot claim a use-count property before a legitimate retry, a redirect, a HEAD-then-GET probe, and a partial or abandoned transfer each have a defined outcome. Those four are the design, not details under it.
  • The credential obligation. A grant URL IS a bearer capability to the content — the same reading artifacts.get_ref already applies to itself when it refuses cross-tenant elevation flat, on the stated ground that a presigned URL is "materially broader than the metadata artifacts.list returns." Routing substitutes such a value into outbound tool arguments, which §7 rule 7 and §13 forbid persisting unredacted. Part 7 states that obligation as an invariant now, so the deferred phase inherits it rather than rediscovering it.

6. Artifact bytes are stored AS AUTHORED. The redactor governs what is EMITTED, not what is stored. The two producers today hold two postures and neither stores redacted bytes, so the honest contract is stated rather than a third posture invented.

internal/protocol/artifacts.go::handlePut calls the redactor and DISCARDS its result (if _, err := s.redactor.Redact(ctx, redactView)), storing req.Bytes verbatim: the redactor is an ADMISSION GATE — a refusal path — not a transform. internal/runtime/dispatch/dispatch.go holds no redactor reference at all. The settled contract is the one the rest of the system already behaves by: an ArtifactRef passes the redactor unredacted precisely because it is a reference (D-022), and an artifact exists to hold the bytes the event stream and the prompt must not carry.

Three consequences bind:

  1. The godoc and the glossary stop describing a transform. handlePut's comment says the payload is "routed through" the redactor and that it "may rewrite"; a rewrite is discarded. The wording is corrected to name the gate it is.
  2. The read side inherits the boundary and does not relax it. artifacts.get returns stored bytes to the caller's own verified identity — the same principal that reaches them through artifact_fetch inside its own run. What it must not do is place those bytes anywhere the redactor governs: the response is not an event payload, is not written to the trajectory, and is not logged. Part 7 states this.
  3. It is a reason the external arm is deferred rather than merely postponed. Handing stored-as-authored bytes to their own author and handing them to a third party are different questions, and this entry declines to answer the second.

7. THE SUBSTITUTION INVARIANT — and the arrival path it must be checked at.

The invariant is named first as a contract:

A resolved artifact value never re-enters the model's context or the observable record. When the runtime substitutes a resolved value into a dispatched tool argument — resolved bytes on the in-process arm, and a grant on any future external arm — that value is DISPATCH-LOCAL. It does not appear in the trajectory, in the observation the ObservationRenderer interleaves into the next chat thread, in any canonical event payload, in an audit payload, or in a log. The model authored an id and continues to see an id; the substitution is the runtime's and it ends at the tool boundary.

The primary mechanism is a bound on PRODUCTION, not a search over arrivals. The substitution has ONE call site, and a mechanical AST scan holds it to one — the shape of D-349's minting scan and D-223's lockstep. An invariant about where a value does not travel is enforced most durably by bounding where it is produced; enumerating every place it might arrive is the check that goes stale. The scan carries a reasoned allow-list, and a list entry matching no call site is itself reported, so the list stays a description of the code (D-349, item 5).

That is necessary and not sufficient, because there is a live arrival path the LLM edge does not inspect, and this decision names it rather than assuming it away. CompleteRequest carries Tools []ToolDeclaration, ToolChoice string and ParallelToolCalls bool (internal/llm/llm.go:145–159), and ChatMessage carries ToolCalls []ToolCallStructured (:277) whose element holds Args json.RawMessage (:217–222). That is a tool-call ARGUMENTS field reachable at the LLM edge as req.Messages[i].ToolCalls[j].Args, and it is live rather than vestigial: the React prompt builder replays a trajectory step's call.Args into it (internal/planner/react/prompt.go:919, and again at :1032, :1228, :1352), and the bifrost translator maps all of it onto the provider (internal/llm/drivers/bifrost/translate.go:487–505). findContextLeak (internal/llm/safety.go:330–370) walks m.Content.Text and m.Content.Parts and does not walk ToolCalls[].Args.

So the one field that could carry a resolved value back to a provider is the one field the edge net does not read. findContextLeak widens to cover Messages[].ToolCalls[].Args, and it lands in the routing phase (D) alongside the substitution it guards.

Three reasons the widening is taken rather than argued away:

  1. The alternative argument is circular. "The substitution never reaches the trajectory, so Args cannot carry it" restates the invariant as its own evidence. An invariant with a single production-side guard and no arrival-side check is asserted, not enforced — and the arrival is where a violation becomes observable to a provider.
  2. The check is the one D-026 already specifies for this class. Args is offloadable content by D-241's own test — it is machine-authored, tool-shaped, and has an ArtifactStub offload path — so it belongs on the offloadable side of D-241's line, next to RoleTool text and binary DataURL parts, not on the exempt conversation-text side. The widening applies D-026's existing rule to a field that fits it, and adds no new rule.
  3. It is a defence in depth over a bound the routing phase itself introduces. The production-side scan protects against a second substitution site being added; the edge check protects against a resolved value reaching Args by any route, including the replay path that already copies Args forward turn over turn.

Scope, stated so the widening is not read as wider than it is. It extends findContextLeak's existing byte-threshold test to one additional field. It does not change the threshold, does not touch D-241's conversation-text exemption, does not add an exemption, and does not alter ErrContextLeak's type or the llm.context_leak event. A tool call whose arguments legitimately exceed the threshold is the same bug the check names everywhere else: a producer that should have passed a reference.

RFC §6.5 is corrected in the same change (§2). Its CompleteRequest listing and two following paragraphs state "No Tools, no ToolChoice, no FunctionCall" and that bifrost's Tools / ToolChoice are "intentionally NOT used." D-167's native tool-calling cutover superseded all three, and §6.2 already describes the projector that consumes the result. A stale RFC is drift to fix, not to route around — and this entry cites §6.5 as an anchor, so it fixes it here.

8. OWNERSHIP IS D-349's RULE READ ONTO A REF. Nothing new is invented.

A ref resolves only within the caller's VERIFIED triple — the transport-established identity identity.WithVerified seats and identity.FromVerified reads. Three consequences follow from the shipped mechanism:

  1. The read surface registers with the gate, or the build is red. artifacts.get carries an identity scope in its request body, so it is a bodyscope registry row like its four siblings. D-349's COVERAGE scan is bidirectional: a *Request type in internal/protocol/types carrying an identity-scope field that joins no registered surface fails go test, and a row naming a type the canonical packages no longer declare fails just as loudly. Registration is not a courtesy.
  2. Its posture may not exceed its sibling's. D-349 registered the artifacts cluster as four rows — list elevatable under either admin-tier claim, put and delete administrative-claim-only because both are writes, and get_ref FLAT with its published refusal code, on the reasoning that a hand-off to content is materially broader than a hand-off to metadata. artifacts.get serves that same content over a different transport and therefore takes the same flat row. artifacts.list stays the only elevatable READ in the cluster, and the fifth row does not quietly become a wider door onto what the fourth guards narrowly.
  3. Any crossing goes through the named verb and lands on the minting list. A ref reached outside the verified triple — an admin fan-out, a fleet view — re-scopes through identity.WithElevated(ctx, id, reason), which refuses an empty reason and leaves a marker SCOPED TO THE TENANT IT AUTHORIZED, so authorising one crossing never authorises the next. D-349's MINTING scan holds every call of that writer to a reviewed list, resolving the identity package by import path so an alias is followed. A crossing added for artifacts is a reviewed list entry or a failing test.

No artifacts-local "host-derived, never caller-chosen" rule is declared, and declaring one would be a regression. Reconcile already makes the body's scope an INPUT and the transport's identity the AUTHORITY, and identity.With already refuses to move the tenant past the verified anchor. The ref ID is caller-chosen — it must be; it is what the model names — and the SCOPE it resolves in is not. That separation is precisely what the reconciler produces. Restating it here would be the thirteenth copy of a comment D-349 deleted twelve of, and a comment travels to the next handler more readily than the check it describes.

The same reading settles the routing arm without new machinery. An artifact-reference tool parameter resolves under the DISPATCHING run's identity, which descends from the verified triple through identity.WithRun — and D-349 gave WithRun the same narrowing rule exactly so that attaching a run cannot become the seam that moves the boundary sideways. A tool reaches the bytes its own run's identity reaches and no others, with no identity logic in any tool driver.

And the read/write asymmetry mirrors D-350's, deliberately. There, read projections stayed bare-name and process-global while the WRITE narrowed to the owner. Here, List's wildcard FILTER semantics are preserved while the read KEY narrows to the triple. Both are the same finding: a projection's reach and an operation's authority are separate questions, and collapsing them produces either a leak or a dead surface.


Non-goals — explicit, so a later phase does not read silence as permission.

  1. No tabular or row-addressed windowing, and no MIME-keyed read behaviour of any kind (part 3, with its two prerequisites named).
  2. No metadata-revision method, no change to the content-addressed id shape, no ninth ArtifactStore method in the phases this entry authorises.
  3. No third-party byte egress: no loan or grant URLs, no grant tokens, no externally-reachable base-URL config, no change to ServerConfig.
  4. No ranged read on ArtifactStore in the discovery/ceiling or windowing phases — that is its own §9 conformance phase (below).
  5. No new isolation principal. TaskID does not become one and does not stop being a provenance label. The tuple stays (tenant, user, session) (+ run for the quadruple).
  6. No Supports* capability ceremony on the read path (§4.4). Presigner remains the single documented exception it already is, and artifacts.get is mandatory on every driver precisely so no second optional capability is needed.
  7. No auto-truncation of the model's context. D-026's fail-loud posture and D-241's narrowing are untouched; this entry adds no exemption to findContextLeak.
  8. No ProtocolVersion bump. An additive method with an additive wire type and a capability advertisement; the D-223 and D-209 gates regenerate to a clean diff.

The §13 primitive-with-consumer statement — verified against the code, including what does NOT qualify.

Consumer 1 — the MCP-Apps Data-Delivery path (Console, in-wave). web/console/src/lib/mcp-app-host-client.ts::fetchArtifactText (:183) is the host's ONE byte path: it calls client.artifacts.getRef and fetches the returned presigned_url. ArtifactsSurface.handleGetRef type-asserts artifacts.Presigner, which only the s3 driver implements, against a DefaultDriver of inmem. So on four of five drivers the call throws and #payloadToResult (web/console/src/lib/chat/renderers/app-bridge-host.ts:935–947) delivers its honest by-reference stub placeholder in place of the tool's data. artifacts.get gives that path a driver-independent read. This is D-218's trap on the arm D-218 did not cover: it fixed ui:// App DOCUMENTS by raising an inline cap; the Data-Delivery payload is a different read through the same presign door. This is a genuine consumer — the premise is not that the host fails to resolve bytes, but that the resolver it has is served by one driver.

Two §17.6 fixes ride with it, both verified in source:

  • #payloadToResult's by-reference SUCCESS branch returns {content: [{type:'text', text}], isError} with NO structuredContent, while the inline branch sets it (web/console/src/lib/chat/renderers/app-bridge-host.ts:929). One result reaches the App as structured data or as text depending on its SIZE. The failure branch is already honest; the success branch is the one that drops a field.
  • #payloadToArgs carries a bare catch { return {} } on the input path (web/console/src/lib/chat/renderers/app-bridge-host.ts:907), with a JSDoc rationalising an empty argument map as "a faithful 'no input'". A fetch failure and an absent input are different facts, and §13 names this shape by hand.

Consumer 2 — artifact_fetch + the <session_artifacts> manifest (Runtime, in-wave). The D-167 manifest lists the session's artifacts each turn on the triple and instructs the model it may artifact_fetch any listed ref. artifact_fetch reads with TaskID: ""; internal/llm/materialize.go:154–158 writes TaskID: id.RunID. Because List treats an empty field as a wildcard and Get / GetRef are exact-key, a materialised multimodal input is a row the model is invited to fetch and a read that answers found-false. This pair is the consumer that exercises part 2 end to end: the manifest produces the id, the tool resolves it, and the reconciled key is what makes the round trip close.

Explicitly NOT claimed as consumers — stated so a reviewer can check the correction rather than take it.

  • The tool:<name> attachment disposition (D-189) is NOT a routing consumer. AttachmentDisposition.ToolName() has exactly one production reader that ACTS on the named tool — internal/planner/multimodal.go:99stubPartForcedTool — which renders an ArtifactStub whose Fetch.Tool names it. (Two further readers exist and are grammar/validation rather than dispatch: disposition.go:110, where Valid() uses it to recognise the parametrised form, and :315, where the resolver degrades to ref when the catalog cannot resolve the name.) It is prompt-rendering policy. internal/runtime/dispatch/ holds zero references to Disposition or StubFetch, and StubFetch.Tool is written in three places and read back by no executor: the runtime renders "call this tool" as text and the model may comply. The carrier is fully plumbed (wire → task record → RunContext → prompt, with a task.input_disposition.resolved event); the EXECUTION side does not exist. Naming it a consumer of pass-by-reference routing would be describing a prompt hint as a dispatch mechanism.
  • tools.Tool.HandlesMIME catalog affinity is INERT and is not claimed. The field exists (internal/tools/tools.go:181) with a working matcher (MatchesMIME, :195–218) and a live reader (internal/planner/multimodal.go:319), and NOTHING in production writes it: there is no WithHandlesMIME among the eleven DescriptorOptions in internal/tools/policy.go, descriptorConfig has no such field, and the inproc registrar builds its tools.Tool literal field-by-field and omits it. Every assignment in the tree is in a _test.go file. Making the affinity live is a descriptor option plus per-driver population — small, real, and owned by whichever phase needs the affinity, not by this entry's consumer list.

Phasing — the four facets are NOT equally ready, and this entry says which are which.

Phase A — the read side, the reconciled key, and the ceiling (ONE phase). Parts 1, 2, 4, plus part 6's doc correction and the two Console §17.6 fixes. These do not separate: a byte-serving method built on an exact-key read reproduces the enumerate-then-fail shape on a NEW surface, and the key reconciliation without a byte path has no consumer and would land as a primitive alone (§13). The ceiling rides here because it is the same response fields and the same tool signature; splitting it ships two truncation signals a release apart. Both verified consumers land in this phase.

Phase B — byte-offset windowing. Part 3's shipped half. Depends on A for the response shape and for the ceiling that makes it honest. Ships against the existing whole-blob read, and its plan states that cost property rather than implying an incremental one.

Phase C — the ranged read (a §9 five-driver conformance phase, and this entry says so plainly). ArtifactStore.Get returns ([]byte, bool, error) with no offset, Range, io.Reader, or ReaderAt, and every driver materialises the whole blob: fs os.ReadFile, sqlite and postgres SELECT bytes into a []byte, s3 io.ReadAll(out.Body), inmem a map lookup plus a deep copy. A window at offset N therefore costs a full materialisation, and paging a large artifact in small windows is model-triggerable IO amplification. Adding a range parameter to a tool does not change that; the fix is an interface change with five implementations and a conformance-suite extension, which is a phase.

It also carries a per-driver cost question, and that question is the PHASE's to answer — this entry deliberately does not prejudge it. s3 maps to a Range header on GetObjectInput (the driver already holds an io.ReadCloser it currently drains eagerly) and fs maps to ReadAt; both are straightforwardly incremental. For sqlite and postgres the settled fact is narrower than it may look: the Go driver API offers no ReaderAt or incremental blob handle through database/sql. That is not the same as the engine being unable to range — both support server-side byte-range extraction over the stored column (substring(bytes from $1 for $2) on Postgres bytea, substr(bytes, ?, ?) on SQLite BLOB), neither of which needs an incremental Go API. Whether that makes a given engine incremental in practice, and at what cost, is the ranged-read phase's investigation.

What this entry binds is the shape of the claim, not its content: the conformance suite asserts the CONTRACT (correct bytes at offset, correct eof) and never a cost, and NO capability claim about which drivers are incremental is baked into interface godoc — a substring()-based implementation would falsify exactly such a claim, and godoc is the artefact operators read. A performance claim is earned per driver by the phase that implements it. Ordering B before C is deliberate: settle the semantics against a simple read, then make the read cheap.

Phase D — in-process pass-by-reference routing. Part 5's shipped half, plus part 7's invariant and its minting-style scan. Depends on A (it resolves through the same reconciled key); independent of B and C.

Deferred, not a phase: third-party egress. Its three blockers (the address, the grant's semantics, the credential obligation) are separate design questions that happen to share a feature's name. Each deserves its own decision.

Order: A → (B and D in parallel) → C.

Dropped rather than designed: tabular windowing (part 3), and the two consumer claims corrected above.


Consequence. Docs-only at this entry, with one exception noted below. When the phases land: internal/protocol/methods/methods.go gains a fifth artifact method in both canonical sets; internal/protocol/artifacts.go gains handleGet and a fifth bodyscope registry row; internal/protocol/types/artifacts.go gains the request/response pair, mirrored into internal/protocol/singlesource, the Console typed client and wire-manifest.gen.json (D-223) with the generated Protocol reference regenerated (D-209). internal/artifacts narrows the read key across five drivers (with fs's on-disk layout sized separately) and adds the List identity precondition, with the conformance suite extended for both plus the first-writer provenance row, and ScopedArtifacts narrowing its Equal comparison alongside its godoc. internal/config gains two ArtifactsConfig fields with validation and an updated examples/ config. internal/tools/builtin/artifact_fetch.go gains offset and the truthful bound fields. internal/llm/safety.go::findContextLeak widens to Messages[].ToolCalls[].Args, and internal/llm/materialize.go's scope aligns with its siblings. web/console/src/lib/mcp-app-host-client.ts routes the Data-Delivery read through the new method, and web/console/src/lib/chat/renderers/app-bridge-host.ts closes the dropped structuredContent and the bare input-path catch. Each phase carries its smoke additions (§4.2) and the operator skills naming the touched surfaces are updated in the same PR (§18). Framework-framed only (§13).

Two corrections taken HERE rather than deferred to a phase, because both are documentation-class drift this entry's own reading surfaced. First, RFC §6.5's CompleteRequest listing and two following paragraphs are corrected per part 7 — D-167's native tool-calling cutover superseded the "No Tools, no ToolChoice, no FunctionCall" prose and the "bifrost's Tools / ToolChoice are intentionally NOT used" claim, while §6.2 already documented the projector consuming the result. §2 makes a stale RFC drift to fix, not to route around, and an entry citing §6.5 as an anchor is the right place to fix it. Second, internal/llm/materialize.go stamped Source: {"phase": "32", "stage": "auto_materialize"} into artifact provenance — internal phase numbering persisted into stored metadata and surfaced through artifacts.list. The §13 phase-jargon scan reads comments, not string literals, so it passed; neither key was read anywhere. Replaced with {"producer": "llm.auto_materialize"}, naming the feature rather than the phase, matching the producer key its dispatch.go sibling already uses. Deliberately NOT taken here: adding a canonical Source["source"] discriminator on this path, which would change what artifacts.list reports and filters on and belongs to a phase that can test it.

Cross-references. D-026 (the context-window safety net this gives a read side; untouched, not widened), D-022 (the canonical by-reference representation), D-241 (the narrowing that makes part 7's direction the only live one), D-133 (which reserved artifacts.get for its first consumer — this is it), D-167 (the artifact_fetch builtin + <session_artifacts> manifest that make up consumer 2), D-218 (the presign trap, closed there for App documents and reached here in the general case), D-189 (the disposition policy whose tool:<name> arm is corrected out of the consumer list), D-349 (the body-identity gate, the verified anchor, the named elevation verb, and the three-part lockstep this entry's ownership rule IS, rather than restates), D-350 (the read-projection vs write-authority asymmetry mirrored in part 2), D-351 (the bounded-guarantee bar parts 2, 4 and C hold themselves to, and the "pin the call, not the identifier" lesson part 7's scan follows), D-338/D-340/D-346 (the fail-closed boot opt-in shape the deferred egress config should take), D-025 (the surfaces are compiled artifacts; per-call identity rides ctx). RFC §5.2 (the advertised artifact method set), §6.5 (the LLM-edge safety net + the ArtifactStub), §6.10 (Artifacts — the addendum this entry anchors), §6.4 (tool dispatch), §7 (the Console as a Protocol client). brief 05 (mandatory artifact routing, the wildcard listing rule, the artifacts.list / get / get_ref / delete surface), brief 07 (the artifact-fetch escape hatch), brief 13 (read-only injected prompt blocks). CLAUDE.md §2, §4.2, §4.4, §5, §6, §7, §8, §9, §10, §11, §13, §16, §17.1, §17.3, §17.6, §17.8, §18.


D-348 — A reopened session REPLAYS its rendered MCP Apps: reduceHistoryTurns folds the durable mcp.app_available event onto the turn (reusing the LIVE MCPAppRefView), and the renderer resolves the already-persisted tool context BEFORE mounting so an unresolvable one renders an honest placeholder instead of a half-mounted iframe

Date: 2026-07-25

Status: Accepted (v1.22). Zero-wire. Extends D-293/D-298 (the session-reopen reduction) over the D-225 tool-context capture and the D-226/D-342 data-delivery lifecycle; composes with D-173 (injected client, no direct transport) and closes a runtime-attach gap left by D-346/#375. Leaves D-343 (progressive streaming) reserved and untouched.

Context. A rendered ui:// MCP App VANISHED when a session was reopened. web/console/src/lib/sessions/history.ts::reduceHistoryTurns folded llm.completion.chunk, llm.cost.recorded, planner.decision, tool.completed/tool.failed, and the task lifecycle — but not mcp.app_available; and HistoryTurn had no app field, so the type could not represent an App even if the event had been reduced. The reopened turn therefore degraded to the deliberately-terse MODEL-FACING text the tool emitted for the LLM (the rich payload lives in structuredContent, out of model context by design), which reads as a broken or empty turn. This is a reduction gap, not a durability gap: the tool context is already persisted as a session-scoped StateRecord under a deterministic content-hash key (internal/mcpconsole/toolcontext.go), mcp.app_available is a registered canonical bus event carrying ServerID / ToolCallID / ResourceURI / DisplayMode / RawHTMLTrusted, and state.history already replays it (the flat projection applies no type filter). Nothing needed to be newly stored.

Decision.

  1. HistoryTurn gains app?: MCPAppRefView + serverID?: string, REUSING the live type. The reducer output deliberately mirrors what the live discovery path (decodeAppAvailableapplyAppAvailable) attaches to the bubble — the same MCPAppRefView from $lib/chat/renderers/app-bridge-host, not a second wire shape — so the hydrated message field is assignment-compatible with the live path's and MessageBubble needs no change to mount a replayed App.

  2. reduceHistoryTurns folds mcp.app_available. PascalCase/snake tolerant like every sibling fold; the display-mode hint is normalised against the known inline/fullscreen/pip set (unknown → '', the renderer default) exactly as the live decoder normalises it; LAST-WINS within a run, mirroring the live reducer which overwrites the bubble's app on each discovery. A frame missing ServerID or ResourceURI declares NO app — both are load-bearing for the mount (readResource(serverID, uri)), and this is the same guard decodeAppAvailable already applies, so a half-formed ref that could only mount broken is never hydrated. hydratePastTurns sets app + serverID on the reopened agent message and widens its render gate to answer || terminal || app, so an App-only turn cannot fall through and vanish.

  3. The re-mount reads the ALREADY-PERSISTED context by its deterministic id. The renderer pairs the replayed serverID with the ref's content-hash toolCallId and calls mcp.apps.tool_context — the same identity-scoped read the live render performs. No new storage, no new Protocol method, no ProtocolVersion bump, and no caller-controlled identifier anywhere on the path.

  4. The miss behaviour is DEFINED, and the fetch moves BEFORE the mount to make it observable. Previously the host fetched the context AFTER the ui/initialize handshake, so an unresolvable context produced a live-looking iframe whose data simply never arrived — a silent degradation (§13) that a replayed App would hit far more often than a live one. Now mcp-app.svelte resolves the context during preload, and only then decides what to render. Four explicit outcomes: (a) resolved → mount, build the bridge, deliver after ui/notifications/initialized; (b) null (the adapter's mapping of the Runtime's not_found — unknown / cross-identity / evicted) → the MISS: a new unavailable renderer state renders a stable, honest "This view is no longer available" placeholder (role="status", data-testid="mcp-app-unavailable", token-only styling, muted rather than red because the TURN succeeded — only its interactive view cannot be rebuilt), with NO iframe created and NO bridge ever constructed; (c) throws (any non-not_found Protocol error, e.g. an identity-scope rejection) → the existing loud error state carrying the message, never laundered into the eviction copy; (d) no toolCallId on the ref → mount with no delivery, unchanged — nothing was ever captured, so nothing has gone missing, and this is deliberately NOT the miss path (it is what keeps a runtime with capture unwired working exactly as before).

  5. One delivery mechanism, not two. AppBridgeHostOptions.toolCallId is REPLACED by toolContext (an already-resolved context the host delivers). The host no longer fetches; the renderer decides mount-vs-placeholder and hands the result over. The liveness re-checks around each send stay (a transcript re-render can still close() the bridge while a heavy by-reference half is fetched at the iframe edge between sendToolInput and sendToolResult), and the in-flight-close regression test is re-aimed at that surviving window rather than deleted — on BOTH halves, since the heavy half is more often the result and only the result case reaches the second re-check.

  6. The tool-call id becomes a PROMISE, and the two paths that were breaking it are fixed at the source (§17.6). Item 4 only reads honestly if a non-empty id means a record exists — otherwise "this view is no longer available" is asserted over a context that was never written, turning a pre-existing degradation (a dataless app) into a total render failure. Two producers were lying: (a) MCPConnectionAttacher — the runtime add-connection path — built mcpdrv.AttachDeps with NO ToolContext, so a ui:// app declared by a tool on a server an operator attached from the Console captured nothing while the discovery still advertised an id; only the boot-config path (internal/runtime/assemble) wired the store. (b) The driver stamped the id unconditionally and then captured best-effort, so a transient Capture error (logged-and-ignored per the D-225 posture) left the same false promise. Both are closed: the attacher threads the runtime's store (production and harbortest/devstack, each with an explicit nil check so a typed-nil store never reads as "a capturer is wired"), and captureToolContext now REPORTS whether a record landed, with the id stamped only on true. A no-capture invocation still discovers and still publishes — it simply carries no id, routing the reader to outcome 4. Capture remains best-effort for the CALL itself (the tool result is the planner's source of truth); this governs only what the reference CLAIMS.

  7. A stale preload may not write over the current one. preload awaits twice, so an app prop-identity change mid-flight leaves two preloads in flight — and loadState IS a tracked dependency of the bridge lifecycle effect, so a stale terminal write fires that effect's cleanup and close()s a bridge mid-ui/initialize: the D-342 teardown that got the original MCP-Apps work reverted, reached through a DATA outcome (an evicted context) instead of a theme change. Moving the context fetch into preload did not create that window but lengthened it by a round-trip and added a far more failure-prone divergence source, so it is closed here with a monotonic in-flight token: every post-await write is a no-op for a superseded preload. Two adjacent hazards go with it — the dedup key becomes resourceUri + toolCallId (two calls in one turn can declare the SAME document with DIFFERENT contexts, and the fold is last-wins, so a URI-only key would leave the bridge delivering the first call's data under the second call's app), and the loadState read in that guard is explicitly untracked rather than relying on && short-circuit — the operand ORDER was silently load-bearing, and reversing it would have made the miss state an infinite refetch loop.

A correction, not a departure (HA-39's open sub-question). The tool-context read path is documented for eviction that the write path never performs — there is no TTL and no sweeper in internal/mcpconsole/toolcontext.go. This decision does NOT settle that retention policy. It settles only the Console's behaviour when the read misses, which is required either way, because not_found is already reachable today for an unknown id, a cross-identity id, and an in-memory state driver that restarted. Whether records expire stays with HA-39.

Inherited limitation, explicitly not fixed here (HA-39). A tool context at or above the heavy threshold (D-026) reaches a rendered App as a by-reference stub, and the Console's resolution path depends on presigned URLs, which only the S3 artifact driver implements; on the default inmem/fs/SQLite stores the host delivers a faithful [artifact … — unavailable on this store] block rather than the bytes. A REPLAYED App inherits exactly this — no worse than a live one, and no better. Closing it needs a driver-independent byte-read method and is HA-39's, not this phase's.

Consequence. Console: web/console/src/lib/sessions/history.ts (the app/serverID fields, the mcp.app_available fold, a readBool reader, the display-mode normaliser); a new web/console/src/routes/(console)/playground/[session_id]/turn-projection.ts holding BOTH turn projections — the live appViewFromDiscovery and the replay hydratedAgentMessage (which owns the render gate and the App field mapping) — extracted from +page.svelte, which now calls them: inline in the component neither was importable, so deleting the App fields left the whole feature inert with every test green and the cross-producer pin could only compare against a hand-copied re-implementation that would agree with a one-sided change; web/console/src/lib/chat/renderers/mcp-app.svelte (resolve-then-mount, the unavailable state + placeholder + token-only style, the in-flight preload token, the widened + untracked dedup guard); web/console/src/lib/chat/renderers/app-bridge-host.ts (toolContext replaces toolCallId; #deliverToolContext delivers instead of fetching; the MCPAppHostClient.toolContext contract documents the miss). Runtime: internal/tools/drivers/mcp (captureToolContext reports success; the id is stamped only then; the promise documented on AppRef.ToolCallID and AppAvailablePayload.ToolCallID), internal/protocol/types/mcp_apps.go (the same promise on the wire MCPAppRef.tool_call_id — comment only), internal/runtime/serve + harbortest/devstack (the capturer threaded into the runtime-attach path). Tests: a cross-producer pin that the replayed ref equals the LIVE decoder + page projection, both driven through the REAL functions; interleaving-with-tool-rows, last-wins, per-run isolation, a page-window-boundary case driven through the real loadSessionHistory merge, snake_case, missing-field-drop, and a payload-planted sentinel proving the fold copies only what it names; the extracted mapping's own suite (App fields carried, App-only turn rendered, caption suppressed, nothing rendered for an empty turn); the four resolution outcomes at the renderer level plus both stale-preload interleavings (no mount over a newer miss, no teardown of the current bridge) and a prop-churn guard on the resolving path; a render-level rehydration regression mounting the real MessageBubble + real renderer for BOTH paths (identical document, server, persisted-context id, srcdoc, sandbox tokens — never allow-same-origin — trust flag, display mode); and on the Go side, the emitting-side promise (no capturer / failed capture ⇒ no id, discovery still published) plus a real streamable-HTTP MCP-fixture attach test proving a runtime-added connection captures under the CALLER's identity and advertises the id its record is keyed by. Every mutation was verified to fail: unwiring the attacher's capturer, stamping the id unconditionally, deleting the App fields from the mapping, reverting the render gate, disabling the in-flight token, and dropping the second delivery liveness check. No wire type, method, error, or event moves; no ProtocolVersion bump — therefore no D-223 lockstep churn and no D-209 docs regen. The drive-the-playground skill (surface: playground) documents the reopen behaviour and the placeholder in the same PR (§18).


D-349 — One shared body-identity gate whose per-surface posture is a registry row, plus a three-part lockstep gate that keeps it the only one

Context. Every Harbor Protocol method carries an identity scope in its request body. That scope is caller-supplied, so it is an input; the authority is the identity the transport established for the request. Reconciling the two was spread across thirteen near-duplicate helpers in the control and streaming transports (backfill*Identity, assert*Identity, mergeIdentity, and the state-history and token-rotation shapes), each declaring its per-surface posture in a code comment. The design underneath is two-layer: a transport MAY admit a body naming another tenant if the surface behind it enforces an admin-scope gate. What holds the two layers together should be a type, an interface or a test; thirteen copies of a comment is none of those, and a comment travels to the next handler more readily than the check it describes. A second shape compounds it: identity.With has ~38 production call sites and writes a plain context value, while auth.WithScopes has exactly one and reads back as denied when absent. A write-once value that denies on absence is a different kind of thing from a write-many value that carries no rule — and identity, the load-bearing isolation key, was the second kind. This decision makes identity the first kind and removes the shape that produces per-surface reconciliation copies.

Decision. ONE shared gate — internal/protocol/bodyscope — owns body-identity reconciliation for every Protocol surface, and its per-surface posture is a value the runtime reads rather than a comment a contributor copies. Five parts.

(1) Reconcile(ctx, ScopeRef, Surface, Auditor) takes a REGISTRY KEY, not a policy value. A call site names a posture the registry already declares; it cannot invent one. The registry (policies) is closed and declares each surface per component — Pinned (must equal the established identity), PinnedOrEmpty (an empty component is a surface-interpreted wildcard, a populated one must equal), AdminScoped (divergence permitted under auth.ScopeAdmin or auth.ScopeConsoleFleet, recorded before it is granted) — alongside an operator-facing wire name and a prose reason a reviewer can disagree with. The contract: User and Session equal the established identity; an entirely empty body triple is backfilled from it; a differing Tenant is permitted only where the row says so. Wire bodies carry the triple in two shapes (types.IdentityScope, types.ArtifactScope), which the gate reads through one ScopeRef handle.

(2) The permission and the accountability ship in one argument list. A policy that permits a crossing MUST be handed a non-nil Auditor; a nil sink on such a surface is refused with CodeRuntimeError, the same fail-closed precondition the impersonation gate holds. A permitted crossing publishes audit.admin_scope_used (typed SafePayload, actor = the ctx-established identity) BEFORE the crossing is granted, and a second gate behind the first reads the marker rather than double-recording. A surface whose row pins every component needs no sink and passes nil. The two layers sit one function signature apart, so a surface cannot hold the permission without also holding the record.

(3) The gate fails CLOSED when ctx carries no established identity. There is nothing to reconcile against, and trusting the body there makes a request that reached a surface without an established identity indistinguishable from one that carried a verified triple. The mux's bearer-less opt-in therefore names a CARRIER rather than waiving identity: it says "identity comes from the X-Harbor-* carrier headers" (auth.CarrierIdentityMiddleware, ctx-first so an in-process layer that already established one wins). EVERY mounted route is wrapped in one identity decorator — bearer or carrier, never neither — and NewMux fails construction when neither posture is chosen, so there is no arrangement in which a handler runs without an established identity.

(4) Verified identity gains provenance, and plain re-scoping stops being a place to cross a tenant. internal/identity gains a third context key holding the transport-established triple, written by WithVerified (the request-edge writers only) and read by FromVerified. identity.With — the working-identity writer — now REFUSES to move the tenant past the established one (ErrTenantWidening); re-scoping the user, the session, or both stays unrestricted, so the ~38 internal sites (dispatch, run contexts, steering, pause/resume, spawned and resumed work, driver-side embedding attribution) keep working untouched. identity.WithElevated(ctx, id, reason) is the one crossing, and it refuses an empty reason: an elevation nobody can name is an elevation nobody can audit. A ctx with no established identity is unrestricted — an embedder or a background worker has no anchor to widen beyond. WithRun carries the SAME rule: the quadruple is read for scoping in its own right (tool provenance, per-run capability checks), so attaching a run must not become the seam that moves the boundary sideways.

The marker an elevation leaves is SCOPED TO THE TENANT IT AUTHORIZED, not a boolean. A crossing to T2 permits re-scoping within T2 and refuses T3 exactly as if no crossing had happened, and the gate's second-pass check compares the audited tenant rather than asking "already elevated?" — so authorizing one crossing never authorizes the next, and a second crossing gets its own record.

The guard reaches further than the request edge, and that is the point: any per-row projection that re-scopes to a row's own identity is performing a tenant move. Eight sites cross under an anchor — the four Protocol surfaces, the impersonation path, and three per-row projections (the sessions counter rollup, the tasks search, and the embedding-attribution bridges) — and each seats an audited re-scope naming the tenant it reads. The ~38 remaining identity.With sites narrow within the tenant and are untouched.

(5) THE LOCKSTEP GATE — three mechanical scans, each with a non-vacuity pin. Modelled on the idioms the repository already trusts (the canonical-wire-type lockstep, the driver-conformance coverage gate, the import-graph AST lint). COVERAGE: every exported struct in internal/protocol/types whose name ends in Request and which carries an identity-scope field must join to a registered surface, and every registry row must name a type the canonical packages still declare — bidirectional, so a new surface is registered or the build is red, and a deleted registration fails just as loudly. Non-request scope carriers (projection rows the runtime authors) are an explicit, reasoned exemption list, pinned both ways. ENFORCEMENT: an AST scan over internal/protocol flagging every comparison of a body identity component against an established one — the copy-paste shape itself — with a reasoned allow-list (an entry with an empty reason is itself a violation). MINTING: an AST scan over the module for calls to the verified-identity and elevation writers, held to a reviewed list; the scan resolves the identity package by IMPORT PATH so an alias is followed rather than missed, and a list entry matching no call site is reported so the list stays a description of the code.

Why. Brief 06 (§"Isolation-triple filtering by default"): "Cross-tenant subscriptions are an explicit, audited operation" — generalised here from the event bus to every Protocol surface, with the audit made structurally inseparable from the permission. Brief 06 (§"Cross-tenant isolation tests"): "admin scope can bypass; assertion on the audit event for the bypass" — both halves pinned at every permissive surface. Brief 07 §1 (the single-dispatch architecture: one mechanism the runtime owns, parameterised, collapsing a mode matrix into one dimension): thirteen helpers were the mode matrix; the registry is the dimension. CLAUDE.md §6 rule 9 (identity is mandatory, the runtime fails closed) is what item 3 carries to the request edge, where the input being reconciled is chosen by the caller; §13's silent-degradation rule is what item 2 makes structural rather than advisory; D-311 (make absence representable, and make an operation over an absent value fail loudly rather than succeed emptily) is exactly item 3's shape one layer up — FromVerified's presence bool is the representable absence, and the gate's refusal is the loud operation. D-223 is the gate's model: a hand-maintained surface stays honest when a mechanical check reconciles it against the canonical set in both directions.

Findings I'm departing from. None.

Protocol additions. None. No new method, error code, canonical event type or wire field; ProtocolVersion stays 0.1.0. The make protocol-ts-gen-check and make protocol-docs-gen-check gates regenerate to a clean diff.

Consequence. internal/protocol/bodyscope is new (bodyscope.go / policy.go / registry.go / coverage.go / audit.go / gate.go). internal/identity gains WithVerified / FromVerified / WithElevated / IsElevated / ElevationReason and the ErrTenantWidening / ErrElevationReasonRequired sentinels; With gains the narrowing rule. internal/protocol/auth seats the verified identity and ships CarrierIdentityMiddleware. internal/protocol/transports wraps every mounted route in one identity decorator. Thirteen helpers are deleted across transports/control and transports/stream; nineteen surfaces are registered. internal/protocol/{apps,mcp}.go run the gate inside Dispatch, so their transport-agnostic godoc is true; internal/protocol/{artifacts,posture,control}.go anchor their cross-tenant gates on FromVerified (a granted crossing cannot move the anchor) and seat their crossings via WithElevated. The artifacts cluster's four postures become four registry rows (list elevatable under either admin-tier claim, put and delete administrative-claim-only — both are writes — and get_ref flat with its published refusal code), so the transport grants exactly what the surface honours and an audit trail of granted crossings stays a trail of crossings actually taken rather than crossings attempted. Policy carries the wire code for BOTH refusal branches, because moving a refusal earlier in the request must not change the code a client branches on. The sessions counter rollup's partial marker widens to cover every read behind it: a registry read that could not be taken marks the rollup partial rather than reporting a zero nobody measured, since a zero meaning "we could not look" and a zero meaning "there were none" are different answers. The widening reaches the FILTER, not only the row: has_failed_task / has_intervention / cost_above_cents each refuse to exclude a partial row, so a session whose registry read failed is returned carrying its marker instead of vanishing from the page — the same false-absence class the marker exists to name, arriving through the predicate rather than the value. The canonical wire godoc and the Console's mirrored type both carry the widened contract. Tests pin: the reconciler's whole contract table-driven (backfill, match, user / session / tenant mismatch, mismatch with each admin-tier claim, no established identity, nil sink on a permissive surface, unregistered surface, wildcard components); the identity anchor, the widening refusal and the reason requirement; N≥200 concurrent reconciliations against one shared registry and one shared audit sink under -race with an exact crossing count and a goroutine-baseline check; each gate half plus a non-vacuity companion proving it bites; and the existing end-to-end wire suites keep the four legitimate cross-tenant admin paths (artifacts.list / artifacts.put, the seven posture methods, topology.snapshot, the ?admin=1 event fan-in) working while the no-elevation surfaces keep their flat refusal. Framework-framed only (§13).

Follow-up (recorded, not taken here). The Console renders counters_partial on the sessions LIST page only. The detail page shows the same counters with no partial affordance and overwrites them with client-side enrichment while the field sits unread, and tasks_count has no affordance on either page. The list-page shape predates this decision; what is new is that the marker now covers counters no renderer marks, so a partial row can render as an exact one. Closing it is a Console phase (render the marker wherever a counter appears, and stop overwriting a marked row), not an extension of this one.

Cross-references. D-311 (absence made representable / fail-loud — the class this extends to the request edge), D-223 (the lockstep idiom imitated), D-291 (the mandatory auth-validator posture the carrier decorator completes), D-301/D-287 (the MCP registry is process-global, which is why that surface has no elevation path), D-025 (the gate as a compiled artifact). CLAUDE.md §5, §6, §7, §8, §11, §13, §17. RFC §4, §4.2, §5, §5.4, §5.5. Plan: docs/plans/phase-205-body-scope-reconciler.md.

Status: shipped (Phase 205).


D-350 — A live MCP connection write is OWNER-SCOPED: the registry's discovery-origin mutator replaces the allow-list only on the caller's own (tenant, agent) registration, the boot-declared guard is a property of the NAME, and agent_config.set_revision holds connection descriptors to the add_mcp_connection shape rules

Date: 2026-07-25

Status: Accepted (v1.22). Extends D-301 (the owner tag) and D-302 (the discovery-allowance write) without reversing D-287 / D-301's process-global bare-name model. Composes with D-303 (the (tenant, agentID) installer seam shape), D-331 (the sibling store-boundary owner check), D-340/D-341/D-346 (the wire-descriptor + injection posture) and D-025.

Context. D-302 shipped Registry.SetOAuthDiscoveryOrigins as a bare-name live mutator, resolving its target through Registry.entry(name) with identity mandatory for AUTHORIZATION but no owner filter — which is the right shape for the registry's READ projections (D-287: boot servers stay visible to every session; D-301: the owner tag is a reconcile-VIEW filter) and the WRITE half is where the owner tag now applies. The sibling operation already had the answer: the same-name attach replace consults Registry.OwnerOf and refuses to tear down a registration another owner holds (internal/tools/drivers/mcp/attach.go), and the provider set enforces the same at its own boundary (D-331). Two adjacent seams complete the contract. First, the verb's boot-declared guard belongs before any revision read, so that it is a property of the NAME on every path (internal/runtime/agentcfg/protocol/setdiscoveryorigins.go). Second, agent_config.set_revision — the second door onto the same revision spine — validates LLMParams / Hooks / ToolExposure / Naming and the injection mapping; connection descriptors join that set here, so both doors hold one shape authority.

Decision.

  1. The live discovery-origin write is owner-scoped. Registry.SetOAuthDiscoveryOrigins(ctx, name, owner, origins) resolves through a new ownedEntry(name, owner) that returns ErrServerNotFound unless the registration's owner tag equals the caller's — so an unregistered name, a boot-declared (zero-owner) registration, and another owner's registration all answer identically at the registry boundary. A ZERO owner owns nothing and is refused before the map is consulted, so an owner-less caller never resolves onto the boot-declared entries a bare equality check would hand it; the guard lives at that single resolution choke point rather than in each caller, mirroring RuntimeAddedSources, which likewise returns nothing for a zero owner instead of falling back to the whole registry. The owner tag stays what D-301 made it: a VIEW/WRITE-scope filter, never an isolation key and never a dispatch key. Resolution, dispatch, and every read projection (ListServers, GetServer, OAuthDiscoveryTarget, ReadResource) remain bare-name and process-global, unchanged.

  2. The seams carry the owner. agentcfgprotocol.DiscoveryOriginApplier takes (tenant, agentID) — the SAME pair the D-303 ProviderInstaller seam already carries, so this package's §4.4 boundary is preserved (no internal/tools/auth import). projection.DiscoveryOriginReconciler takes an auth.Owner, matching its own AttachedSources(ctx, owner) — the reconcile re-applies under the owner whose view produced the sources.

  3. The operator sees three distinct outcomes, and the refusal is loud. The registry deliberately cannot distinguish "not mine" from "not there"; that is correct AT the registry boundary but the wrong diagnostic for the caller, because one degrades and the other must not. MCPConnectionAttacher.SetOAuthDiscoveryOrigins therefore reads the owner through Registry.OwnerOf — the SAME comparison the attach replace performs, not a second mechanism — and surfaces a mismatch as agentcfgprotocol.ErrConnectionOwnerMismatch, which the wire handler maps to CodeScopeMismatch / 403. The registry's own owner scoping remains the AUTHORITATIVE enforcement — the classification only chooses the diagnostic — so a registration replaced by another owner between the two reads still refuses the write and degrades rather than applying. Both layers are pinned end to end: one integration leg drives the Protocol edge for the classification, and a second drives the REAL registry directly with a mismatched, zero, and superseded owner, so the enforcement underneath the classification is not proven by unit tests alone. The verb's existing default: arm rolls the just-written revision back, so a refused write has NO observable effect — never the silent applied_live: false degrade, which is reserved for the caller's OWN not-yet-attached connection. Both owner components are mandatory at the applier; an incomplete owner fails closed with ErrRuntimeAddOwnerMissing, mirroring Attach.

  4. The boot-declared guard is a property of the NAME. It is hoisted out of the not-declared branch and evaluated before any revision read, so a caller whose own revision declares a boot-declared name reaches exactly the same ErrBootDeclaredConnection (→ 400) refusal, with no revision recorded and the live registry never reached. Boot state is not revisioned state on either path; edit the yaml and restart.

  5. set_revision validates connection descriptors through the add door's own validator. validateConnectionsSection runs every connections.servers[] entry through validateConnection — one shape authority, two doors, never a second implementation (§13) — covering transport/URL/command coherence, the name rule, the reserved-_meta-key rule, one-auth-mode mutual exclusivity, the stdio rules, and the https-origin allow-list validator. The whole set is rejected on the first offender, naming connections.servers[i], with the %w-preserved ErrInvalidConnection firing the handler's existing 400 mapping; nothing is persisted and an already-active revision is untouched. The door persists the validator's NORMALISED descriptor (trimmed name / URL / provider name, de-duplicated and validated origins) rather than the raw wire values, so the same logical input yields the same bytes and the same content hash at every door. The fail-closed stdio command allowlist — the §7 RCE gate — applies here too (Service.gateStdioConnectionCommands, the same ErrStdioNotAllowed → 403): both doors write the same revision spine, so a command the add door refuses cannot enter through the other and sit there as input for any future attach-from-revision leg. It gates argv[0] in argv form; an empty allowlist refuses every stdio descriptor. The D-346 injection OPT-IN gate stays its own composed half (it needs Service state that shape validation does not), and the allowlist is the other Service-state half.

  6. The descriptor projection is complete in both directions (§17.6). connectionsToDomain and connectionsToWire both carry OAuthDiscoveryAllowedOrigins, so the field round-trips through set_revisionget / list_revisions / diff, matching what the allowance write records. The full-payload door likewise persists the validator's NORMALISED descriptor (trimmed name / URL, de-duplicated origins) rather than the raw wire values, so the same logical input yields the same bytes — and the same content hash — at every door.

Why this does not reopen D-287 / D-301. Nothing about resolution, dispatch, the tool catalog, or the read projections changes: a boot-declared server stays visible to every session and a runtime-added name stays deployment-global. The honest, bounded guarantee D-301 states is unchanged — a shared runtime still trusts co-tenant admins for runtime-added connection NAMES and a collision still fails loud. What changes is narrower and strictly additive: a live WRITE now lands only where the caller's own agent-config revision ownership already reaches, which is the same scope the reconcile view has had since D-301 and the same comparison the attach replace has performed since it shipped.

Consequence. internal/tools/drivers/mcp/registry.go (the owner parameter + ownedEntry); internal/runtime/agentcfg/protocol/{setdiscoveryorigins,service,addconnection}.go (the applier seam, ErrConnectionOwnerMismatch, the hoisted boot guard, validateConnectionsSection, the descriptor projection fix); internal/runtime/agentcfg/projection/projection.go (the reconciler seam); internal/runtime/serve/{mcp_attacher,mcp_detacher}.go (the production concretes, each now carrying compile-time assertions for BOTH seams it satisfies — the mux and run loop bind them through unchecked type assertions that would otherwise degrade a signature drift into a silently unwired applier or a reconcile that quietly stops revoking); one sentinel case in internal/protocol/transports/stream/agentconfig_handler.go. No wire type, method, error code, or event moves — ProtocolVersion stays 0.1.0, and there is no D-223 / D-209 regeneration. Tests pin: the registry's owner scope (owning owner succeeds; non-owning and boot-declared answer ErrServerNotFound with the live allow-list untouched) plus an N=128-per-owner concurrent two-owner run under -race (D-025); the boot-declared refusal on BOTH paths with the applier never called; the owner refusal surfacing loud with the revision rolled back; the owner reaching the live seam from both the direct write and the reconcile; the zero-owner and half-owner queries owning nothing; seventeen malformed descriptor shapes rejected at set_revision with nothing persisted and an active revision unchanged; the stdio allowlist refusing the SAME command with the SAME sentinel at both doors, an empty allowlist refusing every stdio descriptor, and an allowlisted command still landing; a valid descriptor round-tripping including its allow-list and in its normalised form; and a real-driver integration test across the protocol → agentcfg → tools/mcp seam (two owners, one shared registry, the production attacher and wire handler) with a missing-identity failure mode and an N=16 concurrent cross-owner stress. Each guard was verified to FAIL when removed. The use-the-harbor-protocol skill (surface: protocol) documents the owner scoping, the both-paths boot refusal, and the set_revision descriptor rules in the same PR (§18). Two follow-ups are recorded in the phase plan's Risks section rather than fixed here: the stdio allowlist is a binary policy (argv[0]), and whether it should also be an argument policy is an open design question; and the sibling bare-name mutators (SetRawHTMLTrust, Deregister, RefreshDiscovery, Probe) keep the unscoped shape.

Cross-references. D-301 (the owner tag + reconcile view this write scope extends), D-302 (the discovery-allowance write this re-scopes), D-287 (the process-global bare-name model, PRESERVED), D-303 (the (tenant, agentID) seam shape mirrored), D-331 (the sibling store-boundary owner check), D-300/D-340/D-341/D-346 (the credential-plane + wire-descriptor posture this composes under), D-025 (concurrent reuse). CLAUDE.md §4.4, §5, §6, §7, §11, §13, §17.1, §17.3, §17.6, §18. RFC §6.4, §6.16. Plan: docs/plans/phase-206-owner-scoped-registry.md.


D-351 — Re-land the five reverted MCP-Apps host obligations, and make the gate that missed them fail loudly

Date: 2026-07-25

Status: Accepted (v1.22). Completes the Console half of D-227 (items 3 + 4); does NOT supersede or reverse it.

Context. D-227 (109k) records five host obligations of the io.modelcontextprotocol/ui ext-apps dialect as delivered. They were never in the source tree. The merge that shipped 109k (b37ca533) also reverted the entire Console half of the phase over an ui/initialize handshake regression; the Go/backend half survived, and D-342 (109l) later re-landed only the live-theme and Data-Delivery pieces. Absent until now: the <serverID>_ app→host tool-call prefix (HA-41), the ui/notifications/size-changed consumer (HA-38), ui/resource-teardown on unmount plus the request-teardown handler, host-context toolInfo / containerDimensions, and onlistresourcetemplates. A history search confirms the prefix and the size handler never existed at any commit — they exist only as prose in D-227's test notes.

It went unnoticed for four phases because the gate degraded rather than failed. scripts/smoke/phase-109k.sh guarded each obligation with a bare grep that emitted skip when the pattern was absent. The 404/405/501 → SKIP convention (CLAUDE.md §4.2 item 4) exists so a FORWARD-phase script can coexist with a build that predates its surface; it is not for a Shipped phase's own guards. Applied there it converted five missing security- and conformance-relevant behaviours into a green counter — precisely the "a SKIP that should be an OK is a bug" failure §4.2 item 5 names.

Decision.

  1. Confinement stays in the FRONTEND (D-227 item 3 re-affirmed, not reversed), becomes unconditional, and is made SOUND by a registration-time precondition. createAppHandlers.oncalltool routes the app-supplied BARE name through a new qualifyAppToolName(serverID, name) before dispatch. The prefix is applied UNCONDITIONALLY — a cross-server or self-prefixed name (otherserver_drop_table, srv_echo) is still prefixed, so the App chooses the suffix and never the namespace, and serverID is HOST-DERIVED (the backend-minted server_id on mcp.app_available, threaded through the renderer props) so nothing inside the sandboxed iframe can supply or influence it.

    The prefix alone does NOT deliver confinement, and this decision says so plainly. <sourceID>_<tool> is a single-underscore join (internal/tools/drivers/mcp/mcp.go, Provider.Discover) and NEITHER side is charset-constrained: Config.Name is validated only for emptiness, and server-side tool names routinely contain underscores. The join is therefore not injective across arbitrary id pairs: when two ids are underscore-extensions of one another, a single catalog key parses as belonging to either, so a key BUILT by prefixing one id can resolve to a tool owned by the other. Downstream gates evaluate the posture of whichever server the key resolved to, so they cannot detect the mismatch; and because the two keys are distinct strings, duplicate-key registration does not flag it at boot. The contract the surface actually needs is therefore not "prefix the name" but "ids must be separator-safe".

    The fix is to enforce that contract where the ids are minted, rather than to add another string test at the call site. mcp.Registry.CheckServerIDUnambiguous refuses to register an id that sits inside a registered id's tool-name namespace, or that would swallow one — BOTH directions, so ORDER does not matter and boot order cannot decide the resulting state. It is enforced inside Register under the same write lock that installs the entry (the single choke point every attach path funnels through, so it cannot be bypassed and two concurrent ambiguous registrations cannot both win) and pre-checked at the top of Attach before any transport is spawned or catalog row written, so both the boot-declared and the runtime-attach path fail loudly and cleanly with ErrAmbiguousServerID. harbor validate applies the same rule one stage earlier, so an operator learns at validate-time rather than at a refused boot. Ids that merely share a prefix without a separator boundary (github / githubby) stay legal; same-id re-registration (hot reload, runtime re-attach) stays legal. The error names only the id the caller supplied — echoing the id it collided with would leak another owner's server name to whoever probes for it (§6: existence is never revealed across identities).

    The honest claim, therefore: the qualifier scopes an App to its own server's namespace GIVEN registration-time separator-safety, and the registration guard is what makes that precondition hold.

    And the guarantee is bounded to MCP ids. This registry sees MCP servers; the tool catalog is SHARED. internal/tools/drivers/inproc and internal/tools/drivers/http register bare, operator-chosen names into the same namespace with no source prefix, and the guard never sees them — so a non-MCP tool whose name happens to look like <mcpServerID>_<something> reintroduces the same ambiguity through a door this check does not cover. The claim is "unambiguous among MCP server ids", not "unambiguous". Closing the non-MCP door is a dispatch-side identity comparison, not a naming rule, and is recorded in the follow-ups below rather than bolted on here.

    Confinement narrows WHICH tools are reachable and replaces none of the pre-existing gates: identity, the tool's approval / OAuth wrappers, and the current-state paused-server / disabled-tool exposure gate all still fire inside mcp.apps.call_tool.

    On the frontend-vs-backend placement. HA-41 asked whether the revert proves this belongs on the backend as a MCPAppCallToolRequest.server_id. Re-affirmed as frontend for this phase: the trust boundary the control defends is App ↔ Console host, not Console ↔ Runtime — the App is sandboxed code the Console mediates for, while the Console is a Protocol client acting under the user's own identity, and that same user can already reach any catalog tool through tools.call. A wire server_id would be supplied by the very component the control is not defending against. An independent review traced every app→host handler, the vendored SDK's structurally-unreachable auto-forward mode, the notification paths, serverID provenance end-to-end, and the CSP/sandbox posture, and confirmed there is no path from sandboxed App code to a tool call that bypasses oncalltool. A genuinely stronger argument for backend enforcement exists and is recorded as a follow-up below — it is not the tamper-evidence argument this decision originally framed it as.

  2. Not-found becomes a SENTINEL verdict, and the marker path is deleted. internal/protocol/mcp.go::isMCPNotFound classified by substring-matching the rendered error chain ("server not found", "tool context not found"). That chain carries a southbound MCP server's error text VERBATIM, so a remote party got to phrase part of what a Harbor classification read: a transport failure worded the wrong way became a typed CodeNotFound, which a rendered App treats as a PERMANENT "this action does not exist here". The first attempt at this phase widened that same marker set to catch the catalog's tool not found — the same hole, one entry larger. It was replaced rather than extended.

    The edge now classifies ONLY by errors.Is(err, protocol.ErrAccessorNotFound). internal/mcpconsole — the one layer permitted to know both the driver's error taxonomy and the Protocol's — translates mcp.ErrServerNotFound, its own tool-context miss, and the catalog resolve miss into that sentinel at the accessor boundary. The Console adapter maps the resulting not_found onto a typed MCPAppToolNotFoundError and oncalltool re-raises it naming the BARE name the App asked for, so an App degrades deliberately. A transport-shaped failure keeps CodeRuntimeError; both directions are pinned, including a laundering guard that feeds transport failures whose text mimics a not-found.

    The same treatment is applied to the exposure refusal (ErrAppToolExposureDeniedprotocol.ErrAccessorScopeDeniedCodeScopeMismatch), which had the identical forgeability. "You may not" and "there is no such thing" are the two verdicts an App branches on most sharply; neither may be mintable from wording Harbor does not author. isMCPIdentityMissing is the one marker left, and it is unreachable from remote text (it is raised before any southbound call).

    The cost of that correctness is that a MISSED translation is silent — the method keeps working and merely answers CodeRuntimeError (HTTP 500) where it answered CodeNotFound (404). Nothing panics, and a test asserting the error TEXT keeps passing, because the text survives the missing wrap. Two were missed across this work: Probe (caught only by a preflight smoke whose SKIP-on-404 flipped to a FAIL-on-500) and all three Apps seams (caught only by review, while two strings.Contains assertions named after the guarantee stayed green). Both tables now enumerate every translating accessor against real drivers, those two assertions read the sentinel, and the one site that cannot be exercised (ListServers has no not-found branch) is recorded as such rather than left looking covered.

  3. size-changed is consumed, and the CLAMP is CSS. The bridge assigns onsizechange and relays to the renderer, which drives the inline frame's height, coalesced through requestAnimationFrame so a resize storm cannot thrash layout, ignoring non-finite / non-positive reports. The bound is min-height: var(--size-app-inline-min) / max-height: var(--size-app-inline-max) on the frame — a new host-owned token — rather than arithmetic in JavaScript: a JS clamp is one edit from unbounded and would need the token values in pixels, whereas CSS makes ANY reported height (5 px or 500 000 px) land inside the host's envelope by construction, and a misbehaving App scrolls inside its own box instead of seizing the transcript viewport. An App that never reports a size gets no inline height and keeps exactly the previous fixed-height behaviour.

  4. Teardown is graceful, gated, bounded, and fail-safe. AppBridgeHost.close() sends ui/resource-teardown before dropping the transport; the app-initiated ui/notifications/request-teardown is GRANTED (teardown, close, then the renderer unmounts the frame and renders an honest "this app closed itself" placeholder, made sticky so a transcript re-render cannot resurrect an app that asked to be gone). Three properties make it handshake-safe: the teardown request is sent ONLY when the App reported ui/notifications/initialized (a bridge closed mid-handshake closes silently, exactly as before — posting a request onto a transport the App has not finished ui/initialize on is the exact shape behind the revert); #connected is cleared before the first await, so a concurrent Data-Delivery observes a dead bridge at its next re-check and a second close() is a no-op; and the request carries a short timeout with a logged-not-thrown failure, because a wedged App must never pin a Svelte effect cleanup open. Closing the transport is the guarantee; the graceful notice is the courtesy.

  5. toolInfo + containerDimensions are baked in at CONSTRUCTION; resources/templates/list answers. Both host-context slots are populated in the vendored spec shapes (toolInfo: { id, tool } with a real Tool; containerDimensions as { width, maxHeight? } within the spec's intersection type) and never patched mid-handshake — a different tool call is a different app, and the container snapshot is deliberately NOT relayed on resize, since the host resizes in RESPONSE to the App's own report and echoing the new box back would close a report→resize→report loop. toolName is newly threaded onto MCPAppRefView from the discovery event through BOTH the live decoder and the replay reducer (the cross-producer pin from D-348 fails on a one-sided omission). onlistresourcetemplates routes to a new injected MCPAppHostClient.listResourceTemplates; Harbor's Protocol exposes no resource-template method, so the adapter answers the honest empty list rather than erroring — the roots-honesty bar says an advertised capability (serverResources) must be serviceable, and "this host exposes none" is a truthful answer, not a swallowed failure.

  6. The gate is half the decision — and fixing the gate found nineteen more dead guards. scripts/smoke/phase-109k.sh is rewritten from SKIP-tolerant greps into hard assert_grep_present / assert_grep_absent assertions covering every obligation the phase claimed, and scripts/smoke/phase-207.sh guards the additions. Three of those assertions were themselves VACUOUS on the first pass — they matched a JSDoc line, a godoc line and an explanatory comment rather than the code, so renaming the symbol and gutting the body left them green. The mimeTypes one was the serious case: it is the spec-conformance guard, and advertising a NON-SPEC capability key — the exact defect it exists to catch — passed it. All three are re-anchored to code and each is verified to fail under that precise mutation. Pin the call, not the identifier, is the durable lesson: an identifier appears in prose, a call site does not.

    scripts/smoke/common.sh carried the same disease structurally. assert_grep_absent ran on BASIC grep while its siblings ran on EXTENDED, so a caller's ERE pattern was silently reinterpreted: | became a literal (an alternation matched nothing) and \( opened an unbalanced group (grep exited 2, which 2>/dev/null swallowed into the "not found" branch — an absence check that could never fail). Two guards were dead on those two shapes: the TUI Protocol-client architectural boundary (also passing a DIRECTORY without -r, which does not recurse and so reads as "clean") and a migration-runner absence check. All three helpers now force -E, add -r for a directory target, and — the change that mattered most — treat grep's exit 2 as a FAILURE rather than as absence. That last one immediately surfaced seventeen more inert guards across the tree (hand-rolled-fetch bans on seven Console pages, a Protocol single-source check, a spawn-depth check, golden-file template-marker checks) plus a third class nobody had looked for: a guard whose target FILE no longer exists, asserting absence from nothing and reporting OK for years. Every one is repaired and spot-verified against a deliberately-violating file.

    The through-line is the same failure this whole phase exists to close, one level up: a guard that cannot fail is worse than no guard, because it is counted. A SKIP where an OK belongs (§4.2 item 5), an assertion that matches its own documentation, a regex read in the wrong dialect, a scan of a path that was deleted — all four report success without reading the thing they name.

D-342 is preserved, and re-proved with the new machinery attached. All four invariants hold: the bridge is constructed ONCE with the final host-context (toolInfo / containerDimensions join theme + styles at construction, and the spec asserts zero setHostContext calls during the handshake); the lifecycle $effect still depends only on loadState + iframeEl, with the container measured inside the existing untrack block and the app-reported height living in template-only reactive state the effect never reads; every host→app send stays behind oninitialized (the teardown send is gated on it too); and nothing teardown-rebuilds — a resize storm, a theme flip, and prop churn each leave exactly one bridge with close never called. Phase 204's generation token is untouched and still guards stale preloads.

§17.8 gate. Each obligation is asserted against the REAL vendored @modelcontextprotocol/ext-apps App client driving a REAL ui/initialize in a real sandboxed iframe: the App reports back the toolInfo / containerDimensions it actually negotiated, its sendSizeChanged is observed by the host, and both host- and app-initiated teardown fire its onteardown. A hand fixture encoding our own reading of the dialect could not tell right-slot from wrong-slot (the D-216 class); the official client either consumes what we send or it does not.

§17.6 fix bundled. The Playwright handshake spec still passed a toolCallId plus a fetching client.toolContext after D-348 moved that seam to a pre-resolved toolContext option, so its Data-Delivery assertions were exercising a host that had nothing to deliver — a green test guarding nothing. Fixed here rather than deferred.

Consequence. Console: web/console/src/lib/chat/renderers/app-bridge-host.ts (qualifyAppToolName, MCPAppToolNotFoundError, containerDimensionsFromBox, APP_TEARDOWN_TIMEOUT_MS, the onlistresourcetemplates handler, the onsizechange / onrequestteardown wiring, the toolInfo / containerDimensions host-context slots, and the teardown-before-close in close()); mcp-app.svelte (the rAF-coalesced size consumer, the CSS-clamped frame, the sticky closed state + placeholder, the untracked container measurement); mcp-app-host-client.ts (listResourceTemplates, the not_found → typed-error mapping); tokens.css + the chat tokens.contract.json (--size-app-inline-max); MCPAppRefView.toolName threaded through wire-events.ts, turn-projection.ts, sessions/history.ts, and the page-level layout.ts / AppPanel.svelte. Runtime: internal/tools/drivers/mcp/registry.go (ErrAmbiguousServerID, CheckServerIDUnambiguous, the check inside Register under its write lock) + attach.go (the early side-effect-free pre-check, covering the boot-declared AND runtime-attach paths); internal/protocol/apps.go (ErrAccessorNotFound); internal/protocol/mcp.go (not-found classification is sentinel-only now); internal/mcpconsole/mcpconsole.go (markNotFound) + toolcontext.go (the driver→Protocol sentinel translation at the accessor boundary); web/console/src/lib/chat/renderers/mcp-app.svelte also drops its literal NUL separator for an escape, and a repo .gitattributes forces textual diffs for source types, so the file is reviewable instead of rendering as Bin — no method, type, error, or event moves, no ProtocolVersion bump, therefore no D-223 lockstep churn and no D-209 docs regeneration. Docs: the master plan's 109k row and detail state what actually shipped and who re-landed the rest; D-227 carries an appended correcting note (history preserved, not rewritten); docs/notes/downstream-asks.md flips HA-38 to Shipped and records the HA-41 residue this closes (items 1 and 2) and what it deliberately does not (item 3, _meta.ui.visibility enforcement — additive, independent, and named as deferred rather than left looking supported). The drive-the-playground skill (surface: playground) documents the self-sizing frame and the app-closed placeholder in the same PR (§18).

Follow-ups / risks (recorded, deliberately NOT implemented here).

  1. Backend enforcement by exact source comparison — the argument this decision did not engage with, and the only fix that closes the non-MCP door. The sharper point is not tamper-evidence. It is that the RUNTIME can perform a check the Console structurally CANNOT: desc.Tool.Source == expectedServerID, an exact identity comparison against the resolved descriptor. The Console can only manipulate strings, so the best it can ever do is a naming convention — which is why this phase needed a registration-time guard to make that convention sound in the first place, and why the guard's reach stops at the ids it can see.

    That boundary is the load-bearing part. The tool catalog is shared: in-proc and HTTP tools register bare, operator-chosen names with no source prefix (internal/tools/drivers/inproc, internal/tools/drivers/http), so a tool whose name resembles <mcpServerID>_<something> recreates the same ambiguity through a door the registry guard never sees. No naming rule inside the MCP registry can close that; only comparing the RESOLVED descriptor's Source can, because it stops asking what the key looks like and asks what the tool actually is.

    It requires a wire server_id on MCPAppCallToolRequest, supplied BY THE CONSOLE (never by the App) and validated against desc.Tool.Source. That is a Protocol change with a versioning and migration story, not a patch — and it would let the registration guard relax from "refuse the pairing" to "still refuse, but no longer load-bearing for scoping". Deliberately deferred so the next wave can decide it on its merits rather than inheriting it half-built; the frontend control plus the registration guard is sound for MCP-to-MCP in the meantime, which is the reachable surface for a rendered App today.

  2. _meta.ui.visibility is still not parsed (HA-41 residual item 3). A server marking a tool app-only has it advertised to the planner like any other. Additive and independent; named here so the field does not keep looking supported.

  3. The --size-app-inline-max: 40rem bound is a judgement call. It is the first host-owned growth bound; raising it is a one-line token change with no code impact.

Behaviour changes for already-shipped surfaces (documented, intentional). TWO, and the first can take a runtime down.

  1. An existing deployment with separator-ambiguous MCP server ids will FAIL TO BOOT after upgrade. assemble.go aborts assembly when a registration is refused, so a config that carried such a pair before now stops the runtime. The failure is loud, deterministic and names the offending id — the right posture for a condition that silently weakened a scoping boundary — but it IS a hard break, so it is called out in CHANGELOG.md under an ACTION REQUIRED heading, and harbor validate now reports the same condition so the operator meets it before the upgrade rather than during it. Remediation is a rename, and a rename is not free: changing a server's name changes every <name>_<tool> catalog key referencing it — agent-YAML tool allow-lists, disabled_tools, paused_servers, and any persisted agent-config revision that pinned those keys — all of which must move in the same change or the tools read as missing.
  2. A rendered App must send the BARE server-side tool name. oncalltool previously passed the app-supplied name through VERBATIM. An App written against Harbor's catalog keys (sending srv_echo because that is what tools.list shows) now double-qualifies and gets a typed not-found. This is intentional and spec-correct — a conformant MCP App knows only its own server-side names, and accepting Harbor-internal keys is precisely the ambiguity the scoping removes — but it IS a break for any App written against the pass-through behaviour, and the typed MCPAppToolNotFoundError is what makes it diagnosable rather than mysterious. The drive-the-playground skill states both rules in operator terms.

Cross-references. D-227 (the decision this completes; item 3's placement re-affirmed, its Consequence corrected), D-342 (the four lifecycle invariants this preserves and re-proves), D-348 (the pre-mount tool-context resolve + generation token this builds on), D-226 (the Data-Delivery push whose liveness re-checks the new teardown ordering respects), D-225 (the tool-context capture whose tool_call_id the host-context toolInfo carries), D-216 (the wrong-field-vs-real-server class the real-App gate closes), D-173 (the manual-handler no-direct-transport invariant — the new listResourceTemplates lands on the INJECTED client, and the no-direct-transport spy is extended to it), D-091 (chat-module encapsulation — the typed error is declared inside the chat module and raised by the Console adapter, so the Protocol error taxonomy never crosses the boundary), D-121 (the Console conventions the plan's "Console consistency" section cites). RFC §7.3 (the Console as a Protocol client), §6.4 (Tools). brief 14 §2–3 (the shape the spec reads + the roots-honesty bar), §6 (the full host↔view dialect). CLAUDE.md §4.2 items 4–5 (the SKIP-vs-FAIL rule this closes), §4.5 (Console conventions), §13 (no silent degradation), §17.6 (fix what the test finds), §17.8 (real-spec fixtures), §18 (same-PR skill update). Plan: docs/plans/phase-207-mcp-apps-host-obligations.md.


D-352 — The artifact read key is the isolation triple: ArtifactScope.TaskID becomes a provenance annotation, List gains the tenant precondition its siblings had, and the object-store driver's key layout moves because a probe is not a compare-and-set

Date: 2026-07-26

Status: Accepted (v1.23). Implements D-347 part 2 across all five ArtifactStore drivers and the conformance suite. Departs from two of D-347's stated details — the List precondition and the "fs is the outlier" characterisation — and both departures are recorded below rather than applied quietly. Extends D-022 / D-026 (the heavy-content offload this gives a coherent read side) and D-167 (the <session_artifacts> manifest whose invitation this makes true). Composes with D-025.

Context. ArtifactScope carries four fields and the fourth was read two ways. List treated an empty TaskID as a WILDCARD; Get / GetRef / Exists / Delete were EXACT-key on all four. The three production writers did not agree with each other either — internal/llm/materialize.go stamped TaskID: id.RunID, internal/runtime/dispatch/dispatch.go stamped none, and internal/tools/builtin/artifact_fetch.go read with TaskID: "". The D-167 manifest lists on the triple, so it enumerated all three shapes and told the model it could fetch any of them, while a read resolved two of the three as not-found. A discovery surface that hands a model rows it cannot fetch is worse than no surface: the model has no way to distinguish "gone" from "never reachable", so it retries.

Decision. Six parts.

1. Get / GetRef / Exists / Delete resolve on (tenant, user, session, id). ArtifactScope.TaskID is a PROVENANCE ANNOTATION — it records which task produced the bytes and takes no part in resolution. The isolation boundary is and stays the triple; a task runs WITHIN it, the same reading CLAUDE.md §6's clarifying note gives agent_id. ArtifactScope gains Triple() (the read key) and EqualTriple (the resolution-side comparison), and Equal stays exact with its godoc saying so, because "is this the same stamp" is still a question worth being able to ask.

The honest consequence, stated rather than buried: an artifact written under one run's scope becomes readable by a SIBLING RUN IN THE SAME SESSION. That is the intent — the session is the innermost isolation scope, the manifest already lists across the runs inside it, and nothing crosses a session, a user or a tenant. The content-addressed id is what makes it safe: within one session two artifacts sharing an id ARE the same bytes, so removing a field from the read key cannot merge distinct content, only stop an artifact being hidden from itself.

2. The write/dedup key narrows with the read key, so provenance is FIRST-WRITER-WINS and the List filter is lossy. Two tasks storing identical bytes in one session collapse to one artifact carrying the first writer's stamp, so filter.TaskID = "B" does not return a row whose bytes run B stored if run A stored them first. Accepted, because provenance on a content-addressed store is inherently first-writer: the id is derived from the bytes, so "which run produced these bytes" has no single answer once two runs produce them. Two obligations discharged: the ArtifactStore.List godoc states the property AT THE FILTER, and ReadKey_RePutUnderDifferingTask_FirstWriterWins pins it as a conformance row on every driver.

Under a genuine CONCURRENT tie there is no first writer, so the exact stamp is not a property the contract has. The concurrent conformance row therefore asserts what is true — one artifact, one id, every racer can read the bytes, the settled stamp is one a racer supplied — and deliberately does not assert a single returned stamp. Asserting more would be a test claiming a guarantee the interface does not make.

3. List requires a TENANT — not the full triple. It was the one ArtifactStore method no driver validated, so ArtifactScope{} was a legal all-tenants filter at the store boundary and every discovery surface built on List inherited that. It now returns wrapped ErrIdentityRequired without a tenant, and every field below the tenant stays a wildcard within it.

This is a departure from D-347, which says List "validates (tenant, user, session) like its siblings", and the reason is that the stricter rule would break two live surfaces rather than close a gap. internal/protocol/artifacts.go::handleList deliberately permits an empty User / Session — "only Tenant is mandatory for a list" — and types.ArtifactsListRequest.Scope's wire godoc publishes the same contract to every Protocol client. internal/search/artifacts/index.go lists with a tenant-only scope, optionally narrowed by the request's first user / session. Requiring the full triple would silently turn both into refusals: the Console's artifacts page and the artifact search index would answer nothing for a tenant-scoped query, which is a Protocol behaviour change phase 208 has no mandate to make and phase 209 owns the surface for. The named bug — a scope with NO tenant reaching storage as a cross-tenant filter — is closed either way; the admin path that reads another tenant names that tenant explicitly and is gated at the Protocol edge, exactly as before.

A note on the elevation minting list, because the plan asked and the answer is "nothing to add". identity.WithElevated is on D-349's reviewed minting list, and this phase adds no call site to it: ArtifactStore methods take the scope as an ARGUMENT and no driver reads identity from ctx, so there is no store-boundary elevation to mint. The list is unchanged and D-349's minting scan stays green.

4. ScopedArtifacts gets a CODE change and a corrected godoc. GetRef compared ref.Scope.Equal(s.scope), and Equal includes TaskID — so a facade scoped without a task reading a materialize.go-stamped ref returned ErrScopeMismatch, refusing precisely the read the narrowing enables. The comparison is now EqualTriple. Its godoc claimed scope-checks "on every read (Get, GetRef, Exists, Delete, List)" while only GetRef performed one; the claim is corrected to name the one method rather than the other four grown a check they do not need — the drivers are the enforcement point. ScopedArtifacts.List additionally drops the facade's own TaskID from the filter, because a facade that lists a strict subset of what it can Get reproduces the enumerate-then-fail divergence one layer up.

5. fs keeps its layout; s3 had to move, and that is a correction to how D-347 sized this. D-347 records that "the other four hold the task in an index key or a WHERE clause" and singles fs out. That is accurate for inmem, sqlite and postgres and NOT for s3, which encodes the task in the object key exactly as fs encodes it in the path. Both are path-shaped; they diverge on what can be done about it.

  • fs resolves across task directories through its index. The index key drops Task, and every path for an EXISTING artifact is derived from the stored ref.Scope rather than the caller's — only a first Put derives its directory from the caller, because only then is the caller the producer. The on-disk layout is unchanged, so artifacts written by any earlier build stay readable in place. Two properties follow for data already on disk, both pinned by tests that construct the layout by hand (the driver can no longer produce the input): the index rebuild can meet the same triple + id under two task directories, and since write order is not recoverable from a filesystem the collapse rule is stated rather than guessed — the LEXICOGRAPHICALLY SMALLEST TaskID wins, deterministically, whatever order the walk takes; and Delete sweeps every task directory under the triple, because a delete that reported success while leaving a copy a later Get resolves is the silent degradation §13 forbids — and on fs that copy comes back on the next restart.
  • s3's object key drops the task segment. An object store offers no atomic compare-and-set, so dedup there is a probe followed by a write. With the task in the key, N runs racing IDENTICAL bytes into one session each probe, each miss, and each write a SEPARATE object — the store would hold N copies of one content-addressed id and List would return N rows for it. This is not hypothetical: it was caught by running the conformance suite against a real MinIO, where the concurrent row failed before the layout moved. Keying the object on the triple makes the racers write the SAME key, so convergence is structural rather than a race someone has to win. The other four drivers get the property from a mutex or a primary key; this one gets it from the key. Objects under the old .../<session>/<task>/<namespace>/<id> layout stay readable and deletable via a session-prefix scan, and nothing is rewritten in place — a bucket migrates by being read. listPrefix correspondingly stops descending into a task segment and the TaskID filter is answered client-side against the stored stamp.
  • sqlite and postgres take a forward-only migration each (0002_read_key_is_the_triple.sql). The primary key moves to (tenant, user, session, namespace, id); pre-existing duplicate rows collapse under the same smallest-task rule the other two drivers apply, stated in the migration body because write order is not recoverable from the stored rows either. SQLite rebuilds the table (it cannot ALTER a primary key); Postgres drops and re-adds the constraint. Both migrations are exercised by a test that seeds the v1 schema BY HAND with a duplicate pair inserted latest-task-first, because after the migration the primary key forbids the input and a migration whose data path never executes is a migration nobody has tested.

6. The §17.6 fix ships here, not as a follow-up. Making the read key the triple is what makes the D-167 manifest's invitation true, and the phase carries the end-to-end proof rather than the claim: test/integration/artifact_read_key_test.go reproduces all three production writer shapes in one session, builds the manifest through the production planner.BuildArtifactManifest, and fetches EVERY listed row through a real tools.ToolCatalog-registered artifact_fetch — on two real drivers, from a run that produced none of them.

Why. brief 05 (state / tasks / artifacts / sessions): artifacts are keyed on the identity tuple, and a task is a runtime entity inside it. CLAUDE.md §6 (identity is the triple; the clarifying note that a non-principal entity does not widen the tuple, and rule 2's "every storage method takes the triple"). §13's silent-degradation rule is what part 5's Delete sweep and part 3's precondition both answer. §9's conformance-parity rule is why the concurrent property had to hold on s3 rather than be excused there. §17.6 is why part 6 is in this PR. §17.8's "a fixture that cannot tell right-field from wrong-field is a rubber stamp" is why the s3 and Postgres suites were run against a real MinIO and a real Postgres rather than left env-skipped — which is how the s3 concurrency bug was found at all.

Findings I'm departing from. Two, both from D-347 and both above: List's precondition is a tenant rather than the full triple (part 3), and s3 is a second path-encoding driver whose answer had to be stronger than fs's (part 5).

Protocol additions. None. No method, wire type, error code, canonical event or ProtocolVersion movement; make protocol-ts-gen-check and make protocol-docs-gen-check regenerate to a clean diff.

Consequence. internal/artifacts gains ArtifactScope.Triple / EqualTriple / ValidateFilter and the package-level ValidateFilter; ScopedArtifacts.GetRef compares the triple and List filters on it. All five drivers resolve reads on the triple and validate List's tenant. fs gains removeEveryCopy / taskDirs and a deterministic index-rebuild collapse; s3 gains sessionPrefix / resolveBlobKey / resolveBlobKeys and a triple-keyed object layout; sqlite and postgres gain migration 0002. The conformance suite gains six rows (List_WildcardsWithinTenant replacing List_NilFieldsAreWildcards, plus List_RequiresTenant, ReadKey_IgnoresTaskID, ReadKey_RePutUnderDifferingTask_FirstWriterWins, ReadKey_CrossSession_NotFound, Scoped_GetRef_AcceptsSiblingTaskStamp, Concurrent_ReconciledKey_DifferingTasks). artifact_fetch and materialize keep their scope shapes with corrected comments — the fix is in the store, so neither call site had to move.

Behaviour changes for already-shipped surfaces (documented, intentional). THREE.

  1. A sibling run in the same session can now read an artifact another run produced. This is the point (part 1), and nothing crosses a session, a user or a tenant.
  2. A TaskID list filter under-reports a duplicate (part 2). No shipped caller filters on TaskIDartifacts.list passes the wire field through and the Console does not populate it — but the property is a contract change and is stated at the filter.
  3. List with no tenant is now an error rather than an all-tenants listing. No production caller did this; internal/search/artifacts and internal/protocol's handleList both always supply one, and the Protocol edge already refused a tenant-less list with CodeIdentityRequired. The store simply stopped being willing to serve what the surface above it already refused.

Cross-references. D-347 (the design gate this implements part 2 of, and departs from in two named places), D-167 (the manifest whose invitation this makes true), D-026 / D-022 (the offload this gives a read side), D-349 (the minting list this leaves unchanged, and why), D-025 (the concurrent-reuse contract the new row extends onto the reconciled key), D-133 (which reserved artifacts.get for phase 209, built on this key). RFC §6.5, §6.10, §9. brief 05. CLAUDE.md §5, §6, §9, §11, §13, §17.6, §17.8. Plan: docs/plans/phase-208-reconciled-artifact-read-key.md.

Status: shipped (Phase 208).


D-355 — The sibling MCP registry MUTATORS resolve under the caller's own scope: the raw-HTML sandbox write is TENANT-scoped from ctx (boot-declared registrations excepted, and the bound is stated), Deregister is OWNER-scoped atomically with its delete, and RefreshDiscovery / Probe are CLASSIFIED as reads

Date: 2026-07-26

Status: Accepted (v1.23). Completes D-350 across the sibling verbs it recorded as follow-ups. Composes with D-349 (the shared body-identity gate that pins the caller's triple), D-301 (the owner tag as a view/write-scope filter), D-303 / D-331 (the sibling seam shapes) and D-025. Does NOT reverse D-287 / D-301: reads, resolution and dispatch stay process-global and bare-name.

Context. D-350 scoped the FIRST live MCP connection write and recorded the siblings as follow-ups because each landed in a file another in-flight phase owned. Two of them were writes resolving through Registry.entry(name), a process-global bare-name lookup: SetRawHTMLTrust, whose requireIdentity(ctx) verified the caller's triple was PRESENT and then never compared it to the registration's tag, and Deregister, which compared nothing at all. Two others — RefreshDiscovery and Probe — were never examined, which is the more interesting half: an unexamined verb left bare-name because nobody looked is indistinguishable from one left bare-name on purpose, and only one of those is a decision.

The gap in SetRawHTMLTrust is bounded and worth stating precisely rather than dramatising. The verb IS gated — canonicalMCPAdminMethods requires auth.ScopeAdmin, and the SurfaceMCP bodyscope row (D-349) pins all three identity components with no claim that widens the tenant, so id.TenantID is always the caller's own verified tenant. It is an admin-plane write whose landing site was not constrained to anything the caller's own identity reaches, and the flag it sets governs the sandbox posture a rendered MCP App is given, which makes a wrong landing site a lever on another tenant's Console rather than a cosmetic inconsistency. CLAUDE.md §6 treats that class as an integrity matter rather than a style nit.

Decision.

  1. The raw-HTML sandbox write is TENANT-scoped, and the tenant comes from ctx. Registry.SetRawHTMLTrust resolves through a new tenantEntry(name, tenant): it returns the entry when the registration is boot-declared (the zero owner) or when the registration's owner TENANT equals the caller's, and ErrServerNotFound for an empty tenant, for a name nobody registered, and for a registration another tenant owns — the same answer in every refused case, so resolution never reveals which one applied.

    The tenant and not the (tenant, agent) owner, because that is what the door actually carries. types.MCPServerSetRawHTMLTrustRequest carries the identity triple and no agent id, and this phase is zero-wire, so ownedEntry's owner tag is not derivable at that edge. The tenant is, and it is the boundary that matters: D-301 already states that co-tenant admins share the runtime-added connection namespace by construction, while a registration another TENANT owns is outside anything the caller's verified identity reaches. Scoping to the tenant is therefore the strongest comparison the caller's own identity supports and the whole comparison the boundary needs. Should a later phase put an agent id on this family's requests, the resolver moves to ownedEntry; the plan's Risks section carries that pointer.

    The scoping tenant is READ FROM ctx, never taken as a parameter. This is the one place the shape deliberately differs from D-350's owner threading, and the difference is not cosmetic. ctx already carries the verified triple the method requires, and it is the identity D-349's gate reconciled against the request body before dispatch; a parameter would be a seam a caller could populate with a tenant it does not hold. It also makes the write and its COMPENSATING REVERT resolve identically. internal/protocol/mcp.go::handleSetRawHTMLTrust runs both legs through adminwrite.Apply on the same idCtx, calling the setter a SECOND time to undo an applied write whose audit emit failed; a revert that could fail to resolve where the apply succeeded would fail asymmetrically and leave the toggle observably applied but unrecorded — the exact posture that helper exists to prevent. Same ctx, same resolution, symmetric compensation, pinned by a test that breaks the emit for real (a closed bus) and asserts the prior value is restored.

  2. Boot-declared registrations stay writable, and the bound is stated rather than assumed away. A boot-declared server is deployment-global infrastructure: declared in the deployment's own configuration, resolving and dispatching by bare name for every session, listed on every session's read surface (D-287). Its per-server admin preferences have no per-owner home and no other door that can set them, so refusing the write there would not scope the preference — it would delete it. The honest guarantee this write makes is therefore "the caller's own tenant, or the deployment's own boot-declared infrastructure; never another tenant's runtime-added registration", and it is written in the godoc, pinned by TestRegistry_SetRawHTMLTrust_BootDeclaredStaysWritable, and recorded as a follow-up. Narrowing it needs a new operator-facing policy (which tenants may write which boot connections' preferences) — a config surface, not a resolution rule.

  3. Deregister is OWNER-scoped, with the comparison ATOMIC with the delete. Registry.Deregister(ctx, name, owner) removes an entry only when its own tag equals owner exactly; a name nobody registered and a name another owner registered answer identically. The ZERO owner matches the boot-declared registrations and nothing else — the boot loader's same-name hot-reload replace is the one caller that legitimately holds it, and it is removing its own entry. That is strictly narrower than a bare name, which reached every registration regardless of owner. The comparison runs under the SAME write lock as the delete rather than through the shared resolver, because a resolve-then-delete leaves a window a concurrent same-name replace by another owner can land in; a two-owner N=128 concurrent run asserts exactly one removal lands and the transport closes exactly once.

    The owner is threaded from both production callers, and the guard still moved to the choke point. Attach's same-name replace already compared Registry.OwnerOf against deps.Owner before tearing anything down, and the run-start detach leg already enumerated through RuntimeAddedSources(owner), so no live path changes outcome. What changes is WHERE the guard lives: at the registry's single resolution point rather than in each caller, so a future caller cannot reach the mutator without one. projection.ConnectionDetacher.Detach gains the owner parameter, mirroring its own AttachedSources(ctx, owner), and serve.MCPConnectionDetacher passes it through.

  4. RefreshDiscovery and Probe are CLASSIFIED as READS, and the godoc says so. Both write registry state, and both write only what the round-trip they just performed OBSERVED: discovery counts, the discovery timestamp, the measured latency, the reachable state, and — on a probe failure — a truthful error bump. Nothing they write is chosen by the caller; nothing they write is consulted as policy on any later authorization or rendering decision; and the SAME fields are written unsolicited by the transport's own callbacks from any session's ordinary traffic. Scoping them would therefore change nothing about who can affect the state — it would only make a boot-declared server's refresh and probe unreachable. Their bare-name resolution stays and each godoc now states the classification and its reason, so a later reader cannot mistake it for an oversight; the smoke COUNTS both classifications, because a single-match grep would still pass with one deleted, and that is the shape of guard that lets an unexamined verb drift back.

    The distinction the classification rests on is caller-chosen policy versus observation, not "does it write a field". SetRawHTMLTrust persists a caller-chosen sandbox posture consulted at render time; SetOAuthDiscoveryOrigins persists a caller-chosen allow-list consulted at discovery time; Deregister removes the registration itself. Those are the mutators. The six observation writers — RecordAuthChallenge, RecordScopeShortfall, RecordOAuthRequirement, RecordReconnect, RecordDiscovery, recordError — were audited under the same test and stay bare-name; five are reachable only from transport callbacks or the boot attach, and RecordOAuthRequirement is reachable from the wire exactly once, through mcp.servers.probemcpconsole.RegistryAccessor.maybeDiscoverOAuthRequirement, where it records the requirement the probe's own walk returned.

  5. Two resolvers at one choke point, and one deliberate exception. ownedEntry is UNCHANGED (D-350's (tenant, agent) form with its zero-owner refusal); tenantEntry is the new tenant form with its own empty-tenant refusal; both live at the registry's single resolution point rather than in their callers. Deregister is the exception and its godoc says why: its comparison must be atomic with the delete, and it is not the same comparison anyway — ownedEntry refuses the zero owner, which is exactly the tag the boot loader's own hot-reload replace holds. tenantEntry's empty-tenant refusal is dead-defensive on every live path — requireIdentity fires first — and is pinned by a direct test anyway, mirroring ownedEntry's zero-owner guard and RuntimeAddedSources' zero-owner nil: a future caller resolving without a tenant must not fall back to the whole registry, and an untested unreachable guard is how an inert guard survives.

Why this does not reopen D-287 / D-301. Nothing about resolution, dispatch, the tool catalog, or the read projections changes: a boot-declared server stays visible to every session, a runtime-added name stays deployment-global, and ListServers / GetServer / ListResources / ListPrompts / Health / OAuthDiscoveryTarget / ReadResource — plus RefreshDiscovery and Probe — all still resolve by bare name from any session, pinned by TestRegistry_ReadsStayBareName. What changes is narrower and strictly additive: two WRITES now land only where the caller's own scope already reaches.

Consequence. internal/tools/drivers/mcp/registry.go (tenantEntry, the two scoped writes, the two read classifications); internal/tools/drivers/mcp/attach.go (the replace leg threads deps.Owner); internal/runtime/agentcfg/projection/projection.go (the detach seam carries the owner); internal/runtime/serve/mcp_detacher.go (the production concrete passes it through); a godoc note in internal/protocol/mcp.go recording that the apply and its revert share the verified ctx. No wire type, method, error code, or event moves — ProtocolVersion stays 0.1.0, and there is no D-223 / D-209 regeneration. Tests pin: the tenant scope (owning tenant succeeds; another tenant and an unregistered name answer alike with the live flag untouched; boot-declared stays writable; identity stays mandatory; an empty tenant resolves nothing) plus an N=128 two-tenant concurrent run; the revert symmetry at the registry boundary and end to end with a real emit failure; the owner scope on Deregister (cross-owner refused with the transport never closed, zero owner reaching boot state and nothing else, half owner reaching neither) plus an N=128 two-owner concurrent run asserting exactly one removal and one close; the reconcile leg threading the reconciling owner; the production detacher against a real registry; the reads staying bare-name; and a real-driver integration test across internal/protocolinternal/mcpconsoleinternal/tools/drivers/mcp covering the cross-tenant refusal, the boot-declared write, the compensating-revert failure mode over a genuinely closed bus, a missing-identity request over the real REST transport, and an N=16 concurrent cross-tenant stress. Eight mutations were verified to turn a smoke OK into a FAIL — never a SKIP (the tenant comparison, the empty-tenant refusal, the resolver choice in SetRawHTMLTrust, the Deregister owner comparison, each of the three owner-threading call sites, and the deletion of ONE of the two read classifications). The smoke reports 18 OK / 0 SKIP / 0 FAIL against the live preflight server. Its own route probe had to be corrected for the very failure this phase closes: the first draft probed with a REAL request and read any 404 as "route not present", but the verb answers a genuinely unresolvable name with 404 not_found — so a real answer was being reported as a SKIP (§4.2 item 5). The probe now sends an empty body, which separates a mounted route (400 invalid_request) from an unknown method (404 unknown_method), and every live classification reads the typed code rather than the bare status. The same live run also disproved the planned scope_mismatch assertion — the preflight dev token does carry the admin claim — which is why the live legs assert identity-mandatory refusal and the typed not-found shape instead. The use-the-harbor-protocol skill (surface: protocol) documents the enforced write scope in the same PR (§18). Follow-ups recorded in the phase plan rather than fixed here: the boot-declared bound above; Registry.Register's owner comparison still living in Attach rather than at the registry's choke point; and MCPConnectionDetacher.Detach swallowing a cross-owner refusal as already-detached (unreachable in production, and making it loud would break the idempotency the reconcile depends on).

Cross-references. D-350 (the rule this completes; ownedEntry reused, not re-derived), D-349 (the body-identity gate that makes the ctx tenant the caller's own verified tenant), D-301 (the owner tag as a view/write-scope filter, PRESERVED), D-287 (the process-global bare-name model, PRESERVED), D-302 (the sibling allowance write), D-303 / D-331 (the seam shapes mirrored), D-025 (concurrent reuse). CLAUDE.md §4.2 items 4–5 (the SKIP-vs-FAIL rule the mutation sweep enforces), §5, §6, §7, §11, §13, §17.1, §17.3, §17.6, §18. RFC §6.4, §6.16, §7. Plan: docs/plans/phase-211-owner-scoped-registry-mutators.md.


D-353 — artifacts.get is the CONTRACT read: a Protocol method every driver serves, a response truthful about its own bound, an operator-owned fetch ceiling, and byte-offset windows — with row-addressed windowing dropped rather than deferred

Date: 2026-07-26

Status: Accepted (v1.23). Implements D-347 parts 1, 3, 4 and 8. Departs from two of D-347's stated details — the response field set and the config field's validation floor — and both departures are recorded below rather than applied quietly. Builds on D-352 (the reconciled read key this serves on) and D-349 (the body-identity gate it registers with). Closes D-133, which reserved artifacts.get for its first consumer. Composes with D-025, D-026 / D-022, D-218.

Context. Harbor's heavy-content valve was one-way at the Protocol edge. The write half is complete: a tool result at or above the heavy-output threshold routes to the store, a multimodal DataURL above the same threshold auto-materialises, and the LLM edge fails loud on anything that slipped through. What a client could then DO with the resulting reference was one method — artifacts.get_ref — which type-asserts the OPTIONAL artifacts.Presigner capability and answers CodePresignUnsupported when the assertion misses. Exactly one of five shipped drivers implements Presigner, and it is not the default: artifacts.DefaultDriver is inmem. So the Protocol's byte surface was served by one driver and not by the one a fresh clone boots on.

artifacts.get was not a surface invented for the occasion. RFC §5.2's Protocol table has advertised it from the outset, brief 05 §3 lists it in the scope-checked artifact surface, four wire-type godocs (types/memory.go, types/flows.go, types/search.go, types/pause.go) told clients to resolve bytes through it, and the glossary's memory.get entry said the Console does. None of that was true. D-133 is why: it deferred the method explicitly and correctly under §13 pending a consumer. That consumer has arrived.

Two further gaps rode with it. artifact_fetch — the LLM's recovery path onto a ref — could read only a HEAD: max_bytes bounded the read and there was no way to ask for the next window, so a model handed a large CSV could peek at it and nothing more. And the bound itself was two compile-time constants (defaultArtifactFetchMaxBytes, hardArtifactFetchMaxBytes) clamped SILENTLY in both directions, while the offload threshold on the other side of the same valve was a validated ArtifactsConfig field. An operator could tune what went out and not what came back, and a caller could not tell a clamp from an end-of-artifact.


Decision. Five parts.

1. The byte-serving surface is a METHOD, and the base-URL question does not arise — that is the point. artifacts.get lands as the fifth artifact method on the existing control transport at POST /v1/control/artifacts.get, alongside list / put / get_ref / delete. It resolves through ArtifactStore.Get, a MANDATORY interface method, so every registered driver serves it.

A Protocol method is served on the connection the client already holds. Nothing is minted, so nothing needs to know the Runtime's externally-reachable address — and Harbor has no such address to give: internal/config carries no public_url / external_url / advertised-address field and ServerConfig.BindAddr is a LISTEN address. A URL-minting design would therefore require a new config surface plus a deployment-topology story (reverse proxies, container networks, TLS termination). That requirement belongs to the third-party egress arm, which stays deferred; it is not a prerequisite for serving bytes to the caller who already authenticated.

get_ref is NOT a parallel implementation of this (§13), and the distinction is stated in godoc so it stays true rather than being re-litigated. They are not two ways to do one thing:

  • artifacts.get is the CONTRACT — the universal, driver-independent read every client can rely on, mandatory on every driver because Get is mandatory on the interface.
  • get_ref is a driver-specific TRANSPORT OPTIMISATION — where the store can hand bytes off its own edge, a large media download need not transit the Runtime.

Both resolve the same ref, under the same verified identity, under the same registry-row family. They differ in WHO SERVES THE BYTES, not in who may read them. A client that cannot presign now has a path rather than a refusal, which is what makes keeping both defensible; and the reason is written where the code is (the method godoc, the surface godoc, the wire-type godoc) rather than only here, because a claim maintained in one file drifts from the code in another.

Landing it retires the four wire-type godoc forward-references and reconciles the glossary claim.

2. The response is truthful about its own bound, through ONE field set. It carries the ref's metadata, the bytes actually returned, and offset / returned_bytes / total_size_bytes / truncated. Three sources can bound one read — the caller's max_bytes, the operator's default when the caller named none, and the operator's hard ceiling — and all three answer through the SAME fields rather than growing a signal each. A client that wants the rest re-reads at offset + returned_bytes; a client that wants to know whether it saw everything reads truncated.

truncated is computed from offset + returned_bytes < total_size_bytes, not from returned_bytes < total_size_bytes. The difference shows only on the LAST window of a paged read, where the second form reports "more follows" forever and a paging model never terminates. Both the handler suite and the smoke assert the last window explicitly for that reason.

DEPARTURE FROM D-347, stated rather than applied quietly. D-347 part 3 names the artifact_fetch field set as {content, offset, returned_bytes, total_size_bytes, eof} while part 1 names artifacts.get's as total_size_bytes / returned_bytes / truncated. eof is DROPPED. It is the exact complement of truncatedeof == !truncated for every window there is — so shipping both is two names for one fact, which is the shape §13 calls two parallel implementations of one conceptual feature and which the same entry's own "ONE field set… rather than a signal per source" instruction rules out. truncated is the survivor because artifact_fetch already shipped it, so no deployed prompt or stored transcript is invalidated by the choice. The master plan's Phase 209 detail block names the three fields and not eof, three times over, and is the more recent artifact.

3. Windows are BYTE-OFFSET and MIME-agnostic. Row-addressed windowing is DROPPED, not deferred with a design. artifact_fetch gains offset alongside max_bytes and answers the same field set as the Protocol method, so a model paging a large CSV reads byte ranges and splits lines itself. A window may begin and end mid-line, and the tool's LLM-facing description says so in as many words — a model that is told the window is a byte range handles a split line; one that assumes rows silently mangles them.

Row-addressed windowing keyed on stored MIME is dropped because the obstacles are structural rather than tunable: heavy tool results are stamped application/json unconditionally whatever the tool produced; MIME is not part of the content-addressed id; a re-Put of identical bytes dedups first-writer-wins and DISCARDS the new MIME; and the eight-method ArtifactStore interface has no metadata-update method. A mis-stamped MIME is therefore permanent for the life of a (scope, namespace, content) tuple, so "fail loud on a wrong MIME" resolves to "fail FOREVER" — which is not the §13 fail-loud posture but its inversion. Fail-loud is valuable because it routes an operator to a fix; an error with no available recourse is a permanent refusal wearing a diagnostic's clothes.

Reopening requires one of two prerequisites, recorded here so the next author does not re-derive them:

  • (a) MIME joins the content-addressed identity — an id-shape change touching five drivers, every persisted ref, and the dedup contract. That is an RFC change.
  • (b) The interface gains a metadata-revision method — a ninth method, five-driver conformance parity (§9), and a defined answer for what a revised MIME means for a ref already rendered into a model's context.

Neither should be adopted to enable a windowing convenience.

A window is a CONTRACT, not a cost claim, and this entry says so rather than implying otherwise. ArtifactStore.Get returns ([]byte, bool, error) with no offset or range, and every driver materialises the whole blob. A window at offset N therefore costs a full materialisation, and paging a large artifact in small windows is model-triggerable IO amplification. That property is stated in the handler's godoc and in the phase plan's risk section; making the read cheap is a range-aware store method with five-driver conformance parity, which is its own phase. No capability claim about which drivers serve a window incrementally is placed in interface godoc, because godoc is what operators read and a substring()-based implementation would falsify exactly such a claim.

4. The fetch ceiling becomes operator policy, and the clamp becomes observable. ArtifactsConfig gains fetch_default_max_bytes (64 KiB) and fetch_hard_max_bytes (1 MiB) as additive fields with the current constants as defaults, so existing configurations are unchanged (§10). The two constants are single-sourced in internal/config as DefaultArtifactFetchMaxBytes / DefaultArtifactFetchHardMaxBytes, and BOTH consumers — the Protocol byte read and the artifact_fetch builtin — resolve their bounds from the SAME operator configuration through the boot path, so a model and a Console client reading one artifact cannot disagree about what "truncated" means.

The clamp stops being silent (§13). A request above the effective ceiling is SERVED AT THE CEILING and reports it through the same truncated / total_size_bytes / returned_bytes fields — one signal, whether the bound came from the caller, the default, or the ceiling. Deliberately NOT a refusal: a caller cannot know a deployment's ceiling before asking, so a refusal costs it a round trip and teaches it nothing, and for a model it costs a turn. Truthful truncation is the correct posture; silent truncation is the one §13 names. A negative offset or max_bytes, by contrast, IS refused — that is not an omission the runtime can resolve, it is a caller asking for something the method does not offer, and reinterpreting it would be a guess the caller could not detect.

The guarantee is bounded, and says so (D-351's bar). The ceiling bounds ONE read. It is not a budget over repeated reads, and the governance layer's cost ceilings and rate limits remain the mechanism for aggregate consumption. That sentence appears in the config godoc, in docs/CONFIG.md and in the reference config, because a knob that looks like a budget and is not will be trusted as one exactly once.

DEPARTURE FROM D-347, second of two. D-347 part 4 says the fields are "validated in loader.go::Validate (each positive; default ≤ hard)". They validate as >= 0, not as positive. Rejecting zero would make a zero-value config.ArtifactsConfig fail validation — and a zero value is exactly what an operator's existing YAML unmarshals into for a key it does not mention — turning an additive field into a breaking one and contradicting the same part's own "so existing configs are unchanged (§10)". Zero means "the operator named no bound", which is what the omitempty tag declares and what HeavyOutputThresholdBytes and ProtocolConfig.MaxRequestBytes already do; it resolves to the documented built-in through ResolvedFetchDefaultMaxBytes / ResolvedFetchHardMaxBytes. A NEGATIVE value is refused by field name. The default ≤ hard comparison runs on the RESOLVED values, because a default above a configured ceiling is the same misconfiguration whether the operator wrote the default or inherited it. The "positive" property is enforced where it is load-bearing — at the consumer, where NewArtifactsSurface refuses a non-positive or incoherent bound outright with ErrArtifactsMisconfigured, rather than reordering it silently.

5. OWNERSHIP IS D-349's RULE READ ONTO A REF — and the registration is ONE row, not a second copy of one reason. artifacts.get carries an identity scope in its body, so it is a bodyscope registry row like its four siblings; D-349's coverage scan is bidirectional, so an unregistered scope-carrying request type fails go test and so does a row naming a type the canonical packages no longer declare. Registration is not a courtesy.

It takes the FLAT row, and it takes the SAME flat row get_ref already holds rather than a duplicate of it. SurfaceArtifactsRef now governs both artifact CONTENT reads: they hand over the same thing over different transports and so hold one posture, not two, and its Reason is widened to say that. Its posture is unchanged — Tenant: Pinned with PinnedDeniedCode: CodeScopeMismatch — so artifacts.list stays the only elevatable READ in the cluster and the fifth method does not quietly become a wider door onto what the fourth guards narrowly. The surface's own handler refuses a foreign tenant BEFORE the store is consulted, under both admin-tier claims, and a foreign id answers not_found identically to an id that never existed, because the difference between those two answers is precisely what a prober would harvest.

A per-method surface key was considered and is not merely unnecessary — it could not be spelled. A Surface key is a POSTURE key rather than a method name, which the registry's own godoc already states and which two existing keys (pause_page, state_history) already avoid method names to preserve; and "artifacts.get" as a Go string literal outside internal/protocol/methods is a method-literal violation the single-source scan rejects at build. The consequence, stated rather than left to be discovered: a body-scope refusal on artifacts.get reports the wire name artifacts.ref. Accepted — the posture it names is the one that fired.

No artifacts-local "host-derived, never caller-chosen" rule is declared, and declaring one would be a regression. Reconcile already makes the body's scope an INPUT and the transport's identity the AUTHORITY. The ref ID is caller-chosen — it must be; it is what the model names — and the SCOPE it resolves in is not. That separation is what the reconciler produces, and restating it in a handler comment is the thirteenth copy of a comment D-349 deleted twelve of.


Non-goals — explicit, so a later phase does not read silence as permission.

  1. No row-, line- or schema-addressed windowing, and no MIME-keyed read behaviour of any kind (part 3, with its two prerequisites named).
  2. No ranged read on ArtifactStore, no ninth interface method, no change to the content-addressed id shape.
  3. No third-party byte egress: no loan or grant URLs, no grant tokens, no externally-reachable base-URL config, no ServerConfig change.
  4. No ProtocolVersion bump and no new error code. Additive method, additive wire type, existing codes.
  5. No Supports* capability ceremony on the read path (§4.4). Presigner remains the single documented exception it already is, and artifacts.get is mandatory on every driver precisely so no second optional capability is needed.
  6. No relaxation of the context-window safety net. D-026's fail-loud posture and D-241's narrowing are untouched; this entry adds no exemption to findContextLeak.

On the safety net, stated because a byte-returning response looks like a contradiction and is not. The internal/protocol/types package godoc has said "heavy bytes NEVER travel inline through these wire types" since the artifacts surface shipped, and that sentence is now corrected rather than quietly falsified. The net governs what reaches a MODEL'S CONTEXT — a heavy tool result is offloaded so the event stream and the prompt do not carry it — and an explicit read-back by the principal that already reaches those bytes is what the offload exists to make possible. The read side inherits the boundary without relaxing it: an artifacts.get response is not an event payload, is not written to a trajectory, and is not logged.


Why. brief 05 §3 lists artifacts.list / get / get_ref / delete as the scope-checked artifact surface — artifacts.get has been in the brief's surface list from the beginning. brief 05 §1's mandatory-artifacts rule ("a NoOpArtifactStore fallback that silently warns and truncates is an anti-pattern… the in-memory driver is the floor") read forward onto the read side is why this resolves through the mandatory Get and why the smoke asserts it on inmem specifically: a byte path that works only on a presigning driver is the read-side analogue of a floor that does not hold. brief 05 §7's cross-tenant rule is pinned at the handler and over the real wire, with the additional property that a foreign id and an unknown id answer identically. brief 07 §4 describes the renderer replacing heavy outputs with refs — the write half; giving artifact_fetch an offset is what makes a ref the model can work through rather than only peek at. CLAUDE.md §13 (the silent-clamp rule part 4 answers, and the parallel-implementation rule parts 1 and 2 answer), §10 (additive config), §6 (identity mandatory, the isolation triple), §8 (single-source method / type / error homes), §4.5 item 5 + §18 (the lockstep and same-PR doc obligations).

Findings I'm departing from. Two, both from D-347 and both above: the response drops eof as an exact complement of truncated (part 2), and the config fields validate as >= 0 with the positivity enforced at the consumer (part 4).

Protocol additions. ONE method (artifacts.get) and TWO wire types (ArtifactsGetRequest, ArtifactsGetResponse). No error code, no canonical event. ProtocolVersion stays 0.1.0. make protocol-ts-gen, make protocol-docs-gen and make protocol-ts-types-gen regenerate; the Console typed client mirrors the shape by hand; all three gates run green.

Consequence. internal/protocol/methods gains the method in both closed sets; internal/protocol/types/artifacts.go gains the request/response pair and its package godoc stops claiming bytes never travel on a response; internal/protocol/artifacts.go gains handleGet, effectiveMaxBytes and boundedWindow plus two mandatory fetch-bound deps; internal/protocol/bodyscope joins the new request type to the existing flat content-read row; the control transport decodes it and selects that row; internal/protocol/client gains RuntimeClient.ArtifactsGet; internal/protocol/singlesource and the three generator type indexes carry the new types. internal/config gains two fields, two Default* constants, two Resolved* methods and their validation; internal/runtime/serve/mux.go and internal/runtime/assemble/assemble.go resolve the bound onto the surface and the builtin registry respectively. internal/tools/builtin/artifact_fetch.go gains offset, the truthful field set, and a fetchBounds value single-sourced on internal/config. docs/CONFIG.md, the three example configs, the glossary's two reconciled entries, the generated Protocol reference and the use-the-harbor-protocol skill (surface: protocol) update in the same PR (§18).

§17.6 fix, bundled rather than deferred, and found by the mutation sweep rather than by reading. Dropping ANY arm of the artifacts transport's method → bodyscope row switch (reconcileArtifactsIdentity) broke ZERO Go tests. A content read that lost its arm fell through to the default row — SurfaceArtifacts, which is AdminScoped — so a caller holding the admin claim would have crossed tenants at the transport, and the surface's own tenant check silently covered for it. Each layer passed while the other was broken, so a live smoke stayed green either way and NEITHER was individually pinned: precisely the inert-guard shape §4.2 item 5 names. The fix is internal/protocol/transports/control/artifacts_body_scope_test.go, which drives a cross-tenant body under the admin claim against all FIVE artifacts methods — the three admin-scoped rows must GRANT the crossing, the two content rows must REFUSE it flat regardless — and is itself verified against three mutations (artifacts.get, artifacts.get_ref and artifacts.put each losing their row), each failing in exactly the right sub-test. The gap predates this phase and the fix covers the whole cluster, not only the method this phase adds. Removing BOTH tenant gates at once was also verified to turn the live smoke's cross-tenant assertion into a FAIL rather than a SKIP, which is what establishes that assertion is live and that the two layers are genuinely defence in depth rather than one dead copy.

The window returned is a COPY, not a slice of the driver's buffer — pinned by TestArtifactsGetHandler_ResponseDoesNotAliasTheStore, because the in-memory driver hands back bytes a caller must not be able to mutate for the next reader.

Tests pin: the round trip on the DEFAULT driver with get_ref's refusal on the same store asserted alongside it (the asymmetry this closes, asserted rather than assumed); the truthful bound; a seven-case offset table including the last window and both past-the-end shapes; a paging loop that reassembles the artifact from its OWN reported offsets, which fails to terminate if the field set is not self-consistent; the ceiling served-not-refused; the operator default applied; the malformed-request table; the constructor's refusal of an incoherent bound; the no-aliasing property; a D-025 run at N=128 against one shared surface asserting no CONTENT bleed and no BOUND bleed (a per-call bound held on the surface would show up in the second); cancellation isolation; and a real-driver integration test over the real control transport against TWO non-presigning drivers (inmem and fs) covering identity propagation, the foreign-id / unknown-id indistinguishability, the foreign-tenant 403 under both admin claims, a no-established-identity 401, and an N=32 cross-tenant concurrent stress.

One test-side finding, fixed rather than tolerated. The wire-level concurrency stress originally stood up one httptest.Server per tenant — 32 simultaneous listeners — and that starved a sibling PTY test in the same package past the render budget its own godoc documents, turning a green suite intermittently red for a reason unrelated to artifacts. It was measured rather than assumed: the base commit ran the package green 3/3, the first draft failed 2 of 3, and the shared-server version is green 3/3. The fix is not merely cheaper but a STRICTER test — N tenants against N handler instances proves nothing about isolation, while N tenants sharing ONE handler and ONE surface is the shape a cross-talk bug actually appears in. Each request now seats its own identity carrier as its verified identity, so one mount serves every tenant. Recorded because "my new test made an unrelated test flaky" is a failure mode that is easy to misattribute to the unrelated test.

Cross-references. D-347 (the design gate this implements parts 1/3/4/8 of, and departs from in two named places), D-352 (the reconciled read key this serves on), D-349 (the body-identity gate, the flat row, the bidirectional coverage scan), D-133 (which reserved this method for its first consumer — this is it), D-167 (the artifact_fetch builtin + the <session_artifacts> manifest the offset makes workable), D-026 / D-022 (the offload this gives a coherent read side, untouched and not widened), D-218 (the presign trap, closed there for App documents and reached here in the general case), D-351 (the bounded-guarantee bar part 4 holds itself to), D-223 / D-209 (the lockstep gates), D-025 (concurrent reuse). RFC §5.2 (the advertised artifact method set), §6.5, §6.10, §10. brief 05, brief 07. CLAUDE.md §4.2, §4.4, §4.5, §5, §6, §8, §9, §10, §11, §13, §16, §17.1, §17.3, §18. Plan: docs/plans/phase-209-artifacts-get-and-ceiling.md.

Status: shipped (Phase 209).


D-354 — In-process pass-by-reference tool routing, and the substitution invariant held by a production bound, a carrier projection, and one widened arrival check

Date: 2026-07-26

Status: Accepted (v1.23). Ships D-347 parts 5 (in-process arm), 6 and 7. Extends D-026 / D-022 (the heavy-content offload this gives an INPUT leg), D-241 (the offloadable/exempt line the widened check reads a field onto), D-352 (the reconciled read key resolution uses). Borrows D-349's minting-scan shape and D-223's lockstep idiom without restating either.

Context. Harbor's heavy-content discipline is one-directional. A tool RESULT above the threshold routes to the artifact store and the model gets a stub plus a fetch hint; what the model can then do with the reference is artifact_fetch, which pulls bytes back INTO its context. The mirror-image case has no answer at all: a tool that must READ something large — an uploaded document, a stored CSV, a prior tool's materialised result — can only be given that content through its arguments, which means through the model. So the runtime's own safeguard forces the leak it exists to prevent, and the tool author's only recourse is to hand the model an id and hope the tool can find it, which no seam supports.

The primitive that closes it is small, and its shape is already determined by how in-process tools are registered: input schemas are reflection-derived from the Go type (inproc.RegisterFuncschema.Derive), so a reference parameter is a declared FIELD TYPE, not a hand-written schema convention a registrar would have to honour.

What is NOT small is the guarantee attached to it. Resolving a reference means the runtime puts a value into a tool argument that the model did not author and must not see. Every surface the runtime already writes — the trajectory, the interleaved observation, canonical event payloads, audit payloads, logs — is a place that value must not appear.

Decision. Six parts.

1. The primitive is a declared field type. internal/tools/artifactref.Ref is an artifact-reference tool PARAMETER. schema.Derive renders it as {"type": "string", "description": ...} — the model authors the artifact id and nothing else — and inproc.RegisterFunc computes ONCE at registration whether the input type contains one, so a tool with no reference never pays for the bind and the flag is immutable on the descriptor like every other field (D-025).

A tool reads content through Ref.Bytes(), which fails with ErrUnresolved when the reference was never resolved rather than returning an empty slice a tool would measure as an empty artifact. Ref.ID() is the only part safe to log, emit or persist, and the type makes that structural rather than advisory (part 3).

2. Resolution is a store read seated on the dispatch ctx, closed over the run's own triple. dispatch.ExecuteDecision seats the resolver ONCE at the single dispatch entry point — closed over (tenant, user, session) from the run's quadruple, resolving through ArtifactStore.Get on the reconciled read key (D-352), with the task deliberately absent because it is a provenance annotation and not part of what a read resolves on. A tool therefore reaches exactly the bytes its own run reaches, and NO tool driver holds identity logic. This is D-347 part 8 read onto a ref: the ref ID is caller-chosen because it is what the model names; the SCOPE it resolves in is not.

Seating it at ExecuteDecision rather than at each tool-invoking shape is deliberate — a later decision shape inherits it instead of forgetting it — and it cannot widen sideways, because every descendant path re-derives identity from the same triple.

Three fail-loud paths, none of which degrades to an empty value: no resolver seated (ErrNoResolver), an artifact the triple does not reach (ErrArtifactRefNotFound, indistinguishable from a nonexistent one so existence stays unreadable across the boundary), and no artifact store wired at all (ErrArtifactStoreUnavailable — a degraded stack refuses BY NAME rather than seating nothing, so the tool gets a cause rather than the generic absence).

3. The invariant is held three ways, and the ordering is the argument.

A resolved artifact value never re-enters the model's context or the observable record. It does not appear in the trajectory, in the observation the runtime interleaves into the next chat thread, in any canonical event payload, in an audit payload, or in a log. The model authored an id and continues to see an id; the substitution is the runtime's and it ends at the tool boundary.

(a) A bound on PRODUCTION — primary. artifactref.Substitute is the ONE call site at which a resolved value enters a dispatched argument, and ScanSubstitutionSites holds it to one: an AST walk over the module resolving the package by IMPORT PATH so an alias is followed, with a reasoned allow-list whose blank reasons and whose UNMATCHED entries are both reported, so the list stays a description of the code. An invariant about where a value does not travel is enforced most durably by bounding where it is produced; enumerating arrivals is the check that goes stale.

(b) A projection bound on the CARRIER — new here, and the reason it is worth naming. Ref keeps the resolved bytes in an unexported field and projects itself as its id through every serialisation door Go offers a downstream layer: MarshalJSON emits the id, String returns the id, LogValue renders the id. A Ref that reaches json.Marshal, fmt.Sprintf or slog therefore emits an id BY CONSTRUCTION. This is what turns "the runtime must remember not to serialise it" into "serialising it produces the id", and it is why the argument JSON is never rewritten: dispatch hands desc.Invoke the model's own args unchanged, and the bind happens on the DECODED value inside the policy shell — so a retried attempt re-decodes and re-resolves, and the trajectory, the observation and every event payload keep carrying the id.

(c) An arrival check at the LLM edge. findContextLeak widens to Messages[].ToolCalls[].Args. Detail in part 4.

4. findContextLeak widens to Messages[].ToolCalls[].Args, and the scope is exactly one field. ChatMessage.ToolCalls[].Args json.RawMessage is live rather than vestigial: the React prompt builder replays a trajectory step's call.Args into it at four message-construction sites (internal/planner/react/prompt.go — the assistant messages built at :919/:925, :1032, :1228 and :1352, the last two assembling []ToolCallStructured at :1203, :1307 and :1323), and the bifrost translator maps all of it onto the provider (internal/llm/drivers/bifrost/translate.go:134-137translateAssistantToolCalls at :742, where args = string(tc.Args) at :761). The check walked m.Content.Text and m.Content.Parts and did not walk it. So the one field that could carry a resolved value back to a provider was the one field the edge net did not read.

Three reasons the widening is taken rather than argued away, stated because the alternative is superficially reasonable:

  1. The alternative argument is circular. "The substitution never reaches the trajectory, so Args cannot carry it" restates the invariant as its own evidence. An invariant with a single production-side guard and no arrival-side check is asserted, not enforced — and the arrival is where a violation becomes observable to a provider.
  2. It is the check D-026 already specifies for this class. Args is offloadable content by D-241's own test — machine-authored, tool-shaped, with an ArtifactStub offload path — so it belongs on the offloadable side of D-241's line next to RoleTool text and binary DataURL parts, not on the exempt conversation-text side. The widening applies an existing rule to a field that fits it and adds NO new rule.
  3. It is defence in depth over a bound this decision itself introduces. The production scan protects against a second substitution site being added; the edge check protects against a resolved value reaching Args by any route, including the replay path that already copies Args forward turn over turn.

Scope, stated so it is not read as wider. One additional field on the existing byte-threshold test, reusing the same >= boundary. No threshold change. No touch to D-241's conversation-text exemption. No new exemption. No change to ErrContextLeak's type or the llm.context_leak event. A tool call whose arguments legitimately exceed the threshold is the same bug the check names everywhere else: a producer that should have passed a reference — which, after this decision, it can.

5. The third-party arm is DEFERRED on three named blockers, not on effort. An HTTP / MCP / A2A consumer cannot be handed a Go value, and handing it something dereferenceable is three separate design questions that happen to share a feature's name:

  • The address. A remote consumer needs one and the Runtime has no configured externally-reachable address to give — internal/config has no public_url / external_url field and ServerConfig.BindAddr is a LISTEN address. Inventing one is a deployment-topology decision (reverse proxies, container networks, TLS termination) with its own config surface, and when it lands it should take the D-338 / D-340 / D-346 shape: config-or-boot-env, default off, fail-closed, never wire-writable.
  • The grant's semantics. "Single-use" is not a property a presigned URL has — it is expiry-bounded and nothing more. A grant cannot claim a use-count property before a legitimate retry, a redirect, a HEAD-then-GET probe, and a partial or abandoned transfer each have a defined outcome; those four ARE the design, not details under it. And shipping one feature whose two mechanisms carry different security properties is the §13 parallel-implementation shape.
  • The credential obligation. A grant URL IS a bearer capability to the content — the same reading artifacts.get_ref already applies to itself when it refuses cross-tenant elevation flat on the ground that a presigned URL is materially broader than the metadata artifacts.list returns. Routing would substitute such a value into outbound tool arguments, which §7 rule 7 and §13 forbid persisting unredacted. Part 3's invariant states that obligation NOW, so a deferred phase inherits it rather than rediscovering it.

Nothing is stubbed for it. The in-process arm delivers the facet's actual claim for the consumer class Harbor controls.

6. Artifact bytes are stored AS AUTHORED; the redactor governs what is EMITTED (D-347 part 6). internal/protocol/artifacts.go::handlePut calls the redactor and DISCARDS its result, storing req.Bytes verbatim, and dispatch holds no redactor reference at all. The settled contract is the one the system already behaves by: an artifact exists to hold the bytes the event stream and the prompt must not carry, and an ArtifactRef passes the redactor unredacted precisely because it is a reference (D-022). The redactor is an ADMISSION GATE — a refusal path — not a transform. handlePut's godoc (which claimed the payload was "routed through" the redactor and that it "may rewrite") and the matching glossary entry are corrected to name the gate they describe. NO behaviour changes: correcting a comment that describes a rewrite nobody performs is documentation-class drift, and leaving it would invite a later author to "restore" a transform the read side then has to reason about.

Why the substitution is a ctx-seated resolver rather than an argument rewrite. The obvious implementation resolves the ref in dispatch and rewrites the argument JSON before Invoke. It was rejected: it puts the resolved value into the exact artefact (CallTool.Args) that the trajectory persists, the prompt builder replays, and the lifecycle events summarise — so the invariant would have to be recovered by scrubbing every downstream sink instead of never being violated. Seating a resolver and binding on the DECODED value means the bytes exist only on the per-invocation argument value, for the duration of one attempt, behind a carrier that serialises as an id. The invariant is then a property of where the value lives rather than a rule about where it is copied.

§13 primitive-with-consumer compliance. The routing lands with a real in-process consumer exercised end to end in the same PR: examples/tools/artifactstats — a registered tool that declares artifactref.Ref, reads the resolved bytes, and returns ONLY measurements (size, line count, rune count, SHA-256), so the bytes enter the process and do not leave it. test/integration/artifact_passbyref_test.go drives it through the production dispatch.ToolExecutor over a real in-memory artifact store, a real event bus opened on the real pattern redactor, and a real catalog with the lifecycle-emitting shell live.

Findings I'm departing from. None.

Protocol additions. None. No method, wire type, error code, canonical event or capability moves; ProtocolVersion stays 0.1.0, and there is no D-223 / D-209 regeneration.

Consequence. internal/tools/artifactref is new (artifactref.go — the Ref carrier, the Resolver seam, Substitute, TypeContainsRef; scan.goScanSubstitutionSites + Violation). internal/tools/schema derives Ref as a string with a model-facing description. internal/tools/drivers/inproc computes the reference flag at registration and calls Substitute after decode, inside the policy shell. internal/runtime/dispatch seats the run-scoped resolver and adds ErrArtifactRefNotFound / ErrArtifactStoreUnavailable. internal/llm/safety.go::findContextLeak walks Messages[].ToolCalls[].Args. internal/protocol/artifacts.go corrects the stored-bytes godoc in three places. sdk/tools re-exports ArtifactRef / NewArtifactRef / ErrArtifactRefUnresolved — and DELIBERATELY NOT Substitute, WithResolver or Resolver, because seating a resolver is the runtime's act at the dispatch boundary and a tool that could do it would be reaching past the identity scope its run was given. examples/tools/artifactstats is the worked consumer.

Tests pin: the carrier's decode/marshal/String/LogValue projections against a planted marker; Substitute's walk across nested structs, pointers, slices, arrays and map values (with the map write-back, since map elements are not addressable) and its refusals (no resolver, empty id, non-pointer target, depth bound) and its idempotency under a retried attempt; the deriver's string rendering, required-vs-omitempty behaviour and compile/validate round trip; the driver's end-to-end resolve with the argument JSON asserted UNCHANGED; a D-025 concurrent-reuse run at N=128 against ONE shared descriptor, each goroutine carrying its own resolver over content whose LENGTH is derived from its own index so a bleed is a size mismatch rather than a byte compare, plus a cancellation-isolation pair; the LLM-edge arm on both sides of the threshold, at the exact boundary, naming the parallel branch by index, and through the mandatory safety wrapper with the event's LeakSite and SizeBytes asserted — beside a companion proving an ordinary tool call still completes, so the widening cannot be green by rejecting everything; and a real-driver integration suite asserting the marker's absence from the raw observation, the LLM observation, the serialised trajectory, every event payload AND envelope published during the dispatch, and every log record, each arm guarded against vacuity (the tool's measured byte count proves it DID read the content; an event floor proves events were seen; the refusal test proves the log buffer is non-empty), plus cross-tenant / cross-user / cross-session refusals, an unknown ref, a store-less stack, and an N=48 two-tenant concurrency stress.

Mutation-verified, each against the real tree: removing the Args arm fails four tests; adding a SECOND Substitute call site in internal/runtime/dispatch fails the scan naming the file and line; removing the ONE real call site fails the scan as a stale registration (so the live allow-list is non-vacuous in both directions); making the example tool return its resolved content fails the raw-observation, LLM-observation and trajectory arms of the integration suite; and rewriting the argument JSON with the resolved content in dispatch fails the round trip outright — the design refuses the rejected implementation rather than merely not choosing it.

The add-an-in-process-tool skill (surface: tools) and the define-a-tool recipe document the reference parameter in the same PR (§18). Framework-framed only (§13).

Cross-references. D-347 (the design gate this ships parts 5/6/7 of), D-352 (the reconciled read key resolution uses), D-026 / D-241 (the safety net and the narrowing whose line the widened arm reads a field onto), D-022 (why an ArtifactRef passes the redactor unredacted), D-349 (the minting-scan shape the substitution scan mirrors, and the verified-identity rule ownership rides on), D-223 (the lockstep idiom), D-338 / D-340 / D-346 (the fail-closed boot opt-in shape the deferred egress config should take), D-025 (the concurrent-reuse contract), D-167 (the artifact_fetch builtin and <session_artifacts> manifest that supply the ids a model authors). CLAUDE.md §4.2, §4.4, §5, §6, §7, §11, §13, §16, §17.1, §17.3, §17.6, §18. RFC §6.4, §6.5, §6.10. Briefs 03, 05, 07, 15. Plan: docs/plans/phase-210-passbyref-routing.md.


D-356 — An artifact listing is scoped to the caller's own user unless an administrative claim widens it; the session axis stays a filter, not a boundary

Date: 2026-07-27

Status: Accepted (v1.23). Completes the identity bound on artifacts.list that D-352 established for the artifact store's read key. Mirrors the posture the events surfaces already hold — D-294's as-built cross-USER gate in events.FilterFromWire, and D-299's settled per-axis elevation rule — rather than introducing a second vocabulary for one concept (§13). Composes with D-349 (the shared body-identity gate) and D-353 (the byte read, whose posture is unchanged and stricter).

Context. artifacts.list accepts an ArtifactScope whose user and session components may be omitted, and the store treats an omitted component as a list wildcard. The surface stated its identity bound on the TENANT axis — a scope naming a foreign tenant takes an admin-tier claim — and left the USER axis to the body-identity gate, which refuses a body component that NAMES somebody else but by design leaves an OMITTED one alone (bodyscope/policy.go::reconcileComponent: an empty component short-circuits before any rule runs, because "unset" is the surface's word to interpret, not the gate's).

That split left the meaning of an omitted user unstated at the only layer that could state it. A listing row is metadata, not content — but the metadata includes the owning user_id and session_id, which are isolation-principal identifiers, and the full content digest. §6 rule 5 already says an identity-scoped enumeration filters by identity and that cross-scope observation takes an elevated subscription with a matching scope claim; the listing needed to say which scope an unset component meant.

Decision. The two axes are decided separately, because they are not the same kind of thing.

  1. user is an isolation principal, so the listing folds to the caller's own. In ArtifactsSurface.handleList, against the request's VERIFIED identity: a scope user that names somebody other than the verified caller is refused 403 identity_scope_required, and an ELIDED user is folded to the verified caller's own rather than fanning across the tenant. Both are the same widening and take the same claim — auth.ScopeAdmin or auth.ScopeConsoleFleet — and under either claim both pass through untouched, so a named foreign user reads that user and an elided one is the tenant-wide fan-in a fleet view asks for. The error code is events.list's, deliberately: one widening, one refusal vocabulary.

  2. session is NOT an isolation boundary within one user, so it stays the wildcard it has always been. An elided session means "every session of mine"; naming one of the caller's own sessions narrows and needs no claim. events.list draws the same line for the same reason — its user axis elevates on a foreign value, its session axis does not, because the user gate above it already decides whose rows are in play. Folding the session axis too would have broken the everyday "show me my artifacts" flow, which is the flow the Console Artifacts page performs, and would have bought nothing: under the folded own-user filter a foreign session resolves to nothing.

  3. The bound lives at the surface, not in the body-identity registry. The SurfaceArtifacts row's PinnedOrEmpty components are correct and unchanged — the gate's job is to reconcile what a body NAMES against what a token proved, and it structurally cannot express "an empty component means widen" because an empty component short-circuits before any rule runs. What an omitted component MEANS is the listing's own call. The registry row's Reason now says so and stops at "left empty", so the next author reads the split from the table instead of re-deriving it.

  4. A ctx with no verified identity is left unrestricted, matching identity.FromVerified's documented posture and the cross-tenant gate beside it: an in-process embedder or a background worker rooted outside any request has no anchor to reconcile against.

Blast radius, checked rather than assumed. The Console Artifacts page (web/console/src/lib/artifacts/state.svelte.ts::#scope) sends the connection identity's own tenant/user/session, and the Background Jobs artifact card sends the caller's own triple plus a task id — both already name the caller's own user, so the fold is a no-op for them and neither needs a claim. internal/search/artifacts does NOT route through this surface; it calls ArtifactStore.List directly and is unaffected by this change. Its own scoping is a separate, subsystem-wide question recorded under "Follow-up" below rather than half-answered here.

Follow-up, named with precision rather than deferred silently (§17.6). internal/search scopes its four searchers on the TENANT axis alone (search.CrossTenantRequested / EffectiveTenantSet, internal/search/search.go:268 and :286) and passes req.Filter.UserIDs through unexamined: internal/search/artifacts/index.go:73-77, internal/search/events/index.go:78-79, internal/search/sessions/index.go:79, internal/search/tasks/index.go:77. Making the user axis a scoped boundary there is one shared helper plus four call sites plus the aggregate dispatcher, and it should be decided for the subsystem in one pass rather than for one searcher inside a checkpoint PR — a single searcher changed alone would be the §13 two-postures-for-one-concept shape. It is queued as the next wave's first item.

Tests. internal/protocol/artifacts_test.go seeds two users in one tenant, with the caller's own rows deliberately spread across TWO of the caller's own sessions, and pins: the fold (asserting the OWNING USER on every returned row, not merely the row count); the named-foreign-user refusal and its code; both admin-tier claims reopening both the tenant-wide fan-in and the named-user read; the elided session still spanning both of the caller's sessions; a named own-other-session needing no claim; and a foreign session under the folded filter returning nothing.

Mutation-verified, each against the real tree: reverting the fold (UserID: req.Scope.User) fails the fold test with three rows where two are the caller's, and fails the foreign-session arm; deleting the named-foreign-user refusal fails its test outright; and scripts/smoke/phase-208.sh turns OK 29 / FAIL 0 into FAIL 1 under the first mutation, so the gate is not inert.

Documentation (§18). No docs/skills/ playbook demonstrates artifacts.list, so no skill body changes. The hand-written Protocol choreography page that DOES document the posture is updated in the same PR: docs/site/protocol/auth-and-identity.md gains user to the artifacts.list crossing row with its refusal code, plus a short section stating the two axes explicitly, since "omit the user" is the call an operator writes by default.

Cross-references. D-352 (the reconciled read key this completes at the Protocol edge), D-353 (the byte read, whose flat no-claim-crosses posture is unchanged), D-349 (the body-identity gate and why the fold cannot live in it), D-347 (the design gate the artifact read side ships under), D-294 (the events.list cross-user gate whose refusal vocabulary this reuses), D-299 (the settled per-axis elevation posture: empty folds to the caller's own, a foreign user elevates, a single own-session read does not), D-308 (the if !widened guard whose shape the claim path here follows). CLAUDE.md §4.2, §5, §6, §7, §11, §13, §17.5, §17.6, §18. RFC §5.2, §6.10, §7.

Status: shipped (v1.23 checkpoint audit).


D-357 — The artifact read path is byte-correct or it refuses: an admissible window, a truthful offset, a rune floor that kills the paging livelock, and the first consumer of a classification that had none

Date: 2026-07-29

Status: Accepted (v1.24). Completes the read side D-347 opened and D-353 built the Protocol half of. Reuses dispatch.ErrArtifactRefNotFound (D-354) rather than declaring a sibling sentinel. Leaves the LLM-edge fetch hint (D-026 / the registered-builtin lockstep) untouched.

Context — three defects, each verified against the tree rather than inferred.

  1. The tool returned corrupted bytes and misreported their length. artifact_fetch builds its response as Content: string(window) over a Content string field. The Go conversion is lossless, but the observation is JSON-encoded on its way to the model, and encoding/json rewrites every invalid UTF-8 byte to U+FFFD. An 8-byte PNG header is DELIVERED as 10 bytes while returned_bytes reports 8 — measured, not reasoned. The Protocol twin types.ArtifactsGetResponse.Content is []byte, base64 over the wire, and was always correct.
  2. The reported offset was the requested one, echoed. The field's own godoc promises "the byte index the returned window starts at", and the tool's description tells a model to page with offset + returned_bytes. Trimming any bytes off the front of the window while echoing the requested offset drops them with no signal and makes the next computed offset short.
  3. effectiveMax floored only at <= 0. A max_bytes of 1–3 against a multi-byte rune yields a window that trims to empty while truncated stays true, and the documented paging rule then yields the same offset forever.

A fourth gap sits beside them, and the FIRST draft of this phase got it wrong in a way worth recording. The draft claimed an unresolvable artifact reference "terminates the dispatch step" and proposed building recovery. It is shipped: internal/runtime/steering/runloop.go converts EVERY executor error into map[string]any{"error": …} as the step's observation and continues — its own comment says "The runloop does NOT abort the run on a single tool error." What is missing is a machine-distinguishable CLASS on that observation. And dispatch.ErrArtifactRefNotFound already IS the typed classification — with, at the time of writing, exactly one reference in the whole repository outside its own declaration: its own producer. No errors.Is on it anywhere, in production or in tests. A classification nothing classified on.

Decision.

  1. An admissible window, or a refusal that carries facts. The window is trimmed to whole UTF-8 runes at both ends and must be valid UTF-8 after trimming; otherwise the read refuses. Each trim applies only where the fragment is a WINDOWING artefact: a leading continuation is trimmed only at a non-zero offset (at offset zero there is no split to blame, so it is content), and a trailing partial rune only when bytes remain after the window (at the end of the artifact the missing bytes are missing from the artifact, so it is content). A single bad byte in the middle of otherwise-valid text refuses; it is never silently dropped.
  2. The refusal is not a wall. internal/llm/materialize.go stamps a artifact_fetch fetch hint onto EVERY auto-materialised over-threshold attachment, and those are image/audio/file — binary by construction. A blanket refusal would make that instruction fail 100% of the time and reintroduce the incident that hint fixed. So the hint is untouched, and the refusal populates ref / mime / size_bytes / total_size_bytes, empties content, and names the stored MIME, the failing absolute byte offset, and the by-reference route (a tool declaring an artifact-reference parameter receives the bytes without routing them through the model's context). The windowing fields stay zero-valued: a refusal reporting truncated: true would invite a model to page into the same wall forever.
  3. The reported offset is where content actually begins, i.e. the requested offset advanced past any front-trimmed continuation. This is what makes the reassembly invariant (content == blob[offset : offset+returned_bytes]) and the strict-progress invariant (truncated implies offset + returned_bytes > the requested offset) both hold, and the second is what makes a paging loop terminate.
  4. effectiveMax floors at utf8.UTFMax (4), on the operator-resolved default and ceiling as well as on a caller's own max_bytes. It is the ONLY bound in the read path that clamps UP, and deliberately: a boundary-aligned four-byte window always holds at least one complete rune, which is what makes strict progress provable rather than empirical. The alternatives are worse — an empty window with truncated: true is a lie, and refusing a legal request is a wall. It is never silent: returned_bytes reports the truth and both model-facing descriptions state the floor.
  5. The twin divergence is argued in both godocs, not left to be discovered. artifactWindow and boundedWindow stay byte-identical and share the copy-not-alias invariant; the rune discipline and the floor live one layer ABOVE the tool's copy and are deliberately not propagated, because the Protocol response carries []byte and rune-trimming it would short-read an operator's PDF. A binary-bytes-exact test on the Protocol side is the trip-wire for the "restore consistency" refactor.
  6. The classification reuses the sentinels that exist. No new sentinel. planner.ObservationClass ships two values — artifact_ref_not_found (model-recoverable) and artifact_resolver_unavailable (operator misconfiguration, explicitly NOT model-recoverable, so a planner does not burn its step budget) — computed with errors.Is over dispatch.ErrArtifactRefNotFound, dispatch.ErrArtifactStoreUnavailable and artifactref.ErrNoResolver. Message strings are untouched, so no shipped transcript or log grep is invalidated. The class lands on the single-call path (through the run loop's error_class key on BOTH Step.Observation and Step.LLMObservation) and on the parallel path (ParallelBranchObservation.ErrorClass, shared with the Batch decision's tool half). An unclassified failure — a tool's own error — is byte-identical to the pre-phase payload: one key, no class.
  7. The class set is closed at two by decision. artifactref.ErrEmptyID is an argument-shape failure already carried as an invalid-arguments error and the schema's job to prevent; ErrUnresolved and ErrNotAddressable are programming errors for which neither a model nor an operator has a repair. Naming them here so the next author knows where the seam to extend is.

Departures from the plan (§4.3), both recorded rather than quietly applied.

  • The plan's paging bound ceil(total/4)+1 is arithmetically unreachable and was replaced by the provable one. The floor guarantees a four-byte WINDOW, not four bytes of PROGRESS: the tail trim removes up to three, so an artifact alternating a 1-byte rune with a 4-byte one advances one byte on every other call. Measured: 100 such bytes at max_bytes=4 take 40 iterations where the plan's bound allows 26. The shipped cap is ceil(total / max(1, max_bytes-3)) + 1, and TestArtifactFetch_PagingBound_IsTheProvableOne carries the adversarial fixture so the constant cannot be quietly restored. The PROPERTY the plan wanted — termination, plus byte-exact reassembly — is unchanged and asserted for every max_bytes in 1..total+1.
  • The read-path unit tests live in a sibling file (artifact_fetch_readpath_test.go) rather than appended to artifact_fetch_test.go. Same package, same helpers; a separate file keeps this phase's additions off a file a sibling in-flight phase also edits. One pre-existing case in artifact_fetch_test.go moves, because max_bytes: 3 is now served at the floor.
  • A third stale eof reference was fixed. The plan names docs/glossary.md:108 and :110; the ranged read entry names the same nonexistent field in its conformance-contract sentence. Fixed in the same pass (§17.6), which also lets the smoke guard be a whole-file absence check rather than a line-scoped one.

Blast radius, checked rather than assumed. A binary artifact previously "worked" in the sense that it returned something. Making it refuse is a behaviour change for any caller tolerating corruption; it is taken deliberately because the returned content was never usable (D-190 records exactly this for images: artifact_fetch "returned raw bytes the model still could not see"), the byte count it reported was wrong, and §13 names silent degradation as the forbidden shape. internal/llm/materialize.go is unchanged and its lockstep test still passes. internal/config and docs/CONFIG.md are untouched — the floor is a tool property, not a config-key semantics change. No Protocol method, wire type, error code or event is added, so ProtocolVersion does not move and neither generated artifact regenerates.

Tests. The admissibility matrix (every rune width; splits at head, tail and both; PNG / zip / PDF fixtures; one bad byte inside valid text; a leading continuation at offset zero and a trailing partial rune at the end of the artifact, both of which are CONTENT and refuse; empty artifact; offset past the end). The two invariants as PROPERTIES over the full (offset × max_bytes) cross-product of a mixed-rune-width fixture — a table would have missed the livelock. The terminating paging loop for every legal max_bytes under a hard iteration cap. The floor's table on all three legs it can enter by. The refusal's populated/zero-valued field split, and its survival of an actual json.Marshal round trip. Two no-regression pins that an already-admissible window is byte-identical to the pre-phase build. N=128 concurrent invocations against one shared store across two tenants, interleaving reads, refusals and cross-tenant misses, asserting no content bleed, no CLASS bleed, no cancellation cross-talk and no goroutine leak (D-025). The classification on both dispatch paths against a real registered reference-consuming tool and a real store — the wrap chain is the thing under test, so a fake resolver returning the sentinel directly would prove nothing. The run loop's rendering onto both observation slots, asserted independently rather than assumed to alias, from an EXTERNAL test package so it may import dispatch without a cycle. The renderer's emission of the class into the prompt text. The Protocol twin's binary-bytes-exactness at every offset and every bound, plus a one-byte-bound paging reassembly proving the byte read acquired no rune floor. And test/integration/artifact_readpath_test.go across the inmem and sqlite drivers with identity propagation on every leg and three failure modes.

Mutation-verified. Each static smoke guard and each behavioural gate was broken and observed to turn OK into FAIL — never into a SKIP. Recorded per-guard in the PR body.

The prompt-size cost, measured and then mostly removed. The <heavy_results> edit is rendered on EVERY turn of EVERY run, so its size is a permanent per-turn cost in both allocation and tokens. The first version of this decision's edit grew that section 967 → 1732 bytes (+79%) and CI's benchmark gate caught it: BenchmarkReActPlanner_NextStep moved 3.338 µs → 4.548 µs (+36.27%, ±2% both sides, p=0.002) and 8.570 KiB → 10.195 KiB per step (+18.96%), with allocs/op unchanged at 16 — the decisive clue, because it rules out a new allocation and says an allocation that already existed got bigger. That allocation is the assembled system prompt itself; a control experiment (reverting only the prompt text while keeping every classification site) returned the benchmark to base, confirming the classification costs nothing. It is computed only inside err != nil branches, and the benchmarked path — Planner.Next — does not reach the dispatch layer or the run loop at all.

The response was to state the three required facts tersely rather than to accept the cost: the addition is now +191 bytes instead of +765 (whole prompt +3.0% instead of +12.0%, residual +2.92% B/op). The by-reference ROUTE moved out of this block, because the refusal message names it at the moment a model needs it and <available_tools> carries the tool's own description — a fact that belongs on a just-in-time surface should not be paid for on every turn. That is also what brief 13's token-efficiency finding asks for. A mutation-verified byte budget on the always-on prompt (alwaysOnPromptBudgetBytes) now fails in a package test rather than in a CI perf gate.

On the residual, and on the gate's blind spot. The remaining +2.92% B/op is the honest price of three facts the acceptance criteria require, and it is not chased further: a real planner step is dominated by an LLM round trip of 100 ms to several seconds, so ~1 µs and a couple of hundred bytes are a thousandth of a percent of a step and nothing against payloads measured in kilobytes to megabytes. The perf gate compares RATIOS against a benchmark that deliberately stubs the LLM edge, so it can report a large ratio over an irrelevant denominator; that is a property of the gate, not a defect in the code, and it is why the absolute figures are recorded here rather than only the percentage. The perf baseline (docs/perf/baseline.txt) is deliberately NOT regenerated: the regression was removed at its source, so there is nothing to waive, and that file is consumed only by the local make bench-check — the CI gate pairs base-vs-PR on one runner and never reads it.

Documentation (§18). grep -rn artifact docs/skills/ returns one hit — scaffold-a-harbor-agent/SKILL.md, which names artifact_fetch as an opt-in built-in and describes neither its response shape nor its read semantics — so no skill body documents a surface this decision changes. The two MODEL-facing descriptions do change and change together: the built-in's WithDescription (the <available_tools> surface) and the React prompt's <heavy_results> block, which had been silent about offset since it shipped. Both now state the paging rule, the text-only admissibility rule and the mime discriminator, so a model cannot learn two different things about one tool.

Cross-references. D-347 (the design gate the read side ships under), D-353 (the Protocol byte read this deliberately diverges from, and why), D-354 (the reference parameter whose sentinel gains its first consumer, and the route a refusal points at), D-026 / D-190 (the safety net and the recorded fact that the pre-phase answer was useless for exactly these MIMEs), D-167 (the built-in and the manifest that supplies the ids), D-025 (the concurrent-reuse contract). CLAUDE.md §4.2, §4.3, §5, §11, §13, §16, §17.1, §17.3, §17.6, §18. RFC §6.2, §6.4, §6.5, §6.10. Briefs 05, 07, 13. Plan: docs/plans/phase-212-artifact-read-path-correctness.md.


D-358 — The heavy-content threshold answers ONE question per consumer: the LLM-context arm rises to 128 KiB, every non-LLM arm pins at 32 KiB behind its own named constant

Status: Accepted (v1.24). Amends the single-sourcing rule D-022 / D-026 established for config.DefaultHeavyOutputThresholdBytes — from "no other literal copy of the value" to "no second constant may answer the SAME question; a different question takes its own constant even when the answer coincides."

Context. One constant at 32 KiB answered four materially different questions: how many bytes may enter a model's context window, how many bytes a browser page may receive inline, how many bytes a terminal scrollback may absorb, and at what size a search index stops pretending a 256-rune snippet represents a record. The coupling was invisible because the four answers coincided.

The constant's own godoc enumerated three consumers. A grep found eight direct referencing sites in seven files plus nine production wiring sites reached through the operator field artifacts.heavy_output_threshold_bytes. Six of those wiring sites select between an inline payload and an artifact reference on a Protocol reply, which makes the choice a wire-behaviour question (CLAUDE.md §8) rather than a tuning question. The stale enumeration was the ROOT CAUSE of an earlier draft of this phase undercounting its own blast radius: the draft copied the comment instead of grepping.

Decision.

  1. config.DefaultHeavyOutputThresholdBytes rises to 128 KiB and is scoped, in name and godoc, to the LLM-CONTEXT question. 128 KiB is the TOP of the 16 KiB–128 KiB range the heavy-output research brief named reasonable, so this is an answer to a question that brief left open, not a departure from it. The reason is round-trip cost: an ordinary 40 KiB JSON tool result previously cost a stub-then-fetch cycle — one extra planner turn — to read back what the agent had just produced. The cost is ~32k tokens of a 200k window, paid only by results in the 32–128 KiB band; the tail above 128 KiB is promoted exactly as before, and the operator field remains the override for a small-context deployment.

  2. Four consumers FOLLOW the raise, because each of them is the LLM-context question: the dispatch promote-to-stub boundary (internal/runtime/dispatch), the LLM-edge leak guard and auto-materialization (internal/llm), the trajectory-compaction payload budget (derived, no edit), and the tools.content_stats.heavy_threshold_bytes REPORT — which follows because it echoes the offload threshold and counts offload events against it, so reporting a pinned number there would make the field lie. The wire SHAPE of that report is unchanged; only the reported integer moves.

  3. Every non-LLM consumer PINS at 32 KiB behind its own named constant with its own godoc stating its question. Three distinct questions take three constants — search.HeavyPreviewThreshold (source-record classification), an unexported terminal-fold constant in internal/tui/renderers (scrollback absorption), and the new config.DefaultConsoleInlinePayloadBytes (browser-facing inline payload). The two internal/mcpconsole sites are one question in one package and share the third.

  4. The Console-facing arms stop tracking the operator field. pause.list, memory.get / memory.list, the flow catalog and the three mcp.apps.* reads are wired with config.DefaultConsoleInlinePayloadBytes instead of cfg.Artifacts.HeavyOutputThresholdBytes. The reason is §8, not taste: those payloads never enter a model's context, the consumer is a browser that fetches a reference as cheaply as inline bytes, and each selection decides which arm of a sum-typed Protocol reply is populated. memory.get and memory.list MUST agree by their own documented contract, so they are one decision, not two.

    Consequence, stated rather than buried. On a default configuration the pins are a byte-for-byte no-op. They are a behaviour change only for an operator who set an explicit non-default heavy_output_threshold_bytes: their Console-facing bounds decouple and revert to 32 KiB. The one documented instance of an operator doing this — the studio App that rendered only behind a threshold-raising workaround (D-218) — was made obsolete by the dedicated 2 MiB App-document cap. Reopening condition: if a Console-facing payload other than a ui:// document is reported as unusably by-reference, the answer is an additive optional artifacts.console_inline_payload_bytes field defaulting to the pinned constant — NOT a re-coupling of the two constants.

  5. The leak-guard weakening is real for one of three walked classes, and is stated per class rather than as one "designed pair" claim. For binary DataURL parts the pair is exact: auto-materialization offloads at the same threshold the guard compares against, so raising both is structurally behaviour-preserving. For RoleTool message text the counterpart exists one edge earlier — the dispatch promote-to-stub boundary, reading the same operator field — so the pair holds, but it is dispatch↔LLM-edge, not materialize↔LLM-edge. For Messages[].ToolCalls[].Args no offloader exists anywhere in the tree: the prompt builder copies arguments through unmodified and the materialization pass never walks ToolCalls, so raising the threshold is a straight 4× weakening of the only guard there is.

    That arm still moves, for three reasons. (a) It is a DETECTOR whose judgement — "a producer that should have passed a reference didn't" — is only meaningful relative to what the runtime considers heavy; holding it at 32 KiB while the offload boundary sits at 128 KiB would kill runs over 40 KiB of arguments that no producer in the tree can offload, a run-terminating false positive on content the runtime just declared acceptable everywhere else. (b) Two numbers inside one walk, one of them unsatisfiable by any producer, is the §13 parallel-implementation shape. (c) Arguments are model-authored and arrive back from the provider; refusing the replay recovers nothing the outbound turn did not already spend.

    The residual is a property, not an open question: a tool call may now carry up to 128 KiB of arguments through the replay path with no offloader and one detector. The successor work — an arguments offloader stubbing heavy arguments through the same ArtifactStub path a tool RESULT takes — is deliberately not built here: it needs a decision about how a stubbed argument is re-hydrated for a provider's tool_calls block, which is a translator-layer question across every driver. The findContextLeak godoc no longer claims that offloading already happens.

  6. The amended single-sourcing rule. DefaultHeavyOutputThresholdBytes's godoc stops enumerating its consumers — an enumeration rots the moment another one lands and no gate notices — and instead names the QUESTION it answers. Two constants that answer the same question remain forbidden; two constants that answer different questions are REQUIRED, even while their answers coincide.

Not a risk, recorded because an earlier draft asserted it and it is false. The search bound was defended on a context-budget argument — "ten previews at the offload threshold would exceed most context windows" — which is arithmetically impossible: PreviewMaxRunes (256) caps every preview AFTER the heavy check, so ten previews are ~10 KB at any threshold, and the check performs ref-versus-inline selection (an EMPTY preview plus a ref), never truncation. The bound still pins, on the §8 wire-shape argument and the honest-representation argument in its godoc.

heavy_count becomes non-comparable across the upgrade. tools.content_stats counts historical mcp.resource_offloaded events against the CURRENT threshold, so events recorded under 32 KiB are re-classified when the reported threshold moves. This is inherent to reporting a live threshold over a historical event stream and is not introduced here; it is named so an operator reading a discontinuity has an explanation.

Tests, and what each one is a witness FOR. internal/config pins the two constants APART, so a future "re-unification" fails a test rather than silently re-coupling the matrix. internal/runtime/dispatch asserts a 31/64/127 KiB result reaches the planner raw with ZERO writes to the artifact store — the phase's central behavioural change, asserted at projectForLLM rather than at the config layer — and that 128/256 KiB is still promoted. internal/llm covers all three leak classes just below and at the resolved default, plus the 64 KiB DataURL that still auto-materializes. internal/llm/summarizer proves the budget derivation held by asserting the payload can now GROW past the old ceiling, with no edit to the derivation. internal/search/preview_bound_test.go is NEW — the package shipped no test that reached RedactAndCapPreview at all, so the heavy branch was uncovered while the constant silently aliased. internal/tui/renderers's existing 32768-byte fixture is retargeted onto the named fold constant: as a literal it passed either way, which made it a coincidence rather than a test. test/integration/heavy_threshold_test.go drives the surfaces through the REAL serve.BuildMux — the decoupling arm (an operator threshold of 256 KiB inlines a 200 KiB result while memory.get / memory.list / pause.list still ship references at 64 KiB) is what fails if the wiring keeps threading the operator field.

Mutation-verified, each against the real tree: restoring search.HeavyPreviewThreshold's alias fails the preview-bound test AND turns the smoke's static assertion into a FAIL; restoring the TUI fold's alias fails internal/tui/renderers/registry_test.go; re-threading cfg.Artifacts.HeavyOutputThresholdBytes at the pause-list wiring fails the integration test's decoupling arm; and leaving the LLM-context constant at 32 KiB fails the inlined-band dispatch assertion.

Documentation (§18). The change mutates a documented harbor.yaml field's semantics, so docs/CONFIG.md, the two skills that hardcode the value (add-an-in-process-tool, define-the-agent-yaml), the three hand-written docs/site/concepts/ pages that state "32 KB", and the three shipped example configs are updated in the same PR. The docs/site/skills/* and docs/site/reference/config.md pages are include stubs and correctly need no edit. Three glossary entries are corrected and two new terms land.

Cross-references. D-022 and D-026 (the routing invariant and the LLM-edge guard whose comparison value moves), D-218 (the App-document cap that made the one documented threshold-raising workaround obsolete), D-241 (the conversation-text exemption, untouched), D-194 (the dispatch executor). CLAUDE.md §4.2, §5, §8, §10, §11, §13, §17, §18. RFC §6.5, §6.10, §6.13.


D-360 — A caller may name which agent a run executes as; it selects configuration only, and the credential plane stays boot-derived

Date: 2026-07-29

Status: Accepted (v1.24). Closes the one surface where agent_id was absent — the surface that STARTS a run. Upholds D-309 (no single-valued session→agent binding) by persisting on the TASK only. Composes with D-025 (the per-run agent id is per-run state, never a driver field) and D-223 / D-209 (this phase owns the regenerated wire manifest and Protocol reference pages for the wave).

Context. agent_id was an explicit Protocol argument across the agent_config.* family, the user-layer and skills families, and tools.describe — and absent from StartRequest, tasks.SpawnRequest and tasks.Task. The run-loop driver read its agent from a CONSTRUCTION-TIME option (opts.AgentConfigID) whose only production value is the boot constant. The consequence was concrete, not forward-looking: a caller could write a config revision under a new agent id, read it back, diff it and roll it back — and no run would ever use it. Orphan configs were the normal outcome of using the control plane as documented.

Decision. StartRequest gains an optional agent_id; tasks.SpawnRequest and tasks.Task gain the matching field; the run loop resolves a per-run EFFECTIVE agent id from the task and threads it into every run-start projection. Four sub-rulings are settled here so they are not re-derived.

1. The two-check rule. A non-empty agent_id is accepted iff EITHER it equals the runtime's configured default agent id, OR an admin-scoped (ConfigScopeAgent) config revision exists for (caller tenant, agent id). Anything else is refused with CodeInvalidRequest at the Protocol edge, BEFORE the session ensurer and before tasks.Spawn — never substituted with the default. A caller that named A, silently got B, and was told it succeeded is the defect this field closes, so the refusal can never be a fallback.

Neither the agent registry nor a "base revision" is consulted, and both exclusions are load-bearing rather than shortcuts. Registry membership would refuse the ONE id that works today: the boot-configured agent is never registered as a fleet entity (internal/runtime/serve/mux.go says so verbatim, which is why the agents projector SYNTHESISES its row). And the only tenant-scoped registry read is an admin-gated whole-store scan filtered in Go — the "fetch all then filter" shape §6 rule 2 names — on the hot path of every start. A "revision must exist" rule alone would likewise refuse the default agent, whose normal state is to have no active pointer at all. Check (ii) is byte-identical to the read every run-start projection already performs, so the edge asks exactly the question the run is about to ask, one call earlier: no new query shape, no new store surface, no new index.

2. The refusal is not a cross-tenant existence oracle, structurally. The config key layout puts the CALLER's tenant in the tenant slot and the agent id in the session slot, so a foreign tenant's agent is simply not present under the caller's tenant and answers the identical negative a never-existing id does — there is no branch to get wrong. The refusal text is a single constant that names neither the id nor the reason, so a later edit cannot split the two cases apart by accident. Pinned by test at three layers anyway (unit, integration, smoke): an inert guard is still worth pinning, and a future key-layout change must fail these.

3. THE CREDENTIAL PLANE IS UNTOUCHED — the RFC 8693 acting principal stays BOOT-derived. There are now TWO agent-id carriers on a run, with different provenance, and they must not be conflated:

carriervalueconsumerscaller-influenced?
the run ctx's invoking-agent stampboot-derivedMCP _meta.agent_id, RFC 8693 actor_tokenNo
the run's effective config agent idcaller-named, else bootthe eleven run-start config projectionsYes, after validation

Two independent reasons, either sufficient. First, threading the caller's value through would make a client-supplied string the RFC 8693 actor token, contradicting the config godoc's own invariant by name ("the runtime's VERIFIED acting principal — never a client-supplied field") and handing a caller the ability to assert an acting principal to an external authorization server — a §7 credential-plane change, not a control-surface convenience. Second, the exchanged token is cached at (scope, tenant, user, source) granularity with the acting principal DELIBERATELY not in the key, so a run naming agent B under an already-cached (tenant, user) would present a token minted under agent A's assertion and the exchange would not even re-run. A "fix" that threads the value through therefore produces an actor token that is sometimes the named agent and sometimes a stale different one: silently nondeterministic credential identity, the §13 silent-degradation shape on the most sensitive plane Harbor has.

If a future phase genuinely wants a caller-influenced actor token, the prerequisites are (i) the token cache key gains the acting principal and (ii) an RFC PR on §7. Named here so they are not re-derived.

4. The unwired seam FAILS CLOSED. The AgentResolver is optional in the ControlSurface CONSTRUCTOR — a control-only Runtime still builds — but a non-empty agent_id against a surface with no resolver is REFUSED, never accepted and never silently ignored. This is deliberately NOT the optional-and-skip-if-nil posture of the SessionEnsurer seam beside it: that seam's absence degrades an auxiliary behaviour, whereas accepting an unvalidatable agent id would hand it to a driver that ignores it and report success for a request that was not honoured (§13). An OMITTED agent_id against the same surface is byte-identical to today. A resolver ERROR is likewise loud (CodeRuntimeError), never a fall-through to the default.

Departure from D-309's neighbourhood, recorded rather than quietly taken. An earlier draft carried an acceptance criterion that the named agent "surfaces on SessionRow.agent_id / agent_name". That would reintroduce exactly the single-valued session→agent binding D-309 refused, and the producer pins the refusal in code with a projector-field-set pin test. The named agent is persisted on the TASK ONLY. A task has one agent for its entire lifetime; a session does not. SessionRow.AgentID / AgentName stay nil, the filter.agent_ids loud-reject stays loud, and the pin test stays green. D-309's own named follow-up (a first-class "last agent bound to this session" read) is untouched and still open — this decision makes it CHEAPER to build later, since the task rows now carry the data such a projection would aggregate, without pre-deciding its shape.

The run-start ordering change, called out because it is the likeliest regression site. runOne previously ran reconcileConnections before tasks.Get. The three reconcile legs are owner-scoped by auth.Owner{Tenant, Agent}, so they must run under the RUN's effective agent — which is only knowable after the task is read. The tasks.Get therefore moves ABOVE the reconcile. This is an ordering change in an already-shipped function; it is pinned by a recording-detacher assertion in the run-loop suite and by a line-order guard in scripts/smoke/phase-215.sh.

Idempotency. The caller-named agent is folded into the task's content identity (the output_schema precedent), so a reused idempotency key naming a DIFFERENT agent is a loud conflict rather than a silent adoption of the original task's agent. The fold is gated on a non-empty value, so a spawn that names no agent hashes byte-identically to one made before the field existed.

Read-side semantics of an empty TaskRow.agent_id: DEFAULTED, never "unknown". The field is omitempty on both the record and the wire. Absence means the caller named no agent and the run bound the runtime's configured default — which is also true of every historical row, since a row written before the field existed could not have named one. A consumer therefore renders absence as "the runtime default", not as missing data. The alternative (projecting the resolved boot id onto every row) was rejected: it would fabricate a known value the record does not hold, and would misreport historical rows whenever a runtime's configured default changed.

Not in scope, stated so it is not assumed. No entitlement or publication plane ("which agents may this user run", "which agents are published to an org") is answered — the runtime honours a resolvable named agent or refuses it; a consumer decides what to offer. No agent-registry membership requirement. No new Protocol method, error code or version bump; the field is additive. No bodyscope change (StartRequest is already a registered row). The four-tier prompt composition is unchanged: replacement-versus-layering is already expressible as Base-set versus User-set, so no mode discriminator is added (§4.4), and a user layering onto an admin-authored agent needs no chaining because both tiers key on the same agent id under different config scopes.

A narrower answer to the Console-selector question than it looks. agents.list already ships, so §13's "no Console page without its feeding Protocol surface" is satisfied without new work — but the registry read behind it is triple-scoped and then appends the SYNTHETIC default row, so under a NEW session (the state a selector renders in) it returns exactly one row: the default. A selector built on agents.list today can therefore offer only the default agent, which is also the agent check (i) accepts. Coherent, but it means the selector becomes USEFUL only once a consumer also enumerates configured agents. A tenant-scoped "agents that have a config revision" listing is the natural successor surface; it is named here as a follow-up and deliberately not built. No Console page ships in this phase.

Tests. internal/protocol/control_agent_test.go drives the REAL ControlSurface over a real task registry and the PRODUCTION resolver adapter over a real agent-config registry: the validation table (omitted accepted with an empty spawn; the configured default accepted with NO revision; an agent with a revision accepted; an unknown id refused with zero tasks created; a foreign tenant's id refused with a byte-identical error; a non-empty id against a resolver-less surface refused; an omitted id against the same surface accepted), the carry-through onto the persisted task, the agent-sensitive idempotency key, and the D-025 run at N=128 concurrent starts across two agents × two tenants against ONE shared surface, content-checked per goroutine plus a cancellation-cross-talk arm and a goroutine baseline. internal/runtime/serve/runloop_agent_selection_test.go pins the projections following the effective agent, the unnamed run keeping the boot value, the reconcile legs running under the run's owner (the ordering guard), and the credential-plane separation. test/integration/agent_selection_test.go asserts the whole chain with real drivers — including the credential plane on the ACTUAL WIRE, via a real go-sdk MCP fixture server (_meta.agent_id) and a real RFC 8693 broker recording actor_token — plus the user-layer composition order, both failure modes (foreign-tenant refusal with no task row; a broken resolver store failing loud), identity propagation, and a concurrency stress.

Mutation-verified, each against the real tree, each turning OK into FAIL (never into SKIP): reverting the run-start ordering so the reconcile precedes the task read; threading the effective agent into the invoking-agent stamp (fails both the unit pin AND the two real-wire integration assertions); deleting check (i); copying the skip-if-absent posture onto the unwired resolver; folding a resolver error into "allow"; naming the rejected id in the refusal text (which turns the edge into an existence oracle); and dropping the carry-through onto SpawnRequest.

Documentation (§18). docs/skills/use-the-harbor-protocol/SKILL.md (surface: protocol) and the hand-written docs/site/protocol/task-control.md choreography guide document the field, the two-check rule and the configuration-only scope in the same PR. The generated docs/site/protocol/types.md and web/console/src/lib/protocol/wire-manifest.gen.json are regenerated here — this phase owns both for the wave, so the sibling phases rebase rather than regenerate in parallel.

Cross-references. D-309 (the session→agent binding this upholds by persisting on the task only), D-025 (the concurrent-reuse contract the per-run local satisfies), D-059 (agent_id as a registration identity, explicitly not an isolation principal), D-089 / D-196 (the driver-seam shape the resolver adapter follows), D-223 (the Console wire-manifest lockstep), D-209 (the generated Protocol reference), D-276 (the output_schema precedent for a whole-record-marshalled per-task field folded into content identity), D-271 (the token-exchange provider family whose actor token this deliberately does not touch). CLAUDE.md §4.1, §4.2, §5, §6, §7, §10, §11, §13, §14, §16, §17.1, §17.3, §17.6, §17.8, §18. RFC §5.2, §5.5, §6.2, §6.16. Briefs 02, 05. Plan: docs/plans/phase-215-caller-named-agent-selection.md.


D-362 — A meta_annotations key is an annotation PATH: the operator merge nests through the SAME helper the credential-injection write uses, the reserved guard becomes whole-key AND per-segment, the depth cap is hoisted to one constant, and a colliding path fails loud instead of silently

Date: 2026-07-29

Status: Accepted (v1.24). Extends D-278 (the meta_annotations surface) and D-341 / D-346 (the receiver-style credential-injection engine and its wire plumbing, whose meta_key path semantics this aligns the annotation merge with).

Context. Two mechanisms write into the SAME outbound MCP _meta map on the same call, and they disagreed about what a dotted key means. injection.meta_key is a dot-separated PATH: injectMeta walks it creating intermediate maps, so vendor.api_key lands at _meta.vendor.api_key. meta_annotations merged FLAT: buildIdentityMeta did meta[k] = v verbatim, so vendor.account_id landed as the single literal key _meta["vendor.account_id"].

The operational consequence is not cosmetic. A receiver-style MCP server that reads one nested namespace could be handed a per-user credential and had NO route to its non-secret companion value: the companion cannot ride injection (one mapping per connection, already spent, and a non-credential leaf like account_id is correctly refused by the redaction-coverage predicate IsReceiverInjectionCredentialKey), and it cannot ride headers (a documented-secret field that is never persisted). The server sees only the merged result, so it cannot compensate host-side.

Decision. Five parts.

1. The annotation merge nests, through the SAME helper. buildIdentityMeta splits each annotation key on . and writes it via injectMeta — the helper the injection path already uses. Not a second nesting implementation: two mechanisms sharing one helper is the point, and a fork is exactly how the two would drift apart again.

The fork would announce itself SILENTLY, which is why the intermediate-node type is an acceptance criterion rather than a nit. injectMeta type-asserts cur[seg].(map[string]any), and mcpsdk.Meta is a NAMED type over map[string]any — a Go type assertion to map[string]any FAILS on a dynamic type of mcpsdk.Meta. A call site building mcpsdk.Meta intermediates would make the assertion miss, take the create branch, and REPLACE a populated node, wiping every sibling annotation in that namespace with no error. A test asserts the concrete dynamic type of every intermediate node, the same way injectMeta asserts it.

2. The reserved guard becomes whole-key AND per-segment — never per-segment alone. This is the highest-risk line in the change and the one an earlier draft got backwards, in a way that reads as a tightening and is a LOOSENING.

config.IsReservedMCPMetaKey has two arms: an exact-match set (tenant / user / session / agent_id / traceparent / tracestate) and strings.HasPrefix(k, "io.modelcontextprotocol/"). Splitting io.modelcontextprotocol/ui on . yields ["io", "modelcontextprotocol/ui"] — NEITHER segment carries the prefix. A per-segment-ONLY check therefore ADMITS a spec-reserved annotation that is refused today, and would break three shipped tests that pin the current refusal (internal/config/validate_mcp_oauth_test.go, internal/runtime/agentcfg/protocol/setrevision_connections_test.go, internal/tools/drivers/mcp/oauth_test.go) plus a fourth asserting the key never reaches the merged _meta.

The rule is therefore a strict SUPERSET on both arms, expressed once in config.ReservedMCPMetaPathToken: a key is refused when the whole key is reserved OR any dot-segment is. io.modelcontextprotocol/ui stays refused (whole-key arm); tenant.foo becomes newly refused (per-segment arm). All four shipped tests pass UNCHANGED, which is the mechanical trip-wire on getting this backwards, and the smoke's whole-key assertion is the mutation gate.

3. All FOUR doors, not three. internal/config/validate.go (boot); internal/runtime/agentcfg/protocol/addconnection.go's validateConnectionAnnotations, which serves BOTH add_mcp_connection and agent_config.set_revision; internal/tools/drivers/mcp/attach.go's resolveOAuthBinding — the shared boot + runtime-set attach door, which runs its own annotation check and which an earlier draft missed entirely, making its "one guard" framing false; and the merge-time re-check in buildIdentityMeta. The first three error loud; the fourth SKIPS a reserved key, as it always has, because it is a last-resort defence behind three loud doors. The collision case is different and DOES error there (part 4).

4. A path collision fails LOUD, and determinism follows from that rather than from a sort. For one connection the declared _meta path set is every annotation key split on ., plus injection.meta_key when form: meta. No two declared paths may be equal, and none may be a proper PREFIX of another. Both inputs are declared config on the same object, so the check is fully decidable at validation time and is applied at all four doors.

This converts a silent degradation into a refusal. injectMeta overwrote a non-map intermediate with no error and no log, and annotations merge BEFORE injection — so a flat vendor annotation plus injection.meta_key: vendor.api_key silently discarded the operator's annotation. That is the §13 shape, on the exact path this change widens; after widening, annotations can also collide with each other, multiplying the reachable cases.

It also settles determinism BY CONSTRUCTION. The merge iterates a map, and Go randomises map iteration, so {"a":"1","a.b":"2"} would produce different wire bytes per RPC depending on visit order. With prefix collisions refused, distinct non-prefixing paths write disjoint leaves and the merged result is order-independent. The test asserts that property (N=1000 sequential + N=128 concurrent, byte-identical marshalled _meta) rather than assuming it.

The merge must still error because a revision PERSISTED before this rule can carry a colliding pair — nothing rejected one then. buildIdentityMeta already returned (mcpsdk.Meta, error), so it returns a new typed mcp.ErrMetaPathCollision and the call fails. No silent winner, no order-dependent result. injectMeta itself also refuses a non-map intermediate rather than overwriting it — a last-resort defence behind the four doors, and the smallest possible closure of the §13 shape the analysis named.

5. The depth cap is HOISTED, not duplicated. maxInjectionMetaKeyDepth = 16 lived in internal/runtime/agentcfg/protocol, which IMPORTS internal/config; putting annotation-depth enforcement in internal/config while referencing that constant is an import cycle. It moves to config.MaxMCPMetaKeyDepth (exported, same value), and the wire door becomes a consumer. Duplicating it would have been the §13 two-implementations shape on a security-adjacent bound.

The hoist also closes a second asymmetry it exposed: the cap existed ONLY at the wire door, so a boot-declared 20-segment meta_key was accepted where the identical wire-declared one was refused. Boot validation and the driver's own Config.validate() now apply it too (§17.6 — fix what the test finds, wherever it lives). Blast radius verified zero: the only meta_key in examples/ is commented and two segments.

The constant's stated failure mode is CORRECTED in the same change. It did not exist because a too-deep key would be emitted unredacted — the redactor returns ErrRedactionDepthExceeded past audit.MaxDepth, so it FAILS LOUD. It exists so a declared path can never push an audit payload past that ceiling and turn every audit emit for the connection into a hard redaction failure. Same cap, same value, correct reason.

What this deliberately does NOT do. It does NOT widen receiverInjectionCredentialSegments. That set is a security control: the guarantee that any key an operator may declare for an injected value is a key the audit redactor holds to *** (validation and the redactor consult the same predicate). The companion value is not a secret and does not belong in the channel built for secrets — it rides the annotation path, which is exactly why the annotation path had to learn to nest. It also does not add a second injection mapping per connection (that reopens "one auth mode per connection"), does not persist attach-time Headers, does not re-litigate the cap's value (D-346), and changes no internal/audit/ rule.

The §10 backward-compatibility survey, reproduced in full because an earlier draft's claim was FALSE. The draft asserted "no in-tree caller populates it" and made that survey an acceptance criterion; the criterion was already failed at authoring time. Full survey of MetaAnnotations (45 hits); the POPULATING call sites are:

LocationValueDotted?
internal/runtime/agentcfg/protocol/setrevision_connections_test.go{"vendor.tag": "blue"}YES
test/integration/wave_v110_test.go{"deployment": "wave-v110"}no
test/integration/phase148_mcp_southbound_oauth_test.go (x2){"deployment": "prod"}no
internal/config/validate_mcp_oauth_test.go{"deployment": "prod", "team": "search"}no
internal/tools/drivers/mcp/oauth_test.go{"env": ..., "team": ...}no
reserved/empty negatives across the three test filesn/an/a

Non-Go surfaces carry the field but populate nothing (internal/protocol/types/agentconfig.go, internal/agentcfg/agentcfg.go, internal/runtime/agentcfg/protocol/addconnection.go, internal/tools/drivers/mcp/mcp.go, internal/config/config.go, internal/runtime/serve/mcp_attacher.go, the Console typed client, the generated wire manifest, examples/dev.yaml — commented and flat).

The honest finding is three-part:

  1. A dotted annotation key is a deliberately-supported shape on the shipped wire surface, not an accident. setrevision_connections_test.go puts {"vendor.tag": "blue"} inside the canonical happy-path set_revision round-trip and asserts it round-trips. The surface treats a dotted key as WELL-FORMED, so any persisted revision may already carry one.
  2. Semantic blast radius on operator CONFIG is nonetheless zero. Every other populating caller uses flat keys, and the only shipped example is flat. No in-tree config, example, or integration fixture changes meaning.
  3. That round-trip test does NOT break, and that is the subtle part. Nesting is a MERGE-time semantic, not a STORAGE-time one: the revision persists map[string]string with the literal key "vendor.tag" unchanged, which is exactly what the test asserts, and vendor/tag are non-reserved so the tightened guard admits it. It passes untouched — but it is the PROOF that persisted dotted keys exist and will silently change WIRE meaning on the next call after upgrade.

Migration path (required by part 3). The change is a MINOR-version behaviour change to the _meta wire shape for connections that declared a dotted annotation. No config rewrite is required, because the new shape is the shape the operator was already asking for by writing a dotted key against a _meta map whose sibling mechanism has always treated dots as paths. It is announced in CHANGELOG.md, docs/CONFIG.md, docs/glossary.md and examples/dev.yaml — all four of which said "merged verbatim" and became false. The newly-refused declarations (a reserved segment; a path collision; an over-deep or empty-segment path) surface at BOOT or at the wire/attach call, never silently, with the offending key and the rule in the message. No migration for the depth cap: verified zero configs exceed two segments.

Nesting causes OVER-redaction, not a leak — stated, pinned, and deliberately not "fixed". walkRedactKeys replaces a matched key's WHOLE value and does not recurse into it; the injection rule's predicate matches on the LAST -/_/.-separated segment. So an annotation key token.env was NOT redacted flat (last segment env), and nested the node key is token, which matches, collapsing the ENTIRE subtree to *** — siblings included. Redaction COVERAGE is preserved (nothing that was redacted stops being redacted; a credential leaf under a matching node is still *** because its whole parent is); the defect is over-redaction, which degrades audit usefulness, not audit safety.

Reachability, verified rather than asserted: no production call site redacts the outbound MCP _meta map today, so the over-redaction is LATENT — it becomes observable the moment any surface audits the outbound _meta, which D-341 and D-346 both presuppose when they justify the redaction-coverage predicate. It is therefore pinned by a DIRECT redactor unit test over a nested _meta-shaped payload, with no claim of an end-to-end audit capture that does not exist. Fixing it means changing walkRedactKeys' replace-on-match semantics for EVERY rule, which needs its own entry.

Consequence. internal/config gains MaxMCPMetaKeyDepth, SplitMCPMetaPath, ReservedMCPMetaPathToken, ValidateMCPMetaAnnotationKey, ValidateMCPMetaPathCollisions, applies them in validateTools, and applies the hoisted cap to injection.meta_key. internal/runtime/agentcfg/protocol/wireinjectiondescriptor.go drops its local constant and consumes the hoisted one, with a corrected failure-mode message. addconnection.go's annotation validator takes the injection meta_key so the collision check sees the whole declared set. internal/tools/drivers/mcp/attach.go's resolveOAuthBinding gains the same three rules. internal/tools/drivers/mcp/mcp.go nests the annotation merge through injectMeta, gains ErrMetaPathCollision, makes injectMeta refuse a non-map intermediate, and applies the cap in Config.validate().

No wire type, no Protocol method, no Console file changes — MetaAnnotations stays map[string]string, ProtocolVersion is unchanged, and there is no D-223 lockstep regeneration and no D-209 docs regeneration. The only new exported identifiers are config.MaxMCPMetaKeyDepth and mcp.ErrMetaPathCollision.

Tests. Whole-key AND per-segment refusal at every position, with the four shipped spec-prefix tests passing UNCHANGED as the regression gate; flat keys unchanged; dotted keys nesting to depth; the annotation shape byte-identical to the injection shape for the same path; every intermediate node asserted map[string]any and NOT mcpsdk.Meta; identity stamps winning over a flat AND a nested shadow attempt; collisions refused at all four doors naming both keys, and ErrMetaPathCollision on a legacy pair; determinism over N=1000; a concurrent-reuse run of N=128 distinct identities against one shared provider under -race asserting determinism, distinct object graphs and no identity bleed; depth-cap parity across boot / driver / wire; the over-redaction characterisation in internal/audit/rules_test.go (test-only). test/integration/mcp_meta_nesting_test.go drives the seam with real drivers against a go-sdk-derived MCP fixture server (§17.8), asserting the server observes the exact nested JSON with both the annotation and the credential leaf, identity propagation across two tenants and 16 concurrent identities, and three failure modes.

Mutation-verified. Reverting the whole-key arm (leaving per-segment only) turns the smoke's spec-reserved assertion from OK into FAIL; reverting the collision rule, the per-segment arm, the depth cap, the nesting, and the map[string]any intermediate contract each turn a green test red rather than into a skip.

Cross-references. D-278 (the annotation surface), D-341 (the injection engine and the redaction-coverage predicate), D-346 (the wire plumbing and the depth cap's original home), D-300 (the credential-plane invariant this does not touch), D-025 (concurrent reuse). CLAUDE.md §4.2, §5, §7, §10, §11, §13, §17.3, §17.6, §17.8, §18. RFC §3.4, §4.2, §6.4.


D-359 — Egress substitution: the MCP arm of pass-by-reference routing resolves the artifact ITSELF and places the bytes in the outbound body, on a wire-configurable eligibility flag paid for by a fail-closed substitution record

Date: 2026-07-29

Status: Accepted (v1.24). Ships the MCP half of the arm D-347 part 5 deferred, and ANSWERS D-347 part 6 consequence 3 for this shape only. Extends D-354 (the in-process arm whose seated resolver, substitution invariant and scan shape this reuses), D-352 (the reconciled read key resolution uses), D-357 (the classified reference-resolution observation this failure path inherits). Operates inside — and does not move — the trust boundary D-301 accepted. Deliberately does NOT take the D-338 / D-340 / D-346 fail-closed boot-gate shape, and part 4 below is why.

Context. D-347 part 5 deferred "an HTTP / MCP / A2A tool that must be handed something it can DEREFERENCE" on three named blockers: the address, the grant's semantics, and the credential obligation. This phase delivers a different shape, and the difference is what makes the blockers not apply: the runtime resolves the reference itself and places the BYTES into the outbound MCP tool call. No address is published, no grant is minted, no reusable handle exists.

  • Address — nothing dials in. The runtime dials out, as it already does for every MCP call, on a connection the operator configured. internal/config still has no public_url / external_url, and this phase adds none.
  • Grant semantics — there is no grant. One tool call carries one bounded byte slice in its body; there is no reusable artefact whose retry, redirect, HEAD-then-GET or partial-transfer behaviour needs defining.
  • Credential obligation — no bearer capability is minted. The obligation becomes "the outbound body must not be persisted unredacted", which §7 rule 7 and §13 already impose and which this phase discharges by NEVER rewriting the raw argument JSON.

D-347's deferral stands unchanged for the URL-minting design it actually describes. Answering part 6 consequence 3 for THIS shape does not answer it for that one.


Decision. Six parts.

1. The wire encoding is NORMATIVE, and it was measured rather than preferred.

The substituted value is a Go []byte, carried by artifactegress.Payload, written into the DECODED map[string]any at the mapped key and emitted on the wire as RFC 4648 §4 standard base64 with padding. It is never a Go string, and never an MCP typed content block.

Three grepped facts settle it, not taste:

  • There is no argument-side content block; the alternative does not exist. CallToolParams.Arguments is any (go-sdk v1.6.1 mcp/protocol.go:48) — an arbitrary JSON value validated against the server's own inputSchema. The Content union (text / image / audio / resource) appears on exactly three types, all of them results or sampling messages: CallToolResult (protocol.go:82), SamplingMessageV2 (:492) and CreateMessageWithToolsResult (:579). MCP has no typed blob block for tool ARGUMENTS.
  • A Go string in the decoded map corrupts binary — measured. encoding/json rewrites every invalid-UTF-8 byte in a Go string to U+FFFD. Against a ten-byte fixture 25 50 44 46 FF FE 00 80 C3 28: string(raw) round-trips as 18 bytes with three ef bf bd triples (NOT equal), while []byte(raw) round-trips as "JVBERv/+AIDDKA==" → 10 bytes, exact. A first draft's map[string][]string mapping onto Go strings would have made its own byte-exactness criterion unsatisfiable under its own spec — the same defect D-357 exists to fix, reintroduced one layer out.
  • The carrier's MarshalJSON is reachable on the real wire path, so the value keeps a carrier all the way to the socket instead of decaying into a naked []byte.

§17.8 pinning, and why SDK types CANNOT pin it. An SDK-derived fixture is SELF-CONSISTENT AT EITHER PLACEMENT, which is the D-216 failure shape exactly. jsonschema-go's forType has no []byte special case (jsonschema/infer.go:219-240reflect.Slice falls through to {"type":["null","array"], "items": {integer}}), while encoding/json marshals the same field as a base64 STRING. A fixture server declaring Data []byte therefore ADVERTISES an array-typed parameter and ACCEPTS a base64 string, and its own tests pass either way. The gate is therefore three-part: a committed byte-level transcript of the arguments object a real mcpsdk server received; an env-gated HARBOR_LIVE_MCP probe against a real stdio MCP server binary; and a schema-derived attach check (part 3).

2. The substitution mutates ONLY the decoded argument map. There are SEVEN sinks, not five.

The raw args json.RawMessage is never rewritten. The in-process arm established the rule for five sinks; the MCP arm adds two the in-process arm never touched:

#Sink
1raw observation (trajectory.Step.Observation)
2LLM observation (trajectory.Step.LLMObservation)
3serialised trajectory
4canonical event payloads AND envelopes
5audit payloads and log records
6the per-invocation content hash — ToolCallID(runID, source, name, args)
7the DURABLE MCP-App tool context — captureToolContextInput: args → a state.StateRecord, offloading to the ArtifactStore above the inline bound, replayed into a browser by mcp.apps.tool_context

Sink 7 is the worst of the seven and a first draft missed it entirely: it is durable, Protocol-readable, session-scoped rather than run-scoped, and it can mint a SECOND artifact containing the substituted bytes. Mutation-verified: rewriting the raw args makes the integration suite report both the tool-context record AND a newly-minted mcp-apps_* artifact carrying the planted marker.

3. The mapping is OPERATOR-declared and VALIDATED against the server's own inputSchema.

A server-declared "this parameter is an artifact reference" annotation was considered and REJECTED, recorded here so a future author does not re-derive it: a remote server must not drive host-side privileged behaviour, and deciding when the runtime reads its own store is exactly that.

Conversely, Harbor declaring "this parameter takes artifact bytes" against a server whose inputSchema never declared such a parameter is the same shape as advertising an unserviced capability — a soft protocol violation read onto ARGUMENTS rather than capabilities. So each mapped parameter must be DECLARED by the server and declared STRING-typed; absent or non-string fails the ATTACH loudly, not the next call silently.

The bound is stated so it is not rediscovered as a defect: this is a POINT-IN-TIME contract checked at attach. A server that mutates its schema without a tools/list_changed notification (Harbor wires no ToolListChangedHandler today) can drift out from under a validated mapping; the drift then surfaces as a server-side argument-validation error on the wire — a loud failure, never a silent wrong-shape send. Closing it properly is the tool-list-changed work's job.

4. Byte-eligibility is WIRE-CONFIGURABLE. The boot-gate pattern is deliberately NOT taken, and the trust boundary is stated rather than claimed away.

Its sibling wire-writable relaxations (tools.allow_wire_oauth_descriptor, tools.allow_wire_injection) each sit behind a fail-closed, boot-only, default-off opt-in. This one does not, because those gates exist for D-300's invariant — "NO ADMIN-WRITABLE FIELD MAY DETERMINE WHERE A CREDENTIAL IS SENT" — and this field determines where a USER'S OWN CONTENT is sent. Different plane. A boot gate would also break the use case the feature exists for: an MCP server attached over the Protocol must be usable without a redeploy.

What is claimed, scoped:

  • The reachable artifact SET is unchanged — the dispatching run's own (tenant, user, session), enforced by the SAME seated resolver the in-process arm uses, which answers not-found for anything else. Only the RECIPIENT widens.

What is NOT claimed, stated plainly:

  • Every non-safe-subset agent_config.* route gates on an admin scope, so a tenant admin can attach a server they control, map a parameter, declare the connection byte-eligible, and receive a user's artifact bytes on the next run that names an id. A first draft claimed this "grants no reach the connection did not already have"; that measured reach at the RUN, and it is false measured at the ADMIN who writes the field.
  • D-301 already accepted this boundary: "a shared runtime therefore TRUSTS its co-tenant admins," with the stated remedy that a deployment needing hard isolation runs ONE RUNTIME PER TENANT. This entry does not move that boundary and does not invent a new one.
  • A byte-eligible connection can move a secret. D-347 part 6 settles that artifact bytes are stored AS AUTHORED — unredacted — so an artifact may itself contain a credential. Stated rather than softened to "the field carries no secret", which is true of the FIELD and irrelevant to the FLOW.

5. The compensating control is mandatory, and it is what makes part 4 acceptable.

Every substitution emits a canonical mcp.artifact_egressed event carrying the identity quadruple, the server id, the tool name, and one content-free record per substituted parameter (artifact id, parameter name, byte count, sha256: digest) — never the bytes. It rides the driver's existing bus, so it flows through the audit redactor by the same path every other tool event does, and it is stamped on MCPToolValue as an EXPORTED field so it also reaches the trajectory and the LLM observation. (Contrast AppRef json:"-", deliberately EXCLUDED from the observation. This one is deliberately INCLUDED: the model authored the id, and telling it "the id you named was delivered, N bytes" is honest, content-free and replayable.)

It is FAIL-CLOSED, not best-effort — unlike the publishAppAvailable emit beside it. A publish failure, a missing bus or a missing identity ABORTS the call before any wire request is issued: the emit-then-act ordering D-300 item 4 established. A substitution that could not be recorded does not happen. The reason is the whole justification: the single real difference between egress substitution and an admin instructing a model to paste content into a tool argument is that pasting leaves a trajectory trail, and MiB-scale dispatch-local substitution would leave none.

6. The §13 parallel-implementation clause is discharged, not stepped around.

D-347's grant-semantics blocker carries a sentence a first draft skipped: "shipping one feature whose two mechanisms carry different security properties is the §13 parallel-implementation shape." The answer is that the two arms carry the SAME properties on every axis the clause is about:

PropertyIn-process arm (D-354)MCP arm (this entry)
Reachable artifact setthe dispatching run's triplethe SAME seated resolver, the same triple
Value lifetimeone invocation attemptone request body
Dereferenceable later?no — a Go valueno — inline bytes, no address, no handle
Reaches trajectory / observation / events / audit / logs?nono
Failure postureloud, typedloud, typed

That is ONE mechanism with two transports — and it is exactly why D-347 named the URL-MINTING shape as the risk: a grant's lifetime, dereferenceability and credential properties genuinely differ from an in-process bind. The one axis that differs here is the ceiling (a MiB-scale network budget), and it differs because the bounded resource differs. A memory budget is not a security property.

Two further obligations are discharged rather than assumed:

  • No second definition of reach. The MCP-App callback path (internal/mcpconsole/apps.godesc.Invoke(ctx, args)) invokes the SAME catalog descriptor from a browser-driven mcp.apps.call_tool request. There is no run, so dispatch.ExecuteDecision never ran and no resolver is seated; a mapped tool invoked there hits artifactref.ErrNoResolver and fails LOUD. Both alternatives are rejected on the record: seating a second resolver would have to close over the browser request's triple rather than a run's quadruple, producing a SECOND definition of what this feature can reach for ONE feature; degrading — sending the raw id string through — would hand the server "art-abc123" where it expects a document, which either fails in the server's own vocabulary or succeeds on garbage. The consequence is stated as a known partial in the glossary and the operator skills: an MCP App's tool callback cannot use a byte-mapped parameter.
  • No second substitution primitive. artifactref.Substitute walks a Go TYPE tree and structurally has nothing to bind to here — a remote tool's inputSchema is authored by the server and its arguments arrive as an untyped map. Two functions for two structurally different inputs is not two implementations of one thing.

The fence — what it bounds, honestly. The shipped ScanSubstitutionSites bounds where a function is CALLED, not where its output TRAVELS, and it resolves the package by IMPORT PATH, returning early for a file that does not import it — so a file INSIDE the scanned package is invisible to its own scan. Extending its allow-list would also have covered nothing, since the MCP arm cannot call Substitute. The invariant is therefore held by the same three mechanisms re-derived for a value that is not a Ref:

  1. A production bound. The encoder lives in its OWN package internal/tools/artifactegress, and artifactref gains ScanEgressSites keyed on that package's import-path STRING (no import, so no cycle and no same-package blind spot). The residual blind spot — a second call from inside artifactegress itself — is bounded by a test asserting the package's non-test file set, so "the package is short enough to read" is CHECKED rather than asserted.
  2. A projection bound on the carrier. Payload keeps the bytes unexported: MarshalJSON emits the base64 (the one door that must carry content), while String and LogValue emit artifact <id> (<n> bytes). A Payload reaching fmt or slog emits a reference BY CONSTRUCTION.
  3. An arrival check. The integration suite walks all seven sinks with per-arm vacuity guards.

Resolve-once, and the retry amplification it avoids. callTool runs inside tools.RunWithPolicy, whose shell runs MaxRetries + 1 attempts (four at the package default). Resolution and the record happen in the Invoke closure BEFORE the shell, so the transient footprint is ceiling x in-flight rather than ceiling x attempts x in-flight — at 8 MiB x 4 x 128 the difference is 4 GiB versus 1 GiB. It is also correct on the merits: an unresolvable id is a model mistake, not a transient fault, so retrying it burns the budget without changing the answer.

The ceiling is operator config and NOT derived from the heavy-output threshold. tools.mcp_artifact_egress_max_bytes (default 8 MiB) is validated in Validate; a negative value is refused rather than read as "unbounded". Substituted bytes never enter a model's context, so the budget is a NETWORK and MEMORY budget, not a token budget — the independent-ceiling precedent the artifact fetch bounds already set. An oversize value FAILS LOUD naming the artifact, its size and the ceiling; it is deliberately NOT truncated, because a partial document delivered to a remote ingester is a corruption rather than a bounded read, so D-347 part 4's truthful-truncation posture does not apply here.

Findings I'm departing from. D-347 part 5's deferral, for this shape only (parts 1 and 6 above); D-347 part 6 consequence 3's declining to answer the third-party question, answered here for this shape only (part 4); and the D-338 / D-340 / D-346 boot-gate pattern, not taken (part 4).

Protocol additions. One canonical event (mcp.artifact_egressed) and two additive optional fields on AgentConfigMCPConnectionDescriptor (artifact_byte_eligible, artifact_params). No method, no error code. ProtocolVersion does not move. make protocol-ts-gen and make protocol-docs-gen regenerated; the Console typed client mirrors the two fields by hand.

Consequence. internal/tools/artifactegress is new (Payload, Mapping, CompileMapping, Encode, Record, and the typed refusals). internal/tools/artifactref/scan.go gains ScanEgressSites + EgressPkgPath, with one shared walker serving both scans. internal/tools/drivers/mcp gains egress.go (the one call site, the fail-closed record), the event type + payload, the MCPToolValue.ArtifactEgress field, the attach-time eligibility/transport rules and the Discover-time schema check. internal/config gains MCPArtifactParams, the two MCPServerConfig fields, ValidateMCPArtifactParams (the ONE shape authority every door calls), the ceiling and its Validate arm. internal/agentcfg, internal/protocol/types, both agent-config persistence doors and internal/runtime/serve carry the declaration end to end.

A latent wiring gap was found and fixed in the same change (§17.6): agentcfg.normalizeConnections — the revision canonicaliser — hand-lists the descriptor fields it copies, so the new declaration was silently DROPPED between the door that accepted it and the spine that stored it. It read back non-eligible. Caught by the round-trip test, not by inspection.

Mutation-verified, each against the real tree: adding a SECOND Encode call site in internal/runtime/dispatch fails ScanEgressSites naming the file and line; removing the ONE real call site fails it as a stale registration (so the live allow-list is non-vacuous in both directions); rewriting the raw args in callTool fails the integration suite's sink-6 hash arm, and rewriting only the tool-context input fails sink 7 on BOTH the durable record and a newly-minted offload artifact; renaming Payload.LogValue turns the smoke's projection guard from OK into FAIL (never a SKIP); and corrupting the committed transcript fails both the smoke's byte guard and the golden test.

Cross-references. D-347 (the design gate whose part-5 deferral this departs from and whose part-6 consequence 3 it answers for one shape), D-354 (the in-process arm), D-352 (the reconciled read key), D-357 (the classified resolution observation), D-301 (the accepted co-tenant-admin trust boundary), D-300 (the credential-plane invariant this does NOT relax, and the emit-then-act ordering it establishes), D-338 / D-340 / D-346 (the boot-gate shape deliberately not taken), D-216 (the placement bug whose failure shape the §17.8 pin defends against), D-025 (concurrent reuse), D-209 / D-223 (the regenerated docs and manifest). CLAUDE.md §4.4, §5, §6, §7, §9, §11, §13, §14, §16, §17.1, §17.3, §17.8, §18. RFC §6.4, §6.5, §6.10, §7. Briefs 03, 05, 14. Plan: docs/plans/phase-214-mcp-pass-by-reference-egress.md.


D-361 — Run-start connection re-establishment: the attach leg D-287 deferred now ships as the symmetric twin of the detach, re-applying every gate against CURRENT boot policy, with the credential plane untouched by construction

Date: 2026-07-29

Status: Shipped (Phase 216, v1.24)

Where it lives: docs/plans/phase-216-run-start-connection-attach.md, internal/runtime/agentcfg/projection/projection.go (the ConnectionReattacher seam + the attach pass), internal/runtime/serve/mcp_reattacher.go (the production concrete), internal/runtime/serve/runloop.go (the wiring + the failure posture), internal/agentcfg/events.go (two additive canonical events + the closed failure-class set).

Context. projection.ReconcileConnections only DETACHED, and nothing on the run path attached — grep -c Attach internal/runtime/serve/runloop.go returned 0. So a connection an agent's active revision DECLARED but the live registry did not carry never came back: not after a restart, not after a rollback that re-declared one. The admin-gated agent_config.add_mcp_connection verb was the only attach path. D-287's as-built note records this as deliberately deferred ("attaching a declared-but-absent server — the restart-survival 92o attach leg — stays deferred; the live add verb is the attach path") and names its absence as the cause of both of D-287's accepted windows.

Decision.

  1. The leg ships as a second PASS in the SAME function, not a second function. ReconcileConnections becomes bidirectional — the shape ReconcileOAuthProviders already had — with DETACH first, then ATTACH, behind a new driver-agnostic projection.ConnectionReattacher seam. A nil reattacher yields today's detach-only behaviour byte-for-byte. The attach pass re-reads the owner view AFTER the detach pass, so a name replaced within one revision transition is torn down before the attach considers it.

  2. One attach implementation, never two. The production concrete hangs off *serve.MCPConnectionAttacher — the same type the admin add verb drives — so it shares that type's whole-attach lock and closer chain and calls the same mcpdrv.Attach lifecycle. Every gate the add door applies is therefore re-applied without being re-implemented.

  3. Gates are re-applied against CURRENT boot policy, not the policy in force when the revision was written. The fail-closed stdio command allowlist (ErrReattachStdioNotAllowed) and the fail-closed credential-injection opt-in (ErrReattachInjectionDisabled, the twin of the provider side's ErrWireDescriptorDisabled) are both threaded at construction and both default fail-closed, so a caller that forgets to thread them refuses rather than widens.

  4. The credential plane is untouched STRUCTURALLY, not by policy — verified by grep on the branch, not inherited from a comment. The MCP attach path has no token step: resolveOAuthBinding (attach.go:429) → resolveProviderBinding (:568) is a pure function of (ms, mode, resolver) that calls only providers.Get(name) and prov.AllowedDownstreamHosts(). Provider.Token is reached from exactly two call sites, resolveBearerCtx (mcp.go:1511) and resolveInjection (:1547), both of which are per-CALL (callTool, ReadResource, GetPrompt) and neither of which is reachable from Connect (mcp.go:572-623) or Discover (:643). So the leg initiates no consent flow and mints, holds, refreshes and exchanges nothing. This is asserted NEGATIVELY: a re-attach of a connection bound to a provider with no usable credential SUCCEEDS, with Token / InitiateFlow / CompleteFlow call counts all ZERO across the attach, and the shortfall surfaces on the FIRST TOOL CALL through the shipped typed *auth.ErrAuthRequired path.

    The two provider families make no difference here, and the premise that said they would is corrected. Harbor ships BOTH: oauth2 DOES run the interactive flow and DOES persist a sealed bearer through the StateStore (so its durability is the §9 state driver's — durable on sqlite/postgres, lost on inmem), while tokenexchange persists nothing and re-pulls at token-miss time. A claim that Harbor "never runs the OAuth flow, never holds or refreshes a token" describes a tokenexchange-only DEPLOYMENT, not Harbor. The divergence lands one layer AFTER the attach, so it does not reach this leg; both families are pinned by test at the attach anyway, so a later reader cannot conclude one of them needs an interactive leg here.

  5. The interactive consent gate is a CONFIG-TIME operator action this leg neither reimplements nor triggers, and it never parks a run. A run-start reconcile has no admin request to resume and no consenting principal, and the resume-completes-attach continuation is unshipped (addconnection.go:132-136, issue #375; phases 92m / 92n both Pending). An auth shortfall at attach is therefore a terminal, backoff-eligible failure — building a pause here would be a SECOND pause shape, not a use of the existing one.

  6. What cannot be re-established is REPORTED, never silently absent. Two additive canonical events — mcp.connection.reattached and mcp.connection.reattach_failed — reuse MCPConnectionLifecyclePayload verbatim rather than adding a payload type differing by one doc comment. A NEW event type is required rather than reusing mcp.connection.added, whose godoc binds it to "an admin add". Consumers also discriminate structurally: an admin add's Author.RunID is EMPTY, a reconcile's carries the reconciling run's. The failure event's State carries a stable class from a CLOSED set of six — transport_failed (the only retryable one; it is also where a header-authenticated connection lands), stdio_not_allowed, injection_disabled, oauth_binding, owner_conflict, ambiguous_server_id.

  7. Failure is loud, non-fatal and BOUNDED. A refused or unreachable third party never fails the run and never aborts the sweep. Each attach runs under its own bounded context and the whole sweep under a bounded total. Retry is a per-(owner, name) exponential window held on the attacher under its existing mutex (documented internally-synchronised); the FIRST failure emits, suppressed attempts are counted and the count rides the next emitted event; a terminal class is reported ONCE per attempted descriptor; an operator's EDIT to the descriptor resets the window immediately.

  8. Idempotency REUSES D-339's same-name replace and adds no lock. The concrete re-reads the live registry INSIDE the existing whole-attach lock and no-ops when the name is already registered under the reconciling owner — closing the stale-view window between the caller's AttachedSources read and the attach, with no transport churn. Two concurrent run starts attach exactly once.

  9. The cross-owner name conflict is inherited verbatim and classified NON-RETRYABLE. ErrConnectionNameOwnerConflict is not softened, not renamed around, and not shadowed: another owner's live registration is never evicted. This makes D-301's stated bound VISIBLE at run start rather than only at add time — the trust boundary is unchanged, but a Console showing repeated cross-owner conflicts is now the honest signal that a deployment needs one runtime per tenant.

What this supersedes, and what it does not. It ends D-287's attach-leg DEFERRAL clause only. Every other D-287 call is preserved verbatim: the process-global catalog/registry with an owner-scoped reconcile VIEW, the loud mid-run failure chosen over a refcount/drain protocol, and the token retention on re-add. It also CLOSES D-287's first accepted window — "a reconcile racing a concurrent re-add of the same name by the SAME owner can detach the freshly re-added server (heals at the next add or restart)" — which now heals at the next RUN START, with the same-shaped window on this leg's own pass closed by the under-lock re-check in call 8.

A latent PRODUCTION bug this phase's own test surfaced, and fixed in the same PR (§17.6). The bounded per-connection context was NOT sufficient on its own. Against a server that accepts the TCP connection and then answers nothing, the caller's bounded ctx ends the initialize handshake, but the MCP SDK's session teardown then issues its own cleanup request on a context this runtime does not own — which blocked FOREVER. The stall reached the shipped admin add verb's request just as much as this new leg, and the driver's HTTP client had no bound at all (it could even return http.DefaultClient). Fixed at the driver's shared choke point with unownedBoundingTransport: a request carrying NO deadline of its own, and not a server→client event stream (identified by Accept: text/event-stream, the protocol-level signal), gains a bounded one. A request the runtime already bounded keeps its own budget untouched — a blanket transport-level response-header timeout would have silently pre-empted an operator who raised a slow tool's timeout_ms. The old "no headers → http.DefaultClient" allocation shortcut is removed, and the test that pinned it is rewritten to pin the new invariant.

The honest claim is narrower than "connections survive restarts", and is stated that way everywhere. A connection whose live transport depended on operator-supplied static Headers is NOT restart-survivable: the headers are secret and never persisted, and persisting them would put a credential on a Protocol-readable, diffable, rollback-able spine — the D-300 shape inverted. The re-attach dials without them; a server that required one answers 401 and the outcome is reported as transport_failed. The binding claim is "a declared connection whose descriptor is self-sufficient is re-established at run start", and it is stated in the plan's goals, in an acceptance criterion, in a test, and in the operator skill.

Not built here, named as the successor. No single Protocol read joins "declared in the active revision" against "live in the registry", so an operator menu must correlate agent_config.get with mcp.servers.list itself. A per-connection status flag on the server view is the natural home (the shipped needs_allowance flag is the precedent, D-302) and is a wire change with its own generated-file cost. The two events are the contract this phase commits to; the joined read is not. Also unresolved and recorded rather than decided: the retry window is in-memory, so a crash-looping deployment re-dials a dead server once per boot — bounded by the boot rate rather than the run rate, and persisting reconcile-attempt scheduling metadata would put runtime state into the state store for the first time.

§4.3 deviations from the plan's sketch. Three, none touching RFC territory. (1) ConnectionReattacher.Reattach takes the reconciling RUN's identity.Quadruple in addition to the owner: the concrete OWNS event emission (it holds the bus, the scrubber, the class and the suppression count), and the payload's run-vs-admin discriminator is the RunID, which the ctx at that call site does not carry. (2) The concrete lives in its own mcp_reattacher.go beside mcp_detacher.go rather than inside mcp_attacher.go; the state still hangs off the one *MCPConnectionAttacher, so there is no second implementation. (3) ReconcileConnections marks each attach-pass error with ErrReconcileReattach through a single-chain wrapper type rather than a multi-%w fmt.Errorf, because a multi-%w value also satisfies interface{ Unwrap() []error } — the exact shape a caller uses to walk an errors.Join tree — so the caller would descend into the wrap, see the cause stripped of its marker, and misclassify an unreachable third party as a detach failure, silently stopping the discovery-allowance re-apply for every run.

Mutation-verified. Twenty-three static smoke guards and four go-test guard legs, each broken individually: deleting the seam, dropping the reattacher parameter, removing the nil-reattacher path, reversing the pass order, unwiring the run loop / the production boot / the devstack twin, deleting the stdio gate or its sentinel, deleting the injection kill-switch or its sentinel, removing the per-connection bound / the sweep budget / the unowned-request bound, growing a token step on the attach path, deleting one failure class or one classifier arm, deleting one init() registration or one docs-generator join row, and mangling either generated artifact's event name — each turns an OK into a FAIL, never into a SKIP. Six BEHAVIOURAL mutations that COMPILE (removing the under-lock re-check, disabling suppression, emitting the admin-add event type instead, inverting the nil-reattacher branch, dropping the owner tag, swallowing the gate error) each turn a green test red with a diagnostic naming the broken guarantee.

Cross-references. D-287 (the deferral this ends and the accepted window this closes; every other call preserved), D-301 (the owner tag and the owner-scoped reconcile view), D-303 (the bidirectional-reconcile precedent and the ordering prerequisite), D-302 (the discovery-allowance leg that must run after the attach), D-339 (the same-name replace this reuses and the "no forced-reconcile verb" ruling this honours), D-346 (the wire-carried injection mapping and its opt-in), D-350 (gateStdioConnectionCommands, whose godoc named "any future attach-from-revision leg" — this one), D-355 (the owner-threaded registry mutators), D-271 (the two provider families and the no-dual-path rule), D-300 (the credential-plane invariant this does not touch), D-025 (concurrent reuse), D-209 / D-223 (both generators re-run). CLAUDE.md §4.2, §4.4, §5, §6, §7, §9, §11, §13, §17.3, §17.6, §17.7, §17.8, §18. RFC §6.4, §6.13, §6.16, §7.


D-363 — The search cluster's user axis is a scoped boundary: an elided user_ids folds to the caller's own user instead of wildcarding the tenant, and every widening takes the admin-tier claim the tenant axis already takes

Date: 2026-07-29

Status: Accepted (v1.24). A SECURITY fix, not a cleanup. Completes the identity bound on internal/search that D-108 established for the TENANT axis alone, and is the pass D-356's "Follow-up" queued for the subsystem — decided once for all five call sites rather than one searcher at a time, because a single searcher changed alone would be the §13 two-postures-for-one-concept shape. Mirrors D-294's as-built cross-user gate in events.FilterFromWire, D-299's per-axis elevation rule, and D-356's answer for artifacts.list; it is their fourth application, not a new decision about elevation.

Context — the defect, established by execution rather than by inference. internal/search carried CrossTenantRequested / EffectiveTenantSet and no user-axis equivalent anywhere (grep -rn "CrossUserRequested\|EffectiveUserSet" internal/ returned nothing), so req.Filter.UserIDs reached storage unexamined on every searcher. That is two defects, and the second is the wider one:

  1. A NAMED foreign user_ids was honoured verbatim — all four searchers.
  2. ELISION: an EMPTY UserIDs reached storage as a WILDCARD, so the DEFAULT request, with no caller input at all, returned every user in the tenant. newStringSet(nil).allow(x) is true for all x (internal/sessions/registry.go:944,:956) and ArtifactScope.ValidateFilter requires only the tenant (internal/artifacts/artifacts.go:107-113).

The searchers were not equally broken, and the asymmetry was re-confirmed against the branch rather than assumed: sessions/index.go:79 and tasks/index.go:77 leaked on BOTH shapes (tasks additionally because rowScopedCtx:193 compared the TENANT alone, so a same-tenant foreign-USER session took the unelevated identity.With seat); artifacts/index.go:73-79 leaked on both via ArtifactScope{TenantID: tenant} with UserID unset and a UserIDs[0] read that also dropped users 2..N; events/index.go was SAFE on elision (scopeUser defaulted to the caller at :70) but leaked on a named value, because :78-79 overwrote that default while :89 still handed the bus Admin: crossTenant — false — so the foreign user was read as an ordinary scoped read. A fix that examined UserIDs only when populated would have closed half the bug and left the half that fires with no attacker input.

Decision. The user axis becomes a scoped boundary with the SAME shape as the tenant axis, in the same package, gated on the same predicate.

  1. An elided user_ids FOLDS to the caller's own user. EffectiveUserSet(callerUser, req) mirrors EffectiveTenantSet field for field (both now share one dedupeSorted helper, so a future edit to one cannot silently miss the other). An omitted isolation principal is the caller's own value, never "everyone".
  2. A widening takes the admin-tier claim. CrossUserRequested reproduces FilterFromWire's three-branch switch — 0 folds, 1 gates on inequality, len>1 gates as a fan-in even when every entry is the caller repeated — so the two helpers agree by construction and a reviewer can diff them. ErrCrossUserRequiresAdmin is a distinct sentinel so a refused caller learns WHICH axis refused them.
  3. A widened read does NOT fold its elided axes. WidenedUserSet(req) returns nil for an elided axis, which each searcher passes to storage as the documented wildcard. Under either claim a named foreign user reads that user and an omitted one fans across the tenant, so the fleet view the claim exists for still works.
  4. The session axis stays a filter, not a boundary. A single session id — including one of the caller's OWN other sessions — needs no claim, because the user fold above it already decides whose rows are in play and a foreign session under the folded filter resolves to nothing. Only a MULTI-value session set elevates, via CrossSessionFanInRequested and ErrCrossSessionRequiresAdmin.
  5. The gate fires at all five sites, in one axis order. The four searchers AND the aggregate dispatcher, tenant then user then session fan-in. Query rewrites the sub-request before fan-out, so a per-index-only gate would be five chances to forget instead of one; both sentinels join the aggregate's hard-error set so a per-index refusal propagates rather than degrading into a partial union.
  6. No new scope, no new dependency, no construction-site change. The predicate is the Deps.AdminScope the searchers already hold, which internal/server/search_scope.go:38-41 resolves as ScopeAdmin || ScopeConsoleFleet — exactly the admin-tier set D-356 named for the identical decision one surface over. A search.crossuser scope would be D-108's rejected per-subsystem entitlement one axis over.
  7. The refusal answers CodeScopeMismatch, not the identity_scope_required D-356 chose for artifacts.list. That divergence is deliberate and recorded rather than smoothed over: CodeScopeMismatch is what this surface's TENANT axis has published since the cluster shipped, and one surface answering two codes for one class of refusal is worse than two surfaces answering different codes. Reconciling them means changing a shipped surface's published code and belongs to a Protocol-consistency phase with a deprecation window (RFC §5.3).

What was rejected. Routing search.events through events.FilterFromWire (fixes one of four and imports the event subsystem's wire vocabulary into three packages with no events in them — adopted as the SEMANTIC source instead); gating at the Protocol edge in Dispatch (splits the two axes across layers, which is the drift shape that produced this — the cluster's first phase enforced one axis where it was looking and never enumerated the others; the searchers are also reachable in-process by an embedder); a second Deps.UserScope predicate; and reusing ErrCrossTenantRequiresAdmin for both axes.

No compatibility knob, deliberately. The tenant-wide wildcard is the bug, not a posture anyone chose, and §13 forbids identity-downgrading knobs. The behaviour change is user-visible and is stated rather than knobbed away: for an ordinary operator with no admin-tier claim the Console's ⌘K palette returned session, task and artifact rows belonging to every user in the tenant, and now returns only their own. It needs no Console change — the palette sends no filter (web/console/src/lib/components/ui/GlobalSearch.svelte:89 sends {query, page_size: 8}), so it lands on the folded path.

Blast radius, surveyed rather than assumed. No production caller sets Filter.UserIDs: every types.SearchFilter literal in the module is in a _test.go, search.query is the only search method with a typed Console client method, and its single call site sends no filter. So there is no legitimate cross-user reader to break — which is also WHY the bug survived: the one consumer never exercised the leaking parameter, so no test and no page ever looked at the user axis.

Three deviations from the phase plan, each named rather than absorbed (§4.3).

  1. A second sentinel and a third helper. The plan's API section listed one sentinel and two helpers, but its own acceptance criteria required a multi-session fan-in refusal (D-299's len>1 rule) and required a widened read not to fold its elided axis. Reusing the cross-user sentinel for a session fan-in would hand operators a wrong message — the same argument the plan used to reject reusing the cross-tenant sentinel — and resolving the widened path needs a helper that does not fold. Hence ErrCrossSessionRequiresAdmin, CrossSessionFanInRequested and WidenedUserSet, all mapped to the same wire code so no client gains anything to branch on.
  2. search.events does NOT fan on an elided axis even under a claim. Its replay filter is single-valued and its fan-in flag WRITES an audit.admin_scope_used notice into the ring, so turning an elided axis into a deployment-wide replay would widen a read nobody asked for and write a false accountability signal on every ordinary admin search. The index keeps its pre-phase own-user scope on that one path — narrower than the criterion, never wider, and unchanged from what shipped.
  3. A per-row axis re-bound on search.events. Filter.Matches short-circuits its WHOLE identity comparison when Admin is set, so setting that flag for a granted CROSS-USER read would have made the read also cross TENANTS — an over-return this change would have introduced. The searcher re-applies the effective tenant and user sets per row. That containment also resolves the plan's risk 4 for the user axis (a widened multi-user read now returns every named user's rows rather than the first user's), and it tightens the pre-existing cross-tenant admin path, which previously returned the whole ring.

Also fixed here, under §17.6, and explicitly NOT represented as a security fix. artifacts/index.go discarded the heavy bool its three siblings bind. The discard was real; the consequence commonly attributed to it was not — RedactAndCapPreview returns the empty string WITH the flag, and this index populates Ref unconditionally, so no bytes could leak. It is fixed because the call site could not tell a capped preview from an empty one and the row shape was correct by coincidence rather than by construction.

A finding recorded rather than claimed. The plan's departure section noted that a granted crossing is unaudited because search.Deps carries no audit sink. That is still true for sessions / tasks / artifacts. It is NOT true for events: the bus writes an audit.admin_scope_used event into the replay ring on every admin-flagged replay, naming the scoped triple, so a granted cross-user read of that index leaves a record. The gap is therefore narrower than recorded, still real on three searchers, still axis-symmetric (the tenant axis has the same gap), and is not widened here.

Tests. The helper contract table-driven, including the repeated-own-user fan-in and a shared table driving the user and tenant helpers together so an edit to one that is not made to the other fails. Per searcher (x4): the elision fold asserted by the OWNING USER of every row rather than by a count, the named-foreign refusal, the own-user-named equivalence, the multi-user fan-in, and both admin-tier claims reopening the widenings through the PRODUCTION SearchAdminScopeFromAuth rather than a test-local always-true predicate. Plus: the sessions session-axis arms; rowScopedCtx driven directly in-package (it is otherwise reachable only behind the request gate, and an untested guard is how an inert guard survives); the events bus-Admin arm and its cross-axis containment; the artifacts effective-set iteration and heavy-bool row shape; the aggregate-edge refusal and its hard-error propagation; and the wire code across both the per-index and aggregate paths. internal/search/concurrent_reuse_user_axis_test.go runs N=128 concurrent searches split between two users of ONE tenant against one shared Searcher per index under -race, asserting every row's UserID equals its requester's — the assertion that would have caught this when the cluster shipped — plus cancellation isolation and a goroutine-leak baseline. test/integration/phase218_search_user_axis_test.go drives all five methods through the Protocol dispatcher with real drivers on every seam and the production ScopeChecker, over two tenants x two users x two sessions, and ships three failure modes (a forced redactor error, a foreign-user refusal that is a code and not an empty page, a closed session registry that propagates) plus an N=64 cross-package stress.

Mutation-verified, each against the real tree, and each turning OK into FAIL — never into SKIP:

  • Reverting the sessions fold to req.Filter.UserIDs (with the unused local silenced so the package still builds): FAIL 5, and TestSessionsSearcher_ElidedUserFoldsToCaller fails with CROSS-USER LEAK: row victim-sess belongs to "victim", caller is "attacker" — that output IS the reproduction of the shipped defect.
  • Reverting the artifacts scope to UserID unset: FAIL 3, and the elision test fails on both the victim's row AND the victim's filename reaching the caller through the Ref.
  • Deleting the ErrCrossUserRequiresAdmin refusal branch from all five sites: FAIL 7 — four per-site static guards plus the aggregate, the per-searcher arms and the integration test.
  • Reverting rowScopedCtx to the tenant-only compare: FAIL 2 — the static guard, and TestTasksSearcher_RowScopedCtx_ForeignUserSameTenantElevates fails because the crossing is seated unelevated.
  • Reverting the events Admin flag to crossTenant alone: the granted widening returns 0 rows and TestEventsSearcher_WidenedReadSetsBusAdmin fails — the shape that would otherwise look exactly like a working gate. FAIL 1.
  • Discarding the artifacts heavy bool again: the smoke's discard guard goes FAIL. FAIL 1.
  • Gating the aggregate edge UNCONDITIONALLY (swallowing the entitled path): the LIVE leg goes FAIL against a booted dev server, so the widened half is guarded too and not merely asserted in prose. FAIL 2.

A correction the live run forced, recorded rather than papered over. The smoke was authored asserting that a foreign user_ids answers 403 live. Booted against a real harbor dev, it answered 200 — because harbor dev mints exactly ONE bearer and its claims decode to ["admin","console:fleet"], so every widening that probe can send is legitimately GRANTED. The claim-ABSENT refusal is therefore not reachable from any live probe on a dev build; the body-scope reconciler's smoke records the same constraint for the same reason. The leg was rewritten to assert the half a live probe CAN reach and a static grep cannot — that the granted widening still ANSWERS, so the fold did not swallow the entitled path — plus an unauthenticated probe that must still be refused. The refusal itself is pinned by the per-searcher unit legs (both widenings, both claims, through the production SearchAdminScopeFromAuth) and by TestE2E_Phase218_NamedForeignUserIsRefusedNotEmptied, which drives all five methods through the dispatcher and compares the wire code exactly. Live totals on the clean tree: OK 34 / SKIP 0 / FAIL 0.

Documentation (§18). No docs/skills/ playbook demonstrates search.* (verified by grep over docs/skills/, not assumed), so no skill body changes. docs/site/protocol/auth-and-identity.md gains the search rows' crossing policy in the same PR. web/console/src/lib/sessions/types.ts's claim that the session filter's free-text query is "forwarded to search.sessions" is corrected — it is not, and it is precisely the sentence that would send the next auditor of this question down the wrong path.

Follow-up. The unaudited granted crossing on sessions / tasks / artifacts is pre-existing, axis-symmetric and not widened here; closing it means adding an Auditor to search.Deps, threading it through every construction site, and emitting on BOTH axes. Recorded here rather than half-done for one axis.

Zero-wire. No method, error code, canonical event or wire type moves; ProtocolVersion holds at 0.1.0; no protocol-ts or protocol-docs regeneration.

Cross-references. D-108 (the cluster this repairs, and its closed-scope finding), D-294 (the events.list cross-user gate whose three-branch semantics this reproduces), D-299 (the per-axis elevation posture: empty folds, a foreign principal elevates, a single own-session read does not, len>1 on any axis is a fan-in), D-308 (a widened read does not fold its elided axes), D-349 (the body-identity gate and the elevation minting rowScopedCtx uses), D-352 (the store-level wildcard this deliberately does not touch), D-356 (the same decision for artifacts.list, and the follow-up this discharges), D-311 (the false-absence shape an empty page would be), D-025 (concurrent reuse). CLAUDE.md §4.2, §5, §6, §7, §9, §11, §13, §14, §16, §17.1, §17.3, §17.6, §18. RFC §4, §4.2, §5.2, §5.5, §6.10, §6.13, §7.


D-364 — The safe memory path becomes reachable over the Protocol: StartRequest.caller_memory composes into the External tier under ONE fixed runtime-owned key, bounded at the edge, because the surface was steering consumers into the trusted spine

Date: 2026-07-31

Status: Shipped (Phase 219, v1.25). Upstream asks HA-45 / HA-46, reshaped twice on review.

The defect is not the one that was reported. The ask was "there is no additive path for injecting recalled conversation memory." There is: planner.MemoryBlocks (internal/planner/planner.go) carries two tiers rendered as separate system messages behind a five-line anti-prompt-injection preamble, in a documented most-stable-first order that preserves KV-cache windows. What is true is narrower and worse: that path is populated only in-process and appears NOWHERE in internal/protocol/ — a grep across internal/protocol/, internal/server/ and web/console/src/lib/ for MemoryBlocks / memory_blocks returned nothing. So a Protocol consumer reached for the knob that LOOKS adjacent, RunOverrides.SystemPromptOverride, which is a full REPLACE of the base+user spine (internal/planner/react/prompt.go) that silently suppresses the operator's durable user layer and seats caller content in the TRUSTED base position. The safe path being unreachable is what steers consumers into the unsafe one.

Decision — one additive optional start field, admitted to the External tier ONLY, composed at map-key granularity under one fixed runtime-owned key.

  1. StartRequest.caller_memory, a json.RawMessage, on start. start is the per-run request, so the field binds atomically to the run it belongs to. Absent → byte-identical wire shape and run behaviour, golden-compared rather than asserted.
  2. The caller names NO key. It supplies a value; the runtime writes it under runctx.CallerSuppliedKey = "caller_supplied". There is therefore no reserved-key deny-list to maintain and no future collision surface: runtime producers may add sibling keys forever and can never collide with a caller, and a caller can never shadow, rename or displace a runtime key. Phase 84e's own plan already settled the mechanism — "the tier renders a map, so the future producer composes additional keys alongside recalled_turns rather than competing for the slot." This is that next producer.
  3. Conversation is NOT caller-writable. ProjectMemoryBlocks writes that slot unconditionally whenever the patch is non-empty, so a caller writing it would be two producers on one slot with silent last-writer-wins — the §13 shape. The reported need is served by External, so widening buys nothing and costs the collision.
  4. The identity contract is not weakened, because on this path it is not ENGAGED. The contract exists to stop the runtime handing a run memory belonging to another (tenant, user, session). It binds a STORE READ. This path performs none: the bytes arrive in the request body under the caller's verified triple and reach the run minted for that same triple. §6's boundary is "can A's data reach B's run"; a per-run request field cannot cross it in the direction that matters, because content flows IN, never out. The MemoryBlocks godoc is amended to state the contract per provenance rather than deleted or left false — a false invariant is the worse outcome, because a future author reasons from it.
  5. Runtime-retrieved-on-caller-intent is REJECTED, not deferred. D-211 already wired SearchTurns into the run loop behind memory.retrieval: semantic, with retrieval_top_k / retrieval_min_score as the operator's dials. A Protocol "retrieval intent" field would be a second way to ask for retrieval Harbor already performs — the §13 shape — and it answers a question the reported consumer never asked. Their words were "inject recalled conversation memory": they had the content and no safe slot for it.
  6. A THIRD prompt tier was rejected. It costs a new wrapper, a new golden fixture, and an edit to the load-bearing most-stable→least-stable injection order whose stated purpose is KV-cache prefix stability — for provenance separation the External tier's map already provides at zero planner cost. This phase is invisible to the planner, and a regression test makes that a fact rather than an intention.
  7. A bound enforced at the edge, before a task exists. maxCallerMemoryBytes = 32 KiB, refused invalid_request before Spawn, mirroring output_schema. Not an operator knob: a dial on "how much untrusted caller content may enter a prompt" is a security-posture downgrade dressed as tuning. Reopening condition, stated once: if a legitimate caller is refused, the answer is an additive optional config key defaulting to the constant — never a raise of the constant.
  8. CAP ORDERING IS AN INVARIANT. maxCallerMemoryBytes MUST stay strictly below the control transport's maxBodyBytes (64 KiB). Both refuse with an identical CodeInvalidRequest, so a field cap that rises to meet the envelope cap becomes UNREACHABLE dead code while every status-code test keeps passing — the transport simply answers first. The refusal text NAMES the field, the smoke pins the ordering mechanically, and the over-cap smoke payload is sized to land BETWEEN the two caps.
  9. Observable, size-only. memory.caller_block_admitted carries bytes / tier / key and no fragment of the content. It fires at admission, which precedes planning, so it lands whether or not the run subsequently succeeds. A Console that cannot tell caller-asserted memory from runtime-retrieved memory can audit neither (RFC §5.2, D-062).
  10. The fix is documentation as much as code. SystemPromptOverride's godoc, the generated Protocol reference, the Console client and both affected operator skills point at the additive field. A field nobody finds reproduces the defect in a new place.

Correction (D-375). Item 7 above calls a per-request byte dial on this content "a security-posture downgrade dressed as tuning". That framing is retracted: maxCallerMemoryBytes is a RESOURCE BOUND AND WIRE-SIZE GUARD, not a security boundary. Nothing may be inferred from its size about how much content a caller can put in front of the model — the same principal, needing no additional claim, can send more through the uncapped query (which lands in the UNFRAMED conversation position) and through the claim-free agent_config.session.set_user_prompt (1 MiB body, landing INSIDE the system prompt). What contains this payload is POSITIONAL — the tier it reaches and that tier's framing — never its size. The conclusion that it is not an operator knob survives on the operational argument (a byte dial buys nothing an operator can act on, and a configurable value is a foot-gun against item 8's cap-ordering invariant). The value is unchanged at 32 KiB. D-375 stated this correction was attached here; the marker was omitted and is attached by D-386.

What else was rejected, recorded so it is not re-proposed. A RunOverrides carrier — rejected on a concrete bug, not taste: the override slot is keyed by the identity triple and consumed read-once, so two concurrent starts in one session would race for it and one would silently run without the memory it was promised (tolerable for a temperature; a correctness bug for content), and the slot's own godoc documents a drop window. memory.put — wrong tier, wrong lifetime, wrong shape, wrong claim: it is admin-gated, it MUTATES durable session memory rather than decorating one run, its {user_text, assistant_text} shape cannot express structured recalled content, and what reaches the prompt afterwards is whatever the configured strategy decides to keep.

Two shipped-code defects fixed here under §17.6, both found while verifying the bound chain.

  1. internal/runtime/runctx/memory_fetch.go claimed the LLM-edge context-leak guard was "the authoritative backstop" for an oversized memory tier. It never has been. findContextLeak opens with offloadableText := m.Role == RoleTool (internal/llm/safety.go) and applies the byte check to text ONLY when that is true; the accompanying comment states the exemption outright. Memory tiers render as llm.RoleSystem. The real backstop is the token-budget guard (ErrContextWindowExceeded), which fires after the whole prompt is built and fails the run late. The comment is corrected, and the correction is WHY this phase's bound sits at the Protocol edge rather than being left to a downstream guard that does not exist. D-026's invariant is not weakened — it never covered this path, and this is the first statement of that honestly. Both halves are pinned by smoke greps, so a future author who re-adds the claim, or who changes the exemption the argument rests on, is forced to re-derive rather than silently invalidate it.
  2. maxOutputSchemaBytes is already unreachable dead code, named with file:line precision and deliberately NOT reproduced. It is 64 KiB (internal/protocol/control.go) while the control transport caps the entire body at 64 KiB, so a schema large enough to trip the field check cannot fit in a body the transport will read — and its unit tests pass only because they call dispatchStart directly rather than over the transport. Fixing it (lower the constant, or move the check to the transport) is phase 146's surface and carries its own smoke and wire-doc obligations. This phase's cap-ordering guard and names-the-field assertion exist precisely because the same trap was one constant away.

Risks, stated rather than implied. The bound is the ONLY bound: one caller-supplied block cannot alone exhaust a context window, but a caller block PLUS a large retrieval_top_k PLUS a long trajectory can, and that aggregate is the governance layer's concern. There is no per-tenant rate or volume accounting on admitted caller memory — spend IS metered at the LLM edge, but admission itself is not, and the fix belongs in internal/governance, not at the Protocol edge. And this phase makes it possible to put untrusted content in front of a model over the wire: that is the point, the mitigation is positional rather than filtering, and the honest residual is that an operator who pipes third-party content through caller_memory without redacting it has a data-leakage path no prompt wrapper closes — stated in the field godoc, the Protocol reference and both skills.

Zero-version-move. One additive optional wire field and one canonical event. No method, no error code, no new bodyscope row (a FIELD addition changes no posture — asserted by a test rather than assumed). ProtocolVersion holds at 0.1.0. make protocol-ts-gen, make protocol-docs-gen and make protocol-ts-types-gen regenerated; the Console typed client mirrors the field by hand. This phase owns the generated wire manifest for the wave's Stage 1 (D-223 / D-209).

Deviations from the plan (§4.3), each named rather than absorbed.

  1. internal/protocol/client/client.go needed no edit. RuntimeClient.Start takes the whole types.StartRequest, so it carries the field by construction. The plan's file list named it; the change would have been a no-op.

  2. ComposeCallerMemory gained a second sentinel, ErrCallerMemoryTierShape. A non-map External tier cannot be composed into at key granularity, and overwriting it would silently destroy whatever the other producer put there — the exact failure this design exists to avoid. Silently succeeding there would be §13 silent degradation, so it fails loud.

  3. runctx.ExternalTierName is exported. The admission event reports which tier the content reached; the label needed a home, and the composition home is it. It is documented as a LABEL, not the renderer's source of truth — the planner still owns how it renders its own wrappers.

  4. The recalled_turns + caller_supplied composition leg is driven through memory.Open with an injected deterministic Embedder rather than through devstack. devstack exposes no Embedder seam and memory.Open refuses a semantic config without one (fail-loud, never a stub), so semantic recall cannot be enabled through the assembled stack. memory.Deps.Embedder is a declared injection point; the fixture is a value on a seam, not a re-implementation of a subsystem, and the production FetchMemoryBlocksComposeCallerMemory sequence is the code under test.

  5. The smoke's phase-gate SKIP arm was deleted rather than kept. The skeleton documented it as "the ONE skipping arm" that needs no edit on ship. Mutation-verified: deleting StartRequest.CallerMemory produced OK 0 / SKIP 1 / FAIL 0 and exit 0. The phase has shipped, so absence is a regression — §4.2 item 5, and the same rewrite the wave-v1.24 audit made to scripts/smoke/phase-215.sh. The grep is still load-bearing for the OPPOSITE of the intuitive reason: decodeRequest does not reject unknown fields, so a build without the field answers 200 and silently drops the payload, which is byte-indistinguishable from success. The skeleton's header comment asserting strict decoding was corrected in the same edit — it was false, and it was the sentence a future author would have reasoned from.

  6. Two coverage targets are not met, and one of them is unreachable rather than unmet. internal/protocol/types holds at its 62.6% baseline against a 65% target, because the target rested on a premise that does not hold: a struct FIELD addition contributes zero statements to a coverage denominator, and a JSON round-trip exercises encoding/json rather than this package. Its actual uncovered surface is nineteen IsValid* enum validators plus three partially-covered version.go functions, none of them caller-memory code — covering them to hit an arithmetic target would be padding, so the shortfall is recorded instead. internal/protocol improved 78.4% → 78.7% against an 80% target (every new branch covered; the residual is pre-existing), leaning on §14's "explicitly improves it toward the target" clause, stated rather than glossed. Both are written into the plan's coverage section as an as-shipped table so the next reader is not told the targets were met. The untested IsValid* validators are worth a follow-up on their own — they are wire-contract code with no test at all.

  7. A finding the run-loop test surfaced, recorded because it moves a boundary this phase assumed. A syntactically MALFORMED caller_memory never reaches the run loop's composition step even from an in-process caller that bypasses the Protocol edge: the task record's whole-record marshal fails, and Spawn returns a serialization refusal rather than persisting an unusable row. Only an explicit JSON null — valid JSON, so it persists — actually exercises the run loop's own refusal. Both are pinned as tests rather than left as folklore, because it is the difference between "the run loop guards this" and "nothing has to, because the row cannot exist", and a future change that made the record marshal tolerant would silently relocate the failure to a place nobody is watching.

Mutation-verified, each against the real tree, each turning OK into FAIL and never into SKIP. Clean-tree live totals: OK 19 / SKIP 0 / FAIL 0.

  • Deleting the caller_memory field from StartRequest: the wire-field guard goes FAIL (it was a SKIP before the gate was rewritten — that finding is deviation 5).
  • Renaming the manifest key: the D-223 lockstep guard goes FAIL.
  • Renaming CallerSuppliedKey, and separately changing its VALUE: both go FAIL. The first draft of that guard grepped the bare substring CallerSuppliedKey, which a rename to CallerSuppliedKeyRENAMED still satisfies — an inert guard, caught by mutation and rewritten to an anchored whole-declaration match.
  • Making the runtime recall producer write caller_supplied: the one-producer guard goes FAIL.
  • Moving the ComposeCallerMemory call site above the emitter: the line-ordering guard goes FAIL (the admission event would be emitted through a nil emitter and vanish).
  • Re-adding the false ErrContextLeak claim, and separately removing the RoleTool byte-exemption the bound reasoning rests on: both go FAIL.
  • Raising maxCallerMemoryBytes to 64 KiB: the cap-ordering guard goes FAIL — the guard that stops this phase joining maxOutputSchemaBytes.
  • Deleting the admission emit: the event-presence guard and the integration suite go FAIL.
  • Putting the caller's content on the event payload: the marker-absence guard and the integration suite go FAIL.
  • Dropping the edge cap check: the over-cap 400, the no-task-on-refusal count, and both go test -race gates go FAIL.
  • Treating an explicit null as absent: the null-refusal guard and the count go FAIL.
  • Moving the refusal AFTER Spawn: every status code stays correct and ONLY the task-count check goes FAIL (0 → 3) — the load-bearing half, demonstrated rather than asserted.
  • Making the refusal text stop naming the field: the over-cap guard goes FAIL even though the status is still 400. Together with the cap-ordering guard and the between-the-caps payload sizing, that is what makes the field's own bound provably reachable rather than shadowed by the transport's identical answer.
  • Making ComposeCallerMemory overwrite the External map: the composition test and the integration suite go FAIL on the missing recalled_turns.
  • Pointing composition at Conversation: the tier-isolation assertion, the end-to-end prompt-position assertion and the N=32 cross-tenant leg go FAIL.
  • Making the run loop silently continue instead of failing on an inadmissible payload: the run-loop suite goes FAIL with task status = "complete", want "failed" — the §13 silent-degradation shape, caught rather than assumed.

Tests. The Protocol edge table (absent / object / array / string / exactly-at-cap / one-over-cap / explicit null / malformed / incomplete triple), the refused-before-Spawn property against the REAL task registry, the idempotency content-identity fold, and the omitted-field wire-shape golden. The composition matrix (nil mb, Conversation-only, External-already-populated, empty raw, null raw, non-map tier), the no-aliasing property, and TestComposeCallerMemory_ConcurrentReuse_NoCrossTalk at N=128 under -race with per-goroutine marker presence AND every sibling's absence. The planner-invisibility regression: the composed tier renders inside the byte-unchanged wrapper framing, and the four-section injection order is unmoved. A bodyscope test asserting StartRequest keeps its SurfaceControlTask row. test/integration/caller_memory_test.go drives the real control transport over httptest with real drivers and a RECORDING LLM edge, asserting on the bytes that reached CompleteRequest.Messages: the marker appears in EXACTLY one message, that message is the system-role external-memory tier carrying the untrusted framing, the runtime's own conversation tier survives beside it, and the marker is absent from every other message. Three failure modes (over-cap refused with no task created; unauthenticated refused before the body is consulted; a run whose LLM call FAILS still emits the admission event) plus N=32 concurrent runs across two tenants against ONE httptest.Server and ONE handler.

Documentation (§18). use-the-harbor-protocol (surface: protocol) and configure-memory-and-skills (surface: memory) updated in this PR; glossary gains "caller-supplied memory block" and amends "semantic recall" and "UNTRUSTED memory framing".

Cross-references. D-211 (the semantic-recall run-loop wiring this composes with, and the retrieval mechanism a caller-intent field would have duplicated), D-026 / RFC §6.5 (the context-leak invariant this does not weaken and now states honestly), D-062 (the Protocol-observability ordering rule the admission event satisfies), D-360 (phase 215's additive-start-field shape, its no-task-on-refusal property and its rewritten smoke — the templates followed here), D-349 (the body-identity gate whose StartRequest row is asserted unchanged), D-358 (the named-constant rule and the threshold this deliberately does not re-couple to), D-025 (concurrent reuse), D-209 / D-223 (both generators re-run; this phase owns the manifest for Stage 1). CLAUDE.md §4.1, §4.2, §5, §6, §7, §11, §13, §14, §16, §17.1, §17.3, §17.6, §18. RFC §5.2, §5.5, §6.2, §6.5, §6.6. Briefs 04, 13. Plan: docs/plans/phase-219-memory-tiers-on-the-run-surface.md.


D-368 — The inert-smoke baseline drains to zero, and thirteen of its twenty-four entries were the classifier's own false positives rather than debt

Date: 2026-07-31

Status: Accepted (v1.25). A tooling-integrity fix. It repairs the instrument the wave-v1.24 §17.5 checkpoint used to measure smoke-script inertness, then drains the list that instrument produced. No Go production code, no Protocol surface, no config key, no Console route.

Context — the headline is not "24 unwritten smokes." The wave-v1.24 checkpoint switched on a gate in scripts/preflight.sh: a smoke belonging to a shipped phase that reports OK: 0 and FAIL: 0 asserted nothing, and per CLAUDE.md §4.2 item 5 that is a bug. Twenty-four scripts violated it on day one and were parked in scripts/smoke/inert-baseline.txt as declared debt. Measurement showed the count itself was wrong: phase_is_shipped misclassified two independent ways, and thirteen of the twenty-four were not shipped phases at all.

  1. The row regex could not see a third of the master plan. It matched ^\| *${n} +\|, which requires at least one space before the closing pipe. docs/plans/README.md writes both | 104 | and | 85a|. Measured on the branch: 233 of 339 phase rows visible, 106 invisible. An invisible row fell into the "no row at all" arm and was treated as Shipped.
  2. The status vocabulary named two of the eight not-shipped words in use. It recognised Pending*, Post-V1* and Deferred* (the last appears zero times), defaulting everything else to Shipped. Census of the status column: Cut 4, Ready now 3, Revisit after 3, Superseded by … (not shipped) 1, Reverted 1, Deprecated → superseded by … 1 — 13 rows, 6 words, all reading as shipped.

The two faults are one change and the fix is about fifteen lines. It can only relax, never tighten, and that is a verified property rather than an argument: both faults default to Shipped, the strict arm, so a correction can only move a script from INERT_SHIPPED/INERT_BASELINED toward INERT_PENDING. Both classifiers were run over all 360 phase tokens (master-plan rows ∪ smoke script names): 345 unchanged, 15 relaxed (yes → no), 0 tightened. Thirteen of the fifteen are the baseline entries; the other two (85k, 109j) were already classified correctly by accident because their smokes assert something.

Decision.

  1. phase_is_shipped splits into phase_row_status (row lookup, regex ^\| *${n} *\|) and phase_status_arm (leading-word vocabulary, three outcomes: shipped / not-shipped / unknown). unknown still resolves to the strict arm; the strictness is unchanged, only its silence is removed.
  2. The eleven genuinely-inert shipped-phase smokes get real assertions. Eight (01, 02, 03, 04, 07, 23, 24, 42) flip static-only → unit-tests and run a named test, modelled on the scripts/smoke/phase-05.sh exemplar; 79 asserts three shipped artefacts; 132 and 132-stream convert a bare delegation pointer into a tripwire over the legs they depend on.
  3. scripts/smoke/inert-baseline.txt drops to zero data lines, and empty becomes its documented steady state, asserted by scripts/smoke/phase-223.sh.

The category (b) and (d) buckets are empty, and (d)'s emptiness required a documentation correction. No script was a delete candidate: the nearest were the six Cut/Deprecated/Superseded phases, whose plan files still exist and whose smoke deletion scripts/drift-audit.sh refuses — and whose correct classification is "not shipped", which the classifier fix already delivers. No script was environment-dependent either, and that contradicts the baseline file's own header, which justified the WARN-not-FAIL stale posture on environment-dependence ("a live MCP server, a benchmark budget, a platform-gated tool"). All 24 were run on a plain checkout with no server, no provider keys and no MCP binary; every one reported OK: 0 / FAIL: 0, and not one had a branch that would have asserted under a richer environment. The header is corrected rather than left standing on a false premise. The WARN posture survives on a narrower and honest justification: a stale entry is provoked by a line an operator deliberately added under an emergency, and making it a hard FAIL would block the very commit that pays the debt down. A MISSING entry stays a FAIL — that is the direction that matters.

go test -run exiting 0 on an empty filter is why the eight repairs are not exit-code checks. go test -run NoSuchTest ./pkg prints "no tests to run" and exits zero. A smoke that names a test and asserts only the exit code therefore reports OK forever after that test is renamed or deleted — the same "the pass value is also the can't-tell value" shape as a SKIP standing in for an OK. scripts/smoke/common.sh gains one helper, assert_go_tests_pass, which runs go test -v -run '^(T1|T2|…)$' and greps a --- PASS: line per name; a rename is a FAIL. This was mutation-verified on every one of the eight, and the captured output shows go test exiting 0 in each case — the exit-code-only guard would have been a false OK on all eight.

The three residual gate holes, each decided rather than deferred.

  • Hole 1 — a smoke that exits 0 without printing a summary was invisible to BOTH gates. assess_smoke_output returned early when the captured output had no OK:/FAIL: lines, on the documented assumption that "the non-zero exit path already accounts for that." It does not: a script that prints and exit 0 without calling smoke_summary records nothing in any inert bucket and leaves TOTAL_FAIL untouched. Probed, not inferred. Fixed: no summary + rc 0 is now a FAIL naming the missing call. Latent rather than live today (every smoke calls smoke_summary), but nothing required it, and "nothing requires it" is how the next one gets written.
  • Hole 2 — a baseline line naming a deleted script rots forever. The preflight stale sweep skips entries whose file is missing ([ -f "${entry}" ] || continue), so the line is never reported stale. Fixed in the meta-smoke rather than in the sweep: phase-223.sh asserts every remaining line names an existing file, and additionally that its phase is Shipped. Fixing it in the sweep would have meant deciding what a missing file means for a run that never executed the script; asserting it as a property of the FILE is the honest home, and it survives a future re-baselining. The drained file makes both vacuous today, which is the point of asserting them.
  • Hole 3 — an unparseable row was indistinguishable from a missing one, and the loudness was misdirected. The strict "unknown ⇒ Shipped" default is correct and is preserved; what was wrong is that the operator saw a FAIL on the script while the broken thing was the row. That is how thirteen entries entered the baseline unnoticed. Fixed: a UNRESOLVED_PHASE_ROWS report names the phase, the reason (no row / unknown status word) and the defaulting. Scoped deliberately to the classification call site — the inert gate — rather than to every smoke: 21 smoke scripts have no master-plan row at all (108a108p, 123, 73e/f/h/j), and reporting all 21 on every run would be noise an operator learns to scroll past, which is the failure mode this entry exists to close, not to reproduce. A row's unreadability has no consequence until the gate asks about it; that is exactly when it is now named.

The meta-smoke's negative arms are FAIL, not SKIP. As authored during planning, scripts/smoke/phase-223.sh reported OK: 2 / SKIP: 4 — each SKIP printing its measured gap. Now that the properties hold, every arm is a FAIL: a SKIP would be indistinguishable from a pass and would let all six regress silently. Its assertion 6 also had to be strengthened: the vocabulary check greps inside the phase_status_arm function body rather than the whole file, because a status word can legitimately appear in a comment. The plan's author had already caught the same shape once — a guard keyed on the bare token smoke_summary reported OK against the comment describing the unfixed behaviour — which is why the no-summary arm greps the exact operator-facing phrase did not call smoke_summary. If that phrase is reworded, the smoke must be updated in the same commit.

Mutation records — eleven repairs plus the meta-smoke and the classifier, each turning OK into FAIL, never into SKIP. 01/02/03/04/07/23/24/42: rename the named test in the Go source → FAIL naming the test that never ran. 79: (a) break a benchmark's compile → FAIL; (b) chmod -x scripts/perf/check-regression.sh → FAIL; (c) rename the perf-regression CI job → FAIL. 132 / 132-stream: delete the leg-6 / leg-7 markers from scripts/smoke/phase-112b.sh → FAIL naming the missing leg. 223: add a baseline line → FAIL; point a line at a nonexistent script → FAIL; add a line for the unshipped 85a → FAIL naming its Ready now status; revert the row regex → FAIL at 233/339; delete the Cut arm → FAIL naming 'Cut'; reword the no-summary phrase → FAIL.

One §17.6 bundled fix outside the plan's file list, named rather than smuggled. scripts/drift-audit.sh wrote its markdownlint output to a fixed /tmp/harbor-markdownlint.out, and make preflight runs the audit internally — so two sibling worktrees running preflight concurrently (this wave's own dispatch model) clobber each other's diagnostic and the operator reads someone else's violations. The verdict was never wrong; it comes from the exit code. The file the failure message names was. Fixed with mktemp. It is bundled because it is a defect in the gate this phase exists to make trustworthy, and because a diagnostic that can silently belong to another process is the same class of thing as a guard that can silently assert nothing.

Two open questions handed to the coordinator, not settled here. (1) The master plan's status column has ten leading words across thirteen distinct strings and no schema; this phase teaches the classifier the current vocabulary and makes an unknown word reported, but the durable fix is a constrained vocabulary in docs/plans/README.md. (2) Normalising every phase cell to a single | NNN | form would remove fault 1 at the source; it touches 106 rows of the master plan and is the coordinator's call. Related and worth stating so it is not mistaken for a regression: three rows read Ready now (85a, 85b, 85f) and now classify as not-shipped. When one of them ships, its smoke must gain real assertions in that phase's own PR — the gate will FAIL it otherwise, which is the intended behaviour.

Cross-references. D-104 (the smoke classification scheme and the PREFLIGHT_REQUIRES classes, unchanged), D-025 (the concurrent-reuse contract this phase does not engage — no Go artifact is built; the parallel-batch concern it does raise is per-phase go test log paths). CLAUDE.md §4.1, §4.2 (items 3, 4, 5, 9, 11), §11, §13, §16, §17.5, §17.6. RFC §3.4 (fail loudly), §4.3 (conformance gates — the 07 and 23 repairs run the shipped in-mem conformance suites), §8 (the CLI layer this gate belongs to).


D-366 — The agent-config spine takes an optional expected_content_hash, evaluated before the idempotent re-set; the guarantee is exact in ONE process and stated as ABSENT across processes

Date: 2026-07-31

Status: Accepted

Context. Every write onto the durable agent-config revision spine was unconditional last-writer-wins with no conflict detection anywhere on the path. Two writers composing into one agent's config silently reverted each other and both were told 200. The defect surface is narrower than "all sixteen doors lose updates", and saying so is part of not over-claiming: set_revision / user.set_revision replace the WHOLE payload, so any concurrent edit to any section between a caller's read and its write is reverted (the large one); two writers to the same section revert each other; rollback moves the pointer unconditionally and can discard a write that landed after the caller decided to roll back; the list-merging verbs re-read and merge, so a concurrent add of a DIFFERENT member survives.

Decision. ONE optional field, expected_content_hash, on all SIXTEEN spine-writing request types, and ONE comparison inside the registry driver's existing read-modify-write. Present and matching ⇒ the write proceeds. Present and not matching, or present with no active revision ⇒ refused with a new machine-branchable revision_conflict code (HTTP 409) and NOTHING is persisted. Absent ⇒ byte-for-byte today's behaviour on every door (§10).

Content hash, not revision id. agent_config.rollback is a shipped first-class verb that repoints the active pointer WITHOUT necessarily changing the content, so rolling back to the content a writer already read leaves the content identical and the revision id different. A revision-id token would refuse that write — turning "the operator restored exactly what you read" into a false positive on Harbor's own recovery path. The guarded quantity is a value, so the token is a value. It also composes with the shipped idempotent re-set, which is already defined by content equality over one canonical form (NormalizePayload) and one hash function (ContentHash), instead of introducing a second notion of "unchanged" beside the content one on the same write path. Accepting EITHER token is rejected as the §13 two-mechanisms shape: two fields answering one question means four combinations to specify and a client that can silently pick the weaker one. The ABA case (R1(H1) → R2(H2) → R3(H1)) is reachable and resolves in content hash's favour — a writer holding H1 is accepted at R3, but its base state and the current state are the same bytes, so the write it produces is the write it would have produced at R1, and H2 was already discarded by whoever wrote R3. Lost-update prevention is about not silently discarding a change; at R3 there is no change left to discard.

A new code, not a reused one. CodeRevisionConflict = revision_conflict → 409, the same state-forbids posture as CodeSessionRunning / CodeSessionErased. Nothing existing fits: the body was well-formed (so not invalid_request) and the server did not fault (so not runtime_error, which would additionally make the conflict unbranchable — a client could not tell "re-read and retry" from a server bug). No data field is added to the Error envelope: Message is explicitly advisory, and the client learns the current state by re-reading agent_config.get, which already returns both revision_id and content_hash. That re-read is also how the conflicted client reaches agent_config.diff, which takes revision ids — one extra round trip on a rare path, paid rather than waved away.

The evaluation order is load-bearing and is the subtle part. The precondition runs BEFORE the shipped idempotent-re-set short-circuit. The other order would let a stale token be converted into a 200 whenever the caller's payload happens to equal the CURRENT content — a success that misleads the caller into believing its base was still valid, which is the §5 silent-degradation shape. A transposition is the ONE mutation that leaves every grep-for-presence guard green, so it is pinned twice: by a line-ORDER assertion in the smoke, and by TestSetRevision_ConditionalWrite_PreconditionPrecedesIdempotentReset, which is the only test in the tree that catches it (every other conflict test submits content that DIFFERS from the current content).

Where the atomicity comes from — and where it does not. It comes from exactly one thing: the agent-config Service's 256-way striped per-owner write lock, taken by every door as its first act after identity validation and held across its whole read-modify-write including the registry call. It does NOT come from the store, and the store says so in its own interface godoc: "The StateStore itself does NOT enforce CAS — it stores and returns the int." The driver writes the revision record and then the active pointer as two ordinary Save calls, and saveActive mints a fresh event id every time, so the active-pointer slot is overwritten unconditionally.

So the guarantee is bounded, and the bound is stated rather than inferred: the precondition is exact within a single Runtime process, and two Runtime processes sharing one Postgres or SQLite StateStore can still lose an update. That residual is written into the SetOptions godoc, the CodeRevisionConflict godoc, the generated docs/site/protocol/errors.md row, and the use-the-harbor-protocol skill — and it is PINNED BY A TEST THAT ASSERTS IT AS ABSENT. TestConditionalWrite_CrossProcessBoundIsDocumented builds two independently-constructed registries over ONE real file-backed SQLite store, suspends writer B between its precondition read and its save through a delegating gate, lets writer A complete an entire conditional write under the same token, then releases B — and asserts the lost update STILL OCCURS: both writers are told they succeeded, both descend from the same base, and A's revision is silently no longer active. If a future change ever DID make the write cross-process safe, that test fails and the four texts above must be corrected. A property the mechanism lacks is pinned as absent rather than left to be discovered.

Two constructions were evaluated and rejected on the record, so a later reader does not re-derive them. (1) Deriving the active pointer's EventID from the expected content hash, exploiting Save's same-EventID-different-Bytes conflict which both SQL drivers evaluate inside ONE transaction — unsound: it only collides between two writers sharing the SAME expectation, and a writer whose expectation is stale computes a DIFFERENT derived EventID, takes the different-EventID overwrite path, and clobbers the winner, which is precisely the lost update being prevented. (2) A successor-slot chain making each node's successor claimable exactly once — sound but unaffordable: it turns Active, called on every run start through the projection, from one Load into a chain walk, and re-caching the head reintroduces the unconditional overwrite it was built to avoid. The real fix is named, not hinted: a conditional-write primitive on the StateStore interface (SaveIf(ctx, r, expectedEventID)) across the §9 in-mem / SQLite / Postgres triad with conformance rows. That is a §9 interface change and its own phase; the driver-side check here is written so it becomes that primitive's caller with a one-line change.

Sixteen doors, and the count is asserted rather than documented. Twelve agent-scope (set_revision, rollback, skills.upsert, skills.delete, set_tool_exposure, set_prompt_layers, set_llm_params, add_mcp_connection, remove_mcp_connection, set_mcp_discovery_origins, set_oauth_provider, remove_oauth_provider) and four user-scope (user.set_revision, user.rollback, user.skills.upsert, user.skills.delete). Not doors, each verified rather than assumed: agent_config.set_llm_provider takes the same write lock but never calls the registry — it drives the LLM-provider installer plus an audit emit — and the five agent_config.session.* verbs write the ephemeral session overlay, not the durable spine. A seventeenth spine writer added later without threading the field fails three ways: the smoke's EXACT-count assertion, the reflection table, and TestConditionalWrite_AllSixteenDoorsAcceptTheToken, which DRIVES each door with a stale token — the only one of the three that catches a door which declares the field and drops it on the floor.

The internal compensation writers stay unconditional, deliberately. The revision rollbacks and empty-payload neutralisations that undo a forward write whose live side-effect then failed have no caller and no base: they restore a pointer the same locked read-modify-write just moved. Applying the caller's token there would turn a successful undo into a refusal and leave the half-applied state the compensation exists to erase. They take an explicitly-named compensatingWrite zero value so the choice reads as a decision rather than an omission.

The token is a PRECONDITION, never an AUTHORITY. It is compared strictly after the identity and scope gates, it can only ever cause a write to be refused, and no value of it widens what a caller may write. Pinned at both layers: a valid token with an incomplete identity triple is refused by the identity gate, a valid token with no admin scope is refused by the scope gate, and an invalid payload is still refused by the validator — none of them reported as revision_conflict.

Interface shape. ONE options struct (agentcfg.SetOptions) on the existing SetRevision / Rollback methods, not a parallel SetRevisionIf — one write path that takes a precondition, not two write paths that differ by whether they check one (§13). The four precondition rows live in the SHARED internal/agentcfg/conformance suite rather than the statestore driver's own tests, specifically so a second driver cannot ship the interface without them (RFC §9 parity).

Also fixed here, under §17.6. The integration test surfaced two fixture bugs that were real findings rather than test noise: a same-tenant hash recorded before a sibling principal's seed had landed (the admin tier keys by (tenant, agent) with the user slot deliberately excluded, so two users in one tenant SHARE a slot — now asserted explicitly so a future change to that keying surfaces here), and a concurrency leg driving the bare socket, which carries no verified scope and was therefore testing the auth gate rather than the precondition.

Mutation-verified, each executed against the real tree with a booted dev server, each turning OK into FAIL and never into SKIP. (1) Delete the precondition block → FAIL 6. (2) Transpose the precondition and the idempotent short-circuit → FAIL 1, the ordering pin, and it is the ONLY guard that moves. (3) Drop the field threading from one of the sixteen doors → FAIL 1, exactly that door, while the reflection guard stays green — which is why the behavioural table is the load-bearing one. (4) Return CodeInvalidRequest instead of CodeRevisionConflictFAIL 2 on a rebuilt live server, proving the live leg asserts the CODE and not the status alone. (5) Remove the 409 status binding → FAIL 1. (6) Deregister one conformance row → FAIL 1, caught by a registration-count guard added after noticing that a declaration-count guard would have stayed green. (7) Persist the revision record before the precondition → FAIL 5, proving the nothing-persisted assertions are load-bearing. (8) Delete the process-bound sentence from the godoc → FAIL 2, the honesty guards. Live totals on the clean tree: OK 26 / SKIP 0 / FAIL 0.

Zero version movement. ProtocolVersion holds at 0.1.0 — a new optional request field and a new additive error code are not a breaking change (RFC §5.3). Both generated artifacts re-run (D-209 protocol-docs-gen, D-223 protocol-ts-gen), and the Console wire module mirrors the field on all sixteen types by hand.

Cross-references. D-025 (concurrent reuse; the Registry and the Service are both compiled artifacts and both carry N=128 tests), D-196 (the driver aggregator), D-209 (the generated Protocol reference), D-223 (the Console wire-manifest lockstep), D-351 (never claim a property the mechanism lacks — the bar this phase's honesty requirement is measured against), D-262 / D-312 (CodeSessionRunning / CodeSessionErased, the 409 state-forbids posture this follows). CLAUDE.md §4.2, §4.3, §5, §8, §9, §10, §11, §13, §14, §16, §17.1, §17.3, §17.6, §18. RFC §5.3, §6.11, §6.16, §7, §9.


D-365 — extra_instructions reaches the Protocol as one optional RunOverrides field, and the run-level value COMPOSES BELOW the tenant value with no run-level clear

Date: 2026-07-31

Status: Shipped (Phase 220, v1.25). Upstream ask HA-45, narrowed on review.

The ask's premise was refuted by the code, and the narrowing is the decision. The upstream ask stated Harbor offers "no additive sibling" to whole-spine prompt replacement and asked for a new additive mechanism. planner.LLMOverrides.ExtraInstructions (internal/planner/planner.go) has been documented ADDITIVE since the tenant-override completion, renders verbatim into <additional_guidance> (internal/planner/react/prompt.go, buildAdditionalGuidance / overrideExtraInstructions), and survives a SystemPromptOverridebuildSystemContent reads rc.LLMOverrides on BOTH branches, a property already pinned by TestApplyLLMOverrides_SystemPromptOverrideReplaces and TestComposition_ExtraInstructionsStillAdditive. The real gap is REACH, not mechanism: RunOverrides exposed session_id / reasoning_effort / temperature / max_tokens / system_prompt_override / model and no additive field, so the only producer was the admin-gated tenant-override record. Building a second additive mechanism on top of the first would have been §13's "two parallel implementations of the same conceptual feature". This phase ships reach: one optional wire field, zero new prompt semantics, no new section, no new error code, no ProtocolVersion move.

Decision 1 — the run-level value COMPOSES BELOW the tenant value, tenant first, joined by a blank line, in ONE production site.

Three shapes were available and the reason for the choice is an authorization asymmetry, not taste.

  1. The field's declared semantic is ADDITIVE. A field that is additive with respect to the base prompt but replacing with respect to its sibling producer would carry two meanings.
  2. Replacement would destroy a property the system holds today. governance.set_tenant_overrides is ADMIN-scope-gated. runs.set_overrides requires only a verified identity triple targeting the caller's own session — no admin claim. And today the tenant's additive block is UNREMOVABLE by any session-level caller: a session SystemPromptOverride replaces the base spine but leaves <additional_guidance> intact. Per-field last-writer-wins would hand a non-admin caller a silent delete on an admin-set compliance block — a privilege inversion. Composition preserves the property.
  3. Composition order is already the trust ordering. buildAdditionalGuidance renders operator-baked → override-additive → per-turn repair, earlier meaning higher authority. Tenant-before-run continues that monotone descent and reuses the identical "\n\n" join, so the rendered shape is indistinguishable from a single block written by one author.
  4. Refusal was rejected. Failing a run-level set because a tenant record happens to exist would make an invisible, admin-owned condition a hard block on a per-run contribution, and a failure the caller cannot diagnose.

There is NO run-level clear, and a present-but-empty value is accepted rather than refused. An empty / whitespace-only run-level value contributes nothing and returns the tenant value UNTOUCHED — the exact pointer, so a run with no session contribution resolves byte-identically to today's. That is what stops "clear" being reachable by the back door of "set it to empty". The join also emits no dangling separator.

Consequence stated rather than hidden: the two contributions are NOT distinguishable to the model. Both sit in one trusted block. Per-source attribution is phase 222's job, on the durable per-agent surface where the contributors actually live.

Decision 2 — NO per-field scope gate, and the METHOD's authorization tier is carried as a NAMED OPEN RISK rather than silently resolved.

<additional_guidance> is joined RAW — buildAdditionalGuidance performs no escaping, in deliberate contrast to <user_instructions>, which passes through escapeUntrustedSection and carries an explicit subordinate framing. The position is operator-trusted by construction. Before this phase the only producer of that text was the ADMIN-gated tenant record; after it, any caller who can reach runs.set_overrides for their own session can write into it. That is a real widening and it is written into the field's godoc, the Protocol reference and the operator skill in those words.

It grants no authority CLASS that surface does not already grant. system_prompt_override is on the same struct, reachable by the same caller, and is strictly more powerful: it replaces the entire base spine, verbatim and unescaped. A deployment that trusts a session caller with system_prompt_override already trusts them with strictly less. Gating the weaker capability behind admin while leaving the stronger one ungated on the same method would be incoherent, so no per-field gate was added. If a deployment wants operator-only prompt authorship the gate belongs on the runs.set_overrides METHOD, covering both prompt-text fields, and that is a separate decision about an already-shipped surface. Named, not resolved.

BINDING NON-GOAL — this is NOT the home for recalled conversation memory, retrieved documents, or any user-authored text. <additional_guidance> is verbatim and unescaped; recalled content belongs in D-364's StartRequest.caller_memory, which composes into the UNTRUSTED-framed <read_only_external_memory> tier behind an anti-prompt-injection preamble. A trusted position must never hold user-authored content. The two phases shipped one wave apart precisely so a consumer has the right slot for each, and the field's godoc names caller_memory explicitly — pinned by a smoke guard, because a field nobody is diverted away from reproduces D-364's defect in a new place.

The bound on an oversized value is the TOKEN guard, not the byte guard — verified, and stated because the obvious assumption is wrong. findContextLeak treats only RoleTool text as offloadable (internal/llm/safety.go), so system-role text is byte-EXEMPT from ErrContextLeak — the same exemption D-364 corrected a false claim about. A large extra_instructions is bounded by the transport's whole-body cap and, late, by the LLM edge's token-budget guard (ErrContextWindowExceeded). No per-field byte cap is added, because system_prompt_override — unbounded on the same struct today — would make a cap on the weaker sibling pure ceremony. Both bounds are cited in the godoc rather than implied away.

Mechanics. RunOverrides.ExtraInstructions *string (extra_instructions,omitempty); runsprotocol.PendingOverride.ExtraInstructions, copied BY VALUE in validate so a caller mutating its request struct afterwards cannot change the stored slot; the join in joinAdditiveGuidance, called from the session arm of ComposeLLMOverrides — the ONE production composition that cmd/harbor's run loop, the devstack twin and the integration test all reach through RunLoopDriver.resolveLLMOverrides. The joined result is a NEWLY allocated string: appending into the tenant record's own string would corrupt the record every other concurrent run reads, the exact D-025 failure, and the N=128 concurrent-reuse test asserts the shared record is byte-unchanged afterwards. events.RunOverridesSetPayload.SetExtraInstructions carries the FLAG only — the value is caller-supplied free text and never rides the bus (CLAUDE.md §7), asserted by marshalling the whole event and substring-scanning it.

Zero version movement. One additive optional field on an existing wire type. No method, no error code, no bodyscope row change, ProtocolVersion holds at 0.1.0. All three generators re-run (protocol-ts-gen, protocol-docs-gen, protocol-ts-types-gen) after rebasing on phase 219, which owns the manifest for Stage 1; the Console wire module mirrors the field by hand and ALLOWED_OVERRIDE_KEYS gains the key. The wire_surface_digest does NOT move, and that is expected rather than evidence of a failed regeneration — the digest is NAME-level (protocol version + method names + error codes + capabilities + wire-type names), and a field addition moves none of those.

Deviations from the plan (§4.3), each named rather than absorbed.

  1. The unit tests land in a new sibling internal/runtime/runs/protocol/extra_instructions_test.go rather than appended to overrides_test.go. Same protocol_test package, same shared helpers; a 300-line append to an already-400-line file buys nothing. The plan's file list is updated to match.

  2. A driver-level test was ADDED beyond the planTestResolveLLMOverrides_ExtraInstructionsJoin_TenantThenSession in internal/runtime/serve. The plan asserted the join is reached through RunLoopDriver.resolveLLMOverrides, but resolveLLMOverrides is unexported and the integration test (like its phase-92b predecessor) calls ComposeLLMOverrides directly. An in-package test closes the claim mechanically instead of leaving it as prose.

  3. The react test is named TestComposition_AbsentExtraInstructionsRendersNoGuidanceSection, not ..._IsByteIdentical. "Byte-identical to the pre-change output" is not expressible as an assertion inside one build. The property is pinned in two halves that ARE: the react test asserts an absent field renders no <additional_guidance> section and produces a body byte-equal to a bundle without the dimension, and the join table asserts the composer returns the tenant's exact pointer when the session contributes nothing — stricter than equality, and the half that would actually catch a re-derivation.

  4. The smoke's phase-gate SKIP arm was DELETED, not kept. The skeleton opened with "the field is absent → skip every downstream guard". Mutation-verified: deleting the wire field produced OK 0 / SKIP 1 / FAIL 0 and exit 0 — the §4.2-item-5 failure the guard was meant to prevent, and the same finding D-364 recorded on its own skeleton one stage earlier. The phase has SHIPPED, so absence is a regression. The skeleton's live legs also read ${HARBOR_DEV_TOKEN} directly (issue #624 — empty outside preflight, so every live assertion SKIPs while the script exits 0); they now resolve through common.sh's dev_bearer, and an unresolvable bearer is a FAIL rather than a SKIP.

  5. The skeleton's run_filtered_tests helper was replaced with common.sh's assert_go_tests_pass. The former SKIPs when -run matches nothing, so a renamed or deleted guard test reads as "phase not yet landed". The latter greps -v output for a --- PASS: line per NAME, so a rename is a FAIL. Same reason as deviation 4, one layer down.

  6. A stale cross-reference in docs/skills/use-the-harbor-protocol/SKILL.md was corrected under §17.6 — phase 219 pointed the system_prompt_override contrast at "§7", which is the topology-snapshot section; runs.set_overrides is documented in §1.

  7. mktemp template portability is now gated by drift-audit.sh, because this phase shipped the second instance of a class that already held a release twice. The first CI run of this PR failed the preflight gate on mktemp: too few X's in template 'phase220-gotest' — AFTER every one of the smoke's twenty-six assertions had reported OK. GNU mktemp (Linux, where CI runs preflight) rejects a template with fewer than three trailing Xs outright; BSD mktemp (macOS, where contributors run preflight) accepts it and invents a suffix. This is the identical macOS/Linux divergence shape as the non-portable \t / \d grep-escape guard that already sits in drift-audit.sh — a construct that passes locally and dies, or silently never matches, in CI. That guard exists because the escape class held the v1.22 release twice; the mktemp class was ungated. A sibling was found in the same sweep and fixed under §17.6: scripts/smoke/phase-184.sh carried mktemp -d 2>/dev/null || mktemp -d -t harbor-smoke, whose fallback has never fired because the first arm succeeds on both platforms — which is exactly why it sat there unnoticed. Both now use the explicit "${TMPDIR:-/tmp}/name.XXXXXX" form (-t is deprecated in GNU coreutils). Verified against real GNU coreutils rather than reasoned about: in debian:stable-slim, mktemp -t phase220-gotest reproduces the exact CI message and exits 1, while mktemp "${TMPDIR:-/tmp}/phase220-gotest.XXXXXX" succeeds — both offenders and both fixes confirmed. The new guard scans every mktemp in scripts/ that PASSES a template (a bare mktemp / mktemp -d is portable by construction and is not flagged) and requires it to end in XXX. Three false-positive shapes were found by running it against the real 92-invocation corpus and fixed before it landed: it must not treat . as a shell terminator (that truncated every name.XXXXXX template before its X's), must not split on $ (that truncated every ${TMPDIR:-/tmp}/… template), and must strip shell quoting before the empty-template check. It matches only COMMAND-POSITION mktemp, which is what keeps it from tripping on its own source — and drift-audit.sh is deliberately still in scope, since it runs its own mktemp for the markdownlint output path and a guard that exempts its own file cannot see it. Mutation-verified four ways, each OKFAIL with file:line and the offending template: reintroducing this PR's own offender; reintroducing the phase-184 one (proving a second invocation later on the same line is caught); shortening a dotted template to name.XX (proving it enforces three X's, not merely "contains an X"); and breaking drift-audit.sh's own mktemp (proving it is not blind to itself). Clean tree: OK 1397 / WARN 0 / FAIL 0.

Mutation-verified, each executed against the real tree with a booted dev server, each turning OK into FAIL and never into SKIP. Clean-tree totals: OK 26 / SKIP 0 / FAIL 0.

  • Turn the join into out.ExtraInstructions = session.ExtraInstructionsFAIL 4: the precedence guard plus the join-table, run-loop and integration suites. The guard that stops the privilege inversion.
  • Swap the join order (run-level first)FAIL 4: the ordering guard plus the same three suites.
  • Route the composed value through escapeUntrustedSectionFAIL 1, the react verbatim suite — the assertion that keeps the trusted and untrusted positions distinguishable.
  • Drop SetExtraInstructions from emitAuditFAIL 2: the nil-check guard and the audit-flag suite.
  • Delete the wire field entirelyOK 19 / SKIP 0 / FAIL 7. Under the shipped skeleton this was SKIP 1 / FAIL 0.
  • Strip the trust statement from the godoc, field intactFAIL 3, the three documentation guards independently.
  • Rename the Console hand-mirrored keyFAIL 1, the D-223 lockstep guard.
  • Make an empty run-level value clear the tenant blockFAIL 4: the no-clear guard plus three suites.
  • Alias the caller's pointer in validate instead of copying → the by-value sub-test goes red while its two siblings stay green, proving the assertion is the load-bearing one.
  • The live legs cannot silently pass on an older build, probed rather than assumed: the handler decodes with DisallowUnknownFields(), and an unknown override key answers 400 against the booted server — so a build without the field answers 400 to the smoke's payload and the leg FAILs (it asserts 200), never SKIPs.

Cross-references. D-364 (the untrusted-framed tier this phase's non-goal points at, and the same RoleTool byte-exemption finding), D-232 (the session-override apply seam this widens by one field), D-235 / D-236 (the layered-prompt trust boundary the rendering order continues), D-025 (concurrent reuse — the join allocates a fresh string, N=128 under -race), D-209 / D-223 (both generated artifacts re-run; the Console mirror hand-maintained), D-351 (never claim a property the mechanism lacks — the bar the open-risk statement is measured against). CLAUDE.md §4.1, §4.2, §4.3, §5, §6, §7, §10, §11, §13, §14, §16, §17.1, §17.3, §17.6, §18. RFC §5.2, §6.2, §6.5, §6.15. Briefs 02, 13. Plan: docs/plans/phase-220-extra-instructions-on-run-overrides.md.


D-367 — ExtraSystemBlocks on the agent-config PAYLOAD: ordered, name-addressed, admin-written, rendered VERBATIM

Date: 2026-07-31

Status: Accepted

Context. The agent-config payload's prompt surface was two flat *strings — PromptLayers{Base, User}. N independent capability sources that each want to contribute one prompt block therefore collapse into one opaque string, and removing one contributor's text means re-deriving the whole composition from prose. The upstream ask (HA-45) proposed a fix on the per-run override bundle.

Decision. A new agent-config payload SECTION, extra_system_blocks, carrying an ORDERED list of {name, body} blocks, written by ONE admin verb (agent_config.set_extra_system_blocks) as a WHOLE-SECTION desired-state replace, rendered VERBATIM in declared order into the existing <additional_guidance> position behind a plain-text [name] label.

Departure from the ask: the payload, not the per-run bundle. A per-run bundle is consumed at the next message and reconstructed by whoever assembles the request, so a per-run block list inherits the same "who reconstructs the rest" problem it is meant to solve — the second contributor still has to know, and re-send, the first's block. On the config payload the list is durable state the registry owns, readable by name through agent_config.get, and mutable by a name-addressed read-modify-write that D-366's expected-revision token makes safe against a concurrent second contributor. That token is why this phase ships ONE section-replace verb rather than per-item upsert/delete verbs.

Correction (D-370). The sentence above was true of every write EXCEPT the first. On an agent with no config, agent_config.get answers set: false with no hash to echo and every non-empty token was refused, so the only token a first contributor could express was the empty one — which means unconditional. Two contributors composing onto a FRESH agent therefore both wrote unconditionally and the second silently reverted the first. D-370 adds the reserved first-write token expected_content_hash: "-", which succeeds only while no active revision exists; the composition protocol described here is expressible at its base case from that decision onwards. This paragraph is left as written (the log is append-only) with the correction attached rather than folded in.

Ordering is the declared slice order, and NormalizePayload MUST NOT sort it. This is a deliberate asymmetry with two sibling sections. Skills.Names is sortDedup'd and OAuthProviders is sorted by name, both because ContentHash's own godoc says a re-ordering of a SET must not perturb the hash. For blocks a re-ordering DOES change the rendered prompt, so it MUST change the hash, mint a real revision, and appear in the diff (ExtraSystemBlocksDiff.Reordered, which has no analogue on the sorted siblings). The carrier is a SLICE, never a map keyed by name: map iteration order is not a composition order. No map appears anywhere on the write → normalize → hash → project → render path, and the smoke greps for its absence. The mutation "someone made blocks consistent with skills by adding a sort" turns four tests and one live smoke leg red.

Trust is argued from the WRITE DOOR, not assumed. The section has exactly one write door and it sits in canonicalAgentConfigAdminMethods — the same auth.ScopeAdmin tier that writes PromptLayers.Base, which is already rendered verbatim and is strictly MORE powerful (it is the whole spine, and a session SystemPromptOverride replaces it wholesale). Escaping a block while leaving Base unescaped would defend against a writer who can already replace the entire prompt — incoherent — and would mangle an operator's angle brackets. Contrast the layer that IS escaped: PromptLayers.User has a CLAIM-FREE lower-tier write path (agent_config.session.set_user_prompt), which is exactly why renderUserInstructions runs it through escapeUntrustedSection. Blocks have no such path, and this phase makes keeping it that way a TESTED invariant rather than an assumption.

The obligation this creates, stated rather than engineered away: escaping is not the boundary here — the write door's authority tier is. A capability that wants to surface user-authored or model-authored text MUST NOT put it in a block; it uses the UNTRUSTED-framed memory tiers (D-364's start.caller_memory) or PromptLayers.User. That obligation is in the wire godoc, the domain godoc, the planner godoc, the glossary entry and here. Two guards make a future reopening LOUD rather than silent, and both are mutation-verified: the smoke's admin-tier assertion (the verb must be in BOTH closed method sets) and the session-verb assertion (user.go must stay free of the section).

A <block name=…> prompt tag was REJECTED. The attribution the ask needs is a DATA-MODEL property, not a prompt-syntax one: the gap is that a contributor cannot find and replace its own contribution in the CONFIG, and the read surface plus a unique name closes exactly that. Minting a tag from config data would make the prompt's structural taxonomy a function of caller input, and would make the name charset load-bearing for structural safety rather than merely for legibility. The plain [name] label is for a human debugging a transcript; it is not a security boundary and must not be described as one — to the model, two blocks from two capabilities are one contiguous run of trusted guidance.

Three positions, not two mechanisms for one concept (§13). Base is one value, admin-only, the verbatim spine, replaced wholly by a session override. User is one value with a claim-free write path, escaped and framed subordinate, suppressed with the spine. Blocks are N, ordered, named, admin-only, verbatim, additive, and they SURVIVE a session override — for free, because buildAdditionalGuidance is reached on BOTH branches of the base request. Neither is expressible in the other. The selection rule is in godoc so a future contributor does not re-derive it: spine → Base; user-authored → User; per-capability additive attribution → ExtraSystemBlocks.

Names are unique and charset-restricted ([A-Za-z0-9._-]{1,64}), refused at the write door. Uniqueness is what makes remove-by-name well defined, which is the composability property the section exists to provide, so a duplicate is refused with invalid_request naming BOTH offending positions rather than silently de-duplicated. The same validation runs at the second door (set_revision), so a section that door persists is one the first door would have accepted.

Absent ⇒ byte-identical (§10). The section is an optional pointer field; a stored revision written before this phase unmarshals to nil, normalizes out of the canonical form entirely, and does not perturb its ContentHash. renderExtraSystemBlocks returns "" for nil/empty, so buildAdditionalGuidance joins nothing. Pinned by a BYTE-EQUALITY test against the pre-change composition, not by inspection. No harbor.yaml key, no ProtocolVersion bump, no new error code.

No cap on block count or body size, deliberately. The two real bounds are ProtocolConfig.MaxRequestBytes at the wire door and the LLM edge's token-budget guard (ErrContextWindowExceeded). The byte-leak check does NOT cover this content — verified rather than assumed, because the obvious assumption is wrong: findContextLeak treats only RoleTool text as offloadable, so system-role text is byte-exempt from ErrContextLeak. A per-section cap would be operator policy with no consumer asking for it, and PromptLayers.Base — unbounded on the same surface today — would make it pure asymmetry.

Mutation-verified, each turning OK into FAIL, never into SKIP. (1) Add a sort to the block normalizer → the order-preservation AND order-is-semantic-hash tests both FAIL. (2) Change the carrier to map[string]… → the compile breaks first, which is the point of choosing a slice. (3) Route block bodies through escapeUntrustedSection → the verbatim-render test and its escaped-user-layer contrast both FAIL. (4) Drop the blocks arm from a sibling verb's carry-forward → that cell of the reflection-driven preservation matrix FAILS. (5) Remove the verb from canonicalAgentConfigAdminMethods → the admin-tier lockstep FAILS. (6) Drop the duplicate-name refusal → the write-door test and the set_revision parity test both FAIL. (7) Add a sort to payloadToWire, rebuild the binary and re-run the LIVE smoke → the live ordering leg FAILS. That last one also exposed an INERT guard in the smoke skeleton: its two-block fixture was [alpha, beta], already sorted, so the ordering assertion reported OK against the sorting mutant. The fixture is now deliberately reverse-alphabetical ([zulu, alpha]). Live totals on the clean tree: OK 29 / SKIP 0 / FAIL 0.

One §17.6 cross-phase fix, named rather than smuggled. This section's verb is the SEVENTEENTH spine-writing door, and D-366's exact-count guards caught it exactly as designed — scripts/smoke/phase-221.sh reported three FAILs (17 found, 16 wanted) on the wire types, the json tags and the Console mirror. Bumping the counts alone would have been the wrong fix: D-366's behavioural table (TestConditionalWrite_All…DoorsAcceptTheToken) and its reflection twin are hand-enumerated, so they would have stayed green while the new door went undriven. Both were extended, so the new door is DRIVEN with a stale token and asserted to refuse with ErrRevisionConflict like its sixteen siblings; the smoke's three counts, the two test length assertions, the two test names and the glossary's door count all move together. Mutation-verified: removing the new door's table row turns the length assertion FAIL, and reverting one smoke count to 16 turns that leg FAIL — neither SKIPs. D-366's own text is left untouched (the decisions log is append-only); the count it records was true when it was written.

Correction (v1.25 §17.5 checkpoint, 2026-07-31 — two overclaims in the ordering paragraph above).

  1. "No map appears anywhere on the write → normalize → hash → project → render path" is FALSE, and the sentence should never have been written that broadly. Two maps do appear on that path, both local and both correct: seen := make(map[string]int, len(blocks)) in validateExtraSystemBlocks (internal/runtime/agentcfg/protocol/extrasystemblocks.go) and seen := make(map[string]struct{}, len(in)) in normalizeNamedBlocks (internal/agentcfg/agentcfg.go). Neither determines order — they are membership sets over an already-ordered slice — and that is the real invariant. The claim narrows to: no map is the CARRIER, and nothing on the path SORTS. Both halves are true, and both are what the design actually depends on. The SetExtraSystemBlocks godoc, which repeated the broad phrasing, is corrected to the narrow one and now states the distinction explicitly so a future author does not re-derive the wrong invariant.
  2. "and the smoke greps for its absence" overstates the guard. scripts/smoke/phase-222.sh greps the two TYPE DECLARATIONS (ExtraSystemBlocks[^\n]*map\[ against the wire and domain files) — it checks the carrier, never the path. That is the right check for the narrowed claim and the wrong evidence for the broad one. This entry's own mutation record already conceded the guard's limit ("Change the carrier to map[string]… → the compile breaks first"), which is worth reading as the tell: a guard whose mutation cannot make it fire is documenting a compile-time property, not enforcing a runtime one. The load-bearing guard for ordering is the must-not-sort one, which IS falsifiable and IS mutation-verified.

Neither correction touches the decision. The carrier is still a slice, order is still semantic, NormalizePayload still must not sort, and every test cited above still holds. Only two claims about how thoroughly that is enforced are narrowed to what is true.

Cross-references. D-366 (the expected-revision token, which is what makes the whole-section replace safe for two contributors and therefore why there are no per-item verbs), D-364 (the UNTRUSTED-framed caller-memory tier — where user-authored text goes instead), D-365 (the per-run additive string; a DIFFERENT carrier, and neither is a carrier for the other), D-283 (the reflection-driven rebuild-completeness guard, which covered the new section in both directions the moment it was added to ConfigPayload), D-025 (concurrent reuse; N=128 against one shared Service), D-209 / D-223 (the generated Protocol reference and the Console wire-manifest lockstep, both re-run after rebasing on D-366). CLAUDE.md §4.2, §5, §6, §7, §9, §10, §11, §13, §17.1, §17.3, §17.6, §18. RFC §5.2, §6.2, §6.5, §6.15, §6.16.


D-370 — Three wave-v1.25 release blockers: the add door COMPENSATES its live attach, the expected-revision protocol gains an expressible FIRST-WRITE token, and caller_memory is redacted like its siblings

Date: 2026-07-31

Status: Accepted. A §17.5 checkpoint fix. Three findings from an adversarial behaviour review of the v1.25 wave, each PROVEN by execution rather than inferred, each fixed here and each mutation-verified. No Protocol version move, no new method, no new error code, no new wire field, no config key.

F1 — a 409 on add_mcp_connection left a live, unremovable MCP server

The defect. agent_config.add_mcp_connection is the ONE spine-writing door whose live side effect must run BEFORE its conditional write: whether the server answers is the input to what gets written, so the dial → handshake → discover → register happens first and the revision is recorded after. Every OTHER door with a live effect orders them the other way (set_oauth_provider writes at setoauthprovider.go:157, installs at :163, rolls the write back at :168; remove_oauth_provider and set_mcp_discovery_origins follow the same shape through adminwrite.Apply).

So on this door the write could fail with the server already up, and the recErr arms in BOTH the attachErr == nil and the errors.Is(attachErr, ErrAuthRequired) branches bare-returned. Reproduced: seed two revisions so the caller's token is stale, then add with the stale expected_content_hash409 revision_conflict, attacher called once, active revision carrying zero connections. The server was dialed, handshaken, registered and exposing tools, and no revision named it — so remove_mcp_connection (removeconnection.go:105) answered ErrConnectionNotFound and it could not be removed. The last lifecycle event was the transient pending, so the Console showed it parked there forever, and an inline wire-OAuth provider installed for the binding stayed installed because the wireProviderIsNew uninstall existed only on the attach-FAILED branch. internal/protocol/types/agentconfig.go asserted the opposite: "a moved base is refused with the revision_conflict error code (HTTP 409) and NOTHING is persisted."

Decision — compensate, do not reorder. Reordering the door to write-then-attach was rejected on a concrete cost, not taste: the common attach-failure path would then mint a revision and roll the pointer back, leaving an orphan revision record in list_revisions for every failed dial, and the door's contract that a failed attach records NO revision is an operator-visible property. So the live half is undone instead. Service.compensateAttach runs on recErr in both branches: detach (idempotent, owner-scoped), uninstall a NEWLY-installed inline wire provider (a bind-by-name provider that pre-existed the call is left alone — it outlives this add), then emit the terminal ConnectionStateFailed carrying the scrubbed write error. The caller still receives the ORIGINAL recErr; a compensation is how the runtime keeps its own promise, not a second outcome to branch on. A compensation step that itself fails is logged at ERROR with the residual named, never swallowed (§13).

Correction (D-381). The compensation described above is UNCONDITIONAL, and that is a defect of its own: it tears down whatever this add attached, whether the add CREATED that registration or merely REPLACED one. A same-owner same-name re-attach is allowed and intended (internal/tools/drivers/mcp/registry.go — "a re-attach that supersedes a still-live connection is the operator replacing their own"), so re-adding an ALREADY-LIVE connection with a stale token drove the compensation against a server the ACTIVE revision still names: tools deregistered, transport closed, a terminal failed emitted for a healthy connection, in-flight runs stripped of its tools. The claim this entry makes — that a revision_conflict is side-effect free — therefore remained false, now in the DESTRUCTIVE direction, and the boundary tests below missed it because every one of them uses a FRESH connection name. The same shape held in the provider plane: the inline branch of prepareWireOAuthBinding reported wireProviderIsNew = true unconditionally, so a re-add — or even a failed DIAL — uninstalled a provider the active revision still declares. D-381 scopes both teardowns by the active revision. This paragraph is left as written (the log is append-only) with the correction attached rather than folded in.

A new seam, wired to the object that attached. agentcfgprotocol.ConnectionDetacher (one method, DetachConnection(ctx, tenant, agentID, name)) is satisfied by serve.MCPConnectionAttacher itself rather than by a second concrete, so the compensation tears down through exactly the registry + catalog the attach used — true by construction rather than by wiring discipline. The teardown body is the SHARED serve.detachSource, which the run-start reconcile's MCPConnectionDetacher.Detach now also calls; a second copy would drift on the next change to either half (§13). Wiring is the same optional type-assertion the DiscoveryOriginApplier already uses in mux.go, guarded by the same compile-time interface assertion that makes a signature drift a build failure rather than a silent no-op. A nil detacher does NOT degrade silently: it logs the residual live server at ERROR, because a runtime that genuinely cannot undo an attach must say so.

A second, quieter half of the same defect, fixed here. On the auth-required branch a parkForAuth failure returned an error after the revision WAS recorded and before any terminal event — leaving the lifecycle on pending forever. The revision exists (so the connection is nameable and removable) but the Console reader is stranded; a terminal failed is now emitted first.

The other sixteen doors were checked for the same shape, mechanically, and none has it. Grepping every door file for the four live-effect collaborators (attacher, providerInstaller, discoveryApplier, llmProviderInstaller) reduces the seventeen to four: thirteen (set_revision, rollback, skills.upsert, skills.delete, set_tool_exposure, set_prompt_layers, set_llm_params, remove_mcp_connection, set_extra_system_blocks, and the four user.* doors) are PURE registry writes with no live effect at all, so there is nothing to half-apply. Of the remaining four, set_oauth_provider, remove_oauth_provider and set_mcp_discovery_origins all write first and compensate the live effect on failure — the correct order, verified by reading each adminwrite.Apply closure. add_mcp_connection was the outlier. agent_config.set_llm_provider was checked too and is NOT a door (D-366): it drives an installer and never calls the registry, and its install already carries its own uninstall-on-failure.

Mutation-verified, each turning a green test red. (1) Delete the compensateAttach call in the online branch → four tests FAIL, including the live-registry and terminal-event assertions, while the "caller gets a 409" and "no revision persisted" assertions stay GREEN — which is exactly how the defect shipped past its own tests. (2) Delete it in the auth-required branch → only the auth-required test FAILS. (3) Drop the wire-provider uninstall from the compensation → the orphan-provider test FAILS. (4) Drop the terminal failed emit → the two lifecycle-event tests FAIL. Boundary tests pin it the other way: a SUCCESSFUL add detaches nothing (the compensation never fires on the happy path), and a FAILED attach detaches nothing (mcpdrv drained its own closers; a second teardown would be of a server that was never up).

Correction (D-381). Two of the guards described in this paragraph did not hold what they claim. (a) The seven-test suite's detacher double declared DetachConnection(_ context.Context, _, _, name string) — it IGNORED the tenant and agent it was handed — so mutating the production call to pass a wrong tenant left all seven GREEN, while in production that mutation is a SILENT no-op (Registry.Deregister answers a foreign owner with ErrServerNotFound; serve.detachSource swallows it as idempotent), restoring invisibly the exact leak this entry closes. The double now models owner scoping structurally and the mutation turns four tests red. (b) Every boundary test uses a fresh connection name, so none of them could see the unconditional-teardown regression the correction above records. D-381 adds the re-add cases.

F2 — the FIRST conditional write was unprotectable

The defect. D-367 prescribes the composition protocol: "A second contributor composes by read-modify-write, sending the read revision's content hash as the expected-revision token so a concurrent sibling's write is refused rather than silently reverted." That protocol had no expressible form at its OWN base case. On an agent with no config, agent_config.get answers set: false with no hash, and the driver refused every non-empty token against an agent with no active revision — so the only token a first contributor could send was the EMPTY one, which means unconditional. Reproduced: contributor B reads set=false, A writes [alpha], B writes [beta] with the only token it can express, final content is [beta]. Silent loss, no conflict raised, both told 200 — the exact lost update the token exists to prevent, on the one write where two contributors composing a fresh agent are most likely to collide.

Decision — a reserved sentinel, expected_content_hash: "-" (agentcfg.ExpectNoActiveRevision). It succeeds only when the agent has no active revision and is refused with ErrRevisionConflict the moment one exists, on both the content-write door and the pointer-move door.

Why the sentinel and not a zero-state token from the read. Returning a stable zero-state token from agent_config.get when set: false was the alternative and it is strictly worse: the value it returned would STILL have to be a reserved non-hash string (a caller echoing it into a hash-shaped field has the same collision question to answer), so it is the same sentinel PLUS a new field on every get response, PLUS the Console mirror and generated-reference churn that a response-shape change carries, PLUS a second place for the reserved value to drift. The cost the reviewer correctly named — a sentinel inside a hash-shaped field — is paid by picking a value that is not a possible hash and PROVING it: ContentHash is sha256 hex, so a real token is exactly 64 lowercase hex characters, and "-" can never be one. That is asserted by a conformance row (hashShaped) rather than argued, in both directions: a real recorded hash must be hash-shaped, and the sentinel must not be.

Additive, byte-for-byte. Absent token ⇒ today's unconditional write, unchanged. A 64-hex token ⇒ unchanged. The three arms live in ONE shared checkExpectation helper the content door and the pointer door both call, so the two can never disagree on what a token means, and the mismatch message now names the sentinel so a caller that hits the old refusal is told what to send.

The conformance suite carries it, so a second driver inherits it. Three new rows beside D-366's four (FirstWriteSentinel, FirstWriteSentinelRefusedOnceSet, FirstWriteSentinelPreventsLostUpdate), run under both scope arms. The last one REPRODUCES the lost update and then shows it closed — the row a future reader can read as the bug report. scripts/smoke/phase-221.sh's exact-count guards moved 4 → 7 with them; per D-367's own precedent, bumping counts alone would have been the wrong fix, so the three rows are behavioural rather than declarative.

Mutation-verified. (1) Make the sentinel arm always succeed (degrade to unconditional) → the refused-once-set and lost-update rows FAIL under both scopes. (2) Delete the sentinel arm entirely so it falls through to the no-active-revision conflict → the first-write and lost-update rows FAIL under both scopes.

D-367's prose is corrected in place with a visible marker, not silently rewritten. The log is append-only, so the original paragraph stands and a > **Correction (D-370).** block is attached beneath it. The godoc says the same thing at all four places a reader meets the protocol: the seventeen wire request fields, the SetOptions field, the agentcfg.ExpectNoActiveRevision constant, and extrasystemblocks.go's file doc.

W1 — caller_memory was persisted verbatim while its siblings were redacted

The defect. internal/tasks/engine/engine.go routes Description and Query through redactSpawnFields; CallerMemory was copied raw. Proven against the REAL patterns redactor: stored Query = "please use Bearer ***", stored Description = "desc Bearer ***", stored CallerMemory = {"token":"Bearer sk-…"}, and the durable driver's whole-record marshal — the bytes that land in the StateStore on disk — contained the raw secret. types/control.go documented the prompt-injection residual and said nothing about at-rest persistence, so an operator who saw query redacted would reasonably infer the same of caller_memory. The inconsistency is the part that could not survive; the fix could have been either redaction or an honest godoc.

Decision — redact, chosen on measured evidence rather than assumption. The reviewer was right that redacting structured JSON through a text redactor could be unsafe or lossy, so it was MEASURED before choosing. Driving the canonical rule set over representative payloads: objects, arrays, numbers, booleans, null and nesting all survive structurally intact; non-secret values pass through unchanged; only secret-shaped KEYS (api_key / password / secret / token / cookie / authorization) and inline Bearer … / Basic … VALUES are replaced. It is not a text redactor on this path at all — Engine.redactRawJSON decodes, walks the value, and re-encodes, and the engine ALREADY takes that exact path for MarkComplete's structured result.Value. So redaction is the same mechanism on the same shape in the same file, not a new risk.

Three consequences, each handled rather than absorbed.

  1. Idempotency. spawnRequestsEqual compared the STORED CallerMemory to the RAW request byte-for-byte, which would now differ for any payload containing anything secret-shaped and raise a false ErrIdempotencyConflict on an honest retry. That compare is redundant — spawnRequestContentHash already folds the PRE-redaction bytes, and the pre-redaction hash is strictly stronger because it still separates two payloads whose redacted forms collide. It is removed, with the reasoning left where it was, matching the treatment Description and Query already get.
  2. Malformed input. redactRawJSON is deliberately TOLERANT of non-JSON bytes (it re-quotes them as a string), which is right for a tool result and wrong here: it would have turned a malformed document into a valid one, the whole-record marshal would then have SUCCEEDED, and an unusable row would have persisted with the failure silently relocated to whatever read it later — silently invalidating the property D-364 deviation 7 pinned as a test. So redactCallerMemory validates first and refuses loud with tasks.ErrInvalidRequest naming the field, EARLIER than the marshal. The existing test that pinned the accidental refusal keeps passing and its rationale is rewritten to describe the explicit one.
  3. What reaches the model. The redacted form is what the prompt sees, which is already true of Query (the run loop reads the stored task).

The godoc is corrected in three places and the residual is restated honestly rather than dropped. types/control.go, tasks.Task.CallerMemory and tasks.SpawnRequest.CallerMemory now state the at-rest behaviour — and state that the audit redactor is a PATTERN redactor, not a sanitiser: it does not detect PII, does not detect a credential that looks like prose, and cannot make hostile text safe. The untrusted prompt framing remains the mitigation for injection, and an operator piping third-party content through caller_memory still has a leakage path neither a prompt wrapper nor a pattern redactor closes. Claiming otherwise would be exactly the over-claim D-351 forbids.

Mutation-verified. Restore the raw copy → the redaction test, the structure test and the malformed-refusal test all FAIL while the Description / Query assertions stay GREEN, which is the shipped inconsistency reproduced.

Cross-references. D-366 (the expected-revision token this makes expressible at its base case, and whose sixteen-door survey this extends to seventeen), D-367 (the composition protocol corrected in place), D-364 (caller_memory's admission path, its untrusted framing, and deviation 7's malformed-payload pin), D-351 (never claim a property the mechanism lacks — the bar the redaction residual is written to), D-025 (concurrent reuse), D-196 (the driver aggregator), D-209 / D-223 (both generators re-run; neither manifest moved, because no wire SHAPE changed). CLAUDE.md §4.2, §5, §6, §7, §9, §11, §13, §17.5, §17.6, §18. RFC §5.3, §6.11, §6.16, §7, §9.


D-371 — The spawn idempotency index key carries the full identity triple: dedup is bounded by an isolation boundary, not by the entropy of a session id

Date: 2026-07-31

Status: Accepted (v1.25). A §6 rule-2 fix found by the v1.25 adversarial checkpoint review and landed in-wave. Structural, not behavioural-for-legitimate-callers: no caller inside a single triple sees any change.

Context — the defect, verified by grep rather than by the report. internal/tasks/engine keyed its spawn idempotency index on (SessionID, IdempotencyKey). The review named two sites; there were six, and one of them was the hydration rebuild, which is the site that decides whether the shape survives a restart:

  • engine/engine.go:102 — the idempotencyKey type declaration.
  • engine/engine.go:168 — Spawn's dedup probe.
  • engine/engine.go:279 — Spawn's index insert.
  • engine/engine.go:306 — Spawn's compensation delete on failed group wiring.
  • engine/engine.go:999contentHashLocked, the re-persist path's hash lookup.
  • engine/backend.go:99 — the Hydrate rebuild, replaying persisted records at boot.

tenant_id was in none of them. user_id was in none of them.

The reachable consequence, stated at its true size. This was NOT a handle disclosure. spawnRequestsEqual (engine/engine.go:1114) compares existing.Identity != req.Identity as its first leg, so a colliding entry from another tenant fails the divergence compare rather than being returned — the caller gets ErrIdempotencyConflict. So the reachable damage is a cross-tenant denial plus an existence oracle: tenant B, presenting a session id and key that tenant A already used, is permanently unable to spawn under that key and learns that A holds it. Session ids are high-entropy, so this is not reachable by guessing. It is fixed anyway, and the reason is the whole point of the entry: an isolation boundary is held by the shape of the key, not by the entropy of one of its components. The same reasoning made D-363's user-axis fold unconditional rather than knob-gated.

Decision. idempotencyKey carries (TenantID, UserID, SessionID, Key).

  1. tenant_id joins the key. CLAUDE.md §6 rule 2 admits no exception for "the other component has enough entropy."
  2. user_id joins it too, and the reasoning is recorded rather than assumed. An idempotency key names ONE CALLER'S RETRY, and a caller in Harbor is the triple — not a session. That a session belongs to exactly one user is an invariant maintained by the session subsystem; this key does not lean on it. Leaning on it is the identical mistake one component over, and it costs nothing to not make it.
  3. RunID does NOT join it, and the type spells the three fields out rather than embedding identity.Quadruple so it cannot drift into the key later. A run-scoped idempotency key would defeat dedup across exactly the retries it exists to collapse — each retry is a new run.
  4. One construction site. idemKeyFor(identity.Identity, string) is the only place an idempotencyKey is built; all six sites route through it, so a write and the read that must match it cannot drift apart. Six hand-built struct literals were how a field went missing from all six at once.

Persistence: no migration, and the evidence is structural rather than a judgement call. The index is derived state that is never written to any store. The durable driver persists task records keyed by the task's own ULID (drivers/durable/record.go:61, taskKindPrefix+string(rec.Task.ID)) under the identity triple's StateStore scope; the idempotency key appears in no key and no payload. The engine REBUILDS the index at Hydrate from each persisted task's own Identity + IdempotencyKey (engine/backend.go:99) — and Task.Identity is a full identity.Quadruple that already carries TenantID. So the corrected key is derivable from records written by the pre-fix code: existing rows are byte-identical, nothing is rewritten, and an upgrade rebuilds the narrower key from what is already on disk. TestDurable_RestartSurvival_CrossTenantIdempotencyKeyStaysIsolated pins this by writing through one engine instance and re-deriving through a second over the same store.

Tests, mutation-verified. The invariant lands in the driver-agnostic conformance suite (internal/tasks/conformancetest) so both shipped drivers — and any third — inherit it: Spawn_DifferentTenantsCanReuseKey and Spawn_DifferentUsersCanReuseKey spawn a colliding (session, key) pair across the boundary and assert two distinct tasks, neither flagged Reused, each readable only by its owner, with the foreign Get still ErrNotFound — plus a same-tenant retry that still dedups, so the fix is shown to narrow the key rather than disable idempotency. Reverting idemKeyFor to the pre-fix shape fails both subtests on both drivers (tasks: idempotency key reused with divergent SpawnRequest: key="collide-key") and fails the durable restart test at its second spawn.

Documented surface updated in the same PR (§18). StartRequest.IdempotencyKey's wire godoc (internal/protocol/types/control.go) and the hand-written docs/site/protocol/task-control.md both said "per session"; both now say the triple. No wire shape changed — idempotency_key is the same field with the same JSON tag — so the generated Protocol reference regenerates with prose only.

Cross-references. D-363 (the search cluster's user axis — the same "an omitted or unexamined isolation component is a boundary hole, fix it unconditionally" call, one subsystem over), D-352 (the artifact read key IS the isolation triple), D-356 (an artifact listing scopes to the caller's own user). CLAUDE.md §5, §6 (rules 1, 2, 9, 10), §9, §11, §13, §17.6, §18.


D-377 — The model-visible tool name is BOUNDED and shortened tail-first, independently of the catalog key

Date: 2026-07-31

Status: Accepted

Context. Tool catalog keys are <sourceID>_<tool> (internal/tools/drivers/mcp/mcp.go:936). The catalog is FLAT and process-global (internal/tools/catalog.go:120byName), so a source id has to be globally unique, and operators reach for long, high-entropy ids to get that. The id is then repeated on every one of that source's tools, and a tool name is not paid once: it is paid on EVERY turn, TWICE per tool — once in the req.Tools[] declaration and once in the <available_tools> prompt section — plus once more each time the model writes the name to invoke it.

The obvious remedy, re-keying the MCP registry so ids can be short, was probed and DISPROVED: a perfectly re-keyed registry (one Registry per owner) still fails at catalog.Register("github_add"): duplicate tool name, because the binding constraint is the flat catalog, not the registry key. D-301 settled that the catalog is flat; reversing it is an RFC, not a phase.

Decision. Leave the catalog key long and globally unique. BOUND the model-visible name instead, at maxToolNameBytes = 44 — deliberately below the provider's 64-byte ceiling — and shorten over-budget names TAIL-FIRST:

text
<...retained tail (35 bytes)...>_<8-hex digest of the full sanitized name>

This is possible because the indirection already exists and is already load-bearing: resolveDeclaredToolName maps whatever the model returns back to the real catalog key by recomputing the forward transform over the catalog. Nothing about the key moves; no isolation property changes; no RFC is needed.

Tail-first, because the head is the part that repeats. Sibling tools of one source share the head and differ only in the tail, so the tail is BOTH the discriminating half and the semantically useful half (it holds the verb). Head truncation is what caused D-378's silent collapse; keeping the tail fixes the cause rather than reporting it.

Why 44 and not a rounder number. 44 implies 44 - 8 - 1 = 35 bytes of retained tail. Measured against a representative 30-verb GitHub-MCP tool set, 44 is the SMALLEST budget at which every verb still renders byte-exact — the longest, get_pull_request_review_comments, is 32. Budget 40 already clips that verb (29/30 survive); budget 24 loses a third of them (18/30); budget 48+ buys no comprehension and gives back roughly half the saving. The number is calibrated to a measured property, and TestSanitizeToolName_KeepsTheVerbVisible is what fails first if anyone tightens it.

Correction (D-386). "44 is the SMALLEST budget at which every verb still renders byte-exact" is wrong, and the arithmetic in the same paragraph shows why: shortenToolName retains budget - toolNameDigestBytes - 1 = budget - 9 bytes of tail, and the longest verb in the cited corpus, get_pull_request_review_comments, is 32. The smallest budget at which all 30 render byte-exact is therefore 41 (41 - 9 = 32), not 44. Budgets 41, 42 and 43 all render 30 of 30. The rest of the paragraph is unaffected — 40 does clip that verb (29/30), 24 loses a third of them (18/30), and 48+ buys no comprehension — and the SHIPPED value stays 44, which now reads as 3 bytes of deliberate headroom above the measured floor rather than as the floor itself. TestSanitizeToolName_KeepsTheVerbVisible is still what fails first if anyone tightens the budget past 41.

Measured effect (30 tools, declared-name bytes per turn across both surfaces; characters are measured exactly, tokens are NOT — no tokenizer is vendored, see below):

catalog keykey lenbefore (budget 64)after (budget 44)change
github6133413340% — in-budget names pass through unchanged
high-entropy typical3631242640−484 B/turn (−15%)
owner-encoded long6438402640−1200 B/turn (−31%)

The token figure is an ESTIMATE and is labelled as one. No tokenizer is vendored in this repo; the only in-repo estimator is DefaultTokenEstimator (internal/planner/compression.go:79), a chars/4 English heuristic that UNDERSTATES high-entropy alphanumeric strings. Dividing the measured character deltas by 4 (repo heuristic) and by 2.5 (the ~2-3 chars/token that high-entropy strings actually cost) brackets the long-key saving at roughly 300–480 tokens per turn. That range is derived by division from exact character counts, not measured — do not quote it as measured.

A short per-source ALIAS was evaluated and REJECTED. An alias (s3_create_issue) saves more, and it is the first idea anyone has. It fails on a property the current transform has and an alias cannot: sanitizeToolName is a PURE function of its argument. It reads no catalog, no ordering, no per-run state, so a name's model-visible form cannot shift. An alias assigned from catalog composition or ordinal position shifts the moment the catalog GROWS — and it grows mid-run by design, since discovered tools are appended per turn. A replayed historical tool_call would then no longer match any current declaration and the provider 400s. Making the alias stable instead requires per-run alias state, which the planner may not hold (§5 / D-025 forbid mutable state on the compiled artifact) and which buildToolDeclarations cannot reach. It would also be a SECOND mapping beside the existing one, which is §13's "two parallel implementations of one concept". The purity is recorded in godoc as load-bearing, not incidental, and TestSanitizeToolName_Deterministic pins it.

A prerequisite fix, not optional: <available_tools> leaked the raw key. Verifying the indirection found it is NOT total. renderToolNameDesc (internal/planner/react/prompt.go) rendered t.Name RAW while req.Tools[] declared the sanitized form — so the model was already being shown a name it could not call (clock.now listed, clock_now declared), and half the per-turn prefix cost was paid on a surface no shortening would have touched. Both surfaces now go through one transform. This is a pre-existing correctness bug fixed under §17.6, not scope creep: shortening declarations while leaving the prompt raw would have made it strictly worse.

Surfaces audited for raw-key leaks (the shortening is only safe if the indirection is total): declarations → sanitized; <available_tools>was raw, now sanitized; assistant tool_calls[].Name trajectory replay (3 sites) → sanitized; RoleTool result messages → carry ToolCallID, never a name; model-to-runtime invocation parsing (5 projector sites) → resolveDeclaredToolName; slog sites → operator-only, not model-visible, correctly left raw. Two latent leaks are RECORDED, not fixed: renderActionForLLM's CallTool branch echoes the raw key but is unreachable for CallTool (which routes through the native pair path), and dispatch error strings (tool %q ...) can carry the raw key into an observation. Neither is on the declaration path; both are follow-ups.

Cross-references. D-378 (the silent-drop fix this ships with — a shortening scheme makes collisions likelier, so the loud diagnostic is what keeps it safe), D-301 (the flat catalog, which is why the key cannot shrink), D-025 (why per-run alias state is not available to the planner), D-209 / D-223 (both generated artifacts re-run). CLAUDE.md §5, §11, §13, §17.6, §18.


D-378 — A tool declaration dropped on a name collision is ANNOUNCED, never silent

Date: 2026-07-31

Status: Accepted

Context. buildToolDeclarations dedups on the model-visible name, because two catalog tools declared under one function name make provider-side dispatch ambiguous — the model's returned name resolves to whichever tool matched first. The dedup was correct; its FAILURE MODE was not. The collider was dropped with a bare continue: no error, no event, no log, no diagnostic of any kind.

Combined with the old head truncation (D-377), that turned a naming accident into total silence. Measured on a 64-byte source id with a 30-tool server: 30 catalog tools in, 1 declaration out. The <available_tools> prompt section meanwhile still listed all 30 (it dedups on the RAW name), so the model was TOLD about thirty tools and could call exactly one — and nothing anywhere said so. This is §13's forbidden silent degradation, exactly.

Decision. Keep the drop — it is the right behaviour, and the alternatives are worse (see below) — but make it OBSERVABLE. A new canonical event, planner.tool_declaration_collision, carries a typed SafePayload naming the run identity, the colliding model-visible name, the catalog tool that KEPT the declaration, and the catalog tool that was DROPPED. The remedy is operator-side (rename one of the two), so the payload names both.

Why an event and not an error. buildToolDeclarations runs at turn time on every planner step. Returning an error would fail EVERY run of an agent whose operator has two ambiguously-named tools — a config problem escalated into a total outage, and one best caught at registration rather than on turn N. Why not a log: this path has no logger, and the planner struct deliberately holds none (§5 / D-025 — no mutable state on the compiled artifact). rc.Emit is the established diagnostic channel for exactly this shape, with three precedents that are all soft-degrade paths made loud the same way: planner.repair_exhausted, trajectory.compression_failed, planner.action_extra_field_dropped.

Why not disambiguate instead of dropping. Appending a disambiguating suffix to the second tool would keep both declared, and is tempting. It cannot work: resolveDeclaredToolName recovers the catalog name by recomputing the forward transform per tool, STATELESSLY. Any order-dependent disambiguation is not reproducible from the returned name alone, so the round trip breaks. Dropping and announcing is the only shape consistent with the stateless inverse D-377 depends on.

Verdict on every seen/continue site in the projection path — they are NOT all the same defect. This was checked per site rather than fixed reflexively:

siteverdict
deriveDiscoveredFromTrajectory (~L61-75)Legitimate. Dedups RAW names accumulated across tool_search observations. Same name = same tool; no information lost. Unchanged.
mergeDiscovered (~L182-197)Legitimate. Set union of two raw-name slices, order-preserving. Same name = same tool. Unchanged.
buildToolDeclarations always-loaded loopTHE DEFECT. Dedups on the SHORTENED name, so distinct catalog tools collide and one vanishes. Now announced.
buildToolDeclarations discovered loopBoth. Benign when discovery re-surfaces an already-loaded tool (same raw name — the intended skip); a real collapse when the raw names DIFFER. Split by comparing raw names.
buildToolDeclarations reserved-control seedingWas a silent defect too. An operator tool mapping onto _spawn_task was dropped silently. The control still wins — the planner cannot function without it — but the drop is now announced.

The distinguishing rule is one line: seen now maps the model-visible name to the REAL catalog name that claimed it, so the drop can compare them. Same raw name means benign, stay quiet. Different raw names means a lost surface, announce. A diagnostic that fired on the benign path would be noise, and noise is how a real signal gets ignored; TestBuildToolDeclarations_BenignRediscoveryStaysQuiet pins that direction.

Residual collisions remain reachable, which is why the diagnostic is load-bearing rather than theatre. D-377's digest removes LENGTH-induced collisions, but the disallowed-character mapping is many-to-one by construction: clock.now and clock/now both sanitize to clock_now at any length. That is a catalog-naming problem no transform can fix, and it is the case the event exists for.

Known limit, stated rather than engineered away. rc.Emit may be nil — the RunContext.Emit contract says concretes must nil-check, and tests routinely omit it. When it is nil the drop IS silent again. Production always wires Emit, so this is a test-harness gap, not a production one; it is recorded here rather than papered over, because a reader deserves to know the guarantee's exact edge.

Mutation-verified — every guard was broken and watched go RED, never OK to SKIP. (1) Restore head truncation without the digest (the original bug) produces catalog declarations = 1, want 30 — 29 tool(s) collapsed, reproducing the defect exactly. (2) Restore head truncation WITH the digest: the collapse test passes but KeepsTheVerbVisible fails on all 30, confirming the two guards are independent. (3) Delete both emitToolDeclarationCollision calls: both loudness tests fail (collision events = 0, want 1 — the drop was silent). (4) Revert renderToolNameDesc to the raw name: MatchesDeclaredNames fails (the model is shown a name it cannot call). (5) Drop the declared == dropped benign guard: BenignRediscoveryStaysQuiet fails with 30 spurious events. (6) Revert maxToolNameBytes to 64: the cost guard fails (long key costs 3840 bytes/turn vs typical 3124 — the declared name is not bounded).

Cross-references. D-377 (the tail-first shortening that removes the length-induced cause; this entry covers what shortening cannot remove), D-025 (why the planner holds no logger and no per-run state), D-209 / D-223 (the new event type regenerated into the Protocol reference and the Console wire manifest). CLAUDE.md §5 ("Fail loudly"), §11, §13 (silent degradation), §17.1, §17.3, §17.6, §18.


D-376 — The drift-audit's guards are mutation-verified by an external harness, on a constructed corpus, per-guard rather than per-exit-code

Date: 2026-07-31

Status: Accepted

Context. scripts/drift-audit.sh is the mechanical instrument behind CLAUDE.md §16's workflow and half of §13's rejection-on-sight list: the mirror invariant, the plan↔smoke pairing, the RFC §N.M and brief NN cross-reference resolution, the forbidden-name scan, the godoc-jargon scan, the scaffold module pin and release ledger, the operator-skill frontmatter delegation, and the two macOS/Linux portability guards. Nothing verified the instrument. Its guards had been mutation-verified by hand, with the results recorded in code comments; no automated check re-ran those mutations. A regression that re-broke one would have been invisible, because a guard that cannot fire is indistinguishable from a corpus with no violations — the exact failure mode the v1.24 and v1.25 waves found roughly a dozen times across the smoke corpus, here one level up in the tool whose job is to detect it.

Decision. scripts/smoke/phase-224.sh executes the mutations rather than trusting the comments. Per guard: build a throwaway fixture corpus, apply the defect that guard names, run the real audit against it, assert the audit printed that guard's own FAIL (or WARN) line, and assert the pristine corpus printed that guard's OK line and not its bad line.

Four properties make it a verifier rather than a mirror, and each answers a specific way this could have been vacuous.

  1. It runs the REAL subject, byte-identical. Re-implementing the audit's logic would test the copy, not the program — which is precisely where phase 223's tautological assertion came from (rows_seen="${rows_total}", true by construction). The fixture's copy of the audit is cmp-asserted identical before any case runs.
  2. The oracle is external. Every expected verdict is a literal string written by hand in the harness from the guard's own message. Nothing is derived from the audit's output; there is no expected="$(drift-audit …)". The one place the harness reads the subject's source is the forbidden-name word list, which cannot be written literally in this repository (§13) — and that read is safe because the oracle stays external: an emptied list would inject nothing, the audit would report OK, and the harness would report FAIL. The extraction is additionally asserted non-empty.
  3. The corpus is constructed, never observed. Mutations land on a tree built under mktemp -d "${TMPDIR:-/tmp}/…-XXXXXX", one fresh copy per case, removed by an EXIT trap. The working tree is read-only to the harness. The per-run temp path is not incidental: the audit itself carried a fixed-/tmp markdownlint output path that two concurrent audits clobbered, and make preflight runs the audit internally while sibling worktrees run preflight at once.
  4. The verdict is per-guard, never the exit code. A mutation that happens to trip a different guard would make an exit-code check report "caught!" while the guard under test slept.

The harness is deliberately NOT a check inside drift-audit.sh. A self-check living in the subject shares every global failure of the subject: an early set -e exit, an inverted summary, a dropped exit status would take the self-check down with it and still print green. It runs the audit as a separate process and reads only its stdout and exit status.

Property 4 paid for itself on the first run: the harness found two live defects, both fixed in the same PR (§17.6).

  1. brief NN resolution could not fail. Check 3 turns on nullglob and does not turn it off until check 9, so by check 6 the unmatched glob in ls "docs/research/${num}-"*.md expanded to nothing, leaving a bare ls of the current directory — exit 0, "resolves". Every brief citation in every phase plan had been unverified. Confirmed on the live repository, not only in the fixture: with a bogus citation planted in a real plan, the pre-fix audit printed [OK] … 3 brief reference(s) resolve. Fixed with a brief_exists() loop that is correct with nullglob either way — a guard must not be a function of a shell option some earlier check happened to set.
  2. A smoke with no PREFLIGHT_REQUIRES header aborted the whole audit. Under set -euo pipefail, no header ⇒ grep exits 1 ⇒ pipefail propagates ⇒ the command substitution fails ⇒ set -e kills the run: exit 1, no diagnostic naming the file, and six later guards never reached. The one defect the guard exists to report was masking six others. Fixed with a load-bearing || true, commented as such so a cleanup does not delete it. An exit-code-only harness would have read this abort as "mutation caught" — property 4 is what separated them.

Coverage is stated numerically and enforced mechanically. 18 guard units, 18 covered, 0 declared uncovered, 22 mutations; the smoke prints the tally in its own output.

Correction (D-384). Both halves of that sentence were wrong, and each was wrong in a way this entry already names elsewhere.

The count. There were nineteen guard units, not eighteen. drift-audit.sh gained a nineteenth — D-374's smoke body-identity delegation — seven commits after this harness shipped. It emits no ok of its own, so the mechanical census structurally could not see it, and the hand-written exception list (which said "two") was never updated. The harness went on printing "18 of 18, 0 declared uncovered" over a guard with zero coverage. An ok-keyed census cannot see a guard that emits no ok; the hand-maintained half of that list is therefore the load-bearing half, which is now stated in the script rather than implied by a number. And the uncovered guard was, of all of them, the one whose delegation was written if [ -x helper ]; then … fi with no else — so a lost executable bit removed it with no failure, no skip and no output. A guard that vanishes while the summary reads green is the defect this phase exists to remove, sitting inside the phase's own instrument.

The coverage. Every mutation planted its defect in internal/fixture/fixture.go, including for guards whose population is a DIRECTORY LIST. So the harness verified SHAPES and never POPULATIONS, and two real regressions passed it: narrowing the godoc scan's roots from internal/ cmd/ sdk/ to internal/ (dropping D-282's adopter-facing sdk/ extension) and deleting the forbidden-name scan's entire cmd/ block each produced OK: 25 SKIP: 0 FAIL: 0. This is NOT the shape gap the paragraph below disclaims — the cmd/fixture and sdk/fixture files already existed in the corpus; nothing was ever planted in them. It is D-374's own "a sweep is only as wide as the population it enumerated", one level up, in the harness written to catch that class.

D-384 supplies both: a case per population limb, a population census inside the audit, else arms on both delegations, and a corrected count of 20 guard units over 34 mutations. Silent partial coverage is the defect being removed, so it must not be reintroduced by the fix: the harness cross-checks every ok call in the audit against the verified set, and a new guard with no case FAILs ("a new guard shipped with zero coverage"), as does a claimed message the audit no longer emits ("a guard was renamed"). The residuals that a mutation harness structurally cannot cover are named in the plan's coverage census rather than left implied — the markdownlint case verifies the wiring, not the lint ruleset; the scaffold pin exercises the local-tags rung only.

What this does NOT claim. Each case proves a guard is not inert against one mutation of the shape it is written for. It does not prove completeness. The escape guard's continuation-line blindness and its five-of-eight helper alternation were shape gaps in a guard that was otherwise live, and no harness of this kind can enumerate shapes. Where a hardest-known shape exists it is the one used: the godoc case plants jargon in a production file whose comment names a _test.go file (the v1.25 F5 shape); the escape payload puts [^\n] in the second argument of a file-first helper on a backslash-continued line; the mktemp payload hides the invocation behind a one-shot env assignment inside a command substitution.

Shell smoke, not a Go test. The subject is a shell program invoked as one (make drift-audit, scripts/preflight.sh); a Go test would shell out to exactly this and would need a new package purely to host it. The house precedent for a meta-guard over shell and docs is a smoke (phases 102, 223), it uses the common.sh vocabulary §4.2 item 3 binds new assertions to, and — because phase 224 is Shipped — it inherits phase 223's inert-smoke gate, so the harness that guards guards is itself guarded against going dark.

Meta-verified: the harness was broken on purpose, four ways, and went red each time. (1) Revert the audit's godoc _test.go PATH anchoring to the unanchored body match → the test-path case reports MUTATION NOT CAUGHT … THIS GUARD IS INERT. (2) Narrow the escape guard's helper alternation back to assert_grep[a-z_]* → the portability case fails. (3) Neuter a harness-side mutation function → its case fails. (4) Add a new ok guard to the audit with no case → the census refuses it. Breakages 1 and 2 are real historical regressions of the subject rather than synthetic harness damage, which is what makes them evidence. Totals on the clean tree: OK 25 / SKIP 0 / FAIL 0.

Cross-references. D-368 (the inert-smoke gate this harness is protected by, and whose meta-smoke is its exemplar), D-104 (the PREFLIGHT_REQUIRES classification whose guard defect 2 disabled), D-282 (the sdk/ godoc scan extension the jargon case covers). CLAUDE.md §4.1, §4.2, §13, §16, §17.2, §17.3, §17.6. RFC §3.4, §4.3, §8.


D-372 — The pending-override slot map takes a bound with a stated drop policy: evict-oldest, loud, no TTL

Date: 2026-07-31

Status: Accepted

Context. runs.set_overrides records a one-shot next-message override into an in-process map keyed by the identity triple (internal/runtime/runs/protocol/overrides.go). A slot is written by the method and removed by the Consume the next message performs — so a session that records an override and then never sends a message leaves its slot behind, which the package godoc already described as the override being "dropped". The word was wrong: nothing dropped it. The map had no TTL, no eviction and no size cap, so those slots accumulated for the life of the process. Sessions are unbounded, so any authenticated caller reaches unbounded growth by recording an override under a fresh session id in a loop — an availability defect with no elevated claim in front of it. The same shape sat one directory away in the agent-config session-overlay store, whose per-slot write-lock sync.Map was append-only over the same unbounded (triple + agent) key space; it is fixed in the same PR, because patching one and leaving its twin is how a defect class survives its own fix (CLAUDE.md §17.6).

Decision. The slot map holds at most DefaultMaxPendingOverrides (4096) entries. Admitting a new identity at capacity EVICTS the oldest-recorded slot. The write-lock map is REFCOUNTED instead: an entry exists only while a writer holds or awaits it.

The policy is stated, not implied — a silent eviction would itself be the §13 shape. Four choices, each with the alternative it refuses:

  1. Evict, do not REFUSE. A capacity refusal lets one caller filling the map deny the surface to every other tenant — it converts a memory-growth defect into a cross-tenant availability one, which is strictly worse. Eviction confines the damage to the evicted slot.
  2. Oldest-recorded, not newest. A slot's whole purpose is to be consumed by the very next message, so the longer one has sat unconsumed the likelier it is already abandoned. Drop-newest holds the identical bound and discards the write of the caller who just asked for it — which is why the direction is pinned by its own test rather than left to the bound's arithmetic.
  3. Loud, once per eviction, with no first-in-window suppression. §5's drop-oldest-plus-window guidance is written for high-frequency event channels; this is an operator-initiated control-plane write, and below the bound an eviction is impossible. A line here is therefore never routine, and suppressing the second one would hide the scale of whatever is producing them.
  4. No TTL. The slot already has a lifetime — "until the next message". A second, time-based expiry axis would be a second mechanism answering one question, with its own clock and sweeper to own. The bound alone closes the growth.

Correction (D-383). Choice 1 above is REFUTED as written. It rejects refusal because refusal "lets one caller filling the map deny the surface to every other tenant", and asserts that "eviction confines the damage to the evicted slot". Eviction as shipped did neither: the recording-order list is process-global, so one tenant recording overrides under fresh session ids evicted every OTHER tenant's slot, continuously. Executed on the shipped code — after one attacker tenant wrote DefaultMaxPendingOverrides fresh sessions, victim slot present = false and store size = 4096, every slot the attacker's. So the recorded reasoning did not merely overstate a benefit; it named the exact harm the chosen policy produces, as the reason for choosing it. The CONCLUSION (evict, do not refuse) survives; the argument for it does not, and the property it claimed was absent until D-383 added a per-tenant sub-bound to supply it. The real trade between the two policies is narrower than stated: refusal denies the caller that just asked, eviction spends a slot that is likely already abandoned — a choice between two costs, not a containment argument. This paragraph stands as written (the log is append-only) with the correction attached rather than folded in.

An evicted slot is indistinguishable to its session from one never recorded: the next message runs with no override, exactly the already-documented "recorded, then never sent" path.

Shape. NewStore becomes variadic (WithMaxSlots, WithStoreLogger), so every existing call site compiles unchanged and the DEFAULT constructor — the one production uses — is the bounded one. A non-positive WithMaxSlots is IGNORED rather than honoured: an unbounded map is the defect, so there is no way to configure the bound away (the same posture as refusing an identity-downgrading knob). Ordering is a container/list beside the map, so eviction is O(1) rather than an O(n) scan on a path whose rate an attacker controls — a bound whose enforcement is itself a per-request linear scan is a poor answer to an availability defect. Consume pops the list alongside the map; the write-lock twin takes its reference BEFORE acquiring the slot lock and drops it AFTER releasing, so an entry is only ever deleted while nothing holds or awaits it — a delete that ignored the refcount would hand a waiter a different mutex than the holder released and silently stop excluding.

Mutation-verified, each turning OK into FAIL, never into SKIP. (1) Delete the capacity check in Set → 6 unit FAILs and 3 smoke FAILs. (2) Evict Back() instead of Front() → the direction pin FAILs while the plain bound tests stay green, which is the whole reason the direction has its own row. (3) Drop the recency refresh on re-Set → 1 FAIL. (4) Drop the list pop in Consume → 1 FAIL, and it took a second draft of that test to catch: the first asserted only that surviving slots survived, which stays green because the leak shows up one eviction later, when a tombstone absorbs an eviction and the map grows PAST the bound. The test now forces the eviction and asserts the resident count EXACTLY. (5) Drop the default bound from NewStore → 2 FAILs, the guard that production is bounded and not merely the option-configured store. (6) Never delete the write-lock entry → 4 FAILs. (7) Delete it ignoring the refcount → the mutual-exclusion row FAILs alone, under -race.

Cross-references. D-025 (concurrent reuse — the Store is a compiled artifact; the bound holds under N=128 concurrent writers), D-130 (runs.set_overrides, the surface this bounds). CLAUDE.md §4.4, §5, §6, §11, §13, §17.6.


D-373 — A failed active-pointer write COMPENSATES its orphan revision; the atomicity it does not have is stated rather than implied

Date: 2026-07-31

Status: Accepted

Context. The agent-config registry driver persists a revision and then moves the active pointer as two ordinary StateStore.Save calls (internal/agentcfg/drivers/statestore/statestore.go). A store error between them left a revision that EXISTS and that nothing references. The severity is bounded and worth stating precisely rather than inflating: the pointer is the source of truth, so an orphan is invisible to Active and to the run-start projection, and its content was never applied to any run. What it damages is the OPERATOR view — ListRevisions enumerates by record kind rather than by walking the parent chain, so the orphan appears in history between two real revisions, belonging to no chain and never having been active. That reads exactly like a lost write. It also burns a revision id the next successful write's parent chain will not mention.

Decision. On a failed active-pointer write the driver DELETES the revision record it just wrote, then returns the store's own error. Not a transaction, and not a sweep.

Correction (D-380). The delete above is UNCONDITIONAL, and that is a regression this same wave introduced. "The write failed" is what the store SAID, not what the disk did: the commonest production shape of a failed write is a deadline firing after commit, a dropped ack, a proxy timeout or a reset connection — in every one of which the pointer is durably on disk NAMING the revision this compensation removes. Deleting it manufactures a dangling pointer, and because every door reads through the pointer the agent is then unrecoverable by any later write. The entry below traded a cosmetic list_revisions artifact for an unusable config, which is strictly worse than the WARN it was fixing. D-380 makes the delete CONDITIONAL on a re-read of the pointer, and retains the record when the answer is unknown. Everything else in this entry — the un-cancellable context, the why-not-a-transaction and why-not-a-sweep reasoning, the honesty about what is not claimed — stands unchanged. This entry is left as written (the log is append-only) with the correction attached rather than folded in.

Why not a transaction — the constraint decides it. The StateStore interface spans the §9 triad (in-mem / SQLite / Postgres) with conformance parity. Both SQL drivers could span the two writes in one BeginTx; the in-memory driver would need a hand-rolled equivalent, and every future driver would inherit a method whose semantics are "atomic where the backend has transactions". §9 names a feature that only works on one backend a design smell, and §4.4 forbids the Supports* ceremony that would paper over it. A conditional-write primitive on the StateStore (SaveIf) across all three drivers with conformance rows is already the named real fix for the neighbouring cross-process gap (D-366); a transaction primitive belongs in that same interface phase, driven by both callers, not retrofitted here for one.

Why not a sweep. Leaving the record and filtering unreferenced revisions out of the reads keeps the damage and adds a second mechanism to hide it; any filter that decided reachability would have to walk the parent chain, turning one list into a chain traversal. A background sweeper adds a loop, a schedule and a scan over a maintenance-elevated ListKind. Compensating at the point of failure needs no new interface method (Delete is already on the interface, so all three drivers already implement it), no new record kind, no migration and no goroutine — and it runs while the caller still holds the agent-config service's per-owner write lock, so nothing can be pointing at the record it removes.

The compensation runs on an un-cancellable context, and that is the subtle part. The likeliest production reason for the pointer write to fail is the caller's context being cancelled or timed out — so a compensation issued on that same context would fail on exactly the occasions it exists for, and the orphan would survive the common case while the code read as if it were handled. context.WithoutCancel keeps the identity and trace values and drops only the cancellation; a bounded timeout keeps a hung store from outliving the call. This is pinned by a test whose fault store cancels the caller's context at the moment the write fails and then asserts the delete arrived on a LIVE context — passing ctx straight through turns it red.

What is NOT claimed. This is compensation, not atomicity, and no text says otherwise. A process that dies between the two writes still leaves an orphan. A store that refuses the delete as well as the write also leaves one — and that case is REPORTED: the returned error wraps both the original cause and the delete failure and names the record as unreferenced, with an Error-level log beside it, because a residual record is a fact an operator must be told about rather than a detail to swallow (§13). A caller branching on ErrStateUnavailable is unaffected either way.

The invariant lives in the SHARED conformance suite, so a second driver inherits it. conformance.Run grows a second, MANDATORY parameter — a FaultFactory that builds a Registry whose store is armed to fail the write that publishes a revision as active. It is a parameter rather than an optional capability because the invariant is owed by the interface, and it is a separate constructor rather than a method on Factory because the arming is driver-specific (record kinds here, table names elsewhere) and only the driver's own test can do it. A driver whose two writes ARE one atomic operation arms the fault on that operation and passes the row unchanged — the row asserts the RESIDUE, not the number of writes. A second driver cannot compile without supplying one.

Mutation-verified, each turning OK into FAIL, never into SKIP. (1) Return the store error directly instead of compensating → 3 driver FAILs plus both conformance scope arms, and 3 smoke FAILs. (2) Compensate on the caller's ctx instead of WithoutCancel → 1 FAIL, the cancelled-context row, alone. (3) Swallow the delete error and return the cause → 1 FAIL, the honesty row. (4) Deregister the conformance row → COMPILES CLEANLY (an unused function parameter is legal in Go) and every Go test stays green; only the smoke's registration-count guard fires. That last one is why the guard counts t.Run REGISTRATIONS rather than func test… declarations — the same lesson D-366's rows already carry.

Cross-references. D-366 (the expected-revision precondition on this same write path, and its named real fix SaveIf — the interface phase a transaction primitive would belong to), D-370 (the compensating detach on add_mcp_connection, the same compensation shape one layer up: a live effect whose write then failed), D-025 (concurrent reuse). CLAUDE.md §4.4, §9, §11, §13, §17.3, §17.6.


D-379 — HA-47 (keying the live MCP registry by (owner, name)) is REFUSED; D-301's namespace guarantee gains the instrument it never had, and the ScopeUser token-Kind hazard it silently protects is recorded

Context. A v1.25 proposal (HA-47) observed correctly that internal/tools/drivers/mcp/registry.go keys servers by the bare connection name while each entry carries an auth.Owner{Tenant, Agent}, and that attach.go consults that owner to refuse a cross-owner same-name attach (ErrConnectionNameOwnerConflict). The inference was that the owner already decides coexistence and should therefore become part of the key, letting a coordinator attach one logical MCP server for N (tenant, agent) pairs under one short name — removing the pressure to encode the owner into the server id, which spends bytes from the ReAct declaration path's 64-byte tool-name budget.

Decision. The re-key is refused on two independent grounds, and D-301 is reaffirmed unchanged.

(1) It is inert for its own motivating case. The token tax is levied on the CATALOG key, not the registry key: MCP descriptors are named fmt.Sprintf("%s_%s", source, t.Name) (internal/tools/drivers/mcp/mcp.go:936) into the flat, process-global catalog.byName (internal/tools/catalog.go:120). Verified by execution, not by reading: with the registry conflict removed BY CONSTRUCTION (one Registry per owner — exactly what the re-key achieves) two owners attaching github still fail, at catalog.Register("github_add"): tools: duplicate tool name. A control arm with distinct owner-prefixed names attaches both, so the failure is the NAME and not the harness. The re-key therefore cannot shorten a single id — the long-id workaround survives it intact. D-301's context section predicted exactly this outcome; it was found rather than rediscovered in production.

(2) It would move the refusal PAST the dial. Today's conflict fires before provider.Connect. Post-re-key the surviving catalog gate fires only after the transport is live — measured as the closer chain growing 1 → 2 in the probe. A clean pre-dial refusal degrades into a post-dial one that spawns a transport it must then tear down.

And the coexistence/isolation separation the proposal required cannot be made. "Each owner resolves its own" is a RESOLUTION property, and resolution has no owner to read: every registry read resolves bare-name (registry.go:796) and all twelve mcp.servers.* wire requests carry name and no agent id. registry.go:857 already states the fact — "The wire door for these writes carries the caller's verified identity triple (tenant, user, session) and no agent id, so the (tenant, agent) owner tag … is not derivable at that edge." Making it derivable would make agent_id a read-scoping filter, which CLAUDE.md §6's clarifying note and auth.Owner's own godoc both forbid. Keying by (tenant, name) is derivable but drops the "several agents in one tenant" half of the ask; a compound key that still enforces global bare-name uniqueness is a no-op.

What this entry actually lands: the missing instrument. D-301 has two halves. owner_scoped_test.go pins the reconcile-VIEW half. The NAMESPACE half — the bounded guarantee traded for the process-global catalog, that "a collision fails loud" and a shared runtime "TRUSTS its co-tenant admins" — was pinned nowhere, so anyone re-attempting HA-47 had to rebuild the probe from scratch to learn what the decision already knew. internal/tools/drivers/mcp/cross_owner_name_collision_test.go now pins the BEHAVIOUR (not the key shape): the refusal is loud and typed, it is PRE-DIAL, it leaves the first owner's catalog and registration untouched, a distinct-name control arm proves the harness measures the name, and a third test pins the catalog as the independent second gate. scripts/smoke/phase-167.sh gains a counting arm — go test -run with a pattern matching nothing exits 0, so the arm requires the exact PASS count and FAILs on a rename.

A latent, load-bearing hazard is recorded, not fixed. internal/tools/auth/tokenstore.go:41 builds the token Kind as tools.auth.access.<scope>.<subjectID>.<sourceID>. Under ScopeAgent the subject is the agent and the Kind disambiguates; under ScopeUser there is NO agent component, so two agents in one tenant sharing a user and a connection name would share one bearer cache row — D-301's "cross-serving another tenant's OAuth bearer". It is unreachable TODAY only because the same-name refusal this entry declines to remove prevents the precondition. Any future work that relaxes cross-owner connection-name uniqueness must address this first — it is a credential-plane concern (§7), and it lives nowhere near the guarantee protecting it. Filed as issue #638 rather than fixed here; a test-only PR is the wrong vehicle for a credential-plane change.

Mutation-verified, every guard turned red on purpose, never OK → SKIP. (1) Apply the actual re-key (cross-owner same-name no longer refused) → the typed-error assertion FAILS with the post-dial catalog.Register duplicate. (2) Same mutation with the typed-error assertion neutralized so execution reaches it → the PRE-DIAL assertion FAILS with closers 1 -> 2, proving that assertion has independent teeth. (3) Make the catalog accept duplicates → only the catalog-gate test FAILS, arms 1–2 stay green. (4) Make the ambiguity rule refuse any second id → only the CONTROL arm FAILS, which is the sole instrument covering that class. (5) Rename one guard out of the smoke's -run pattern → the smoke arm reports got 2 and FAILS instead of passing vacuously. Standalone smoke on the clean tree: OK 7 / SKIP 0 / FAIL 0.

If this is ever revisited, it is an RFC PR plus a superseding decision covering the catalog, the separator-ambiguity rule, and the ScopeUser token Kind TOGETHER — re-keying any one alone is inert or unsafe. The alternative that needs no RFC, and which the token argument actually points at, is decoupling the MODEL-FACING tool name from the catalog key: internal/planner/react/tool_name_sanitize.go already sanitizes and length-bounds the name for the LLM and resolveDeclaredToolName maps the returned name back, so the declaration layer can shorten what the model sees without moving any key.

Correction (D-386). The paragraph above was written against pre-D-377 code and said the sanitizer "64-truncates". It does not: D-377 landed in the same wave and bounds the model-visible name at maxToolNameBytes = 44, shortening tail-first with an 8-hex digest rather than truncating head-first. The alternative this entry points at — decoupling the model-facing name from the catalog key — is therefore no longer hypothetical; it is what D-377 shipped, and it shipped without moving any key, exactly as predicted here.

Protocol additions. None. Test + smoke + docs only; no production behaviour changes.

Cross-references. D-301 (REAFFIRMED — the owner tag as reconcile-view filter, the process-global bare-name catalog, and the bounded namespace guarantee this entry finally instruments), D-287 (the shared catalog/registry/dispatch model D-301 extends), D-350 (the owner-scoped WRITE, whose read/write asymmetry this preserves), D-303 (the owner-tagged provider set with the same shape), D-025 (the registry as a compiled artifact). CLAUDE.md §5, §6 (agent_id is not an isolation principal), §7, §11, §13 (no false safety property), §16 (settled decisions are not re-litigated silently), §17.1–17.3, §17.6.


D-374 — Unknown request members are refused, not discarded: the control transport joins the strict-decode posture every other Protocol handler already had, and caller_memory gets a capability so the loss is detectable against a Runtime that predates the field

Date: 2026-07-31

Status: Shipped (v1.25 §17.5 checkpoint, in-wave). Found by the v1.25 adversarial checkpoint review (W5).

The defect. internal/protocol/transports/control decoded every request body with json.Unmarshal, which discards a member no struct field matches. A client that sends caller_memory (new in v1.25) to a Runtime predating it therefore receives a 200 and a task id, and the run proceeds without the memory the caller believes it supplied. That is the CLAUDE.md §13 silent-degradation shape at a version boundary, and it is unrecoverable downstream: the bytes are gone before any validator sees them. caller_memory is the instance; the class is every additive optional field the Protocol will ever gain.

Decision — both halves ship, because they cover different populations and neither alone closes the hole.

  1. Strict decoding on the control transport. One helper, decodeStrict, at the package's single decode point; every handler in the package routes through it (start / the nine steering controls / topology.snapshot, plus the posture, search, MCP, MCP-Apps and artifacts clusters the same package serves). An unknown member is refused CodeInvalidRequest, and the refusal NAMES it — a refusal that will not say which member is unusable for a client trying to learn what the Runtime supports, and it is otherwise indistinguishable from the transport's own malformed-body answer, which carries the identical code. The echoed decoder detail is bounded (the member name is caller-controlled and the body may carry 64 KiB of it) and can never carry a decoded VALUE, because encoding/json reports names and types only.
  2. A capability, CapCallerMemory ("caller_memory"), advertised unconditionally by every Runtime wiring task control. Strict decoding is forward-only: it cannot reach a Runtime already deployed without it, which is exactly the Runtime in the reported scenario. The capability can, because a build predating the constant cannot advertise it — so a client checks VersionHandshake.Accepts(CapCallerMemory) (or runtime.info.capabilities) and treats ABSENCE as unsupported instead of discovering the loss after the run.

The capability mechanism already existed and was reused; no second one was invented (§13). internal/protocol/types/version.go has carried Capability + canonicalCapabilities + Capabilities() + VersionHandshake.Accepts since the versioning-discipline phase, with per-instance advertisement projected through PostureSurface.wiredCapabilitiesFor onto runtime.info.capabilities, and consumers already branching on it (the Console's client.capabilities(), the TUI's HasCapability). Eight capabilities were registered; this is the ninth.

What IS new is the granularity, and the bounding rule is written down so it does not become a capability per field. Every prior capability advertises a SURFACE — a method cluster. This one advertises an additive optional REQUEST FIELD. The asymmetry that justifies it: a missing method announces itself (CodeUnknownMethod), a missing optional field does not. The rule: an optional wire field earns a Capability only when its absence is undetectable by probing the method — i.e. an additive optional member on a method that already existed. A field on a NEW method is covered by that method's own capability and must not get a second one. Recorded on CapCallerMemory's godoc, not only here.

The strict-decode / deprecation-window tension, resolved on evidence rather than assumption. §8 requires a deprecation window for breaking Protocol changes, and turning on DisallowUnknownFields does refuse bodies a prior Runtime accepted. Three findings decide it, in order of weight:

  1. Strict decoding is already Harbor's shipped Protocol posture, at this same ProtocolVersion 0.1.0, on every OTHER request handler. The entire internal/protocol/transports/stream family decodes with DisallowUnknownFields (sessions, memory, tools, agents, agent-config, governance, auth, tasks, runs, flows, events-list, pause-list, state-history, aggregate), as does internal/protocol/client. Two agent-config wire types state "rejected BY NAME via DisallowUnknownFields" in their godoc as a load-bearing security property. The control transport was the outlier; the asymmetry was an omission, not a policy. A third-party client that is forward-compatible against Harbor's Protocol already cannot send an unknown member to memory.put or agent_config.set_revision. Two decode postures for one concept is itself the §13 parallel-implementation shape, and closing it is what this does.
  2. A deprecation window is not expressible here, and that is diagnostic rather than convenient. DeprecationKind is a closed set of four — method / error_code / wire_field / capability. "Unknown-member tolerance" is none of them: there is no Subject to name, no Replacement, and nothing being removed from the surface. The registry that would hold the window cannot represent the entry. The window mechanism was built for retiring elements OF the surface; unknown-member tolerance is not an element of the surface, it is the absence of validation on inputs the surface never defined.
  3. A window here would be self-defeating. A deprecation window is a grace period during which BOTH behaviours are safe. The old behaviour is the defect — its entire content is "for the next N versions we will continue to discard members you believe we honoured." You cannot run a grace period on a data-loss bug. And the break is directional and self-announcing: a client sending a benign decorative member learns at the first request, with the member name in the message. That is the least-whipsaw failure available; the alternative is a client that never learns.

Conclusion: no window, strict decode ships now, unconditionally, with no per-type or per-version toggle. A staged mechanism was considered and rejected — the stages would have had to be a Deprecation entry the format cannot express plus a lax path kept alive behind it, which is two implementations of one decode (§13) guarding a behaviour that is wrong on both sides of the switch. The scoping that IS applied is the one that costs nothing: the change is confined to the control transport, because that is the only package that was lax.

Correction (D-383). "The only package that was lax" was wrong, and wrong in the way this entry's own closing lesson predicts: the population was defined by the SWEEP's subject (internal/protocol/transports/*, plus internal/protocol/client) rather than by the set of things that could exhibit the bug — every handler in the tree that decodes a request body into a struct. internal/server/dev_bootstrap.go decoded with a bare json.Unmarshal, and it is the one handler in the tree that MINTS A CREDENTIAL. Executed on the shipped code: {"scope":[]} — a caller asking for a no-scope token — answered 200 with scopes=[admin console:fleet], and {"tenant_id":…,"user_id":…,"session_id":…} answered 200 with identity=(dev,dev,dev). D-383 closes it. Nothing else in this entry changes; the strict-decode posture, the capability, and the six defects it surfaced all stand.

Six real defects the strict decode surfaced, fixed here under §17.6 rather than papered over. The first three were live in Harbor's own tests and smoke harness; the fourth was in the shipped Console, the fifth in the smoke corpus, and the sixth in the Protocol's own published wire types — all recorded separately below. The first five are the silent-loss shape; the sixth is its inverse and worse, a published contract the Runtime never honoured.

The count grew four times while this entry was being written — two, three, four, five, six — and that is the finding, not a footnote. Each widening of where the sweep looked produced another instance, and each sweep in turn was refuted by a gate rather than by the sweep that preceded it: the test suite found the fixtures, CI's frontend job found the Console, CI's preflight found the smokes, and preflight again found the posture types. Every one of those sweeps was described as "mechanical" when written.

What made them wrong was never the mechanism; it was the population. Three keyed on a surface form (-d '<literal>', a single-line request() call) instead of the set of things that could exhibit the bug; the fourth walked methods and so could not see a type no method references. The rule this leaves behind, in two parts: a sweep's result is only as wide as the population it enumerated; and the population must be derived from the declared universe — the canonical surface — never from a traversal of it, and never from the syntax the current code happens to use. Each of the three guards this entry ships enumerates from a generated source for exactly that reason, and each fails loudly rather than vacuously when its enumeration comes back empty.

  1. test/integration/events_page_test.go and phase125_state_history_test.go sent "identity" to artifacts.put / artifacts.get_ref. The artifacts wire types scope by "scope"; there is no identity field. The member had been silently discarded for four phases while the fixtures read as though the scoping were load-bearing — and they passed, because the scope was backfilled from the verified header anyway.
  2. internal/protocol/transports/control/artifacts_body_scope_test.go sent one shared body carrying "id" to all five artifacts methods. ArtifactsListRequest and ArtifactsPutRequest have no id, so those two rows were testing a body the transport had quietly edited. The helper is now method-aware.
  3. scripts/smoke/common.sh::assert_json_path_resolves — the SHARED smoke helper — sent the same stray identity to artifacts.get_ref. This is the §17.6 "grep production for the same call site" rule paying out a third time, and it is the one that would have failed CI preflight rather than a unit run.

The sweep was mechanical, not by eye. A throwaway checker strict-decoded every literal -d '{…}' body posted to a /v1/control/<method> route across scripts/smoke/, docs/site/, docs/skills/ and docs/recipes/ against the wire type that method's handler decodes into. 29 literal bodies; after the three fixes the only remaining hit is the deliberate 400-demonstrating example added to the use-the-harbor-protocol skill. In particular the five tagged executable quickstart steps scripts/smoke/phase-113a.sh runs against a live server were verified clean. The checker was not kept — it duplicates what the transport now enforces, and a scanner that only reads literal bodies would be a guard whose coverage silently shrinks as bodies become interpolated.

A FOURTH site, in production, and the first draft of this entry wrongly claimed there was none. That draft said "production was checked for the same shape and is clean: the Console reaches artifacts through the typed client's per-method request types." The reasoning was that the typed client constrains the caller-supplied body — which is true and irrelevant, because the offending member is not caller-supplied. HarborTransport.request folds the identity triple into EVERY request body by default, so the identity key is added below the typed surface, where no amount of caller-side typing can see it. Four artifacts-page Playwright specs failed on the branch and named the bug precisely.

The claim was refuted by CI, not by the check that produced it, and the reason is worth recording: the sweep that produced it read scripts/smoke/, docs/site/, docs/skills/ and docs/recipes/ and not web/console/. A negative result is only as wide as what it read, and "production is clean" was stated more broadly than the evidence supported.

Six Console call sites needed omitBodyIdentity: true — the five artifacts methods (list / put / get / get_ref / delete, all scoping by scope) plus search.query, whose SearchRequest scopes through filter. Only the artifacts five are covered by e2e; search.query would have failed in production rather than in CI, and it was found only because the second sweep was mechanical over all 108 call sites rather than targeted at the four failing specs.

The re-sweep found its own first version had been wrong too. The initial call-site regex was line-oriented and matched 78 sites; the formatter wraps the path onto its own line, so it missed 30 of them — including the single pre-existing omitBodyIdentity site (governance.set_posture) that was known to exist. A sweep that cannot see a case you already know about is not evidence about the cases it did not name. The multiline-aware version resolves all 108 with zero unmatched routes.

A vitest assertion was ENCODING the bug, and it is inverted rather than deleted. harbor-client.spec.ts asserted that a search.query body's identity member EQUALLED the connection identity. It passed for as long as it did precisely because the Runtime accepted and discarded the member — a green test documenting a wire shape the Protocol never defined. It now asserts the body has no identity AND that the triple still rides the X-Harbor-* headers, so the fix cannot be "achieved" by dropping identity altogether. A new identity-less-body.spec.ts covers all six methods the same way.

Two halves, deliberately, because each alone is satisfiable without the other. The static guard checks the call site PASSES omitBodyIdentity; the behavioural spec checks the flag actually SUPPRESSES the fold on the wire. A source-only check goes green against a transport that ignores the option; a behavioural-only check covers whichever methods someone remembered to enumerate. Mutation-verified in both: removing the flag from two call sites turns exactly those two behavioural cases red, and the static guard names them independently.

A FIFTH site: the smoke corpus, found by CI preflight after the Console fix landed. scripts/smoke/phase-213.sh and phase-218.sh sent identity to the search cluster — 5 failing legs across search.query / search.sessions / search.tasks / search.events. The identity-less set is ELEVEN methods, not the six the Console needed, because all five search.* methods share SearchRequest; the Console only calls search.query, so a Console-scoped sweep could never have found the other four.

Why the earlier smoke sweep missed them — the mechanism, not the instances. That sweep keyed on the syntactic form of the curl invocation: a literal -d '{…}' adjacent to a literal route, skipping any body containing $. Three constructions are invisible to that shape, and the corpus used all three: a body passed as a helper-function argument with no -d at all (assert_post_status_auth <status> <url> <body> …), a body built by variable interpolation ("{\"identity\":${ID},…}" — explicitly skipped, and the skip was documented as a limitation rather than treated as a hole), and a route built from a loop variable (/v1/control/${m}). This is the same error as the Console sweep's line-oriented regex: matching a surface form instead of enumerating the population. Three times in one change.

scripts/smoke/common.sh does NOT fold identity for its callers — verified rather than assumed, since a shared helper doing so would make per-script fixes symptom-treatment. assert_post_status_auth passes $body through verbatim; the corpus's only helper-level instance was the one already fixed in assert_json_path_resolves.

A broader one-off scan asked the next question — does any smoke body carry an unknown member that is NOT identity? 318 literal bodies were decoded against their nearest method's manifest field set. It surfaced exactly one genuine defect, in scripts/smoke/phase-73h.sh: a bulk-cancel body carrying run at the TOP LEVEL, where ControlRequest declares only identity / payload / event_idrun belongs inside IdentityScope. That leg would have stayed green, because its case arm accepts 400 as well as 403; the request now dies at the decode step, which lands BEFORE the scope check the leg exists to exercise, so it would have silently degraded from "the scope-refusal path holds" to "some 400 happened". Fixed. The scan's other 48 hits are all artifacts of nearest-anchor attribution without variable resolution (a SCHEMA='…' literal, VAR='{…}' assignments read as bodies, a start body attributed to a neighbouring resume anchor) — verified individually, none real. The scanner was not kept: at that false-positive rate it would be noise, and the shipped guard deliberately covers only the demonstrated identity class.

What the smoke guard does NOT cover, stated rather than implied: unknown members other than identity. A general "any unknown member" check over shell-built bodies is not tractable — the 48 false positives are the evidence. That residual is covered by preflight executing the corpus against a live Runtime, which is what caught this class in the first place.

A SIXTH defect, and the most serious: two Protocol wire types published a field the Runtime never read. GovernancePostureRequest and LLMPostureRequest each declared exactly one field, tenant_id, whose godoc promised "non-empty + different from the caller's resolved tenant = requires auth.ScopeAdmin." Nothing decoded either type. The control transport decodes the whole posture family — the five runtime.* / metrics.* reads plus governance.posture and llm.posture — into the shared RuntimeInfoRequest envelope (internal/protocol/transports/control/posture_handler.go). So tenant_id was discarded, and an admin naming another tenant received its own tenant's posture with a 200: silently wrong data on an admin audit path.

What the pre-existing status actually was, established by execution rather than inference. The working hypothesis was that the identity-less body produced the 403 the smoke expected. It did not. Driving the real handler shows the lenient decode reduced {"tenant_id":"other-tenant"} to a zero-valued RuntimeInfoRequest, the empty body identity was BACKFILLED from the verified identity, and the answer was 200 — so scripts/smoke/phase-72g.sh took its 200 arm, which is a SKIP, not a pass. Assertions 6 and 7 were vacuous. Worse than vacuous: the SKIP text read "dev token carries admin scope; cross-tenant rejection check inapplicable", which was not the reason and is precisely why nobody looked. A guard that skips is bad; a guard that skips while explaining itself incorrectly is what keeps it skipping for four phases.

The fix is REMOVAL, not implementation, and the reason is §13. The cross-tenant selector for the posture family already exists and works: PostureSurface.Dispatch compares the body tenant against the ctx-verified tenant, demands auth.ScopeAdmin / auth.ScopeConsoleFleet, sets crossTenant, and emits governance.posture_read_admin carrying RequestedTenant. It is pinned in BOTH directions by TestPostureDispatch_CrossTenantRequiresAdmin and its audit twin — both mutation-verified here (force the gate open → the refusal arm fails; force it closed → the grant arm and both audit subtests fail). D-079's gate was never missing; only this second spelling of it was. Implementing tenant_id would have created two selectors for one concept on the same methods — the parallel-implementation shape — and made "what happens when identity.tenant and tenant_id disagree?" a question the Protocol had to answer for no benefit. The types are deleted, the Go client now sends the RuntimeInfoRequest envelope its siblings send (it previously sent {} with no identity at all and worked only on the backfill), and phase-72g now drives the REAL gate.

Why the identity-less enumeration missed them — a FOURTH distinct mechanism. The join walked method → request type → fields. methods.md maps both methods to RuntimeInfoRequest (correctly — that IS what the handler decodes), and RuntimeInfoRequest HAS an identity field, so both methods were correctly classified identity-ful. The two orphan types were never reachable from that walk because no method points at them. The previous three misses were "population defined by syntax"; this one is "population defined by reachability". Same root error — deriving the population from a traversal instead of from the declared universe — but it fails differently, and only this form is detectable by comparing the manifest's type set against the reachable set.

The generalisation was checked, not assumed. A transitive closure over the manifest's ref graph from every method's request/response reaches 336 of 342 types. The six unreachable ones are all legitimate: Version / VersionHandshake / Deprecation (the version-negotiation surface, served at the handshake entry point rather than as a method row), Error (the envelope every method returns), and IdentityTierView / RateLimitView (the value type of GovernancePostureResponse.identity_tiers and its nested child — the manifest emits a map field as a bare object with no ref, so they are reachable in reality and invisible to the walk). No other true orphans. TestManifest_NoOrphanWireTypes now pins this with those six on a reasoned allowlist, plus allowlist-hygiene checks in both directions and a zero-row-parse hard failure. Mutation-verified: reintroducing GovernancePostureRequest turns it red naming the type.

The remaining corpora are structurally immune, which is why the fix stops here. The Go Protocol client and the TUI marshal typed wire structs (types.ArtifactsPutRequest{…}, types.SearchRequest{…}) — those structs have no identity field, so the member cannot be added. Only the two hand-written-body corpora (TypeScript object literals, shell strings) could express it, and both are now guarded.

The smoke-corpus guard: scripts/check-smoke-body-identity.sh, wired into make drift-audit (which preflight runs). It derives the identity-less method set from the same generated join the Console guard uses, then anchors every method reference in every smoke script and attributes each identity-declaring body literal to its NEAREST anchor — flagging it when that anchor is identity-less. Keyed on the METHOD NAME, which survives all three constructions above. A blind ±N window was tried first and produced false positives wherever an identity-less call sits near a legitimate identity-ful one (phase-183's artifacts.list beside runtime.health); nearest-anchor attribution is what makes it precise enough to keep. It resolves one level of VAR='/v1/control/<method>' indirection so a "${VAR}" call site still anchors. Both a zero-row parse and a zero-identity-less-method result are hard failures, never vacuous passes. Its limits are stated in its own header rather than implied: a body whose nearest anchor is not its actual target, or one built by jq -n with no literal key, is not caught — it is a shell corpus, and a complete static decision is not available.

The Console guard (check (e) in web/console/scripts/check-protocol-ts-lockstep.mjs, wired into npm run lint). Every request() call site must pass omitBodyIdentity iff its Protocol request type declares no identity field — enforced in BOTH directions, so neither a missing flag nor a spurious one survives. The route → request-type join is read from the GENERATED docs/site/protocol/methods.md rather than re-derived, keeping one source for it (cmd/harbor-gen-protocol-docs, itself gated by make protocol-docs-gen-check and its own Go lockstep tests). Three failure modes are refused rather than tolerated: an unknown route, a request type absent from the manifest, and — the one that matters — a methods table that parses to zero rows, which would otherwise make the whole check silently inert.

Mutation-verified, each turning OK into FAIL, never into SKIP.

  1. Delete dec.DisallowUnknownFields() from decodeStrictTestDecode_UnknownMemberRefusedAndNamed FAILS on all three arms (status = 200, want 400 for start), TestDecode_UnknownMemberDetailIsBounded FAILS, and the LIVE smoke leg FAILS (a start carrying an unknown member returned 200, want 400) — which also drags the no-task-on-refusal counter leg red (count 0 → 2), because the accepted body really did spawn tasks.

  2. Remove types.CapCallerMemory from wiredCapabilitiesFor → all five arms of TestPostureSurface_Info_WiredCapabilities FAIL, and against a rebuilt binary the live smoke leg FAILS naming the advertised set.

  3. The negative half is guarded too: TestDecode_KnownMembersStillAccepted posts a body of only declared members (including caller_memory) and requires 200, so the strict decode cannot pass by refusing everything — the regression that would be worse than the bug.

  4. TestDecode_TrailingDataRefused pins that the swap did not LOOSEN anything: json.Unmarshal refuses a second document after the first and a bare Decoder.Decode does not, so decodeStrict checks dec.More().

  5. Remove omitBodyIdentity from one artifacts call site → the Console lockstep guard FAILS naming the file, line, method and request type.

  6. Add omitBodyIdentity to start (whose StartRequest DOES declare identity) → the guard FAILS on its converse arm, so the flag cannot be sprayed everywhere as a blanket fix.

  7. Break the generated methods table's row shape → the guard FAILS with "parsed ZERO rows … this check has gone inert" rather than passing vacuously. This is the mutation that matters most: a join-driven guard whose join silently yields nothing looks exactly like a clean run.

  8. Restore identity in phase-218's loop body (route from a loop variable) → the SMOKE guard FAILS naming an identity-less method 2 lines away.

  9. Restore it in phase-213's helper-argument body (no -d at all) → FAILS naming search.query 1 line away. Items 8 and 9 are the two constructions the previous sweep could not see, which is exactly why they are the two that had to be mutation-verified.

  10. Break the methods table for the smoke guard too → "parsed ZERO method rows … gone inert", exit 1.

One incidental finding, worth recording because it is the same failure mode one level up. The first draft of the smoke guard tripped drift-audit's own portability scan (\n inside a printf sharing a continued logical line with a grep -E). It was a false positive of that scan's heuristic, and the fix was to restructure the pipeline (sed instead of a printf loop) rather than to weaken the scan — a guard that gets relaxed to accommodate new code stops guarding the thing it was written for.

Live totals: scripts/smoke/phase-219.shOK 23 / SKIP 0 / FAIL 0 against a live server, OK 9 / SKIP 1 / FAIL 0 static-only. The D-375 guard was moved OUT of the live-gated region into the static section after the static-only run showed it skipping — a truthfulness guard over a Go source file has no business behind a route probe, and behind one it would have been silently unreachable on any run without a server.

Zero-version-move. No wire type, method or error code changes; one capability constant is added to the canonical registry. ProtocolVersion holds at 0.1.0 — a capability addition is the RFC §5.3 minor-class change, exactly as the eight before it. make protocol-ts-gen, make protocol-ts-types-gen and make protocol-docs-gen re-run (only the wire-surface digest moves; a capability constant is not a wire type). The Console consumes capabilities as ReadonlySet<string>, so there is no TS union to extend.

Cross-references. D-364 (the caller_memory field whose silent loss is the reported instance), D-375 (the sibling correction from the same review, on the same field's cap), D-223 / D-209 (the manifest and generated-reference lockstep, both re-run), D-093 (the Console client's hand-maintained wire types the lockstep gate keeps honest). CLAUDE.md §4.2, §5, §8, §13, §17.6, §18. RFC §5.2, §5.3.


D-375 — The caller-memory cap is a resource bound and wire-size guard, not a security boundary: the same principal can send more content into a MORE trusted prompt position

Date: 2026-07-31

Status: Shipped (v1.25 §17.5 checkpoint, in-wave). Found by the v1.25 adversarial checkpoint review (W2). Truthfulness fix — the value is unchanged.

The defect. maxCallerMemoryBytes (32 KiB) was described in security terms in six places — most sharply as "a dial on how much untrusted caller content may enter a prompt is a security-posture downgrade dressed as tuning" (D-364 item 7, and verbatim in the constant's godoc). It is not a security property, and describing it as one invites a later author to reason from it — "the caller's content is capped, therefore X is contained" — an inference that does not hold for any X.

Why it does not hold, with the paths named. The SAME principal, needing no additional claim, can put substantially more content in front of the model, through positions carrying LESS framing rather than more:

  • StartRequest.Query has no cap of its own. Its only bound is the control transport's 64 KiB whole-body envelope, so a caller may spend nearly twice this constant on it — and it becomes the run's user turn, the conversation position, which carries no anti-prompt-injection preamble at all. The 32 KiB tier it is being compared against does.
  • agent_config.session.set_user_prompt is the session-safe tier: identity-mandatory, no scope claim (the handler's agentConfigSessionSafeRoutes arm falls through with a bare comment; every other route demands auth.ScopeAdmin or auth.ScopeAgentConfigUser). It writes prompt_layers.user, which renders inside the system prompt, and its transport bounds the body at 1 MiB — 32× this constant. It is escaped, which is a real mitigation and a positional one; it is not a byte bound.

So the cap's size tells a reader nothing about containment. What actually contains this payload is positional: it reaches exactly one tier, that tier ships the five-line untrusted framing, and it can never reach the trusted spine (that is SystemPromptOverride, and making this path reachable is what stops consumers reaching for it — D-364).

Decision — describe the bound as what it is, and leave the value alone. It is a resource bound and wire-size guard, and the work it does is real:

  • Nothing downstream re-checks these bytes. findContextLeak byte-exempts everything that is not a tool-role message and memory tiers render under the system role, so an unbounded document travels to the token-budget guard and fails the whole run late, after the prompt is assembled. The bound at the edge turns that into one cheap refusal before a task exists. (This is D-364's own finding; only its characterisation is corrected.)
  • The cap-ordering invariant is unaffected and remains the operational reason the constant is not configurable: it MUST stay strictly below the transport's maxBodyBytes, because both refuse with an identical CodeInvalidRequest and a field cap that rises to meet the envelope cap becomes unreachable dead code every status-code test keeps passing against.

"Not an operator knob" survives, on a different argument. D-364 justified it as a security-posture downgrade; that justification is retracted. The conclusion holds anyway: a per-request byte dial on an admission whose containment is positional buys nothing an operator can act on, and a configurable value is a foot-gun against the cap-ordering invariant (a value at or above the envelope cap silently disables the field's own check). The reopening condition is unchanged in substance and tightened in form — an additive optional config key defaulting to the constant, validated strictly below maxBodyBytes.

Every location corrected, found by grep rather than by the review's list. The review named the ones it happened to see; these are all of them.

LocationWhat it said
internal/protocol/control.go (maxCallerMemoryBytes godoc)"a security-posture downgrade dressed as tuning"; "It is the ONLY bound on this content class"
internal/protocol/types/control.go (CallerMemory godoc)stated the cap without stating what kind of bound it is
docs/decisions.md D-364 item 7the source of the phrasing — correction attached, log stays append-only
docs/glossary.md "Caller-supplied memory block""Bounded at 32 KiB at the Protocol edge" with no characterisation
docs/skills/use-the-harbor-protocol/SKILL.md §3the over-cap rule, uncharacterised
docs/skills/configure-memory-and-skills/SKILL.md §1"bounded at 32 KiB per request at the edge"
scripts/smoke/phase-219.sh (header + over-cap leg)"the Protocol-edge bound is the ONLY bound"
web/console/src/lib/protocol/client.ts (callerMemory doc)the over-cap rule, uncharacterised
CHANGELOG.md (unreleased 1.25.0 entry)reproduced the "security-posture downgrade" sentence
docs/plans/phase-219-...mdreproduced it; corrected in the as-shipped section

Mutation-verified, both sides. Re-add "security-posture downgrade dressed as tuning" to the maxCallerMemoryBytes godoc → the S7 leg goes red naming the offending framing. Delete the positive statement ("RESOURCE BOUND AND WIRE-SIZE GUARD") instead → S7 goes red on the other arm, so a future author cannot satisfy the guard by removing the characterisation rather than correcting it. Both were observed; a one-sided guard here would have been the weaker instrument, because the cheapest way to silence a grep-for-a-bad-phrase is to say nothing at all. The guard is STATIC (no server needed), which is what makes it run on every invocation rather than only under preflight.

What did NOT change. The constant is 32 KiB, before and after. No behaviour, no wire shape, no test outcome outside the new guard. This is a truthfulness fix, not a tuning change — and the reason it is worth a decision entry is that the false framing was already load-bearing in prose two phases downstream.

A related overclaim, corrected in the same pass. D-364's phrase "the bound is the ONLY bound" is true about resource exhaustion and was being read as being about content admission. The godoc now separates the two: it is the only thing that re-checks these BYTES before the token-budget guard, and it is not the only path by which a caller's content reaches the model.

Cross-references. D-364 (the decision this corrects; item 7 carries the pointer), D-374 (the sibling fix from the same review, on the same field's version boundary), D-367 (the same "this label is not a security boundary" discipline, applied to the extra-system-block [name] label — the precedent this follows). CLAUDE.md §5, §7, §13, §18. RFC §6.5.


D-380 — The compensating delete is CONDITIONAL on re-reading the pointer, and an UNKNOWN answer retains the record: "the write failed" is what the store said, not what the disk did

Date: 2026-07-31

Status: Shipped (v1.25 §17.5 checkpoint, in-wave). Found by the v1.25 adversarial checkpoint review (F1). CORRECTS D-373, which this same wave shipped.

The defect. D-373 added a compensating delete to the agent-config StateStore driver: if the revision record is written and the active-pointer write then errors, delete the revision so no unreferenced record is left in history. The delete is unconditional, and the failure mode it assumes — a write that did not happen — is not the commonest one. A deadline that fires after commit, a dropped ack, a proxy timeout, a connection reset while the response was in flight: all of these report a failed write over a write that LANDED. The pointer is then durably on disk naming the exact revision the compensation removes.

Why that is strictly worse than what it fixed. The condition D-373 set out to remove is cosmetic and inert: an orphan revision is invisible to Active and to the run-start projection, and only clutters list_revisions. The condition it introduces is neither. Every door into an agent's config does a read-modify-write through the active pointer, and loadActiveRevision fails loud on a pointer that names nothing (correctly — silently answering "no config" would be the §13 shape). So the agent is not merely degraded, it is unrecoverable: no subsequent write can repair it, because every subsequent write has to read the pointer first.

Reproduced by execution before anything was changed, against a store that commits the pointer and then returns an error:

text
SetRevision (pointer write committed then errored) = agentcfg: state store unavailable: save active pointer: injected: ...
Active() after the compensation: set=false err=active pointer references missing revision "01KYX3CW54..." rev=""
ListRevisions: n=0
a subsequent healthy SetRevision = active pointer references missing revision "01KYX3CW5M..."

The control arm — the same fault with the compensation neutralised, i.e. the pre-D-373 behaviour — leaves Active() answering set=true err=<nil> and the next healthy write succeeding. The regression is the compensation, not the fault.

Decision. The delete is conditional on a re-read of the active pointer. After the pointer write fails, the compensation reads the pointer back (on the same WithoutCancel context, for the same reason the delete uses one) and deletes the revision ONLY when the pointer does not name it. When the pointer DOES name it the write landed despite the error: the record is left exactly where it is, and the returned error says so — …the active pointer nevertheless names revision X, so the write landed and the revision was retained — re-read the active revision before retrying — rather than implying a rollback that did not happen. The caller still receives an error wrapping ErrStateUnavailable and the store's own cause, because "the store told us it failed" is not something the driver may paper over; what it may not do is act on that report as if the disk agreed.

The UNKNOWN answer retains the record, and this is the deliberate half. A store that refuses the re-read gives no answer, and "the pointer is absent" is then indistinguishable from "I cannot tell". Deleting on a cannot-tell puts the unrecoverable outcome straight back, on precisely the population where it is likeliest — a store sick enough to fail the write is sick enough to fail the read. The two costs are not comparable: retaining risks one unreferenced row in an operator's history view; deleting risks an agent no write can repair. So the unknown answer retains, and the residue is REPORTED — the returned error names the retained record and wraps the read failure, with an Error-level log beside it (§13: a residual record is a fact an operator must be told about, not a detail to swallow). This is written into the function's godoc, not only here, because the next contributor's instinct on reading "the write failed" will be to clean up.

The silent half, closed in the same pass: a write that landed is ANNOUNCED. Retaining the record because the pointer names it means the config really did change. Suppressing agent.config.revised because the call is about to return an error would leave every observer's view stale behind a change that happened — the same §13 shape one layer over, and the one D-370 named on the add_mcp_connection door when a parked auth left no terminal lifecycle event. So the compensation reports whether the write LANDED (a distinct boolean, false on an unknown answer — the caller may only act on a confirmed landing) and the driver emits the revision event on that branch before returning the store's error. The caller's error and the bus then tell the same story, and a retry of the same payload hits the idempotent re-set short-circuit rather than minting a second revision or a second event.

The re-read asks a narrower question than Active does, deliberately. A new loadActivePointerID reads the pointer record and returns ONLY the revision id it names; it does not resolve that revision. Resolving would conflate the answer with the very condition being compensated (a revision that is missing), so the compensation would read a dangling pointer as "no pointer" and delete on it. The pairing of a revision-kind prefix with its active-pointer kind now lives in ONE function, activeKindFor, which keysFor itself consumes — so the compensation cannot read a pointer belonging to a different scope than the revision it is reasoning about, and an unpaired prefix is a loud error rather than a defaulted-to-agent-scope answer.

The shared conformance suite grows the fault's TWIN, and it is mandatory for the same reason the first one is. conformance.Run takes a third constructor, CommittedFaultFactory, building a Registry whose store commits the publishing write and then reports it failed. The two arms model opposite disk states behind one identical error value, and a cleanup verified against only one of them is either useless (missing the orphan) or destructive (deleting live config) against the other. The new row asserts the PROPERTY, not the mechanism: after a reported-failed write that landed, the agent must still be readable, any active revision must resolve, and history and the pointer must agree. A driver whose publish is genuinely atomic satisfies it by answering "no active config" and passes unchanged.

W5 — the atomicity rows were armed over ONE driver, and now run over two. statestore_atomicity_test.go wired the in-memory state driver exclusively, which is one driver's answer to a question §9 owes in parity — and the in-memory store is the weakest possible witness for a residue assertion, since "the record survived" and "the record was removed" are answered by a map under the same lock as the writes. Both fault arms now also run over the durable SQLite driver (TestStateStore_Conformance_SQLite), where a file, a WAL and a real transaction answer them; verified as EXECUTING, not merely registered — the four WriteAtomicity_* / CompensationSafety_* rows report PASS under both scope arms of the SQLite suite. Postgres is deliberately not added blind. An arm that cannot be executed on the branch that adds it is exactly the inert guard this wave keeps finding, the state driver's own DSN-gated conformance already pins Save/Load/Delete parity beneath this layer, and the agentcfg driver above it is pure interface-consumer code.

A registration guard, in Go rather than only in a smoke. Deregistering a conformance row COMPILES — an unused function parameter is legal Go — so the suite stays green while the invariant stops being asserted. D-373 recorded that hazard and answered it with a t.Run-counting grep in one phase's smoke script. TestConformance_FaultRowsAreRegistered now asserts the same thing inside the package: both rows registered exactly once, each driven by its own fault factory, and both factories still mandatory parameters. Verified by mutation: deregistering the new row leaves the driver package reporting ok and fires this test alone.

Mutation-verified, each turning OK into FAIL, never into SKIP. (1) Delete unconditionally (restore the D-373 shape) → 3 driver tests FAIL plus the new conformance row under BOTH scope arms of BOTH state drivers. (2) Delete when the pointer re-read fails, leaving the rest of the fix in place → exactly 1 FAIL, the unknown-answer row, alone — so that guard has independent teeth rather than riding on its neighbour. (3) Deregister the conformance row → the driver package stays ok and only the registration guard fires, which is why that guard exists. (4) Drop the revision event from the landed branch → 1 FAIL, the announcement row, alone. (5) Issue the announcement on the caller's cancelled context instead of the un-cancellable one → the same row, alone.

The announcement guard's FIRST version was inert, and the mutation is what said so. It cancelled the caller and waited for the event, which reads like a test of the un-cancellable announcement and is not one: the in-memory bus does not consult the context on Publish, so mutation (5) left it green. The fix is a liveCtxBus wrapper that refuses a Publish arriving on a dead context — making the in-memory arm answer the question a durable bus would have answered on its own — after which both (4) and (5) turn it red. Recorded because the guard looked correct by inspection and only the mutation distinguished it from decoration, which is this wave's recurring finding rather than a one-off.

The same shape elsewhere, swept and reported rather than assumed absent. Two other compensation sites were read against this failure mode. internal/runtime/agentcfg/protocol/addconnection.go's compensateAttach has the same shape one layer up — it detaches a live MCP connection when recordConnectionRevision returns an error, including when that write committed and then errored, leaving a config entry naming a server that is no longer attached. Its blast radius is bounded rather than unrecoverable (the descriptor is still in config, so run-start re-establishment and remove_mcp_connection both still reach it), and it sits outside this change's territory; it is recorded here so the next contributor there has the finding. internal/tasks/engine/engine.go's spawn compensation deletes the durable task record after a group-wiring failure, which admits the same commit-then-error interleaving on persistGroupLocked; it is benign because both recovery paths (reconcile's fail-fast sweep and allMembersTerminalLocked) skip an absent member rather than wedging on it — verified by reading those two functions, not inferred.

Protocol additions. None. No wire shape, no error code, no ProtocolVersion movement; the change is entirely inside the driver's failure path plus its shared conformance rows.

Cross-references. D-373 (CORRECTED — the compensating delete this makes conditional; the correction marker is attached in place there), D-366 (the expected-revision precondition on this same write path, and its named real fix SaveIf — the interface phase where a genuine conditional write would land and make this compensation unnecessary), D-370 (the compensating detach on add_mcp_connection, the sibling shape named above), D-025 (concurrent reuse). CLAUDE.md §5 (fail loudly; no silent degradation), §9 (triad parity), §11 (conformance suites), §13, §17.5–17.6.


D-385 — A boot-gating mirror of an upstream list is guarded by a lockstep TEST, not by discipline; and two v1.25 decision entries are corrected where their stated evidence was false

Date: 2026-07-31

Status: Accepted (v1.25). A §17.5 checkpoint fix. Four findings from the v1.25 claims review, each PROVEN by execution and each mutation-verified. No Protocol version move, no new method, no new error code, no new wire field, no config key, no migration.

C1 — the bifrost bump left a boot-gating allowlist stale, and its godoc claimed the opposite

The defect. internal/config/validate.go's nativeBifrostProviders is the set llm.provider may name without a matching llm.custom_providers entry, and the set a custom-provider name may not collide with. Its godoc said it mirrors "the v1.5.8 native-provider list" and that "this list updates in lockstep". go.mod moved to bifrost v1.7.4; the list did not. Verified against the module source rather than the report: bfschemas.StandardProviders at core@v1.7.4/schemas/bifrost.go:89 lists 30 providers, Harbor's map held 23, and the delta is exactly bedrock_mantle, deepseek, opencode-go, opencode-zen, runware, sarvam, wafer. The 23 are byte-for-byte v1.5.8's list, so the mirror was a verbatim copy that simply stopped moving — Harbor never intended a subset.

The blast radius is narrower than "any deployment", and the narrowing is worth recording because the first test written for this passed against a deliberately-broken mirror. validateLLMCustomProviders returns EARLY when no custom providers are declared, so the native cross-check never runs on such a config: llm.provider: deepseek alone was always accepted, and the driver — which enumerates StandardProviders live — resolved it. The false refusal reached only operators who declared at least one custom provider alongside it. A test that sets only llm.provider is therefore INERT; the guard now plants an unrelated custom-provider entry specifically to get past that early return, and says so.

Decision — keep the mirror in production, make it a guarded one. TestNativeBifrostProviders_LockstepWithSDK enumerates bfschemas.StandardProviders and asserts set equality in BOTH directions, so the next bump that adds or removes a provider fails the build instead of mis-reporting at an operator's boot.

Why not DERIVE it in production, which would remove the drift structurally rather than detect it. This was the first option considered and it is rejected on a measured cost, not taste: go list -deps puts internal/config at 93 packages and bifrost/core/schemas at 217, and the union roughly triples the closure of the package every binary and every embedder loads merely to parse harbor.yaml — pulling in a JIT-assembly JSON codec (bytedance/sonic, including architecture-specific internal/native/neon) and a JSON-schema reflector to learn thirty strings. §13 forbids pulling heavy frameworks without an RFC. The test-time import costs nothing at runtime and the test binary links the SDK anyway, so the guard is paid for where it is already cheap. The runtime half already consults the live list directly (internal/llm/drivers/bifrost/account.go:216), which is what made the split-brain possible: the validator gated boot on a 23-name copy while the driver resolved against the 30-name original.

A behaviour change the fix carries, stated rather than absorbed. The mirror also backs the collision check that refuses a custom provider named after a native one. Widening it means a config declaring a custom provider called deepseek is now refused at boot. That configuration previously "worked" only by accident and silently: newAccount consults customByName BEFORE isKnownProvider (account.go:137), so the custom entry shadowed the SDK's native provider with no diagnostic. The refusal is the loud form of the same outcome (§5), the message names the collision, and the remedy is a one-line rename.

Mutation-verified, three ways. (1) Delete deepseek from the mirror — the exact shape that shipped — and the lockstep test fails naming it, and the call-path test fails reproducing the operator-visible error verbatim. (2) Add a provider the SDK does not ship and the lockstep test fails in the inverse direction, which guards the case where config would accept a name the driver can no longer resolve, moving a failure past validation. (3) Removing the sentinel custom provider from the call-path test makes it inert rather than red — recorded in the test's own godoc, because that is how the first cut nearly shipped.

W — a no-aliasing godoc claimed an isolation property the copy does not have

The defect. runctx.ComposeCallerMemory's godoc said External is rebuilt "so a later write to either side's External cannot be observed through the other". The copy is external[k] = v over the input's entries — a fresh MAP, not a fresh value graph. A value that is itself a map or slice is shared, and the runtime's own producer writes exactly that shape (memory_fetch.go:147 sets {"recalled_turns": []map[string]any{…}}), so nested sharing is the normal case rather than a corner. The reviewer correctly identified this as the same shape a previous audit already flagged in this file, one level down.

Verdict: the aliasing is REAL but not reachable, so the CODE is right and the CLAIM was wrong. Reachability needs a second live holder of the pre-composition blocks, and there is none: FetchMemoryBlocks builds them per run, the run loop hands them straight to the composer and replaces its local with the result (serve/runloop.go:1223), and the original becomes unreachable. Nothing can observe a nested write because nothing else holds the input. Deep-copying an arbitrary any to close a gap no caller can reach would cost a reflective or round-trip copy per run — the identical objection that already keeps Conversation shared in the same function, and taking it for one tier but not the other would be incoherent.

So the godoc is scoped honestly and the residual is pinned as PRESENT, matching the treatment Conversation already gets in this file: the guarantee is stated over the tier's MAP (top-level add / replace / delete is isolated; the caller's key is never written into the input) with the shallow-copy sharing named, the producer shape that exhibits it named, and the single-holder reason it is safe named — so a producer that starts retaining its blocks past composition invalidates a test rather than a paragraph. TestComposeCallerMemory_ExternalValuesAreSharedNotDeepCopied asserts the sharing in both directions AND re-asserts the fresh-map half beside it, so a future reader cannot conclude that "shallow" means "no isolation at all".

Mutation-verified. A type-preserving deep copy of the nested value turns the residual assertion red with a message telling the next author to widen the godoc and delete the assertion. (A round-trip json deep copy also turns it red, but on the type assertion — []map[string]any becomes []interface{} — so the type-preserving mutation is the one that proves the sharing assertion itself is live.)

D9 — a decision entry claimed a godoc fix in three places; it landed in one

Corrected in place on D-370 with a visible marker, and the missing two now carry the residual. See that entry's correction block.

D10 — a decision entry's no-migration reasoning rested on a false premise, and the premise was false in the direction that would have destroyed the conclusion

D-371 stated that the spawn idempotency key "appears in no key and no payload". The key half is true; the payload half is not — persistedTask marshals the whole *tasks.Task, and Task.IdempotencyKey is exported and untagged, so it is written to every row as IdempotencyKey. The conclusion (no migration) is nonetheless correct, and it is correct BECAUSE the field is persisted: Hydrate re-derives the widened (tenant, user, session, key) index from Task.Identity + Task.IdempotencyKey, both already present on pre-fix rows. Read literally the original clause contradicts the sentence immediately after it, and had it been true, dedup would silently have stopped surviving restarts rather than merely needing a migration. Re-derived correctly: the INDEX is derived state never written to any store, so no persisted shape needs migrating; its INPUTS are persisted and unchanged; the widening is therefore pure in-memory.

A latent hazard the false wording concealed, now guarded. Because the field carries no renaming tag, the on-disk spelling IdempotencyKey is load-bearing: adding json:"idempotency_key" later would orphan the value on every existing row and rebuild an index missing every pre-upgrade key — a silent dedup loss on upgrade, which is exactly the migration the entry says is not needed. The new test asserts the field NAME for that reason.

Mutation-verified, and this is the strongest evidence in this entry. Tagging the field json:"-" fails the new payload test AND fails TestDurable_RestartSurvival_IdempotencyPreserved and TestDurable_RestartSurvival_CrossTenantIdempotencyKeyStaysIsolated — the latter being the very test D-371 cites as pinning its persistence claim. A field that "appears in no payload" cannot break the restart test by leaving the payload.

Cross-references. D-370 (corrected here at its "three places" claim; its W1 residual is the text propagated), D-371 (corrected here at its persistence premise), D-351 (never claim a property the mechanism lacks — the bar both godoc corrections are written to), D-364 (caller_memory's admission path), D-042 (the custom-provider surface whose collision check the mirror backs), D-025 (concurrent reuse). CLAUDE.md §4.4, §5, §7, §9, §10, §11, §13, §17.5, §17.6. RFC §6.5.


D-382 — A declared tool name resolves by RE-DERIVING the forward transform, in the declaration's own precedence; an exact catalog match is not a shortcut, it is a mis-dispatch

Date: 2026-07-31

Status: Accepted

Context. D-377 bounds the model-visible tool name and D-378 announces the declaration a residual collision drops. Both were reviewed adversarially after shipping, and the review found the half neither entry examined: what the model's returned name actually DISPATCHES to.

resolveDeclaredToolName led with an exact catalog match and fell back to scanning Catalog.List() for a tool whose sanitized name matched. Against a residual collision the exact match wins, and it wins for the DROPPED tool whenever that tool's catalog name happens to be the provider-safe string:

text
catalog:      clock.now   (dotted built-in)
              clock_now   (MCP server `clock`, tool `now` -> key <sourceID>_<tool>)
declared:     clock_now, carrying clock.now's description and args schema
announced:    tool_declaration_collision{declared: clock.now, dropped: clock_now}
model emits:  clock_now
resolved to:  clock_now   <- the DROPPED tool
dispatched:   clock_now, with arguments shaped for clock.now

The model read one tool and a different tool ran, with the wrong arguments, and nothing failed, logged, or emitted. That is CLAUDE.md §13's forbidden silent degradation on an EXECUTION path — the worst place for it. It needs no exotic catalog: it is what Harbor's own two naming conventions produce when they meet, since built-ins are dotted and injected tool-source keys are <sourceID>_<tool>.

The defect was also ORDER-DEPENDENT, which is why it survived two entries and a shipped test suite. Register the same two tools in the other order and dispatch is correct, because the exact-match branch happens to agree with the declaration. D-378's own worked example (clock.now vs clock/now) is the variant where no catalog entry is named clock_now at all — so the exact branch misses, the scan runs, and the right tool is dispatched. The entry demonstrated its reasoning on the one case its reasoning did not break.

Decision. Resolution re-derives the FORWARD transform over the same candidates buildToolDeclarations walks, in the same precedence: reserved planner controls, then the always-loaded Catalog.List() view, then the per-run rc.DiscoveredTools. The first candidate whose sanitizeToolName equals the returned name wins. Whichever candidate KEEPS a declaration is therefore the one that declaration's name dispatches to, by construction rather than by coincidence.

The exact-match branch is DELETED, not demoted. It is not merely outranked — it is redundant. A catalog name that is already provider-safe and in budget sanitizes to ITSELF, so the scan matches it by identity; anything else falls through to the verbatim passthrough, which returns the same string the exact branch returned (the executor then fails loud on an unknown tool, unchanged). The branch bought nothing and cost a wrong dispatch, and the godoc says so at the one place a future author would think to re-add it.

Why a stateless re-derivation and not a recorded declared→catalog map. A map built by buildToolDeclarations is authoritative by construction and is the first idea. It was rejected on availability, not on cost:

  • It is per-projection state. The planner artifact may not hold it (§5 / D-025 — compiled artifacts are immutable, per-run state lives in ctx/RunContext), so it would have to ride on RunContext, which means every path that reaches the projector must have passed through buildToolDeclarations first, in the same process, in the same turn.
  • Every path that did NOT — a resumed run, a replayed trajectory, a caller projecting a response it did not declare for, a test — would find the map absent and fall back to exactly the branch this defect lived in. A fix whose failure mode is the original bug, reached silently, is not a fix. Making the absence fail loud instead would fail runs that are otherwise fine.
  • Purity is the property D-377 already depends on and D-378 already leans on ("any order-dependent disambiguation is not reproducible from the returned name alone"). Introducing a stateful inverse beside the pure forward transform is also §13's "two parallel implementations of one concept".

The O(n) cost is real and is the right trade. Resolution is now a scan on every tool call, where a name equal to its catalog key used to short-circuit. n is the visible catalog (hundreds at most) and each step is a rune scan of a short string; the forward path already pays the same cost twice per turn to build the declarations and render <available_tools>, and the whole thing sits next to an LLM round trip. No safe short-circuit exists: the defect's case IS a name that equals a catalog key.

Precedence is stable under the one way the catalog moves mid-run. Discovered tools are APPENDED, so a later arrival can never displace an earlier claimant of a provider-safe name — the declaration and the resolution cannot disagree because the catalog grew between them.

A second defect, found by the same probe and fixed with it. Catalog.List() is the ALWAYS-loaded view (tools.PlannerView.List applies a CatalogFilter that defaults to LoadingAlways); a deferred tool reaches the model only after the tool_search discovery cycle appends it to rc.DiscoveredTools, and buildToolDeclarations declares it from that arm under its SANITIZED name. Resolution scanned List() alone, so every discovered tool whose name is dotted or over-budget was declared to the model and then undispatchable — a deferred clock.now, and every deferred tool of any long-id source. That failure is loud rather than silent, but the tool was unusable and the discovery cycle exists precisely to make it usable. The discovered arm is now scanned, and a discovered name the catalog no longer resolves still passes through verbatim so the executor's unknown-tool failure stays loud.

Reserved controls get an explicit guard, not an argument. An operator tool named _spawn.task sanitizes onto the reserved control _spawn_task and is dropped in its favour, so the reserved name must not resolve to it. The projector's reserved-name switch already intercepts these before resolution — but "unreachable by construction" is precisely the reasoning this function was wrong about once, so the guard is written down rather than argued.

A third instance of the same shape, arriving through the PROMPT — and an unenforced godoc claim over it. renderToolNameDesc asserted that the quick reference and the declaration "can never disagree" because both go through one transform. Sharing the transform is necessary for that and is not sufficient: which tools reach the renderer is decided by the CALLER's dedup, and the two dedups did not match, in two independent ways.

divergencewhat the model saw
renderAvailableToolsSection deduped on the RAW catalog key; buildToolDeclarations dedups on the SANITIZED name and drops the collideron a residual collision the section listed one callable name TWICE — a bullet per colliding tool, each with its own description — against a single declaration. The model read the DROPPED tool's prose under a name that dispatches to the tool that kept the declaration: tool B's prose, tool A's code, one stage earlier in the pipeline than the projector defect.
the declarations seed their dedup with the reserved planner controls (which always win their name); the section did notan operator tool named _spawn.task was dropped from the declarations and still listed as _spawn_task, so the reserved control's name carried the operator tool's description.

Both are fixed here under §17.6 rather than deferred, because they are the same bug with a different exit — two code paths deriving one name mapping independently. The section now keys on the model-visible name and seeds from reservedPlannerControlDeclarations() (the declaration builder's own source, so the two sets cannot drift), and the godoc claim is replaced: it now says the agreement is a property of the two dedups matching, names the test that asserts the two produce the SAME set of names over a catalog holding every collision shape, and tells the reader to read that test before changing either dedup. A comment asserting an invariant over two independently-keyed dedups is exactly the class of claim this fix removes, so it is not re-asserted — it is enforced. The drop is NOT announced from the prompt path: the declaration builder emits planner.tool_declaration_collision for the same catalog on the same turn, and a second emit would double-count one collision.

Two more sites are RECORDED, not fixed — they are outside this change's territory and belong to internal/tools/builtin. Both take a MODEL-AUTHORED tool name and resolve it as a RAW catalog key, which is the inverse of the convention the model is actually taught:

siteshape
internal/tools/builtin/declarative_action.go:210cat.Resolve(envelope.Tool)envelope.Tool is model-authored (the typed {"tool": …} field, or salvaged from free text by the repair parser). The model's only visible vocabulary is the SANITIZED form, so a model that copies the declared clock_now gets tool "clock_now" not found in catalog — loud, but the tool is unreachable. Worse, when some OTHER catalog tool's real key literally equals the sanitized string, this dispatches the DROPPED collider: the exact defect removed above, re-created one package over.
internal/tools/builtin/tool_get.go:40cat.Resolve(args.Name)Same skew, blast radius limited to a schema read.

The root of both is tool_search (internal/tools/builtin/tool_search.go:61), which returns RAW catalog names in its observation — correct for the internal pipeline (deriveDiscoveredFromTrajectory harvests raw, buildToolDeclarations sanitizes at declaration time, and this entry's discovered arm matches it exactly) but it is also the only place the model ever SEES a raw name, so it teaches a vocabulary the native channel rejects. The reconciliation is a internal/tools/builtin change (a shared normalizer, or tool_search emitting the model-visible name), and it needs the planner's transform to be reachable from builtin — a seam decision, not a patch. Recorded with file:line precision here rather than folded in, per §17.6's deferral clause.

The event's semantics were inverted by the DISPATCH, not by the payload — and the fix restores them. declared/dropped were always correct about the catalog; the dispatch contradicted them, which is what made the diagnostic actively misleading about reachability. Fixing dispatch is therefore the diagnostic fix. No payload field is added: the sharp case is DroppedTool == DeclaredName, a comparison of two fields the payload already carries, and a boolean derived from two siblings in the same struct is the optional-capability ceremony §4.4 rejects. What was missing is that a reader knows what the shape MEANS, so the payload godoc now names it: the drop is TOTAL, DeclaredName dispatches to DeclaredTool even when DroppedTool spells DeclaredName, and an operator reading declared_name: clock_now beside dropped_tool: clock_now must not conclude the dropped tool is the one being called. The generated Protocol reference re-renders from that godoc.

The panic NIT, closed by making the function total. shortenToolName computed a retained-tail width of budget - toolNameDigestBytes - 1, which goes NEGATIVE for any budget below 9 and panics on the slice bound. Production cannot reach it (maxToolNameBytes is 44), but sanitizeToolNameTo takes the budget as a PARAMETER — an unvalidated parameter is not the "impossible by construction" carve-out §5 allows a panic for. Below minDigestBudget the result is now a PREFIX of the digest: still deterministic, still pure, still discriminating for as long as the width allows, and exactly budget bytes for any budget ≥ 1 (a non-positive budget yields the empty string, the only length-honest answer). The sweep test is also the parameter's first real caller, which closes the smaller smell that the seam's godoc advertised a measurement sweep that did not exist.

Mutation-verified — every guard was broken and watched go RED, never OK to SKIP. (1) Restore the exact-match-first branch: five tests fail, resolveDeclaredToolName("clock_now") = "clock_now", want "clock.now" and CallTool.Tool = "clock_now" — the reviewer's probe reproduced exactly. (2) Delete the reserved-control guard: three tests fail with resolveDeclaredToolName("_spawn_task") = "_spawn.task" — a reserved planner control resolved to a catalog tool. (3) Delete the discovered-tools scan: two tests fail with a DISCOVERED tool is declared but undispatchable, naming both the dotted and the over-budget case. (4) Remove the total-function arms from shortenToolName: panic: runtime error: slice bounds out of range [13:0]. (5) Restore the prompt section's raw-key dedup: <available_tools> lists "clock_now" 2 times, want 1 — the model reads two tools under one callable name, in BOTH registration orders. (6) Drop the prompt section's reserved-control seed: <available_tools> lists "_spawn_task" but no declaration carries that name — the model is told about a tool it cannot call. D-377's round-trip sweep was re-run against the new resolution path over the same adversarial corpus (empty / 43-44-45-byte boundary / 500-byte / multi-byte UTF-8 / NUL-and-control-byte / all-underscore / uppercase, plus three colliders) and is green.

Where it lives: internal/planner/react/tool_name_sanitize.go (resolveDeclaredToolName rewritten, the new isReservedControlName predicate, shortenToolName made total behind minDigestBudget, and the three godoc paragraphs that were stale about the exact-match round trip), internal/planner/react/prompt.go (renderAvailableToolsSection's dedup key moved to the model-visible name and seeded with the reserved controls; renderToolNameDesc's unenforced "can never disagree" claim replaced with the enforcement that makes it true), internal/planner/react/tool_name_dispatch_test.go (the reviewer's probe plus the declaration↔resolution lockstep guard, the order-independence guard, the discovered-tool guard, the reserved-control guard, the adversarial round-trip sweep, the N=128 concurrent-reuse run, and the budget-sweep totality guards), internal/planner/events.go (the ToolDeclarationCollisionPayload godoc's new "read DroppedTool as UNREACHABLE" section), docs/decisions.md (the in-place corrections attached to D-377 and D-378, and this entry).

Cross-references. D-377 (the forward transform this is the inverse of, and whose raw-key leak audit checked the call sites without checking the callee), D-378 (the collision path this completes on the dispatch side; its worked example is the one variant that dispatched correctly), D-301 (the flat catalog, which is why two conventions meet on one name at all), D-025 (why per-projection state is not available to the planner, and the N=128 concurrent-reuse run), D-351 (never claim a property the mechanism lacks — the bar the "the indirection is total" claim failed), D-209 / D-223 (all three generated artifacts re-run; no wire SHAPE moved). CLAUDE.md §5 ("Fail loudly", no panic outside impossible-by-construction, the concurrent-reuse contract), §11, §13 (silent degradation), §17.6, §18.


D-386 — Untrusted prompt sections are contained STRUCTURALLY as well as positionally, and the v1.25 release notes are corrected against the code

Date: 2026-07-31

Status: Accepted. A §17.5 checkpoint fix plus a §18 documented-surface correction pass. No wire shape, no method, no error code, no config key.

Part 1 — structural containment of untrusted prompt sections

The property, stated as what is now enforced. Content that reaches the model inside an untrusted, tag-framed prompt section cannot be read as that section's own framing. The JSON-encoded memory tiers (read_only_external_memory, read_only_conversation_memory), the skills-context block and the session-artifacts manifest all sit inside wrappers the model reads POSITIONALLY, and the encoder that produces their bodies leaves HTML escaping ON: <, > and & inside string values encode to \u003c / \u003e / \u0026. The plain-text untrusted sections run through escapeUntrustedSection. Untrusted content therefore stays inside the section that frames it and can never open a trusted one — <additional_guidance>, the operator base layer, or the admin-written named blocks.

The framing is the reader that matters, not the parser. JSON string delimiters bound every value against a JSON parser; they bound nothing against the tag framing, and the model reading that framing is the consumer this property is written for. That distinction is now recorded in compactValueJSON's godoc as LOAD-BEARING rather than incidental, with the encoder option commented at its call site, so a future author cannot switch it off as a formatting preference.

Escaping is loss-free and does not cost the KV-cache discipline. A parser decodes \u003c back to <, and the escaped form is deterministic — so the compact, whitespace-free, stable-key-ordered encoding that keeps the prompt prefix byte-identical across turns is unaffected. There was no trade to make.

The population is wider than the caller-supplied field, and that is the reason this is not a note on D-364. caller_memory is the newest way content enters these tiers; it is not the only one, and it is not the one that reaches the most deployments. recent_turns[].user and recalled_turns[].user are ordinary user turn text that the RUNTIME recalls into the conversation and external tiers — they need no Protocol caller at all. An uploaded artifact's filename, MIME type and provenance string land in the session-artifacts manifest, which is plain text rather than JSON. Importer-carried skill bodies land in the skills context. Each is covered, and each has its own test vector.

Trusted positions deliberately render VERBATIM and are NOT neutralised. PromptLayers.Base and the admin-written ExtraSystemBlocks are authored by principals who can already replace the entire prompt; neutralising them would defend against nobody and would mangle an operator's angle brackets. This is the same asymmetry D-367 argued from the write door, and it is pinned in both directions so that "escape everything" is as loud a regression as "escape nothing".

Pinned end-to-end, not only at the unit. test/integration/wave_v125_test.go drives a forging payload over the real wire through the real runctx.ComposeCallerMemory → planner render path and asserts the framing did not move; the unit table covers every wrapper in the file, including the two runtime-populated tiers and the non-JSON artifacts manifest, plus the inverse assertion on the trusted positions. A positional-only assertion cannot see this class — the marker is still inside the external-memory message; it is the FRAMING that moved — which is exactly why the integration leg was strengthened rather than trusted.

Disclosure posture. This entry describes the property now enforced and its test surface. It does not enumerate the reach of what was not enforced before, and the release note follows the same rule: it tells an operator to upgrade and what the guarantee is, without publishing a recipe.

Part 2 — the v1.25 release notes and their neighbours, corrected against the code

A claims review of the v1.25 notes found the notes themselves to be the release blocker. Each item below was re-verified against the code before being written; the corrections land in CHANGELOG.md, docs/glossary.md, docs/plans/, docs/skills/, the hand-written docs/site/ pages, and godoc.

  1. The notes told operators there was nothing to do while two breaking changes sat in the same file. "Nothing else in this release requires an action" stood alongside "strip members Harbor does not declare", "audit all eleven", and "if you send tenant_id, drop it". The two changes that actually break a deployed client — the control transport's strict decode and the removal of GovernancePostureRequest / LLMPostureRequest — were named nowhere in the "before you deploy" summary, which promised two items and listed neither of them. Both are now the first two items of that summary AND the first two items of Action required, and the closing claim is scoped to what is genuinely additive.

  2. The notes denied a feature that shipped. They said two callers creating an agent's first revision "cannot express 'I expect none'" and called the window "deliberately out of scope". D-370 shipped agentcfg.ExpectNoActiveRevision ("-") with three conformance rows and a 4 → 7 smoke-count bump. Corrected, and the same stale framing is corrected in the phase 221 plan and the master-plan detail block.

  3. "No session row … is created" was false. On the start path the session ensurer runs BEFORE the caller-memory validator; only the named-agent check is deliberately ordered ahead of it. The code comment beside the validator was correctly narrow ("no task"); the release note and the master plan had widened it. The four words are struck. Whether the validator SHOULD move above the ensurer is a live question about behaviour, not prose, and it is handed back rather than answered here — the named-agent check's placement reads as deliberate precedent for it.

  4. Coverage. The notes said "Five phases"; six shipped (219–224). Eight of the ten wave decisions went unmentioned — D-370, D-371, D-372, D-373, D-376, D-377, D-378, D-379 — several of them operator-visible: D-377 rewrites every model-visible tool name over 44 bytes, D-378 adds a canonical event, D-371 rekeys spawn idempotency, D-372 bounds a map any authenticated caller could grow, D-370 redacts caller_memory at rest. The bifrost v1.5.21v1.7.4 bump was also unrecorded. All are now in the entry.

  5. Identifier and count drift, each re-verified at the source. The audit event is governance.posture_read_admin (internal/governance/events.go:42), not governance.posture.read_admin — the generated reference was right and four hand-written sites were wrong. The identity-less set is seven request types across eleven methods, not "eleven request types". The agent-config wire door's bound is maxAgentConfigBodyBytes (1 MiB), not MaxRequestBytes. The extra_system_blocks section has one dedicated write verb but three doors that reach it — set_revision and rollback write the whole payload — so "exactly ONE write door" is corrected to name the property that actually holds (every door that reaches it is admin-tier, which is what the trust argument needs).

  6. A godoc that would have produced a guaranteed spurious 409. The four user-tier doors' expected_content_hash godoc told the caller to read its token from agent_config.get. Those doors write ConfigScopeUser; agent_config.get reads ConfigScopeAgent (service.go:792). A caller following the godoc would present a hash from the wrong tier and be refused every time. The correct read is agent_config.user.get, and the godoc now says so and says why.

  7. revision_conflict's "no emitted event" was true of sixteen doors out of seventeen. add_mcp_connection — the one door whose live effect precedes its conditional write — emits a terminal mcp.connection.failed as part of D-370's compensation. The error code's godoc now carries the carve-out and states why it does not weaken the "nothing is persisted" guarantee: a compensation removes state, it does not record any.

  8. Two claims inside the decisions log itself, corrected with visible markers rather than rewritten. D-375 said a correction was "attached" to D-364 item 7; no marker existed, and the log carried exactly one Correction (D- marker file-wide. D-374 deleted two wire types that D-112 records as "remaining declared", without citing D-112 and without attaching a marker — breaking the discipline D-370 had applied to D-367 one PR earlier. Both markers are attached here.

  9. Two measured numbers that were not measured. D-377 said 44 is "the SMALLEST budget at which every verb still renders byte-exact". shortenToolName retains budget - 9 bytes and the longest verb in the cited corpus is 32, so the smallest such budget is 41; 44 is the shipped value and keeps 3 bytes of headroom above it. D-379, written against pre-D-377 code, says the sanitizer "64-truncates"; the bound is 44. The phase 220 plan records 89.8% coverage for internal/runtime/runs/protocol; it measures 87.2% now that D-372's bounded store landed, which is below the 88% target and is recorded as such rather than left at the stale figure.

  10. Stale captures in operator-facing documentation. The use-the-harbor-protocol skill instructed the reader to check capabilities for caller_memory while its own sample response two paragraphs above showed a three-capability list without it, and quoted a live wire_surface_digest that matches no build. The same stale capability capture sat in the versioning-and-compatibility choreography guide. Samples are corrected; the digest is replaced with a placeholder that cannot rot, because a doc that pins a live digest is a doc that is wrong at the next wire change.

  11. Godoc mangled by the internal-jargon strip. Six sites across cmd/harbor/cmd_console.go, internal/planner/events.go and internal/protocol/methods/methods.go had phase and decision numbers removed without repairing the sentence, leaving fragments like "(— today just auth.rotate_token)" and "the subcommand designates to serve". These render on pkg.go.dev as Harbor's public API docs. Rewritten to name the feature, per §13.

What this entry does NOT do. It changes no behaviour outside Part 1's already-shipped containment, and it does not touch the smoke corpus or the start handler's check ordering — both belong to code owned elsewhere in this wave and are handed back with file:line precision rather than edited here.

Cross-references. D-364 (the caller-memory admission path and its untrusted framing; item 7 gains the correction marker D-375 promised), D-367 (the trusted-position asymmetry this reaffirms), D-370 (the first-write sentinel the notes denied, and the compensation the error godoc now names), D-371 / D-372 / D-373 / D-376 / D-377 / D-378 / D-379 (the wave decisions the notes omitted), D-374 (the strict decode and the wire-type removal now promoted to the top of the notes; its D-112 marker is attached here), D-375 (the truthfulness discipline this pass extends from one constant to the whole entry), D-112 (the decision the removal superseded), D-351 (never claim a property the mechanism lacks). CLAUDE.md §2, §5, §12, §13, §16, §17.5, §17.6, §18.


D-383 — Two bounds that were CLAIMED rather than held: the pending-override map gains a per-tenant sub-bound, and the token-minting dev bootstrap joins the strict-decode posture D-374 believed it had finished

Date: 2026-07-31

Status: Shipped (v1.25 §17.5 checkpoint, in-wave). Found by the v1.25 adversarial behaviour review (W3, W4).

What the two have in common, and why they are one entry. Neither is a missing mechanism. In both, a mechanism shipped, a decision entry recorded a property it was believed to give, and EXECUTION showed the property absent. One is a cross-tenant isolation claim; the other is a "this is the only lax package" claim. The lesson is the same in both directions and it is D-374's own: a claim is only as wide as what was actually run against it, and the most dangerous claims are the ones a later author will reason FROM.

W4 — a token-MINTING endpoint silently discarded unknown members

The defect. internal/server/dev_bootstrap.go decoded its optional override body with a bare json.Unmarshal. D-374 swept the Protocol's request handlers and turned on strict decoding across internal/protocol/transports/control; its closing line — "the change is confined to the control transport, because that is the only package that was lax" — was scoped to the packages the sweep READ, not to the set of handlers that could exhibit the bug. This one sits in internal/server and is the single handler in the tree that mints a credential.

Executed, before the fix:

text
typo scopes    body={"scope":[]}                            -> 200 identity=(dev,dev,dev) scopes=[admin console:fleet]
snake variant  body={"tenant_id":"other","user_id":"u",...} -> 200 identity=(dev,dev,dev) scopes=[admin console:fleet]

A caller asking for a no-scope token received a full-admin one. A caller spelling the triple in the snake_case form received a token for a different principal. Both answered 200, so neither could learn it had happened. The endpoint is loopback-gated and dev-only — which is why the reviewer rated it a WARN rather than a FAIL, and it is recorded here at full weight anyway, because the failure mode of a credential minter is not proportional to how easy it is to reach.

Decision: strict decoding, matching the control transport in posture — and the endpoint itself is UNTOUCHED. harbor dev's one-click Console attach is the reason this DX bootstrap exists; removing or further gating it would trade a real adoption surface for a defect that has a two-line fix. decodeBootstrapStrict refuses an unknown member and NAMES it, checks dec.More() so the swap from json.Unmarshal is a strict SUPERSET of what it replaced (Unmarshal refuses a trailing document; a bare Decoder.Decode does not), and bounds the echoed decoder detail at 160 bytes because the quoted member name is caller-controlled. The detail can never carry a decoded VALUE — encoding/json reports names and types only.

The field names were checked against the Protocol convention, and they already match — no rename. The reviewer asked whether tenant / user / session should become snake_case. They should not. types.IdentityScope (internal/protocol/types/control.go) spells an identity scope exactly tenant / user / session, and the bootstrap RESPONSE envelope uses the same three names. The snake_case tenant_id / user_id / session_id spellings elsewhere on the Protocol are on RECORD types describing a stored row — SessionSummary, SearchHit — not on an identity scope supplied as input. Renaming would diverge from IdentityScope, break the request/response symmetry, and break a shipped dev surface (scripts/smoke/phase-116.sh posts the canonical spelling to mint its non-admin token). The snake variant is genuinely not a member of this type; the strict decode is what finally says so out loud instead of substituting an identity.

Mutation-verified, each turning OK into FAIL, never into SKIP. (1) Delete dec.DisallowUnknownFields() → all three arms of TestBootstrap_UnknownMember_RefusedAndNamed FAIL, plus the mints-no-token row and the bounded-detail row. (2) Drop the dec.More() check → TestBootstrap_TrailingDataRefused FAILs ALONE, which is what makes it a separate row. The negative half is guarded too: TestBootstrap_KnownMembersStillAccepted posts every declared member together and requires 200, because a strict decode that refused everything would satisfy the positive rows while breaking the attach flow — a regression worse than the bug. The refusal is asserted on the SIGNER's call count as well as the status code, so a future refactor that answers 400 after signing is caught. scripts/smoke/phase-105.sh carries the same three legs live.

W3 — D-372's recorded rationale was refuted by execution

The defect. D-372 bounded the pending-override slot map and chose eviction over refusal, recording that a capacity refusal "lets one caller filling the map deny the surface to every other tenant" while eviction "confines the damage to the evicted slot". The order list is process-global, so eviction produced the SAME cross-tenant denial — continuously, and destructively rather than merely by refusal. Executed on the shipped code: after one attacker tenant wrote 4096 fresh session ids, victim slot present = false, store size 4096, every resident slot the attacker's.

Decision: supply the property rather than only retract the claim — a PER-TENANT sub-bound underneath the global one, DefaultMaxPendingOverridesPerTenant = 256. Both were available (correct the paragraph, or make it true) and the fix is chosen because this is an isolation boundary, not a doc nit: CLAUDE.md §6 makes cross-tenant interference a security concern rather than a style one, and a shared in-process map where one tenant's writes destroy another tenant's state is an interference channel any authenticated caller reaches with a loop. The paragraph is corrected in place as well — the log is append-only, the original reasoning was false when written, and a later author reasoning from "eviction confines the damage" would be wrong regardless of what the code now does.

Why a per-tenant key works, stated as the asymmetry it rests on. A SESSION id is a caller-chosen string, unbounded and free to mint — which is exactly why the growth defect existed. A TENANT id is a verified claim on the request identity. Bounding per tenant therefore bounds the axis an attacker controls by the axis it does not. The ratio is the guarantee: at 4096 / 256 one tenant can occupy at most a sixteenth of the map, so its churn can never reach the global bound and can never displace a sibling.

Two residuals are stated rather than engineered away, in the godoc and here. (1) Sixteen SEPARATELY AUTHENTICATED tenants can still reach the global bound together; that is not one caller with a loop. (2) Inside one tenant, a user can still displace a sibling user's slot — the sub-bound is keyed on the tenant because that is the boundary the false claim named and §6's outermost integrity boundary, and because a per-tenant bound must remain the OUTER one in any case or a tenant with many users would exceed its share.

Shape. slotEntry carries its element in both order lists, so every admit, evict and Consume stays O(1) — D-372's own reasoning (a bound whose enforcement is a linear scan is a poor answer to an availability defect, on a path whose rate an attacker controls) applies unchanged to the second list. The per-tenant check runs FIRST, and the ordering is load-bearing rather than incidental: consulting the global bound first would, whenever the map happens to be full, hand a tenant already at its own sub-bound a SIBLING's slot as the price of its own overflow — the exact eviction the sub-bound exists to prevent, reintroduced by an ordering. A tenant's list is DELETED the moment it empties, because a per-tenant index that accumulates a key per tenant ever seen is the unbounded-map defect one level up, keyed on tenant instead of session. An oversized WithMaxSlotsPerTenant is clamped to the global bound; the clamp changes no resident count (the global bound is always reached first), and what it does change is which mechanism runs and therefore which line an operator reads — so it is pinned by the log assertion, not by a count.

Every eviction names WHICH bound forced it. A tenant evicting itself is self-inflicted and needs no action; a tenant displaced by the global bound means the deployment is at its aggregate ceiling. D-372's third policy bullet already required the eviction to be loud; a line that reads alike for both situations would hide the distinction the sub-bound was added to create.

Mutation-verified — and four mutations came back INERT on the first pass, which is the finding. The first draft's tests could not see: the ORDER of the two checks (no scenario reached "map full AND admitting tenant at its sub-bound"), the empty-tenant-list delete (invisible through Peek / Consume), the clamp (provably unobservable on resident counts), or a Consume that stops popping the orders on the GLOBAL path (the per-tenant branch tests its list's Len(), which counts a stale element and so absorbs the tombstone — only a multi-tenant fixture reaches the branch that does not). Four rows were added to close them, including one in-package test file — the only one for this package — because the per-tenant index's own growth has no public accessor and every count-based test stays green while it leaks. All ten mutations now turn OK into FAIL, never into SKIP, each with attributable rows.

The bound D-372 established is not regressed. The 128-goroutine -race stress with max=64 still lands at the bound with map and order list never diverging; a second stress spreads 128 writers over 16 tenants and asserts BOTH bounds hold under interleaving.

Protocol additions. None. No wire type, method, error code or capability moves; ProtocolVersion holds. The bootstrap endpoint is not a Protocol method (it is harbor dev's bootstrap route) and its response envelope is unchanged.

Cross-references. D-372 (the entry whose choice-1 paragraph is corrected in place; its conclusion stands, its argument does not), D-374 (the strict-decode posture W4 extends, and the entry whose "only package that was lax" line is corrected in place — its own closing lesson about populations is what predicted this), D-384 (the sibling from the same review, on the instrument rather than the runtime), D-025 (concurrent reuse — the Store is a compiled artifact; both bounds hold under N=128), D-130 (runs.set_overrides, the surface being bounded), D-351 (never claim a property the mechanism lacks — the bar both halves of this entry are measured against). CLAUDE.md §4.2, §5, §6, §7, §11, §13, §17.6, §18. RFC §5.2, §5.5, §6.3.


D-384 — A guard that cannot fail is not a guard: the mutation harness gains the POPULATION axis, drift-audit's delegated guards stop VANISHING, the coverage claim is corrected 18 → 20, and the bifrost degradation fixture derives from upstream

Date: 2026-07-31

Status: Shipped (v1.25 §17.5 checkpoint, in-wave). Found by the v1.25 adversarial behaviour review (W2, NIT 2) and the parallel claims review (D13, NIT 1).

The unifying defect. Four findings, one shape: an instrument that reports OK for a reason other than the absence of the thing it looks for. A harness that plants every mutation in one directory; a delegated guard that disappears with its executable bit; a coverage number that counts what a mechanical census can see rather than what exists; and a conformance fixture self-consistent with the matcher it tests. Each is individually small and each makes its instrument decorative.

W2 — the harness verified SHAPES and never POPULATIONS

The defect. scripts/smoke/phase-224.sh built internal/fixture, cmd/fixture and sdk/fixture — and planted every mutation in internal/fixture/fixture.go. A guard has a SHAPE (what counts as a violation) and a POPULATION (where it looks); only the first was verified. Two REAL regressions therefore passed the harness clean:

regressionharness verdict before
godoc jargon scan narrowed from internal/ cmd/ sdk/ to internal/ (sdk/ is D-282's explicit adopter-facing extension)OK: 25 SKIP: 0 FAIL: 0
forbidden-name scan with the whole cmd/ population block deletedOK: 25 SKIP: 0 FAIL: 0

This is NOT the "shape gap" D-376 disclaims. The fixture files already existed; nothing was ever planted in them.

Decision: one case per POPULATION LIMB, for every guard whose population is a directory list. The godoc scan gets internal/ + cmd/ + sdk/; the forbidden-name scan gets all five of its assembly blocks (root docs, phase plans, research briefs, internal/, cmd/) rather than the two it had; both portability guards get their scripts/smoke/ and scripts/ roots. Each new godoc case also uses a DIFFERENT pattern from the guard's list, so between them four of the five patterns are exercised and the bad markers stay per-case attributable, which the shared message prefix alone would not give.

And the complementary half, inside the audit: a POPULATION CENSUS (check 2b). Almost every guard in drift-audit.sh is a scan, and a scan cannot distinguish an EMPTY population from a CLEAN one — both print the identical OK. A wrong cd, a renamed directory, a deleted corpus and a glob that stopped matching all read as "no violations". The census asserts each scanned corpus is non-empty and FAILs naming the empty one; it runs EARLY so the cause is read before a page of vacuous passes. It uses find rather than a glob deliberately, so it cannot become a function of a shell option some earlier check happened to set — the nullglob incident that made the brief-reference guard unable to fail is the precedent.

The two mechanisms are complementary and neither substitutes for the other, which is stated in both places rather than left to be inferred: the census sees a corpus that VANISHED, the per-limb cases see a guard that STOPPED READING a corpus still sitting there. The residual is named too — a population a guard GAINS later has no case until someone writes one, and the ok-keyed census cannot see that, because adding a directory to an existing guard adds no ok line.

D13 — a guard gated on an executable bit, with no else

The defect. Both of drift-audit's delegated guards were written if [ -x helper ]; then … fi with no else. A lost executable bit — a cp that does not preserve mode, a checkout on a filesystem without the bit, a rename — removed the entire check with no failure, no skip and no output. The guard did not fail; it disappeared, while the summary stayed green. That is the same shape as the pages concurrency bug (issue #644) and the same shape as every inert guard this wave has been draining.

And it compounded. The body-identity delegation (D-374's, added seven commits after the harness shipped) emits no ok of its own, so phase-224's mechanical census could not see it, the hand-written exception list still said "two", and the script asserted "18 of 18 guard units, 0 declared uncovered" over a NINETEENTH guard with zero coverage — the one with the vanishing if.

Decision. Both delegations take a loud else. Four cases cover them: executable bit stripped (both helpers), helper missing, and helper exiting non-zero. The count is corrected to 20 guard units over 34 mutations — 17 emitting an ok, 3 not (NUL-byte, and the two delegations) — and the number is now derived from the counted ok lines in the printed line rather than hard-coded, so half of it cannot go stale silently.

The limitation that produced the wrong number is now stated in the script, because it cannot be mechanised away. An ok-keyed census structurally cannot see a guard that emits no ok. The hand-maintained exception list is therefore the LOAD-BEARING half of that census and must be revisited whenever a delegated or FAIL-only guard is added. Writing "0 declared uncovered" while that list was an unrevisited assumption is exactly the silent-partial-coverage defect phase 224 exists to remove.

The enumeration was over the POPULATION, not the syntax — this wave has been caught four times doing the reverse. Every conditional in drift-audit.sh was read, not grepped for one bracket form. Two more instances of the same class surfaced: if [ -d internal ] / if [ -d cmd ] guarding the forbidden-name scan's Go limbs, which would narrow the scan silently while it still printed its OK. Those are covered by the census rather than by an else (the -d tests exist only to avoid a find error), and a comment at the site says so, so a future reader does not "fix" them into duplicating the census.

Mutation-verified against the REAL regressions, not synthetic damage. Eight mutations applied to drift-audit.sh, each run through the harness: narrowing the godoc roots to internal/ (2 cases red), dropping sdk/ alone (1 red — and it is the D-282 extension, so it names itself), deleting the forbidden-name cmd/ block (1 red), deleting its research-brief block (1 red), narrowing each portability guard to scripts/smoke (1 red each), and removing each delegation's else again (2 red, 1 red). Every one reports MUTATION NOT CAUGHT … THIS GUARD IS INERT against the specific case rather than an exit code. On the clean tree: OK 37 / SKIP 0 / FAIL 0, and drift-audit.sh itself OK 1402 / WARN 0 / FAIL 0.

What the delegated cases do NOT claim. They verify drift-audit's DELEGATION — missing helper, lost bit, non-zero exit — not the helpers' own detection logic. The fixture ships a clearly-labelled STAND-IN for check-smoke-body-identity.sh, because the real helper joins the generated wire manifest against the generated methods table and then walks the smoke corpus, and reproducing all three in a fixture would be testing the copy rather than the program — the trap this harness exists to avoid. The helper carries its own guards and its own mutation record under D-374. The residual is stated in the harness at the stand-in and again in the coverage line.

NIT 2 — the bifrost degradation fixture was hand-authored

The defect. internal/llm/drivers/bifrost/providerfiles_test.go hand-assembled a BifrostError carrying the literal string unsupported_operation in Error.Code. CLAUDE.md §17.8 calls that a rubber stamp: a fixture encoding the implementer's reading of an external contract is self-consistent with the matcher by construction, so it stays green against a matcher wired to the wrong field, the wrong value, or a field upstream has since moved. The D-216 _meta.ui placement bug shipped four phases of green tests exactly this way. The reviewer confirmed behaviourally that the matcher IS still correct against bifrost 1.7.4, so this is fixture hygiene rather than a live defect — and hygiene is the whole point: the fixture could not have told us either way.

Decision. The fixture calls providerUtils.NewUnsupportedOperationError(schemas.FileUploadRequest, schemas.Cohere) — byte-for-byte the call Cohere's own FileUpload makes (providers/cohere/cohere.go), for the provider this test already drives. No go.mod change: it is a subpackage of an already-required module. A precondition asserts the constructor still yields an Error.Code, so a future bifrost that drops or moves it fails LOUDLY rather than silently re-pointing this test at the generic upload-failure path it is not named for.

Mutation-verified. Keying the matcher on a wrong code value, and keying it on the MESSAGE instead of the code, each turn the degradation test red — and the failure now quotes bifrost's own wording (file_upload is not supported by cohere provider), which differs from the string the hand fixture used, which is itself the evidence the fixture had been asserting this repository's paraphrase rather than upstream's contract.

A fifth instance of the class, found while fixing a typo — recorded and DEFERRED (issue #652)

How it surfaced. scripts/smoke/phase-72g.sh named the cross-tenant audit event governance.posture.read_admin (dot, not underscore, before read_admin) — the fourth of four prose sites carrying that typo; the other three were fixed alongside the CHANGELOG. The question worth asking before substituting a character was whether the smoke asserted on that string. It did not: the only occurrence was inside a comment. Nothing executable named the event, so nothing executable was wrong — which is why four sites could drift. Prose that describes a constant it does not derive from will drift; proofreading is not the answer, making one copy load-bearing is.

What was underneath. The comment pointed at TestPostureDispatch_CrossTenantConfigReadEmitsAudit as the guard for the emit. That test subscribes and compares ev.Type against governance.EventTypePostureReadAdmin / llm.EventTypePostureReadAdminthe same constants the emitter publishes (internal/protocol/posture.go:697, :708). It asserts "the event equals the constant the emitter used", true by construction. An oracle derived from its subject is not an oracle, and this is the same defect as every other one in this entry, one layer up in the audit surface.

It is not one test. Enumerated over the declared universe (every EventTypeX events.EventType = "…" declaration) rather than by grepping test files — the population discipline this entry is otherwise about: 112 canonical event types; 34 literal-pinned in some test; 66 asserted ONLY via the emitter's own constant; 12 named in no test at all.

Measured, not inferred. Renaming EventTypeAgentRegistered's wire string in internal/runtime/registry/events.go left go test ./internal/... at rc=0, zero failures.

Neither generated gate covers it, and that is the subtle part. docs/site/protocol/events.md and the Console wire manifest are both generated FROM these constants, so a rename regenerates cleanly and make protocol-docs-gen-check / make protocol-ts-gen-check both pass. Demonstrated: renaming the posture constants AND running make protocol-docs-gen leaves the gen-check satisfied and the Go test green. The manifest's lockstep tests check event COVERAGE, never name STABILITY. An event name is a wire contract with every subscriber's Filter.Types; a silent rename breaks every external subscriber with every gate green.

What ships here: two of the sixty-six. A static leg in phase-72g.sh pins both posture event names as HAND-WRITTEN literals, checked against both the Go constant and the generated reference — a third copy that derives from neither. Its placement is itself the lesson: the first draft sat beside the cross-tenant legs it documents, which is past the script's no-token early exit, so the whole pin was unreachable without a server and reported nothing while the script exited 0 — the exact defect D-374 records correcting on phase-219's D-375 leg, reproduced within an hour of reading it. It now sits above every live probe. Mutation-verified in both arms: rename alone → 2 legs FAIL; rename plus faithful regeneration → all 4 FAIL; the Go test stays green in both.

Why the other sixty-four are DEFERRED rather than fixed here (§17.6). The fix is a golden wire-name registry — one hand-written list of all 112 const → wire string pairs, failing on any rename, addition or removal, deliberately NOT generated from the constants. That is a design decision owed its own phase and decision entry, and it spans ~16 packages including several owned by sibling agents in this wave. Filed as issue #652 with the enumeration and the probe, named here with file:line precision so it does not survive only as a tracker row.

NIT 1 — the D-369 gap in this log

git log --all -S 'D-369' finds nothing and there are zero references repo-wide. Every other gap in the sequence (212/213, 230, 244–247) is a documented reservation, recorded in a wave plan or in the entry that reserved it. D-369 is UNUSED: it was never allocated, never reserved and never referenced — a skipped number, not a pending entry. Recorded here so the ambiguity does not survive as an open question, and so nobody re-allocates it believing it was reserved for something. If a future phase wants it, it is free.

Protocol additions. None. Tooling, tests, fixtures and docs only; no production behaviour changes in this half of the wave.

Cross-references. D-376 (the harness this corrects; its coverage paragraph carries the pointer), D-374 (the guard whose delegation vanished, and whose "a sweep is only as wide as the population it enumerated" rule this is the same error one level up from), D-282 (the sdk/ godoc scan extension the harness could not see being dropped), D-368 (the inert-smoke gate this harness is protected by), D-104 (the PREFLIGHT_REQUIRES classification), D-383 (the sibling from the same review, on the runtime rather than the instrument), D-216 (the placement bug a hand-authored fixture hid for four phases — the precedent behind §17.8). CLAUDE.md §4.1, §4.2, §13, §16, §17.2, §17.3, §17.6, §17.8. RFC §3.4, §4.3, §8.


D-381 — The add door's compensation undoes only what THAT CALL created: a refusal must not be destructive either

Date: 2026-07-31

Status: Accepted. A §17.6 fix to a regression THIS WAVE introduced (D-370 F1), found by an adversarial behaviour review with executed proof and landed in-wave. No Protocol version move, no new method, no new error code, no new wire field, no config key, no new lifecycle state.

Context — a refusal that became destructive. D-370 F1 gave agent_config.add_mcp_connection a compensating detach so a revision_conflict would be side-effect free: the door's live effect necessarily precedes its conditional write, so a refused write could leave a live server no revision named. The compensation it shipped is unconditional — it tears down whatever this add attached, without asking whether the add CREATED that registration or merely REPLACED one.

A same-owner same-name re-attach is allowed and intended. internal/tools/drivers/mcp/registry.go:566-573 says so in as many words ("a re-attach that supersedes a still-live connection is the operator replacing their own"), and attach.go:285-308 implements it as an atomic upsert. So the destructive path is reachable through ordinary operator behaviour: re-add an already-live connection holding a token whose base has moved.

Executed proof, before the fix:

text
after successful add: live=true detaches=0
AFTER THE 409:  activeRevisionNames(github)=true  liveRegistryHas(github)=false  detaches=1
COLLATERAL: a REFUSED write tore down a pre-existing live server that the ACTIVE revision still names

Catalog tools deregistered, transport closed, a terminal failed lifecycle emitted for a connection that is healthy and declared, and in-flight runs stripped of its tools. It heals only at the next run-start reconcile (projection.go:307-345). D-370's own claim — a revision_conflict is side-effect free — was therefore still false, now in the destructive direction. Its boundary tests could not see it: every one of them uses a fresh connection name.

The same shape, one plane over, found by asking the question of the provider path. wireProviderIsNew was presented as the guard that keeps a rollback from uninstalling a provider that outlives the add. It guards the bind-by-NAME branch and not the inline one: the inline branch returned true unconditionally, even though an inline install is an UPSERT and recordConnectionRevision persists the provider onto the revision. So a re-add carrying the same inline binding reported an already-declared provider as "newly installed by this call" — and a refusal uninstalled it, stripping southbound bearer injection from every still-declared connection bound to it. The default: (attach-FAILED) branch is the worse half, because it has no second guard: a connection that merely fails to DIAL took a live credential binding down with it. No conflict, no refusal, just an unreachable third party.

Decision — scope both teardowns by the ACTIVE revision, read at compensation time.

  1. The connection. compensateAttach reads the agent's active agent-scope revision and skips the detach when it still names the connection. The predicate is deliberately the SAME one the run-start reconcile uses to decide "keep it attached" (projection.goif _, stillDeclared := declared[src]; stillDeclared { continue }), so a refused write and a reconcile sweep cannot contradict each other.
  2. Read at compensation time, not before the attach. The reachable failure IS a moved base, so the revision that decides whether the name is still declared is the winning SIBLING's, not the one this caller read. A pre-attach flag structurally cannot answer a post-attach question. wireProviderIsNew is corrected too (an inline install over an already-declared name is not new), but it is corrected for the attach-FAILED path, which has no compensation-time read to fall back on — the two guards cover different windows and each is mutation-verified separately.
  3. A read failure is loud and its resolution is stated. If the active revision cannot be read, the unrefined (unconditional) compensation stands and an ERROR log says so, naming that it may tear down a still-declared connection. Both outcomes heal at the next run-start reconcile; only one of them would be silent, and this is not it (§13).

Why NOT the looser registry.OwnerOf(name) guard the review suggested as an alternative. "Skip the detach when a live same-owner registration existed before the attach" is strictly WIDER than declared-ness, and the extra width is exactly wrong: a live, same-owner, UNDECLARED registration is the orphan D-370 exists to clean up, so an OwnerOf-based guard would refuse to detach it and re-open the original leak in a new shape. It also costs a new seam from this package into the live MCP registry for a signal that is worse than the one the registry interface already provides. Declared-ness is both narrower and the predicate the rest of the runtime already agrees on.

The lifecycle event on the retained path, and the residual named rather than papered over. The state set is closed (pending | online | failed | auth_required) and widening it is a wire change, so the terminal event stays failed — the add DID fail, and leaving the transient pending unclosed is the silent half D-370 correctly refused. But failed alone is misleading about a connection that is healthy, declared and still serving tools, and the per-connection stream is what a reader keys on. The reason string carries the distinction the state cannot: "add refused; the pre-existing connection is still declared by the active revision and was NOT torn down: <scrubbed cause>". The residual stands and is not over-claimed: a reader that models mcp.connection.failed as connection health, without reading the reason, still misreads this event. Closing that properly needs a state the wire does not have.

A second residual, stated for the same reason. When a racing sibling's winning revision declares the same connection with a DIFFERENT descriptor, the retained live server carries THIS call's descriptor while the revision names the sibling's, and the reconcile's attach pass skips names already live — so the divergence persists until that connection is next detached. Retaining is still the better failure: a declared-but-dark connection breaks every run immediately; a descriptor divergence breaks none.

W1 — the seven-test compensation guard could not see an owner regression, and now can. The detacher double declared DetachConnection(_ context.Context, _, _, name string): it ignored the tenant and agent. Mutating the production call to pass "wrong-tenant" left all seven tests GREEN. In production that mutation is a silent no-opRegistry.Deregister (registry.go:547-558) answers a foreign owner as absent and detachSource (mcp_detacher.go:181-184) swallows ErrServerNotFound as idempotent — so it restores invisibly the exact leak D-370 exists to close. The fix is structural rather than an added assertion: the double now models the production resolution rule ((name, owner), foreign owner removes NOTHING and returns nil), so a wrong-owner call is observable the way it is observable in production — by the server still being live. TestAddMCPConnection_CompensatingDetach_IsOwnerScoped adds the precise diagnosis on top, asserting the detach owner equals the owner the ATTACH stamped.

Mutation-verified, each turning a green test red, none OK→SKIP (go test -run <no-match> prints "no tests to run" and exits 0 — the smoke legs go through assert_go_tests_pass, which greps for a --- PASS: line per named test):

  1. Detach unconditionally again → KeepsAStillDeclaredConnection and KeepsAStillDeclaredWireProvider FAIL; the refusal and no-revision-persisted assertions stay GREEN, which is how the regression shipped.
  2. Restore the unconditional wireProviderIsNew = true in the inline branch → FailedAttach_KeepsAStillDeclaredWireProvider FAILS. It does NOT fail the conflict tests, because the compensation's own read covers that path — which is why the attach-failed case has its own test.
  3. Drop && !providerDeclared from the compensation's uninstall → KeepsAProviderASiblingDeclared FAILS. That row drives the sibling write deterministically through the installer hook (the install sits inside the window), never a sleep.
  4. Drop the retained-reason prefix → RetainedReasonNamesTheUntouchedServer FAILS while its event-fired assertion stays green.
  5. Pass a wrong tenant to DetachConnection → FOUR tests FAIL (RevisionConflict_CompensatesTheAttach, UninstallsInlineWireProvider, AuthRequiredConflict_CompensatesTheAttach, CompensatingDetach_IsOwnerScoped), where the pre-fix double reported all seven as passing.

D-370's own boundary tests are unchanged and still pass: a SUCCESSFUL add detaches nothing, a FAILED attach detaches nothing, a fresh-name conflict still detaches exactly once, the nil-detacher arm still fails loud, and a failed compensation is still not swallowed.

Cross-references. D-370 (the decision corrected in place, twice — its F1 side-effect-free claim and its mutation-verification paragraph), D-301 (the (tenant, agent) owner tag whose scoping the W1 double now models, and the process-global bare-name registry that makes a foreign-owner detach a silent no-op), D-379 (the refusal to re-key that registry, which is what leaves owner scoping as the resolution rule), D-366 / D-367 (the expected-revision token whose refusal path this is), D-351 (never claim a property the mechanism lacks — the bar both residuals above are written to), D-380 (the sibling correction from the same review, one layer down; the commit-then-error finding it records against this door is CLOSED here — see the section below). CLAUDE.md §5, §6, §11, §13, §17.6, §18. RFC §6.11, §6.16, §7.

D-380's commit-then-error finding against this door is CLOSED by the same re-read, and the three disk states are pinned by execution. D-380 names compensateAttach as carrying its own shape — detaching on an error from a write that committed and then errored, leaving a config entry naming a server no longer attached. That reading was correct against the pre-D-381 code and is closed by D-381, with no second mechanism: "did the write land?" and "does the active pointer name it?" are the same question asked of the same read, so scoping the teardown by declared-ness answers the commit-then-error question as a side effect. The initial framing of this entry claimed otherwise (that the scoping was "about which registration the teardown addresses, not whether the store's report matched the disk"); that was over-cautious and is corrected here rather than left to become a follow-up for a closed hole. Settled by driving the door through three fault registries at the agentcfg.Registry seam, each modelling a real disk state behind one identical error value:

#disk stateactiveRevisionNamesliveRegistryHasdetachesverdict
1pointer MOVED, store reported failuretruetrue0closed — the landed write's connection is retained
2record landed, pointer STUCKfalsefalse1correct — nothing declares it, so it is the leak
3pointer MOVED, re-read REFUSEDtruefalse1the residual, below

Case 2 is not the same defect wearing a different hat. The pointer is the source of truth and an unreferenced revision is invisible to Active and to the run-start projection, so there is nothing declared to protect and a live server no active revision names is precisely what the compensation exists to close. The orphan record itself is D-380's to remove, and its conditional delete fires exactly here (the pointer disowns the revision) — the two fixes compose. Mutation-verified: detaching unconditionally turns case 1 red at names=true live=false, which is D-380's predicted shape reproduced, while cases 2 and 3 stay green.

The one residual: the unknown answer tears down a landed write (case 3), and the fallback direction is deliberately the OPPOSITE of D-380's. When the store fails the write and then refuses the re-read, "the pointer does not name it" and "I cannot tell" are indistinguishable, so this door falls back to the unrefined compensation and reports it at ERROR. D-380 resolves its own unknown answer the other way — it RETAINS — and the asymmetry is reasoned rather than inconsistent: down there an unknown-answer delete strands a dangling pointer that no later write can repair, because every door reads through the pointer. Here both outcomes self-heal at the next run-start reconcile — the attach pass re-establishes a declared-but-dark connection, the detach pass tears down an undeclared-but-live one — so the bounded blast radius makes keeping the guarantee this door was given the better trade. The residual is narrow (it needs a store sick enough to fail a write and then a read), bounded, self-healing, and pinned by TestAddMCPConnection_WriteLandedButPointerUnreadable_FallsBackLoudly so it cannot change silently. Closing it properly needs the conditional-write primitive D-366 already names as the real fix, not a fourth compensation.


D-387 — Caller instructions are normal-user personalization; prompt position never grants runtime authority

Date: 2026-08-01

Status: Accepted for Phase 225, superseding the affected D-365 prompt-placement claim without removing or admin-gating the wire field.

Decision. RunOverrides.extra_instructions remains available to a verified normal user and keeps one-run semantics. The runtime renders it in a fixed caller-personalization section distinct from tenant/operator guidance, with delimiter-safe encoding. This is containment and attribution, not an assertion that an LLM cannot follow hostile text. Identity, tool availability, authorization, governance, and trusted configuration are enforced outside prompt prose. Caller-memory admission records the original admitted byte count while carrying no content, strict request decoding requires exactly one JSON document, and valid extra system blocks preserve their bytes after empty detection.

Cross-references. D-364, D-365, D-367, D-374, D-375, D-386. RFC §5.2, §6.2, §6.5.


D-388 — Conditional skill writes coordinate body and revision effects; unknown persistence is retained loudly

Date: 2026-08-01

Status: Accepted for Phase 226; supersedes Phase 221's incomplete side-effect-free claim.

Decision. The owner lock and expected-content check precede every admin/user SkillStore mutation. A later revision failure restores the exact prior body or removes only a body created by this operation; a conflict performs no compensation because no mutation began. A revision-record save that reports failure is checked with an exact scoped point-read: only a byte-identical record proven unreferenced is deleted. An unreadable or mismatched answer retains the record and returns/logs the ambiguity. This is coordinated compensation, not cross-store ACID or cross-process CAS.

Cross-references. D-366, D-370, D-380, D-381. RFC §6.7, §6.11, §6.16, §9.


D-389 — Model-authored tool names resolve only through one shared declaration projection

Date: 2026-08-01

Status: Accepted for Phase 227; closes #654.

Decision. The transport-neutral tools layer owns one immutable per-run projection from catalog keys to bounded model-visible declarations and back to the winning catalog entry. Every planner/builtin consumer uses it. Raw catalog keys are not a fallback namespace for model-authored names, and collision losers are announced but never callable through the colliding declaration.

Cross-references. D-377, D-378, D-382. RFC §6.2, §6.4.


D-390 — Runtime-added MCP connections prepare, persist, then activate

Date: 2026-08-01

Status: Accepted for Phase 228; supersedes D-381's destructive unreadable-pointer fallback and closes #653.

Decision. Preparation may dial, authenticate, initialize, and discover, but it publishes no provider, catalog entry, live registration, or online event. Desired state is persisted before activation: the registry stages a reversible replacement first, then the catalog swap is the dispatch linearization point; a refusal rolls both back to the exact prior state. A failed write closes only unpublished resources unless an exact pointer re-read proves the new descriptor active, in which case activation converges while the storage error remains loud. An unreadable pointer never detaches existing live state. Inline OAuth activation is governed by an exact installation receipt. Authentication-required work parks through the unified pause/resume primitive and recreates preparation on resume. Run-start reconciliation consumes the same lifecycle and compares descriptors, not names alone.

Restart safety includes the production OAuth callback, not only the continuation token. A mandatory FlowStore typed wrapper over the existing StateStore seam seals the complete pending-flow envelope (PKCE verifier, client material, identity, expiry, and pause token) with the OAuth KEK. The high-entropy OAuth state is the direct callback lookup key; the reconstructed identity triple and provider-owned source are validated before exchange. A durable one-winner claim prevents duplicate exchange across reconstructed providers, retryable failures release the claim, and a spent authorization code whose token cannot be persisted terminates the pause explicitly. All resume decisions observe the same in-flight first-winner claim, so reject/timeout cannot overtake accepted continuation work.

The process-global same-name refusal gates remain mandatory. #638 stays open and must land before those gates can be relaxed; agent_id does not join Harbor's isolation tuple.

Cross-references. D-301, D-370, D-379, D-380, D-381. RFC §3.3, §6.4, §6.11, §6.16.


D-391 — Release evidence needs external oracles and one executable guard-case registry

Date: 2026-08-01

Status: Accepted for Phase 229; closes #652 and #644 and supersedes affected Phase 223/224 evidence claims.

Decision. Canonical event names are compared bidirectionally with a hand-maintained golden that does not import emitter constants. Drift-audit mutation coverage has one registry joining each guard signature to declared and executed cases; deleting a whole case must fail the census. Shipped smoke scripts that omit a summary fail behaviorally. Documentation validation supersedes only an older run on the same ref; Pages deployment alone owns global deployment serialization.

Cross-references. D-368, D-374, D-376, D-384. RFC §5.2, §6.13.


D-392 — State enumeration is identity-scoped at storage and every granted search widening is audited

Date: 2026-08-01

Status: Accepted for Phase 230; closes #396, #612, and #462.

Decision. Every StateStore driver implements one mandatory identity-and-kind-prefix enumeration operation and filters before returning rows; agent-config history no longer requires maintenance-wide scans. All four search indexes emit one redacted audit fact for an allowed tenant/user widening. A stale erasure ledger first converges the old lifecycle's record before deletion, retaining the ledger on publish or cleanup failure and never mutating the current lifecycle. Delivery is at least once: a bounded HistoryReplayer check suppresses observable retries when available, while an unverifiable retry may duplicate rather than lose the compliance record. agent_id is metadata, never a storage isolation filter.

Cross-references. D-025, D-059, D-218, D-349. RFC §6.9, §6.11, §6.13, §9.


D-393 — Reliability tests synchronize on observable state and protocol-faithful terminal input

Date: 2026-08-01

Status: Accepted for Phase 231.

Decision. Scheduler-dependent counters, unsequenced resumes, polling sleeps, timeout inflation, retry-until-green, and permanent quarantine are not fixes. Tests use per-invocation plans, explicit barriers, cancellation/completion acknowledgements, process-exit signals, and joined goroutines. Tool OAuth completion and planner run re-entry are two distinct pauses: the choreography waits for both tokens before the callback and resumes the run token once after durable token persistence. The PTY harness sends one failed-follow-up retry command; a duplicate while dispatch is in flight is a deterministic local error, not another start.

The reproduced PTY failures were four harness-oracle defects, not permission to weaken the workflow. First, the harness sent legacy escape sequences and then briefly sent Bubble Tea's internal tea.KeyF* enum after the terminal had negotiated Kitty CSI-u; Kitty assigns F1–F12 the distinct functional-key codepoints beginning at 57364. The focused real-PTY decoder test fails without that translation. Second, Bubble Tea emits cursor-addressed cell diffs, so typed hello may appear as separate hel and lo byte fragments even when the screen is correct; the harness now waits for the persisted draft acknowledgement, forces a full repaint, and retains the visual assertion. Third, the failed-follow-up retry toast can be overwritten before a render; the canonical tasks.list row is the stable completion barrier. Fourth, a successful sessions.inspect after rename proves the server commit, not delivery of the asynchronous renamedMsg back through Bubble Tea: the next shortcut could therefore be consumed by the still-open rename input. The workflow now waits for the terminal's rename-result acknowledgement before sending that shortcut. Failure-only SIGQUIT output is captured separately from the bounded screen tail so stack evidence cannot be displaced by render bytes.

The constrained 100-run PTY gate then exposed a production race rather than another excuse for a retry: a late, superseded inspection response in the same identity/generation closed an action modal opened after the newer route rendered. Same-scope stale responses are now discarded without mutating focus; a response from another identity or generation still closes the modal because its targets are stale. The app regression drives both branches before the PTY stress is repeated.

Cross-references. D-025 and the Phase 231 issue set. RFC §5.4, §6.4.


D-394 — OAuth callback convergence uses exact durable stages and one atomic credential record

Date: 2026-08-01

Status: Accepted for Phase 228; supersedes D-083's access/refresh sibling-record write shape.

Decision. A successful authorization-code exchange stores the access token, refresh token, and encrypted exact flow-state marker in one StateStore record. The two credentials remain independently sealed, but there is no second save that can fail after publishing a usable access credential. Before pause resume or destructive pending-flow cleanup, FlowStore also writes a sealed completion tombstone keyed by the exact OAuth state and carrying the exact identity, source, subject, pause token, retry expiry, token marker, and expected DecisionResume. The tombstone, rather than the replaceable current credential slot, is the durable per-flow idempotency and callback-routing oracle: a later successful flow may replace the current credential without making an older callback spend its code again. A retry additionally requires a current credential at the same scoped key and the coordinator's exact terminal decision.

Completion tombstones survive cleanup only through the original flow's bounded retry horizon. A later flow for the same identity opportunistically enumerates the exact completion Kind prefix through ListKindForIdentity, validates every sealed record, and removes expired tombstones only after any residual pending, claim, or terminal records. Other identities and unexpired flows are untouched. Landed-but-unacknowledged completion writes/deletes and partially applied cleanup converge by exact reread.

If the one-time code is spent but credential persistence fails, FlowStore records a durable terminal-rejection stage before releasing its one-winner claim. A transient rejection-resume failure therefore retains a retry path that rejects and cleans up without exchanging the spent code again. Denial and completion retries converge after cleanup failure. Callback logs, responses, pause records, and canonical events never carry untrusted upstream response bodies or redirect error text: seven standard OAuth denial codes form a closed local vocabulary and every other value reduces to static authorization_denied before crossing the callback or direct-provider boundary.

Cross-references. D-083, D-096, D-199, D-390. RFC §3.3, §6.4, §7, §9.


D-395 — Native tool resolution freezes the exact declaration projection for each provider turn

Date: 2026-08-01

Status: Accepted for Phase 227; tightens D-389.

Decision. ReAct snapshots the catalog once per provider turn, builds the prompt quick-reference, req.Tools, and its reverse resolver from that same snapshot, and carries the immutable projection through every response branch. It never rebuilds the projection after Complete. A catalog mutation during the provider call therefore cannot retarget a model-authored name to a newly arrived collider. Scripted consumers emit the model-visible declaration name and continue asserting dispatch reaches the intended internal catalog key.

Cross-references. D-025, D-377, D-378, D-382, D-389. RFC §6.2, §6.4.


D-396 — MCP activation reservations stay private and exact landed auth remains resumable

Date: 2026-08-01

Status: Accepted for Phase 228; tightens D-390.

Decision. Registry.StageRegistration reserves a same-name replacement without placing the staged provider in the live registry map. Direct resource/prompt/observability reads continue reaching the exact prior provider until the catalog source swap succeeds. The catalog remains the dispatch linearization point; registry Commit then publishes the reserved entry and drains the displaced provider. Register, generic deregister, and separator-ambiguous staging cannot invalidate a live reservation. Exact generation-bound teardown can invalidate that reservation: it removes the unpublished handle into a private retryable closing receipt and closes that same handle. A close failure retains the receipt and blocks replacement; only a positive retry clears it. The invalidated receipt can never Commit.

When an auth-required revision write reports failure but an exact reread proves the descriptor and provider landed, the operation converges as successful auth_required: Harbor publishes the exact provider, retains the producer-owned pause, emits the reread revision and pause token, and returns that token to the caller. A lost storage acknowledgement alone never rejects a durable continuation or hides its resume handle.

Cross-references. D-301, D-370, D-380, D-390. RFC §3.3, §6.4, §6.11, §6.16.


D-397 — Agent reach is bounded signed authority enforced after one effective-target resolution

Date: 2026-08-01

Status: Accepted for Phase 232.

Decision. A bearer carries agent_reach as a strict bounded set of agent registration IDs. One shared gate resolves the effective target and checks membership before side effects on control.start, every agent_config.session.* and agent_config.user.* method, and an explicitly agent-projected tools.describe. An omitted start target resolves to the configured default and is checked; an omitted tools target retains its distinct boot-effective projection. Missing/empty reach denies these calls, malformed reach rejects authentication, and bearer-less carrier identity has no agent authority. Tenant-local config is selection evidence only. agent_id remains metadata, never an isolation axis.

Cross-references. D-059, D-066, D-220, D-299, D-349, D-360. RFC §5.5, §6.16.


D-398 — StateStore conditional save compares exact identity-scoped slots across the driver triad

Date: 2026-08-01

Status: Accepted for Phase 233; closes the cross-process residual named by D-366.

Decision. Every StateStore driver implements one mandatory SaveIf operation. A non-empty unique set of identity-and-kind slot expectations compares each current event ID exactly, with an empty expectation meaning absence, and atomically saves one next record only when all expectations match. The next record's slot must be one of the conditions. A mismatch returns one typed condition failure and persists nothing; ordinary event-ID idempotency cannot bypass the comparison. Agent-tier config writes condition their active slot, while user-tier writes additionally condition the agent lifecycle slot so retirement and user mutation have one winner. In-memory, SQLite, and Postgres implement the same semantics and pass one conformance suite; no driver-specific optional capability exists.

Cross-references. D-027, D-025, D-366, D-380, D-392. RFC §6.11, §6.16, §9.


D-399 — Agent-config retirement is a terminal CAS tombstone with same-operation cleanup replay

Date: 2026-08-01

Status: Accepted for Phase 234.

Decision. Retirement CAS-replaces the agent active slot with a backward-compatible lifecycle tombstone carrying operation ID, retirement time, prior revision ID/hash, a fixed cleanup manifest, and durable step progress. The tombstone wins before cleanup and remains the replay oracle: the same operation resumes, a different one conflicts, and no later durable config write, user variant, or rollback can resurrect the agent. The superseded process-local overlay description below is history only; D-400's durable four-slot model is the implementation. EffectiveAgentID(requested) first selects explicit or default without storage access, the shared signed-reach gate then authorizes it, and only then may lifecycle/config lookup return the closed active, unresolvable, or retired result before session/task spawn. A default never short-circuits that lookup. An unauthorized caller sees no unknown/configured/retired distinction. Reach-authorized retirement maps to agent_retired (HTTP 409); retire precondition/different-operation/incompatible-slot conflicts map to agent_retirement_conflict (HTTP 409). Admin historical List/Diff/exact revision reads and user List/Diff under their existing scope plus reach remain available; active/current and mutation doors do not. Cleanup touches only durably attributable (tenant, agent_id) resources, retains immutable agent/user revision history, and never sweeps boot/global or unattributable credentials. agent_config.retire is admin control-plane lifecycle and intentionally does not consume data-plane agent_reach. Recreate mints a new ID. agents.deregister remains fleet-record deletion only.

Canonical lifecycle events. Retirement emits redacted, identity-scoped agent_config.retirement.started, .progress, and .completed events. Payloads contain identity, agent ID, a hash of operation ID, and bounded stage/class, counters, and generation only; raw operation IDs, descriptors, and credentials are forbidden. Every durable transition persists a pending event checkpoint before emission, blocks subsequent cleanup progress until it emits, then CAS-acknowledges it. A bus or acknowledgement failure is loud and the same-operation retry resumes the at-least-once sequence; duplicate delivery is acceptable, lost delivery is not.

D-401 composition (2026-08-02). The frozen retirement manifest adds one signed_oauth_mcp_pair class discovered from every nonterminal published D-401 operation for the exact tenant and agent across stored user/session subjects. Its resource is the pair fingerprint plus a hash of the opaque operation kind only; URL, JWT/JTI, credentials, provider descriptor, and owner identity never enter the manifest or events. A private retirement adapter recovers the exact durable subject from the operation receipt and advances the existing paired-removal graph through DetachExactConnection and its retryable closing receipt. The public admin caller never impersonates that subject, and current authority expiry, revocation, or key rotation is not a teardown gate. Close failure blocks retirement acknowledgement/scrub; exact same-operation retry or another runtime resumes from the durable D-401 phase. Tenant scans and exact descriptor fingerprints prohibit cross-tenant, boot/global, unowned, or sibling teardown.

Cross-references. D-059, D-301, D-312, D-366, D-394, D-398. RFC §5.5, §6.11, §6.13, §6.16.

Dated correction (2026-08-01). The preceding process-local session-overlay write and compensation mechanism is retained as D-399 history only. It is superseded by D-400's durable StateStore overlay/personal-record model, four-slot fences, exact uncertain-write convergence, and paged retirement cleanup; it is not an accepted implementation path.


D-400 — Session overlays and agent-owned session personal skills are durable records fenced by lifecycle and erasure state

Date: 2026-08-01

Status: Accepted for Phase 233a; corrects the process-local overlay premise in D-399 without rewriting that accepted decision.

Decision. A session overlay is a StateStore record under the caller's full (tenant, user, session) triple, and an agent-owned session personal skill is one separately validated StateStore record under that same triple. New personal-record Kinds encode the agent ID solely as ownership metadata; it is never an isolation key, query principal, or widened scope. Existing schema-1 overlay Kinds retain their raw compatibility shape and are selected only through their common prefix plus exact Kind equality. Each write uses SaveIf with exact expectations for the target record, the agent lifecycle slot, the pending session-erasure ledger, and the terminal session-erasure tombstone. SaveIf checks all of those slots but saves one record only; it is not used to claim an atomically enforced collection size.

Personal records carry the complete validated Skill body. Their new Kind uses an encoded agent component and a canonical-name hash, while the payload repeats the canonical name and agent so a key collision or malformed record fails loud rather than aliasing. A logical tombstone is the authoritative delete and suppresses legacy fallback. Schema-1 overlay PersonalSkills entries are read-only migration eligibility for eligible legacy ScopeSession rows; new session writes never place a body in the shared SkillStore. A trusted per-run composite resolver is the only reader for Directory and skill_get, skill_list, and skill_search: it preserves durable ScopeUser and higher shared scopes, replaces only the legacy session tier, applies the same deterministic lexical or opt-in semantic policy to the composed view, and exposes no new wire body by default.

Session erasure adds a mandatory, idempotent SkillStore DeleteSessionScope ledger step that sweeps only exact legacy ScopeSession rows before StateStore scope deletion. Retirement replays cleanup from its tombstone manifest and removes only exact attributable agent-owned StateStore records; it does not delete/retire shared, unattributable, or ScopeUser bodies. This supersedes only D-399's process-local before/after compensation description.

ScanKindForTenant is a mandatory StateStore, storage-filtered maintenance scan: explicit maintenance scope plus tenant ID, literal non-empty Kind prefix, bounded limit, and opaque validated continuation; all drivers return a stable lexicographic (tenant, user, session, run, kind) order. It is an ordered resumable page sequence, not a durable database snapshot. Cutover and retirement use it; after retirement's lifecycle tombstone wins, the owned keyset is frozen. New personal-record Kinds are encoded and collision-safe per agent. Legacy overlay Kinds are raw agentcfg.session_overlay. + agent ID with no delimiter, so scan code uses their common tenant-bounded prefix then exact record.Kind == LegacyOverlayKind(agentID) equality; a and ab are an adversarial no-overmatch row. SaveIf uncertain-write recovery is class-specific: overlay/personal reread target plus lifecycle and erasure pair; cutover rereads cutover target plus epoch/digest/generation; retirement rereads lifecycle target plus operation/progress; cleanup rereads item target plus applicable session fences. Every class accepts only exact intended event/content and never compensates unconditionally.

For a session/user agent-addressed projection, signed reach is always checked before the lifecycle fence or any tenant-local lookup. Once authorized, a lifecycle tombstone produces canonical agent_retired (HTTP 409) for active/current, mutating, skill-list, and every session door; it never becomes a generic unresolvable result. Historical user List/Diff retain their existing verified user scope and signed-reach requirements, rather than gaining admin authority.

The schema-1 cutover is a static, operator-controlled boot contract, not a runtime-membership or writer-advertisement subsystem. skills.session_personal_cutover.tenants is a bounded unique declaration list of {tenant_id, epoch, roster_digest, legacy_writers_drained}. Syntactically or structurally malformed static configuration (empty/invalid fields, duplicate tenant IDs, or an over-bound list) fails boot loud; it is not an admitted declaration. Unlisted tenants and valid declarations with legacy_writers_drained=false remain read-only dual_read; boot discovers no tenants and iterates only admitted declarations. It CASes each declaration to agentcfg.session_personal.cutover.<base64url(epoch)> under CutoverScope(tenant), exactly {TenantID: tenant, UserID: "__agentcfg__", SessionID: "__session_personal_cutover__", RunID: ""}. A malformed or declaration-mismatched durable cutover record never authorizes state_only: resolution remains mutation-refusing dual_read and surfaces a bounded loud diagnostic/error. The existing ErrReservedUser rule already rejects a verified real user named __agentcfg__; an agent ID equal to __session_personal_cutover__ cannot alias the control record because the cutover Kind namespace is disjoint from lifecycle/config Kinds, and tests pin that disjointness. That bounded record contains only mode, epoch, digest, current scan continuation, counters, and generation. It holds no per-overlay classification: each owned personal record carries its per-name copy marker (epoch and legacy content hash), and retirement/erasure status comes from durable terminal fences. After old writers are drained, paged ScanKindForTenant walks the common schema-1 overlay prefix and requires exact legacy Kind equality before it copies any eligible referenced legacy body under the overlay's own identity with all four fences. New code never mutates schema-1 PersonalSkills, so the source is quiescent without pretending a restart-survivable snapshot. Restart resumes from the continuation. A final fresh paged verification pass proves every currently eligible reference is copied or terminally fenced before one final CAS may set state_only. Until then legacy ScopeSession rows are authoritative, copies are non-authoritative, and every session-personal mutation fails loud. The owned body/tombstone record is the sole post-cutover membership record: one personal verb performs one SaveIf and never mutates Overlay.PersonalSkills. Overlay responses project current owned names in state_only and legacy names in dual_read without persisting either projection. agent_config.session.skills.list returns only this session tier; Directory and general skill tools, not that method, compose ScopeUser and higher rungs. Every overlay/personal/composite read captures lifecycle plus pending/tombstone fence EventIDs before and after loading/enumerating; it retries at most MaxSessionSkillReadAttempts = 3, honoring context cancellation/deadline on each attempt, then returns ErrSessionSkillReadUnstable; externally observable exhaustion maps to canonical session_skill_read_unstable HTTP 409. The fixed retirement manifest records cleanup classes: new personal records use collision-safe exact per-agent prefixes, while legacy overlays record the common scan prefix and exact LegacyOverlayKind(agentID) equality rule. It scans after tombstone and mutates each result only under its own full identity. Overlay records retain their schema-1 raw-agent Kind/payload compatibility and have no separate retirement tombstone; lifecycle is terminal, and cleanup never uses unconditional Delete.

Cross-references. D-059, D-256, D-312, D-392, D-398, D-399. RFC §6.7, §6.9, §6.11, §6.13, §6.16.

Deployment compatibility. The default mutation refusal in dual_read is intentional rather than a transparent upgrade. Before v1.26 release approval, reviewers must explicitly accept this compatibility/deployment trade-off. The implementation PR must update docs/CONFIG.md, CHANGELOG.md, the matching operator skill and docs-site stub, and example configuration with the drain, attestation, resumable migration, and state_only completion procedure.

Wire consequence. Refused session-personal mutation returns the canonical session_skill_cutover_pending Protocol error (HTTP 409), backed by a Go sentinel. It is a wire addition despite adding no method or response member: the canonical error registry, stream mapping, Protocol documentation, Console types/manifest, and error-matrix tests regenerate and lockstep-gate together. Read-fence exhaustion adds the similarly canonical session_skill_read_unstable error (HTTP 409), mapped from ErrSessionSkillReadUnstable with the same registry, transport, docs, Console manifest, and matrix obligations.


D-401 — Signed OAuth MCP capability registration is the bounded production exception to D-300's static audience/sink posture

Date: 2026-08-01

Status: Accepted for Phase 233b (HA-50).

Decision. D-300 remains the credential-sink invariant: an administrator's ordinary writable fields do not decide where a credential is sent. A generic boot-declared OAuth credential broker/trust anchor may, however, authorize a bounded new OAuth-fronted MCP capability without a runtime config edit. The broker/trust anchor alone retains the fixed exchange token_url, credential pull URL, runtime broker authentication secret/env, KEK, true scope ceiling, and configured signature issuer/key verifier. It also carries an explicit production opt-in for signed capability authority. A configured static host or audience allow-list remains valid, but is not the mechanism for this dynamic capability path.

The anchor's bounded-expiry ceiling is a required positive boot-only max_authority_lifetime; it is not a hidden global default and never rides the Protocol request. The signed envelope carries only iat/exp; exactly-boundary lifetimes are valid and any over-ceiling envelope fails closed.

The sole production registration/creation write is admin-only agent_config.register_oauth_mcp_capability. It is production-safe only when the boot broker/trust anchor's explicit signed-capability opt-in and verifier are present; it is not enabled merely by running production. It atomically prepares an unpublished provider and MCP connection, CAS-persists exactly one revision, and publishes the pair. It is forbidden to compose set_oauth_provider and add_mcp_connection to obtain this state. The writable request contains only provider name, boot broker name, per-capability audience and normalized requested scopes, a dedicated closed SignedOAuthMCPConnectionDescriptor, expected_content_hash, and a signed authority envelope. It accepts no general MCP descriptor: the exact shape is {name, url, tool_allowlist, tool_denylist, connect_timeout_ms, request_timeout_ms}, and strict decode plus reflection reject OAuth/provider/token URL, injection, discovery, stdio command/env/cwd, headers, credential/secret, and host/sink-list fields. tools.allow_wire_oauth_descriptor and its environment switch remain development-only D-340 controls and are neither required nor consulted by this production path.

The signed envelope, not admin input, authorizes the dynamic values. It binds tenant, agent, broker, provider/capability ID and immutable capability revision, canonical URL digest, audience, normalized scope set, issuer/key ID, timing, and JTI. The durable JTI operation key is tenant-scoped (tenant_id, trust_anchor_name, issuer, kid, jti): tenant is signed and Harbor isolation remains tenant-local. A reserved tenant-control-scope record uses a collision-safe deterministic Kind derived from a canonical length-prefixed tuple hash; its bounded payload repeats tuple hashes/fields, exact pair fingerprint, expiry, phase, and revision identity. First SaveIf-absent claim creates one pair-lifetime operation record. Its sole normal graph is claimed -> revision_committed -> published -> removal_admitted -> removal_revision_committed -> catalog_unpublished -> teardown_receipted -> removed; every transition SaveIf-compares the exact operation EventID. There is no generic aborted phase: a prepared-but-incomplete claim retries its recorded phase. Only claimed or revision_committed may terminally enter expired_incomplete after safe close/compensation and a preserved prior/no-active activation fence; that tombstone remains until expiry+skew before cleanup. There is no claim+revision cross-record ACID assertion: this durable state machine is recovery. Exact tuple+fingerprint resumes phase; same key/different fingerprint rejects. claimed retries prepare again; uncertain revision write exact-rereads active revision/fingerprint to advance, retry, or conflict; revision_committed re-prepares/re-publishes after restart; publish-then- checkpoint errors verify the exact live pair before advancing; published returns the original response; and removed never recreates. A published record survives registration-authority expiry or verifier-key revocation for the full immutable pair-history lifetime, so removal/retirement resumes from its frozen fingerprint. That durable record is a recovery/replay constraint, not a bearer grant: exchange still enforces current entitlement and exact binding. removed is retained as an anti-replay tombstone with pair history and never less than the authority expiry+skew horizon, preventing recreation or replay. Unknown broker/issuer/key, malformed authority, scope widening, or mismatch fails before a live side effect.

Stable-JTI expiry recovery amendment (2026-08-03). A signer may deliberately derive a stable JTI for one immutable OAuth MCP operation. A later freshly verified envelope for that same JTI may renew only an expired_incomplete receipt with the exact replay tuple, registrar, pair fingerprint, sink, audience, canonical scopes, and connection, and must carry a strictly later expiry. Expiry compensation first CAS-enters expiry_admitted, freezing its source phase and candidate revision before any detach or active-pointer side effect; restart completes exact detach, frozen prior/absence restoration, candidate-inactive proof, and exact pending-fence abort before expired_incomplete. Renewal increments the durable authority generation and explicitly reopens only the matching aborted activation fence under a joint exact receipt/fence generation predicate. No receipt is deleted or reclaimed, the original registrar remains owner, and published, removal, removed, foreign-binding, or widened requests never reopen.

One named shared canonical-URL helper supplies signer/verifier matching, pair fingerprinting, transport enforcement, and restart/reconcile. It requires absolute HTTPS; uses IDNA2008 ASCII lower-case host with trailing root dot removed and RFC5952 compressed lower-case IPv6 in brackets; rejects IP zone, userinfo, fragment, and a leading-zero explicit port (omitted 443); uppercases percent hex and decodes unreserved bytes before RFC3986 remove-dot-segments (so %2e participates; empty path /). Query preserves original pair order and duplicates while canonicalizing percent encoding (no sorting; + stays literal plus); absent query omits ?, while explicit empty query retains a terminal ?. Canonical URL bytes are https://host:port/path[?query]; sink is https://host:port. Bearer send rechecks that sink and refuses redirects; no free-form host list is accepted. The exchange endpoint stays boot-pinned; the capability's signed audience is bounded by the envelope and independently validated by the exchange along with tenant, agent, provider/capability, revision, and URL digest. The verified (tenant, user, session) remains the token subject; audience names the destination resource, never the person. Token/cache assertions include the subject plus agent, capability revision, audience, and URL digest. Requested scope outside the true boot ceiling rejects loudly; silent scope intersection is forbidden for this path.

One opaque publisher_epoch lives only in the durable pair-lifetime operation record. After the desired revision reaches revision_committed, a publisher must exact-EventID CAS-mint an epoch before private provider/MCP preparation. Reconcile in another runtime CAS-takes a new epoch, immediately making every older provider, cache entry, bearer, and local MCP handle inert. The epoch never rides the wire, immutable agent-config revision, broker actor assertion, or audit. Every pair-owned token path authorizes exact tenant, operation kind, phase, and epoch before cache lookup, after exchange, and before cached return; the bearer RoundTripper repeats that authorization immediately before any downstream request. Only a private preparation context may authorize revision_committed; normal dispatch requires published and cannot inherit the preparation marker.

The signed provider is pair-owned and outside general ProviderSet; private MCP prepare binds directly to that exact provider instance. Before the final durable-authority proof, activation installs an exact owner/fingerprint/ generation registry reservation that is private and non-dispatchable. Exact teardown can therefore see and close the staged handle while proof is in flight. Publication holds that exact reservation while it verifies the physical revision and pair, operation generation/phase, and activation fence, then commits the catalog source and live registry handle as one ordered critical section. If teardown won or closed the reservation, publication fails and prepared resources close; if publication won, any later teardown necessarily sees the published handle. The catalog source swap alone linearizes data-plane dispatch, and it never occurs before proof. Protocol projections derive from the immutable signed-pair revision, not a live provider map; generic provider resolution cannot bind the pair. A pair-owned live registry may retain only close/reconcile receipts, never authority/projection/dispatch. General bare-name collision checking remains. Catalog withdrawal is the dispatch linearization point for teardown, but the exact generation/provider handle remains in a private, non-dispatchable closing state until every owned transport/provider close or revoke returns success. A close error retains that handle and name reservation for a genuine retry; absent-after-error is never a teardown receipt, and replacement remains blocked. Prepare is never durable and closes on failure or restart; teardown closes transport+provider as one receipt. Generic revision writers remain closed against a pair. Pair-lifetime exclusion and removal maintenance are tenant+agent scoped even when a second caller has another user/session or Service instance; the frozen user/session fields remain the authorization and exchange subject and are not widened into teardown authority. Restart reconciliation treats its initial active-pair read as advisory: after private provider/connection preparation it re-reads the exact physical active revision, pair-lifetime operation generation and phase, and activation fence immediately before activation, then verifies them again before transferring provider ownership. A completed removal or replacement closes the prepared private resources and publishes nothing; immutable history cannot resurrect a removed pair. Paired removal continues the same pair-lifetime JTI record; it is never a second operation. It first exact-EventID SaveIf-advances to removal_admitted, serializing with the exact operation-slot publication fence before desired-state mutation and denying every publisher epoch before local teardown. It then advances through removal_revision_committed (desired pair absent by revision CAS), catalog_unpublished, teardown_receipted (close+revoke from frozen fingerprint for the exact local epoch when present), then terminal removed. An empty remover may advance because durable removal admission is the authority receipt; a stale handle in another runtime remains network-inert and its owning runtime closes it on reconcile. teardown_receipted therefore never infers bearer authority from process-local presence or absence. A definitive desired-state refusal rolls the admission back to published; commit-then-error or unknown outcomes exact-reread the operation phase/EventID, desired revision, catalog source, and receipt, then resume only the missing phase. Expiry, key revocation, or a lost verifier never block it; retirement uses this same removal path.

First-install repair. Before any candidate can become semantically active, set_oauth_provider writes a durable pending-activation/compensation fence under agent scope, bound to exact operation/content fingerprint, attempted revision, and prior active revision/EventID (or no-active), with phase/EventID. Registry.Active, every generic section writer and production registration/creation write, set_revision, rollback, pair removal, retirement, and reconcile consult the exact fence and physical active revision/EventID: pending returns only prior active or no-active and never authorizes the candidate. A foreign operation rejects with typed pending/conflict; only the same operation may serialize and resume. Success SaveIf commits the fence; failure aborts it; unknown transitions remain safely pending across runtimes until exact reread proves either phase. Candidate history is immutable. DeactivateIfActive may compact the physical pointer afterward; it is not the security fence and cannot infer an unknown outcome inactive.

Cross-references. D-025, D-300, D-301, D-303, D-340, D-390, D-394, D-396, D-398, D-399. RFC §4, §5.5, §6.4, §6.11, §6.16. Plan: docs/plans/phase-233b-signed-oauth-mcp-capability-registration.md.


D-402 — Bifrost reasoning preserves the observed source bytes

Date: 2026-08-02

Status: Settled and shipped with Phase 233c / HA-51.

Decision. Reasoning is an ordered provider-output byte stream, not prose that Harbor may normalize. During one Bifrost completion, the first observed non-nil raw delta.Reasoning establishes the raw source. Every subsequent non-nil raw value, including empty or whitespace-only values, is appended byte-for-byte. When that source was observed, the completed llm.CompleteResponse.Reasoning is exactly that concatenation; synthesized ReasoningDetails neither override it nor add separators. Raw reasoning continues to invoke the live callback immediately. A non-nil empty raw delta therefore invokes OnReasoning("", false), establishes raw mode, and still receives the terminal OnReasoning("", true); terminal signaling keys on rawObserved, never accumulated byte length.

Harbor consumes response choice index 0 only, matching the established unary translation contract. Streaming content, reasoning, reasoning details, tool calls, and callbacks from every other choice are ignored. Non-selected choices cannot influence any accumulator or callback.

When no raw reasoning value was observed, Harbor reconstructs details-only reasoning without trimming or otherwise rewriting text. Fragments coalesce by stable semantic block identity: a non-empty provider block ID is primary; otherwise the identity is block type plus index within selected choice 0. An initial ID-bearing fragment aliases its fallback identity so a later ID-less fragment joins the same block. Within a block, text concatenates exactly; only distinct emitted blocks receive one literal \n\n separator in first-seen block order. Encrypted and non-text/content-only blocks remain excluded under D-147's provider-capture boundary and the provider-native deferral.

This corrects only the capture-source precedence described by D-147 and the historical Phase 83e plan. It does not change D-147's decision-schema narrowing, D-148's never/text replay contract, existing Protocol types, methods, events, or Protocol version. The final reasoning bytes flow unchanged through the planner decision, trajectory, live tasks.get, and durable state.history reconstruction.

Why. Bifrost can synthesize one reasoning.text detail per decoded stream delta. Treating those synthetic fragments as independently paragraph-separated blocks creates bytes that were never emitted and cannot be repaired safely by a consumer because intentional line breaks are indistinguishable from corruption. Raw observed deltas are therefore authoritative; details-only providers retain an explicit, block-preserving fallback.

Verification. The regression is decoded JSON/SSE, not a direct Go struct: ["**Preparing to send email**", "\n\n", "I", " need", " to", " compose"] must yield exactly **Preparing to send email**\n\nI need to compose in the live callback, completed response, planner decision, live tasks.get, and, after restart, durable state.history. Details-only multi-fragment and multi-block coverage remains. The fixture's JSON source contains the standard \n\n escape and its decoded middle delta is exactly bytes 0x0a,0x0a, not literal backslash characters. An empty-only raw fixture asserts the exact callback sequence ("", false), then ("", true). A multi-choice fixture asserts that all content, reasoning, details, tool calls, and callbacks from non-zero choices are absent. The shared driver is exercised by N>=100 concurrent/cancelled identity-distinct calls under -race, with no byte, identity, cancellation, or goroutine leak cross-talk. Console history rendering asserts the same exact newline bytes.

As shipped. The Bifrost package measures 90.5% statement coverage. Focused callback/choice/details/concurrency tests pass under -race -count=20, the decoded-wire regression under -race -count=5, and the durable SQLite runtime integration under -race and three consecutive repetitions. The shared-driver fixture uses 128 identity-distinct calls; Console history passes 18/18 and the phase smoke reports 15 OK, 0 SKIP, 0 FAIL. Protocol lockstep and documentation generation remain clean with no wire or Protocol-version change. Per maintainer policy, PR-to-main cloud preflight is the authoritative remaining release gate and was not duplicated locally.

Cross-references. D-025, D-147, D-148, D-298. RFC §6.2, §6.5, §6.8, §6.13. Plan: docs/plans/phase-233c-bifrost-reasoning-fidelity.md.


D-403 — Exact-generation conditional mutation protects authority compensation and publication

Date: 2026-08-02

Status: Accepted and shipped as the Phase 233b compensation correction.

Decision. D-398's mandatory StateStore conditional-mutation surface adds DeleteIf(ctx, SlotExpectation) (bool, error) across in-memory, SQLite, and Postgres. The expectation names one complete identity-and-Kind slot and a non-empty exact EventID. Only that generation may be removed atomically. A different generation or absent slot returns false, nil and mutates nothing; invalid identity, empty Kind/EventID, cancellation, closed storage, and driver failure remain loud. All drivers run the same conformance suite, and durable drivers additionally race independent clients through conditional delete versus replacement and verify the one CAS winner after reopen. This is one mandatory interface, not an optional capability.

The same mandatory surface adds FenceIf(ctx, SlotExpectation, fn) error. Under the driver's SaveIf serialization lock it verifies one exact present EventID, runs one short process-local callback, and releases without changing the record. The callback performs no network or StateStore I/O. Its first consumer is D-401 catalog/registry publication: paired removal must first SaveIf-advance that exact operation generation to removal_admitted, so either publication completes before admission or a stale publisher fails before a bearer-capable handle becomes dispatchable. Context cancellation is checked at admission; once this irreversible callback starts, cancellation is not evidence that publication did not occur.

D-401 additionally CAS-mints a durable publisher epoch on that same operation slot after desired-state commit. Publication and every credential/cache/ downstream use compare the exact epoch; a second runtime's takeover invalidates old local handles without trusting process memory. removal_admitted changes the operation phase under exact EventID before teardown, so all epochs fail closed even when the remover has no local handle. Exact local close still names the matching epoch; an older local generation may remain only as inert cleanup state and can never satisfy or bypass the durable authority receipt.

The shared invalid-expectation row pins the exact canonical sentinels: incomplete identity returns ErrIdentityRequired; empty Kind or EventID returns ErrInvalidRecord. Every refusal returns changed=false and a byte-for-byte reload proves the original generation, version, timestamp, and content remain unchanged. Direct validator tests remain a separate structural unit guard, not a substitute for exercising every driver boundary.

The first consumer is agentcfg.Registry.DeactivateIfActive. A compensation whose pre-operation lifecycle slot was truly absent removes only its exact candidate pointer generation. A compensation with a prior boot/config revision repoints to that exact revision only while the candidate content hash remains active. A concurrent winner is never deleted or rolled back. Immutable candidate revision history remains for diagnosis; no provider, catalog binding, or signed pair remains authoritative.

The earlier Phase 233b inactive-marker implementation is rejected because it added an inactive member to D-400's closed lifecycle envelope. D-400 correctly classified that unknown field as malformed. No reader or classifier is loosened: active, terminal, and corrupt lifecycle records retain their existing strict meanings; terminal and corrupt compensation attempts fail closed and preserve the exact bytes. The pending activation fence remains D-401's semantic security boundary; conditional deletion is only exact post-fence restoration of an absent physical authority state.

Wire consequence. None. This changes the internal persistence and compensation contract only; Protocol types, methods, errors, events, version, and Console lockstep remain unchanged.

Cross-references. D-025, D-366, D-398, D-400, D-401. RFC §6.11, §6.16, §9. Plan: docs/plans/phase-233b-signed-oauth-mcp-capability-registration.md.


D-404 — Signed OAuth MCP connections may carry only the existing bounded artifact-egress policy extension

Date: 2026-08-03

Status: Accepted for the v1.26 signed-capability contract correction.

Decision. The closed SignedOAuthMCPConnectionDescriptor adds only artifact_byte_eligible and artifact_params, matching the trusted content egress declaration already available to generic HTTP MCP connections. The fields are non-secret connection policy. They add no header, host, credential sink, token endpoint, discovery origin, or provider-controlled opt-in, and the signed path remains HTTP-only.

The registration request is untrusted until the boot-pinned asymmetric signer verifies the exact canonical descriptor. Canonicalization trims tool and parameter names, sorts each parameter set, and rejects an empty or duplicate shape. Every admission/config/normalization door caps the declaration at 32 methods, 8 parameters per method, 128 bytes per method or parameter name, and 8 KiB of canonical JSON. The canonical mapping and eligibility bit participate in authority matching, pair fingerprinting, the tenant-scoped JTI replay oracle, revision content hashing, and staged attachment fingerprints. A same-JTI mapping change is therefore a replay conflict, never an in-place widening. An omitted false/nil declaration appends no new fingerprint components, preserving every pre-extension v1.26 pair and operation receipt byte-for-byte across upgrade and restart.

The immutable pair revision stores and projects the declaration, and both the initial attach and run-start restart reconcile pass it into the existing MCP artifact-egress engine. That engine validates every mapped method and parameter against the server's discovered input schema before catalog publication: the tool and property must exist and the property must be string-typed. Any shape, bound, signature, schema, persistence, or attach failure is loud and leaves no published or partially authoritative pair; the existing activation fence and compensation state machine remain the atomicity mechanism. Removal continues to retire the exact immutable pair and operation receipt without a second mapping-specific lifecycle. A schema-rejected committed candidate remains only as immutable diagnostic history: its operation enters a durable rejection compensation phase, restores prior/absent authority, aborts the exact activation fence, and terminates as non-replayable before a corrected new JTI may publish.

Artifact resolution and the mcp.artifact_egressed audit event are unchanged: the acting run's verified tenant, user, session, and exact effective agent bound reach, and audit records the artifact id, destination tool and parameter, byte count, and digest but never the bytes. No second egress implementation or audit path is introduced.

Wire consequence. Two additive optional fields on an existing canonical wire type. The generated Protocol and Console manifests move in lockstep; ProtocolVersion remains 0.1.0 and the product release is a v1.26 patch.

Cross-references. D-026, D-359, D-397, D-401, D-403. RFC §5.3, §6.4, §6.11, §6.16. Plan: docs/plans/phase-233b-signed-oauth-mcp-capability-registration.md.

Apache-2.0 licensed — see LICENSE.