RFC-001 — Harbor: Architecture & V1 Scope
Status: Drafting (active) Author: hurtener Last updated: 2026-05-08 Supersedes: none
This RFC specifies what Harbor is, what it ships at V1, and the binding architectural decisions that all phase plans must respect. Where a section says Settled, the decision is closed unless this RFC is amended. Where a section says Tentative — see §11 Q-N, an open question must be resolved before the relevant phase ships.
This document is the highest-priority artifact in the repository (see AGENTS.md §2). Phase plans, code comments, and contributor docs all defer to it. If a phase plan and this RFC drift, the RFC wins; the plan must be updated.
1. Executive summary
Harbor is a Go-native runtime SDK for durable, steerable, event-driven AI agents. It ships as a Go module plus a single static binary (harbor), with a four-layer architecture:
- Harbor Runtime — the orchestration kernel: tasks, planner runtime, tools, memory, sessions, events, skills, artifacts, the unified pause/resume primitive.
- Harbor Protocol — the canonical event/state contract that the Runtime exposes to any client. Versioned independently.
- Harbor Console — the observability and control-plane UI. A Protocol client; ships with the ecosystem; architecturally decoupled.
- Harbor CLI — the
harborbinary.harbor devboots a local Runtime + Console with hot reload and dynamic agent scaffolding (with draft saving).
V1 ships:
- The Runtime layer with all of the subsystems listed in §6.
- The Protocol layer with one wire transport (Settled in §5; Q-1 RESOLVED 2026-05-14 — SSE + REST).
- The CLI with
harbor dev,harbor scaffold,harbor validate,harbor inspect-events,harbor inspect-runs,harbor version. - A persistence triad (in-memory / SQLite / Postgres) behind every persistence-shaped interface.
- The reference
reactplanner; thePlannerinterface; one second concrete (deterministic) to prove the seam.
V1 does not ship the Console (separate repo), Harbor Cloud (post-V1), durable distributed transports beyond in-process contracts, or planner concretes beyond react and deterministic.
Harbor's three non-negotiable product properties — multi-isolation across (tenant, user, session), the Console-as-Protocol-client decoupling, and the swappable Planner — are baked into the architecture from t=0. They are recorded as binding rules in AGENTS.md §1, §6, §8 and reiterated below.
2. Goals and non-goals
2.1 Goals
- G1. Provide a Go-native runtime with first-class concurrency, durability, and steerability for AI agents — the gap that the wider Go ecosystem currently leaves open.
- G2. Ship the architectural seams that long-lived agent platforms turn out to need (events, identity, pause/resume, mandatory artifacts, swappable planner) from t=0, not retrofitted.
- G3. Operate correctly under multi-isolation:
(tenant, user, session), including concurrent sessions for the same user. Cross-session leakage is a security bug, not a style nit. - G4. Expose a versioned Protocol that a Console (ours or third-party), a CLI, an IDE extension, a TUI, or an observability vendor can implement against without reaching into Runtime internals.
- G5. Keep the Runtime planner-independent: every Runtime feature must be reachable from any conformant
Planner. - G6. Make
harbor devfeel seamless for a developer (local Runtime + Console + hot reload + draft-save scaffolding) while keeping the Console-as-Protocol-client property intact. - G7. Ship doc-and-CI hygiene from t=0: in-repo design (RFC, phase plans, research briefs, AGENTS.md ↔ CLAUDE.md mirror), a preflight gate, per-phase smoke scripts, conformance suites for every multi-driver subsystem.
2.2 Non-goals (V1)
- NG1. A distributed execution backend with at-least-once / exactly-once durable bus semantics. V1 ships the contracts (
MessageBus,RemoteTransport); production drivers (NATS, Redis Streams, Postgres-as-queue) land in a post-V1 phase set. - NG2. Harbor Cloud (managed execution plane). External, post-V1.
- NG3. A library of planner concretes beyond
reactanddeterministic. ThePlannerinterface ships, plus one extra concrete to prove the interface holds. PlanExecute, Workflow, Graph, Supervisor, MultiAgent, HumanApproval are post-V1 phases. - NG4. Embedding the Console binary inside the Runtime binary. Consoles are protocol clients; bundling is a deployment convenience, not an architecture.
- NG5. A persistent durable backend for background tasks. V1 keeps background tasks in-process; the durable backend is a post-V1 phase that slots in behind
TaskRegistry.
3. Architecture overview
3.1 The four layers
+-----------------------+
| Harbor Console |
| (Protocol client; |
| own repo or |
| web/console/) |
+-----------+-----------+
|
| Harbor Protocol
| (events / state /
| task control / obs)
v
+------------------+ Protocol +----------------+
| Harbor CLI |<-------------->| Harbor |
| (`harbor dev`, | | Runtime |
| scaffold, ...) | | (the kernel) |
+------------------+ +----------------+
|
v
Tools, MCP, A2A, HTTP, in-processThe CLI and the Console are both Protocol clients. harbor dev uses the same protocol code path as a remote browser-attached Console. There is no "internal" view of the Runtime — the canonical model is the protocol.
3.2 The runtime/planner separation
The Runtime owns mechanism: sessions, runs, tasks, events, streaming, retries, pause/resume, artifacts, tool execution, memory injection, scheduling, provenance, guardrails. The Planner owns policy: reasoning, decision-making, next-action selection.
The contract is one interface:
type Planner interface {
Next(ctx context.Context, run RunContext) (Decision, error)
}A Decision is a sum type (see §6.2). The Runtime executes the decision; the Planner never reaches into Runtime internals. Tools, memory, skills, artifacts, pause/resume, steering — all are reachable from any Planner via a RunContext view, never via direct package imports.
This decouples reasoning strategy from orchestration. The same Runtime executes a react planner today and a deterministic/workflow/graph/supervisor planner tomorrow. (Settled.)
3.3 The unified pause/resume primitive
A run can pause for many reasons that look distinct on the surface:
- HITL approval (a human needs to approve a planner-chosen tool call).
- Tool-side OAuth (a tool needs interactive auth).
- A2A
AUTH_REQUIRED/INPUT_REQUIREDtask states (the A2A spec's pause-equivalents). - Steering
PAUSE(operator/Console pauses a run).
These are one primitive at the Runtime level, exposed on the Protocol — not four parallel implementations. The Runtime owns the pause coordinator; planners and tools both signal "I need a pause" by returning RequestPause or emitting an authn request event; the Runtime drives the protocol-level event + resume token. Authentication on resume is checked against the original pause's identity scope.
The canonical pause.resumed event carries a typed Decision marker (one of approve / reject / resume / timeout) so wire consumers (the Console, third-party clients, integration tests) can distinguish the kind of resume without parsing free-form Reason strings. The Wave 11 §17.5 audit (issue #113, D-096) pinned the "overloaded Reason string" anti-pattern as a §13 violation — a single event type carrying overloaded shape against a typed enum that should exist. The typed Decision is the load-bearing channel; Reason stays for human-readable context.
Steering→gate bridge (D-097). The wire-side POST /v1/control/approve and /reject paths route through the steering Registry → Inbox.Enqueue → RunLoop.Drain → applier.routeThroughGate → gate.ResolveApproval. The bridge lives in the RunLoop's apply path (the drain that already routes the nine canonical control types); it looks up the matching ApprovalGate by the wire payload's token key (the gate-minted pause token, distinct from any planner-side RequestPause token), calls ResolveApproval, and skips the direct Coordinator.Resume so the gate's own call lands a single, correctly-typed pause.resumed. A wire payload with no token key targets the RunLoop's own pause (the canonical OAuth / A2A AUTH_REQUIRED shape), preserving the pre-D-097 direct-Resume behaviour for non-gate flows.
This is the cleanest single-point-of-truth in the design and the strongest test of the swappable-planner property: a deterministic / workflow planner inherits pause/resume because it is a Runtime feature, not a planner feature. (Settled.)
3.4 The fail-loudly principle
Across the surface, Harbor refuses to silently degrade.
- Pause/resume serialization that encounters a non-serializable handle MUST return
ErrUnserializablenaming the offending field path. There is no "silently set to nil/None" path. - Identity is mandatory. No
require_explicit_key=Falseknob, no default-tenant fallback. Missing identity = fail closed + audit event. - Capability detection ceremony is forbidden when all V1 drivers implement everything. One mandatory interface per subsystem; conformance test is the gate.
- Two parallel implementations of the same conceptual feature (
use_native_X=true|false-style toggles) are a smell. Pick one and deepen it.
These are runtime-wide invariants, recorded in AGENTS.md §13 forbidden practices. (Settled.)
3.5 The concurrent reuse contract (D-025)
Compiled artifacts are immutable after construction. Per-run state lives in ctx + RunContext, never on the artifact. This is the cross-cutting principle that prevents the predecessor's most expensive retrofit: the first version of its flow runtime had thread-safety issues because mutable state on a single-instance "singleton" Flow bled across concurrent invocations once Python's threading model finally allowed parallel execution.
In Harbor every "compiled artifact" — flow.Engine, Tool (any transport), Planner instance, MemoryStore driver, Redactor, LLMClient, ToolCatalog — is built once, shared across N concurrent goroutines, and MUST satisfy four guarantees:
- No data races —
go test -race ./...is the gate; CI runs it. - No context bleed — run A's input/state never reaches run B; verified by per-run identity assertions in the test.
- No cancellation cross-talk — cancelling run A's
ctxMUST NOT affect run B; verified by parallel-cancel tests. - No goroutine leaks — each invocation's goroutines are joined before the invocation returns; baseline-restored test asserts this.
Every phase that builds a reusable artifact ships a concurrent-reuse test (N≥100 invocations against a single shared instance under -race). AGENTS.md §11 makes this mandatory; phase plan template's pre-merge checklist enforces it. Wave 1 phases 01 (Identity), 02 (Config), and 03 (Audit redactor) include this test from t=0; subsequent waves inherit the pattern.
Why it matters at design time, not just at test time: an artifact that needs mutable per-run state pushes the design to expose that state through RunContext, not stash it on the receiver. This shapes interface signatures, registry patterns, and lifecycle conventions across the runtime. Done from t=0, it is free; retrofitted, it requires rewriting every artifact's invocation path. The predecessor learned this. Harbor inherits the lesson.
3.6 The public SDK facade (sdk/) — Settled (D-204)
RFC §1's "ships as a Go module" is a product property for external teams, not only in-module consumers. Go's internal/ visibility rule means every runtime package is import-forbidden outside the module; until Wave D, the only importable surface was harbortest/ (the Phase 71 precedent) — the test kit wearing the runtime's clothes.
The facade is a curated, top-level sdk/ package tree of alias-based re-exports: each sdk/<area> package re-exports its internal/<area> counterpart's public surface via type aliases (type Identity = identity.Identity), re-exported constants/sentinels, and thin function/variable forwards. Settled properties:
internal/stays the implementation home. The facade adds no mechanism and forks no types — an alias IS the internal type, so values flow freely across the boundary and interface satisfiability is preserved. Zero churn to runtime code.- The facade is the API-stability contract. What
sdk/re-exports is the supported external surface; what it omits is deliberately private. Additions are cheap; removals follow the Protocol-style deprecation posture (§5.3). - The V1.2 facade inventory (the audited set the templates, recipes, and devstack already treated as public):
sdk/identity,sdk/events,sdk/config,sdk/tools(+inproc,builtin),sdk/llm,sdk/memory,sdk/state,sdk/artifacts,sdk/skills,sdk/planner(+ thereact/deterministicregistration import paths),sdk/tasks,sdk/steering,sdk/dispatch,sdk/runctx,sdk/assemble, andsdk/drivers/prod(the public blank-import aggregator). Phase 112b's consumer conversions flushed out additive extensions (per item 2's "additions are cheap" posture; recorded in D-206):sdk/audit,sdk/telemetry(+telemetry/eventbus),sdk/governance,sdk/tools/auth,sdk/skills/{importer,tools,generator}, and theErrorClassvocabulary onsdk/tools. The pause/resume Coordinator stays deliberately private (D-205's curation call) — the assembled stack is its supported construction path. - External consumers are gated mechanically. The scaffold templates emit
sdk/imports (a tool-declaring scaffold MUST compile as an external module), and a standing smoke gate compiles a scaffolded external module against the facade — the class of breakage the SDK friction audit found can never silently return. harbortest/remains the test kit; its parameter vocabulary becomes externally satisfiable through thesdk/aliases rather than kit-local forks.- The facade includes ONE curated serving facade —
sdk/server(Settled — D-291). An external binary that has assembled a stack (§3.6 embed path) can also serve the Protocol surface over the network — the third adopter path (§5.6) reaches parity with the stockharbor serve.sdk/serveris a thin alias/forward over the promoted internal serve constructor (§5.6):server.Open(ctx, cfg, server.Options{RegisterCatalog})returns a handle withServe/Close. It is production-only by construction —Openalways builds the JWKS validator fromcfg.Identityand fails loud (naming the missing field) when identity config is absent; there is no dev-signer option and no mock knob on this seam. The local-dev loop usesharbor token keygen→identity.jwks_file→harbor token mint(§8), exactly as an externalharbor serveoperator would. This is the onlysdk/package that mounts a network listener; the embed-headless posture (no listener) stays the default (§5.6).
Phases 112a (the facade tree) and 112b (external consumers + the compile gate) implement the alias tree; Phase 160 adds sdk/server. See D-204, D-291.
4. Identity & isolation contract
4.1 The identity triple
Every Runtime context carries the triple (tenant_id, user_id, session_id). This triple is the load-bearing isolation key for memory, events, artifacts, tasks, tools, skills, planner state, and audit. The session is the innermost scope and the most active concurrency boundary.
A user can be in multiple concurrent sessions. Those sessions must remain isolated: different memory scopes, different event subscriptions, different tool caches. This is non-negotiable. (Settled.)
package identity
type Identity struct {
TenantID string
UserID string
SessionID string
}
func From(ctx context.Context) (Identity, bool)
func MustFrom(ctx context.Context) Identity // panics if absent — handler-only
func With(ctx context.Context, id Identity) context.Context4.2 Mandatory identity
Storage methods on MemoryStore, StateStore, ArtifactStore, TaskRegistry, EventBus.Subscribe, and the catalog filter require the full triple. Missing components fail closed: the operation returns an audit event (identity.required) and does not proceed.
Cross-session reads, cross-tenant queries, and admin observability require an explicit elevated scope claim on the Protocol caller (e.g. an admin JWT scope). Such requests are audited unconditionally. (Settled.)
4.3 Conformance gates
Every persistence-shaped subsystem ships a conformance.RunSuite(t, factory) that all drivers (in-mem, SQLite, Postgres) pass. The suite includes:
- Identity-mandatory tests: missing tenant/user/session components fail closed; the audit event is emitted.
- Cross-session no-leak: two concurrent sessions on the same store with different identity triples never observe each other's data.
- Cross-tenant no-leak: same, at the tenant boundary.
- Concurrency stress: 100 sessions × random ops for 30s under
-race. Final invariant: every read's identity matches the caller's identity exactly.
Phase plans for any persistence-shaped subsystem must invoke this suite. PRs that add new code paths touching identity must include cross-session isolation tests. (Settled — AGENTS.md §11.)
5. Harbor Protocol
5.1 Decoupling rule
The Console NEVER reads internal Runtime objects. The Runtime emits the canonical model; the Console renders projections. (Settled — AGENTS.md §1, §8.)
Reject-on-sight violations:
- Console code that imports a Runtime-internal Go struct.
- Runtime that exposes an internal state shape via the Protocol "for now."
- A Protocol method that maps 1:1 to an internal Go function signature.
- Runtime that imports the Console package, in any direction.
- A "shortcut" debug endpoint that exposes raw internal state and is "only for dev."
5.2 What the Protocol exposes
| Surface | Description |
|---|---|
| Streaming events | The typed event bus from §6.13, server-filtered by identity. |
| Task control | start, cancel, pause, resume, redirect, inject_context, approve, reject, prioritize, user_message (the nine taxonomy entries from §6.3). |
| State snapshots | sessions.inspect, tasks.get, state.history, state.list_trajectories, state.load_planner_checkpoint. |
| Topology | topology.snapshot events; static graph + live queue depth. |
| Artifacts | artifacts.list, artifacts.get, artifacts.get_ref, artifacts.delete — all scope-checked. Heavy bytes always go by ArtifactRef, never inline. |
| Traces / metrics | traceparent propagation; OTel traces and metrics derived from the same event bus. |
5.3 Versioning
The Protocol version is pinned in internal/protocol/types/version.go. Bumping the version is an RFC change. Breaking changes require a deprecation window so third-party Consoles aren't whipsawed. The deprecation-window obligation extends to embedder binaries that mount the Protocol surface via the promoted serve constructor / sdk/server (§5.6, D-291): a self-hosted binary serving the surface is a Protocol server like any other, and a breaking wire change whipsaws its clients identically.
5.4 Wire transport
Q-1 RESOLVED (2026-05-14) — SSE + REST. The Protocol surface is consumable from a browser, a TUI, an IDE extension, a third-party Console, and an observability vendor. The candidate transports considered were:
- gRPC server-streaming: native streaming, language-mature, but TUI/browser ergonomics weak without grpc-web shim; heaviest dependency add.
- SSE + REST hybrid: trivial browser support, simple to operate, no native multiplexing, half-duplex.
- WebSocket + JSON-RPC: full duplex, browser-native, schema discipline weaker without an external IDL.
- NDJSON over chunked HTTP: simplest to debug; weak multiplexing.
The resolution: SSE for the event stream + REST/JSON for the control surface, both server-enforced for identity. Rationale: lowest implementation cost, browser-native (no proxy/shim), matches the gateway sibling project's patterns, no extra dependency; the streaming-only direction (server→client) covers events, the request-response direction (client→server) covers control. WebSocket can be added as an alternate transport in a later phase if multiplexing or full-duplex becomes load-bearing — the internal/protocol/transports/ seam makes that additive, not a migration.
Phase 60 (Protocol wire transport) is no longer a decision gate; it is a normal implementation phase.
5.5 Authentication
JWT, asymmetric algorithms only (RS256/RS384/RS512/ES256/ES384/ES512). The triple (tenant, user, session) is in the JWT claims; the Protocol rejects any request without an identity scope. (Settled — AGENTS.md §7.) Extended scopes (admin, console:fleet) gate cross-session and cross-tenant subscriptions.
Agent reach is signed resource authority, not isolation (D-397). Every bearer used on an agent-addressed data-plane operation carries a bounded, non-empty agent_reach claim naming the registration IDs it may address. The claim is parsed strictly at authentication: malformed, duplicate, blank, or over-limit entries reject the bearer; an absent or empty claim authenticates for unrelated surfaces but authorizes no agent-addressed data-plane call. One shared effective-agent gate enforces the claim before side effects on control.start, all agent_config.session.* and agent_config.user.* methods, an explicitly agent-projected tools.describe, and the MCP Apps data-plane callbacks mcp.servers.resources, mcp.servers.read_resource, and mcp.apps.call_tool. Omitted control.start.agent_id resolves to the configured default before the gate; an omitted Apps agent_id likewise resolves to the configured default so a v1.26.10 host remains compatible, while an omitted tools.describe.agent_id retains its distinct boot-effective projection and is not an agent choice. Successful MCP App resource reads may additionally carry bounded standard _meta.ui.csp.resourceDomains from the exact returned resource content item and provider_origin, a canonical public HTTPS origin projected from that same current admitted provider. Missing declarations stay absent. Only exact HTTPS origins are supported (32 entries, 2048 bytes per entry, 8 KiB recognized projection); malformed recognized declarations refuse the App read. Credentials, private/literal/local endpoints, arbitrary metadata and other CSP directives are not projected. This syntactic origin projection performs no DNS lookup and grants no callback rights. Hosts retain sandbox control and require current render admission before using declarations; existing mounted document CSP is a snapshot. Provider replacement, generation changes, effective-agent retirement and source withdrawal during the read refuse the result. Non-App resource behavior is unchanged; no extra catalog/metadata network request is introduced.
Tenant-local configuration proves only that an agent can be selected; it never grants reach. Bearer-less carrier identity supplies no signed reach and therefore cannot use these methods. agent_id remains registration metadata under §6.16 and does not join the isolation tuple.
Session reach is optional signed authority that narrows D-171's per-request session selection (D-409). A bearer MAY carry a bounded, unique session_reach claim naming the session IDs it may select per request. When the claim is ABSENT, D-171's dynamic per-request session selection is preserved exactly: the effective session is chosen per request beneath the verified (tenant, user). When PRESENT, the shared authentication edge enforces it once, after the effective session has been resolved from X-Harbor-Session, the SSE access-token ?session= projection, or the token's default session claim — the resolved session must be a member or the request fails closed before any handler side effect. Malformed, duplicate, blank, or over-limit entries reject the bearer; an explicitly empty claim grants no session. The claim is bearer authority only: it is never a storage filter, never a request-body member, and never part of the isolation tuple, and it neither weakens nor duplicates tenant/user verification. Carrier-identity / bearer-less mode retains its existing behavior and cannot manufacture signed reach.
Effective agent resolution is ordered and closed (D-397/D-399).EffectiveAgentID(requested) is a pure selection step: it chooses the explicit requested id or the configured default for an omitted control.start.agent_id; it performs no StateStore lookup. The shared signed reach gate runs next. Only after that gate authorizes the selected id may the runtime consult tenant-local lifecycle/configuration and return the protocol-owned closed resolution state active, unresolvable, or retired. Thus a caller without reach cannot distinguish an unknown, configured, or retired agent, and no lifecycle/config read becomes an authority oracle. All three steps complete before session creation or task spawn. A configured default receives the same lifecycle lookup as an explicit id: it never short-circuits around a tombstone.
MCP Apps carry runtime-authored effective-agent authority (D-408). An mcp.app_available event and a nested MCPAppRef returned by mcp.apps.call_tool carry the effective agent_id that produced the App. The Console treats it as opaque host state: it echoes it on the App's subsequent resource listings, resource reads, and tool calls, but the sandboxed App never supplies or changes it. The Runtime does not trust the echo. It resolves omission/defaults, checks signed reach before tenant-local lookup, and only then seats the internal effective-agent execution capability used by pair-owned credentials and the current per-agent tool-exposure gate. Live resource discovery is normal data-plane use, never private preparation and never an InvokingAgent fallback. mcp.apps.tool_context is deliberately not agent-addressed: its persisted record is already scoped to the verified (tenant,user,session) and it performs neither credential selection nor an agent-config read. agent_id remains resource authority, never a fourth isolation component and never southbound MCP content.
5.6 External Protocol serving (Settled — D-291)
An external binary — a scaffolded agent with compiled in-process Go tools, a headless embedder that also wants a network surface — may serve the Protocol at parity with the stock harbor serve. This is a decided contract, superseding the earlier deliberate omission where the serve composition lived only inside cmd/harbor (package main, unreachable to any importer). It rests on two pieces:
- One promoted serve constructor. The config→listener composition (
bootDevStack+ its options + thedevStackserve/close lifecycle) is promoted out ofcmd/harborinto an importable internal package (internal/runtime/serve), the same re-homing move §3.6/D-197 made forassemble.Assemble.harbor serve,harbor dev, andharbor consoleall become thin callers of it. Dev-only policy — the mock-LLM escape hatch (§7 posture, D-089), the hot-reload supervisor, the dev signer + dev-token mint, the bootstrap-token endpoint, draft scaffolding, the embedded Console, post-boot fixture seeding — stays incmd/harborand is composed caller-side through explicit injection seams the promoted options/handle expose (extra pre-listener routes, the transports auth-surface option, an LLM snapshot override, a post-boot hook receiving subsystem handles). The promoted constructor carries no dev knobs of its own. - One curated
sdk/serverfacade (§3.6 item 6) over that constructor, exposingserver.Open(ctx, cfg, Options{RegisterCatalog})→ a handle withServe/Close.
Posture (binding). The promoted constructor requires a non-nil auth-validator factory — identity is mandatory (§4); a nil factory is a loud construction error, never an unauthenticated listener. The constructor mounts ONLY the surfaces every caller shares. Dev-only surfaces exist only via caller-side composition, never inside the constructor and never through the sdk/server facade: cmd/harbor's dev path injects a validator factory built from its ephemeral dev signer and composes the dev surfaces through the item-1 injection seams; harbor serve injects the §5.5 JWKS factory and composes none of them, so those surfaces 404. The facade curates the injection seams out. sdk/server is production-only by construction: it always builds the JWKS validator from cfg.Identity and fails loud (naming the missing field) when absent — no dev-signer, no mock. Local development against sdk/server uses the harbor token bring-your-own-issuer on-ramp (§8), identical to a self-hosted harbor serve.
Compiled-tool registration rides the existing pre-policy seam. Options.RegisterCatalog is carried into the assembly as a sibling callback applied at the existing PreRegisterTools application point — before builtin registration and the catalog Builder's per-entry tools.entries wrapping — so a compiled tool receives the same declared approval / OAuth / policy shell an operator's YAML-declared tool gets. It is an adapter over that one seam, never a second registration path (a post-assembly Catalog.Register bypasses the wrapping and is the documented trap). See D-292.
The embed-headless posture stays the default. Most embedders want no listener; assemble.Assemble + Stack.RunOnce (§3.6) remain the zero-network path. sdk/server is the additive opt-in for the binary that wants the wire surface too. Phases 159 (the promotion) and 160 (sdk/server + harbor scaffold --with-server + the parity gate) implement this section.
6. Runtime layer
The Runtime is the meat of V1. Each subsystem below is a settled architectural decision; sharp edges and open questions are explicit. Phase plan(s) for each subsystem are sized in docs/plans/README.md and the master plan that follows this RFC.
6.1 Core runtime
The Runtime is an async, queue-backed graph of Nodes exchanging Envelopes along Channels. It owns: the executor loop, channel semantics (bounded, drop-policy on backpressure), reliability shell (timeouts, retries, validation), streaming primitive, cancellation, subflows, routers, concurrency utilities (MapConcurrent, JoinK).
package runtime
type Envelope struct {
Payload any
Headers Headers
RunID string // active concurrency boundary
SessionID string
Timestamp time.Time
DeadlineAt *time.Time // wall-clock; checked before scheduling each node
Meta map[string]any
}
type Headers struct {
TenantID string
UserID string
Topic string
Priority int
}
type Engine interface {
Emit(ctx context.Context, env Envelope, opts ...EmitOption) error
EmitTo(ctx context.Context, env Envelope, target NodeRef) error
Fetch(ctx context.Context, opts ...FetchOption) (Envelope, error)
FetchByRun(ctx context.Context, runID string) (Envelope, error)
Cancel(ctx context.Context, runID string) (bool, error)
Stop(ctx context.Context) error
}Settled decisions:
- Identity quadruple
(TenantID, UserID, SessionID, RunID)flows through the Envelope.RunIDis Harbor's term for what the predecessor calledtrace_id; Harbor reservesTraceIDfor OpenTelemetry-style traces (which may span multiple runs). (Resolves brief 01 Q-1.) DeadlineAtis wall-clock, not duration. Set once at the boundary.- The egress fetch dispatcher is always-on. The dual-mode (pre-dispatcher direct fetch vs post-dispatcher per-run demux) the predecessor ships exists for backward compatibility Harbor doesn't owe to anyone.
- Per-run capacity backpressure is a Runtime primitive, not a bolt-on. Without it, parallel runs can deadlock through shared bounded channels under streaming load.
- Planner concerns do not leak into the Runtime: a deadline expiration emits
RunError(DeadlineExceeded)to the egress; planners convert that to a final answer for the user. Working-memory hop dedup is not a Runtime concern. - Bus publishing failures surface to the Protocol; never silently swallowed.
Key data shapes (settled in docs/research/01-core-runtime.md):
Node,NodePolicy(timeout/retry/validate/backoff),RunError(structured),StreamFrame(per-streamSeq, terminalDone).- Routers:
PredicateRouter,UnionRouter,RoutePolicy. - Concurrency:
MapConcurrent,JoinK. - Subflows:
Subflow(factory, parent, opts...)runs a child engine with the parent'sRunID, mirrors parent cancellation, returns the first egress payload.
Validation strategy: Go generics + JSON Schema at the protocol edge. Internal nodes are typed Node[I, O] so the compiler enforces shape; runtime validation handles wire-form ingress where types are dynamic. (Resolves brief 01 Q-3.)
Default queue maxsize: 64 per-channel default, per-engine override, per-channel override available. (Resolves brief 01 Q-4.)
Error routing: errors go to the Protocol unconditionally; egress emission (emit_errors_to_rookery-equivalent) is the optional path. (Resolves brief 01 Q-5.)
Flow-as-Tool registration (Settled — see D-023). A Flow is a typed DAG of Nodes assembled into a runnable unit (the same machinery that powers subflows in §6.1) that can be registered as a Tool in the Tool catalog (§6.4). The planner sees one Tool with an args/result schema; invoking it runs the underlying DAG with the runtime's full reliability shell — NodePolicy per-node (timeout / retry / exponential backoff / validation) plus an aggregate FlowBudget enforced at flow boundaries.
package flow
type Definition struct {
Name string // tool-name when registered
Description string // surfaced to the planner
Entry NodeID // first node in the DAG
Exit NodeID // node whose output is the flow's result
Nodes map[NodeID]NodeSpec // node → policy + edges
Budget Budget // optional intrinsic cap (see below)
InSchema json.RawMessage // derived from Entry's input type
OutSchema json.RawMessage // derived from Exit's output type
}
type Budget struct {
Deadline time.Duration // wall-clock cap; 0 = inherit from parent run
HopBudget int // max node hops; 0 = inherit
CostCap float64 // USD ceiling enforced via Governance counters; 0 = inherit
}
// Compose builds a runnable Engine from a Definition. The engine is reusable
// across invocations; each invocation gets its own RunID + RunContext.
func Compose(def Definition) (Engine, error)
// RegisterAsTool wires a composed Engine into the Tool catalog. Args/result
// schemas come from def.InSchema / def.OutSchema; Transport is FlowTransport.
// The planner cannot tell a Flow Tool from any other Tool — same one method,
// same dispatch path (RFC §6.4 "Code-level tool dispatch").
func RegisterAsTool(catalog tools.Catalog, def Definition, eng Engine) (tools.Tool, error)Resilience composition (Settled). Per-node retry / backoff / timeout / validation come from NodePolicy (§6.1 "Key data shapes"). The Backoff math is exponential with jitter (base * 2^attempt + jitter, capped at MaxBackoff); per-node retries respect MaxRetries; per-node timeout produces RunError(NodeTimeout) and counts against retries. Per-flow caps come from flow.Budget and are enforced at the engine boundary: deadline = min(flow.Budget.Deadline, parent_run.RemainingDeadline); hop budget = min(flow.Budget.HopBudget, parent_run.RemainingHops); cost cap = min(flow.Budget.CostCap, parent_run.RemainingCost). Exceeding any cap emits flow.budget_exceeded and aborts cleanly; the runtime returns a typed ErrFlowBudgetExceeded to the calling planner step. Identity budgets (Governance §6.15) gate the LLM calls inside flow nodes — the two budget systems compose: a flow can be aborted by either its intrinsic cap or the identity-tier ceiling, whichever fires first.
Recipe format (declarative DAG authoring) — V1.1, deliberately deferred. A recipe is a YAML/JSON-shaped file that describes a Flow Definition declaratively (nodes, policies, edges, budget) so operators can author flows without writing Go. V1 ships Go-coded Definition registration (operators write a small Go program that calls flow.Compose(...) and flow.RegisterAsTool(...)); recipes ship as post-V1 phase 100 to keep V1 scope tight. The Definition shape is the contract; the recipe loader is just a parser into the same struct.
6.2 Planner interface, Trajectory, RunContext
type Planner interface {
Next(ctx context.Context, run RunContext) (Decision, error)
}
type RunContext struct {
SessionID, RunID, TenantID, UserID string
Query string
Goal string // current goal (may be redirected by control)
LLMContext map[string]any // visible-to-LLM context (memories etc.)
ToolContext ToolContext // tool-only handles; serialisable/handle-split
Trajectory *Trajectory // append-only execution log
Hints PlanningHints // optional ordering/parallel limits
Catalog ToolCatalogView // schemas only — never Descriptors
Memory MemoryView
Skills SkillLookup
Artifacts ArtifactStore
Control ControlSignals // accumulated steering observations
Budget Budget // deadline, hop budget, cost cap
Clock func() time.Time
Emit func(events.Event)
}
type Decision interface{ isDecision() }
type CallTool struct { Tool string; Args json.RawMessage; Reasoning string }
type CallParallel struct { Branches []CallTool; Join *JoinSpec }
type SpawnTask struct { Kind tasks.Kind; Spec tasks.Spec; GroupID string; CallID string }
type AwaitTask struct { TaskID tasks.TaskID }
type Batch struct { Tools []CallTool; Spawns []SpawnTask; Join *JoinSpec }
type TaskStatusQuery struct { TaskIDs []tasks.TaskID } // planner.TaskStatusQuery — own descendants only; distinct from the tasks.TaskStatus lifecycle enum
type CancelTask struct { TaskID tasks.TaskID; Reason string } // own descendants only
type SteerTask struct { TaskID tasks.TaskID; Directive string } // own descendants only; enqueues onto the descendant's steering inbox
type PauseTask struct { TaskID tasks.TaskID; Reason string } // own descendants only; drives the unified pause/resume primitive
type ResumeTask struct { TaskID tasks.TaskID; Directive string } // own descendants only; resume directive rides the primitive's resume-payload seam
type RequestPause struct { Reason pauseresume.Reason; Payload map[string]any }
type Finish struct { Reason FinishReason; Payload any; Metadata map[string]any }Settled decisions:
Decisionis a sum type. Runtime opcodes (parallel, spawn, await, pause, finish) are different shapes from tool calls. The predecessor's "magic strings asnext_node" pattern is rejected.Batchcomposes one native tool-calling response's heterogeneous intent: N catalog tool calls (joined perJoinSpec, exactly asCallParallel) plus M non-blocking spawns (eachRetainTurn=false; ≥2 unbound spawns auto-join one task group). Only_finish,_await_task,_task_status,_cancel_task,_steer_task,_pause_task, and_resume_taskremain standalone at the projector edge — a terminal decision, a single-target block, and the task observation/cancel/steer/pause/resume controls have no coherent multi-call semantics, while a spawn batched with tools does (this supersedes the blanket standalone rule for reserved planner-control names; a batched await is rejected because it would create a same-step dependency on a sibling's not-yet-existing task id; the task-management controls stay non-batchable as a conservative first grammar — widening later is additive, retracting is not). Spawns are never tool invocations for accounting; a degenerate one-branchBatchis never constructed — one representation per semantic. Observation results key by the providercall_idand reply in original call order.TaskStatusQuery/CancelTask(planner-package shapes —TaskStatusQueryis deliberately NOT namedTaskStatus, which is the tasks-package lifecycle enum) give the planner observation and control over the tasks its own run spawned — descendant-scoped via the parent-task chain, never arbitrary session tasks. The cancel hierarchy is invariant: the operator reaches any task directly regardless of propagation mode (isolatedetaches a task from its parent's cascade, never from operator control; a session-scoped operator cancel sweeps isolate-marked tasks too — there is no uncancellable task); the agent reaches only its descendants; cascade is the default so an interrupt kills everything not explicitly detached. Model-expressibleisolatelands only together with these management meta-tools (power with brake).SteerTask/PauseTask/ResumeTask(planner-package shapes) complete the operator↔agent control taxonomy on the AGENT side: the model steers, pauses, and resumes the background tasks its own run spawned, descendant-scoped via the SAMEisOwnDescendantguard andErrTaskNotOwnDescendantsentinel asTaskStatusQuery/CancelTask(one guard, one sentinel — a run never reaches a sibling's tasks). They introduce NO new mechanism: steer enqueues an agent-issued directive onto the descendant's EXISTING per-sub-run steering inbox (the same inbox the operator's steering targets), and pause/resume drive that descendant through the EXISTING unified pause/resume primitive (§3.3) — pausing a descendant never pauses the run issuing the verb. Human supremacy is invariant: the operator reaches any task through the same surface and always supersedes; the agent presents only the run-owner scope over its own descendants. Non-batchable in this wave (conservative grammar, widening is additive).RunContextis the only surface the planner sees. Planners do not import Runtime internals. The Runtime hands the planner a pre-filtered catalog (visibility already applied), a memory view (scoping already bound), a skills lookup, the artifact store, andControlsignals.- The reference
reactplanner uses functional options for the small set of genuinely policy-shaped knobs. Token budget, hop budget, deadline, max_iters, schema mode, cost cap are runtime-level run options, not planner state. The predecessor's ~70-field, ~50-constructor-parameter planner class is the anti-pattern. - Concurrency: planners are safe to use across runs; the Runtime serializes calls within a run. State keyed by
RunIDis the pattern.
Trajectory:
type Trajectory struct {
Query string
LLMContext map[string]any
ToolContext ToolContext // serialisable half only — see §6.3
Steps []TrajectoryStep
Summary *TrajectorySummary // compaction artefact
Sources []Source
Artifacts map[string]ArtifactRef
HintState map[string]any
SteeringInputs []SteeringInjection
Background map[string]BackgroundResult
ResumeHint *ResumeHint
}Trajectory.Serialize() ([]byte, error) returns (nil, ErrUnserializable{Field: "..."}) if any entry is non-JSON-encodable. There is no silent-drop path. (Settled — closes the predecessor's silent-context-loss bug.)
Schema repair pipeline lives in internal/planner/repair/ and is reusable across concretes: salvage → schema repair → graceful failure → multi-action salvage. Configurable per-concrete (arg_fill_enabled, repair_attempts, max_consecutive_arg_failures). (Settled.)
Portable compaction (D-462; RFC 002, phase 268). A runtime-owned coverage record identifies the exact trajectory prefix replaced by a summary. ReAct replays the summary plus every uncovered exchange, preserving complete native call/result groups, a recent tail, and newly settled results not yet presented to a decision. Compaction may repeat; an existing summary is not a fence against future activity. This supersedes only the single-compression / summary-only projection of D-055 and D-202. Candidate generation occurs outside inspection locks and publication validates the source prefix; failure preserves the prior checkpoint. Unversioned legacy summaries never receive guessed coverage.
The first increment fixes replay and standalone trajectory estimation; complete assembled-request capacity enforcement remains a phase 268 acceptance criterion. No long-term memory, provider-native compaction, public transcript expansion, or cold execution relaunch is introduced. See RFC 002 and the phase 268 plan.
6.3 Steering and the unified pause/resume primitive
Same-run step tranches. A configured finite tranche charges each completed nonterminal planner decision exactly once, including control and task decisions, tool-dispatch failures, and unavailable executor paths. Terminal completion is not charged; tool-call accounting remains a separate metric. A durable receipt is a private selector scoped to the verified (tenant,user,session,run) identity and resumes the same live run without replay. A foreign selector is indistinguishable from a missing selector. Across process restart, the current Runtime retains the receipt for inspection but reports typed restart unavailability: the checkpoint has no trusted relaunch boundary and must not reconstruct a run from mutable current planner or catalog state. A future explicit relaunch seam may extend this contract without widening identity or creating a new run.
The v1.27 continuation surface is completed by governed virtual-child run views, reference-only artifact/output forwarding, and a durable task-progress projection. Child profiles remain derived configuration views rather than identity principals; forwarded content remains behind authorized artifact references; and TaskRow may carry the additive bounded progress_snapshot, virtual_key, and virtual_label fields. The Console mirror is hand-maintained while the generated wire manifest remains owned by the Go canonical wire source.
Steering is a Runtime capability, surfaced over the Protocol. Planners observe Control signals; the Runtime owns the inbox.
Control event taxonomy (nine types — Settled):INJECT_CONTEXT, REDIRECT, CANCEL, PRIORITIZE, PAUSE, RESUME, APPROVE, REJECT, USER_MESSAGE.
Pause reason taxonomy (four types — Settled):approval_required, await_input, external_event, constraints_conflict.
Pause/resume primitive:
package pauseresume
type Pause struct {
Token Token // opaque, runtime-issued
Reason Reason
Payload map[string]any // sanitized; depth/size-bounded
PausedAt time.Time
}
type Token string // opaque to clients; runtime owns the encoding
type Coordinator interface {
Request(ctx context.Context, req PauseRequest) (Pause, error)
Resume(ctx context.Context, token Token, payload map[string]any) error
Status(ctx context.Context, token Token) (Status, error)
}Tool-context split. The predecessor's silent-context-loss bug is closed by splitting ToolContext into:
- A serializable half: IDs, configs, plain values. Serializes via standard JSON.
- A non-serializable half: live callbacks, loggers, sockets, file handles. Registered with the Runtime under a handle key; on resume the handle is re-attached from the Runtime's live registry by key. If the handle cannot be re-attached, resume FAILS with
ErrToolContextLost{Handle: "..."}— never silently. (Settled.)
Handle registry persistence. 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. (Resolves brief 02 Q-4.)
Steering authn/authz. Per-event scopes. CANCEL, APPROVE, REJECT, PAUSE, RESUME require the originating user/admin scope. INJECT_CONTEXT, USER_MESSAGE accept the session-scoped user. PRIORITIZE requires admin. REDIRECT requires the user (the agent's owner). Cross-tenant steering requires admin. (Resolves brief 02 Q-3.)
Steering payload bounds: depth ≤ 6, ≤ 64 keys, ≤ 50 list items, ≤ 4096 chars per string, ≤ 16 KiB total. Enforced at the Protocol edge. (Settled.)
User-message projection. Applied USER_MESSAGE text reaches the next decision request verbatim as user input, after complete prior tool exchanges; it never rewrites the system prefix. The runtime also keeps the applied text as inert trajectory context for subsequent steps in the same run, including when cross-run memory is disabled. When cumulative memory is enabled, its existing required context checkpoint must succeed before further inference. Historical context cannot reapply a control or authorize an external action. In-flight steering (D-479). A verified nonempty user message interrupts the current planning attempt, not the run. Per-run instruction generations fence re-planning, terminal completion and decision admission. Discard superseded decisions, serial pending calls and late output; carry first-attempt inputs and already-applied controls into the replacement request. A saved intent that loses admission is settled explicitly as not executed under the existing bounded cleanup context. Required persistence/accounting failures are terminal, never masked as cancellation retries. Preserve already-started invocation outcomes. An instruction-generation fence also refuses queued parallel invocations and policy retries without cancelling active sibling calls. Superseded approval requests are withdrawn through the existing Coordinator as rejections, never approvals; required withdrawal failures terminate after settlement. Short-circuit parallel joins wait for cancelled siblings so a success cannot hide failed required cleanup. The control payload contains only a nonempty message string; attachments, extra fields and invalid messages are rejected before interrupting execution (422 payload_invalid). Live consumer acceptance remains open.
Hard Stop (D-478). A verified CANCEL with payload.hard: true cancels the identity-scoped execution context immediately at inbox admission, not at the next planner boundary. Cancellation and terminal completion arbitrate under one per-run lock: an accepted Stop cannot become success through a late model response; a terminal decision rejects new controls. Queued dispatch checks cancellation, active execution receives it, and returned tool evidence remains evidence rather than authorization to replay an action. Cancellation bookkeeping uses an independent five-second context preserving identity. A control response acknowledges admission, not proof of upstream termination. Clients wait for the terminal task outcome; independently running external jobs need their own cancel API. Provider socket termination before and after response headers is verified through the governed Bifrost transport; live consumer acceptance remains tracked separately in PR #779.
Rejected HITL gate is terminal. APPROVE and RESUME resolve an outstanding pause and the planner re-enters. REJECT, by contrast, resolves the pause via Coordinator.Resume with a rejected: true marker and terminates the run with Finish{constraints_conflict} — a rejected human-in-the-loop gate is a constraint the planner cannot resolve, not a recoverable signal. (Settled — D-071. The alternative "re-enter the planner on REJECT so it can replan" was considered and rejected for V1: it lets a rejected gate loop indefinitely. A planner that should replan-on-reject is a future planner-policy concern, not a steering-primitive one — it would be a separate RFC change.)
Pause-state serialization format: JSON with format_version: 1. Settled to align with the event bus (also JSON) and operational simplicity. (Resolves brief 02 Q-2.)
NoOp decisions are not part of the Planner interface. Wait-for-steering and trajectory-summarization are Runtime short-circuits. (Resolves brief 02 Q-5.)
6.4 Tool catalog and transports
The planner reasons about exactly one concept: a Tool. The catalog hides whether the tool is in-process Go, MCP, A2A, or HTTP.
type Tool struct {
Name string
Description string
ArgsSchema json.RawMessage // JSON Schema (object)
OutSchema json.RawMessage
SideEffects SideEffect
Tags []string
AuthScopes []string
CostHint string
LatencyHint time.Duration
SafetyNotes string
Loading LoadingMode // Always | Deferred
Examples []ToolExample
Source ToolSourceID
Transport TransportKind // InProcess | MCP | A2A | HTTP | Flow
Policy ToolPolicy // resilience shell — see below
}
type ToolPolicy struct {
TimeoutMS int // 0 = inherit from RunContext.Budget.Deadline
MaxRetries int // 0 = no retry
BackoffBase time.Duration // exponential base; 0 = sensible default (100ms)
BackoffMax time.Duration // cap; 0 = sensible default (30s)
RetryOn []ErrorClass // which RunError classes are retryable; default = transient/timeout/5xx
Validate ValidateMode // both / in / out / none
}
type ToolDescriptor struct {
Tool Tool
Invoke func(ctx context.Context, args json.RawMessage, rc *RunContext) (ToolResult, error)
Validate func(args json.RawMessage) error
}
type ToolCatalog interface {
Register(d ToolDescriptor) error
Resolve(name string) (ToolDescriptor, bool)
List(filter CatalogFilter) []Tool
}
type CatalogFilter struct {
TenantID, UserID, SessionID string
GrantedScopes []string
LoadingModes []LoadingMode
NameRegex *regexp.Regexp
}
type ToolProvider interface {
Connect(ctx context.Context, rc *RunContext) error
Discover(ctx context.Context) ([]ToolDescriptor, error)
Close(ctx context.Context) error
SourceID() ToolSourceID
}Settled decisions:
- The unification is at the type level: every
Toolis the same struct regardless of source. The dispatch is one switch in one place. CatalogFilterkeys on the full identity triple plusGrantedScopes. The predecessor filters by tenant only; Harbor goes further from t=0.- Argument validation runs at the catalog edge; failures are typed
tool.invalid_argsevents (not tool errors) so the planner can reformulate via LLM retry feedback. - Result normalization is a layered pipeline (explicit field-extraction → typed-content blocks → heuristic binary detection → size-based safety net). The size-based safety net mandates routing through the
ArtifactStore; there is no inline-large-payload escape.
Reliability shell wraps EVERY tool invocation, regardless of transport (Settled — D-024). The minimum-expression tool — a plain Go function registered via tools.RegisterFunc(name, fn, opts...) — gets the same reliability shell as a Flow tool: per-call timeout, exponential-backoff retry, validation, identity-aware cancellation. The runtime's Dispatcher (§6.4 trio) wraps every tool invocation in the ToolPolicy shell once, regardless of Transport. The shell is identical to NodePolicy for runtime nodes (§6.1) — same backoff math, same retry classes, same validation modes — so a developer who learned NodePolicy already knows ToolPolicy. Defaults fire when ToolPolicy is zero-valued so the most common case ("@tool-decorate this function") needs zero ceremony to be production-resilient.
// Minimum-expression tool: a plain Go function registered with sensible defaults.
// Reliability shell (timeout, retry, backoff, validation) applies automatically.
catalog.RegisterFunc(
"summarize",
func(ctx context.Context, args SummarizeArgs) (SummaryResult, error) { ... },
)
// Same function with an opinionated policy:
catalog.RegisterFunc(
"external-fetch",
fetcher,
tools.WithPolicy(tools.ToolPolicy{
TimeoutMS: 5000,
MaxRetries: 3,
BackoffBase: 200 * time.Millisecond,
RetryOn: []ErrorClass{ErrTransient, ErrTimeout},
}),
)tools.RegisterFunc derives ArgsSchema and OutSchema from the Go signature via generics + reflection (no manual JSON-Schema authoring for the common case).
Transports shipped at V1:
- InProcess — tool authors register a Go function via generics + reflection (schemas derived from input/output types).
- HTTP — UTCP-style manifest, static auth (API key, bearer, cookie), retry, rate-limit handling.
- MCP southbound — Go MCP client driver (stdio + streamable-HTTP + SSE); auto-detect transport via
MCPTransportMode = Auto | SSE | StreamableHTTP. - A2A southbound — full A2A spec compliance from t=0. Agent Card discovery (
GET /.well-known/agent-card.json), JSON-RPCmessage/send,message/stream(SSE),tasks/get,tasks/cancel,tasks/pushNotificationConfig/*. Registry with route scoring (trust tier, latency tier, capability match). - Flow — a Harbor Flow (DAG, see §6.1 "Flow-as-Tool registration") registered as a Tool. The dispatcher invokes the underlying engine; the per-node
NodePolicy(retry / exponential backoff / timeout / validation) and the aggregateflow.Budget(deadline / hops / cost cap) compose with identity-tier Governance ceilings. The planner sees a Flow Tool the same as any other Tool — one args/result contract, one dispatch path, one set of failure modes (tool.invalid_args,tool.error, plusflow.budget_exceededmapped toErrFlowBudgetExceeded).
A2A northbound (V1 candidate — Tentative — see §11 Q-2). Exposing Harbor as an A2A server (so other agents can call us) is a strong V1 candidate but adds protocol-server scope. Lean: defer to V1.1 unless an early adopter demands it.
HTTP tool definitions: both inline (Go code: RegisterHTTPTool(name, method, urlTemplate, ...)) and out-of-process via UTCP-style manifest. Inline is the dev-loop ergonomic; manifest is the operator deployment shape. (Resolves brief 03 Q-3.)
Tool-side OAuth + HITL uses the unified pause/resume primitive. The runtime emits tool.auth_required (auth URL, scopes, state), the Coordinator opens a pause record, the user completes OAuth out-of-band, the callback handler resumes the run with the token. The same primitive serves A2A's TaskState.AUTH_REQUIRED. (Settled.)
Runtime MCP tool-side OAuth (agent-bound; planned — D-240). Adding a NEW MCP connection over the Protocol (agent_config.add_mcp_connection, §6.16) can require authorization. Rather than minting a second auth path, the runtime-added connection authenticates through the same tool-side OAuth primitive above, with BindingScope == ScopeAgent — the agent-bound token keys by the registration agent_id (the admin authenticates once during agent setup; every session invoking the agent reuses the agent's token). Three properties make this faithful to the existing primitive rather than a fork: (1) the OAuth OAuthProvider gains a runtime config-registration seam so a server added at runtime — not just at boot — can carry an OAuthConfig (the config registry becomes internally-synchronised shared state, preserving the compiled-artifact concurrent-reuse contract); (2) the MCP transport resolves the agent-bound token from the provider and injects Authorization, surfacing a typed auth-required error rather than a string heuristic; (3) the add parks on the unified Coordinator via InitiateFlow, and the callback's CompleteFlow persists the agent-bound token and resumes the pause — a resume-driven continuation then re-drives the attach so the server comes online (the resume completes the attach). Persisted connection descriptors are reconciled at run-start: a declared-but-absent connection is re-attached (reading the agent-bound token) so a server authorized in a prior process comes online on the next run. Detaching a connection a rollback no longer declares is deferred — revocation stays the job of the pause/resume tool-exposure projection (a paused server is excluded from the next-run view without a live-transport teardown), consistent with the warm-transport / next-turn-projection model. Spec-faithful discovery (401 → WWW-Authenticate → RFC 9728 protected-resource metadata → RFC 8414 authorization-server discovery → RFC 7591 dynamic registration → PKCE) removes the operator-supplied-config requirement for the common case. Decomposition + staging: docs/plans/wave-mcp-oauth-decomposition.md (phases 92k–92q); completes the 92f add-connection lifecycle and issue #375. (Planned — D-240; per-phase decisions D-241..D-247 reserved.)
Runtime MCP re-attach is idempotent at the live layer (D-339). The agent_config.add_mcp_connection attach is synchronous — it dials, handshakes, discovers, and registers the server's tools into the live catalog before returning the resolved state — while its mirror agent_config.remove_mcp_connection defers the physical tool deregistration + transport teardown to the next run-start reconcile (the detach-only leg above). That timing asymmetry strands a coordinator that re-attaches a still-live same-name connection on a running runtime before a new run has triggered the deferred detach: the blind register hits the catalog's duplicate-tool-name reject and the attach returns failed, leaving the agent with zero tools for that server until the process restarts. The attach path therefore treats a same-name live registration as a first-class REPLACE: when the connection name being attached already has a live in-memory registration, the attacher synchronously deregisters the existing server's tools (the catalog source-deregister leg) and closes its transport (the registry deregister leg — never a silent map overwrite that would leak the old transport), then registers the new connection — an atomic same-name upsert performed inside the attacher's existing serialise-the-whole-attach lock. Re-attach becomes idempotent by construction: no teardown_now verb, no forced-reconcile round-trip, no window where the caller must sequence a remove before an add. Completes the add-connection lifecycle (issue #375). (Settled — D-339.)
Tool-exposure projection and dispatch admission. Pause and disable controls exclude a tool from both the planner's list and name resolution. Deferred loading changes initial presentation while retaining discovery and resolution. Planner-originated calls must resolve through the sealed per-run view before executor dispatch, including remembered names and parallel calls. The trusted run-completion hook (§6.17) uses a separate runtime-owned view that retains source ownership and desired-connection admission but includes planner-disabled and deferred sinks. Logical names resolve to the acting agent's admitted physical registration; no missing or foreign source falls back to the process-wide catalog. Bare embedded hooks without an agent projection retain their host-supplied catalog behavior.
External tool-credential provisioning (D-271). An external credential authority — a fleet orchestrator coordinating many runtimes, an enterprise token vault, an STS — can hold a user's downstream integration credentials (M365, Google Workspace) centrally and supply them to a runtime's tool calls, so one grant serves N runtimes instead of N independent consents and N sealed copies. The shape is pull-based token exchange, never push: the non-interactive tokenexchange driver on the OAuth flow-strategy driver registry (the same seam the oauth2 interactive driver registers on) performs an RFC-8693-shaped exchange against the operator-configured broker at token-miss time, presenting the runtime's own broker credential plus the verified ctx identity triple — never identity from a request body. Brokered tokens are TTL-cached in memory only and never persisted to the TokenStore (the broker stays the single source of truth; no per-runtime shadow copy). Broker failure fails the run loudly — there is no silent fallback to the interactive flow; a broker "consent required" refusal surfaces the same typed auth-required error as the interactive path and parks on the unified pause/resume primitive, so central consent + resume re-drives the call. Every actual exchange emits the canonical tool.credential_exchanged audit event (zero token bytes); the interactive-flow methods return the typed ErrNonInteractive. A downstream credential arriving in-band from a northbound Protocol client is rejected as the credential-passthrough pattern §7 forbids. This is the southbound mirror of the northbound external-identity posture (jwks_url + the TokenIssuer seam). The runtime's own broker client credential resolves through a credential-source seam on the provider entry: env (boot-time process-env resolution, the default) or remote — an authenticated pull from the coordinating credential authority at first need, memory-only with TTL, single-flight, fail-loud — so a credential minted after the runtime booted reaches it with zero touch, and the broker secret never enters the runtime's environment (D-285). Plans: docs/plans/phase-142-tool-credential-exchange.md, docs/plans/phase-154-broker-credential-source.md.
For the signed-capability exchange profile, the immutable binding's exact destination is checked against the broker response's Audience and Resource fields before the bearer is accepted. Harbor deliberately does not decode the returned third-party JWT and compare its aud claim with the RFC 8707 resource URI on this path: an authorization server may use a provider-specific application identifier as the JWT audience while the broker's authenticated response still names the exact resource destination. The ordinary tokenexchange profile retains its best-effort JWT aud comparison and fail-closed mismatch behavior; tool.credential_exchanged.AudienceVerified remains false for the signed profile because that claim comparison was not performed (D-449).
The credential-bearing exchange POST is hardened to the same bar as the discovery and credential-source fetches: a post-DNS dial backstop refuses any resolved private-range / link-local / unspecified address (the DNS-rebinding defence for a client that carries the runtime's broker client_id/client_secret), the proxy is disabled, and every redirect is refused (a redirecting token endpoint would replay the credential form to the redirect target) — loopback is the one deliberate carve-out, since the boot-declared token_url may legitimately name a localhost sidecar. For the standard containerized local-dev topology — a coordinator reachable only behind a private-IP TLS sidecar — a dev-only, fail-closed, boot-only opt-in (tools.oauth_providers[].allow_private_token_url, or the global HARBOR_DEV_ALLOW_PRIVATE_EXCHANGE boot env; effective posture is the OR of the two, default off) relaxes ONLY the private / link-local / ULA branch of that dial backstop, and ONLY for the provider's own operator-declared token_url. It never relaxes the unspecified-address block, never touches the loopback carve-out or the redirect refusal, and is never Protocol-writable or derived from a discovered / wire descriptor — the credential-plane invariant (no admin-writable field determines a credential sink) is preserved because the knob is boot-declared and keyed to the same trusted sink. When the env hatch fires, every boot prints a [DEV-ONLY PRIVATE-IP TOKEN EXCHANGE — DO NOT USE IN PRODUCTION] stderr banner; production posture is unchanged (D-300, D-338).
Wire-carried OAuth-provider descriptor for runtime install (dev-gated; D-340). agent_config.set_oauth_provider and the oauth_provider binding on agent_config.add_mcp_connection bind by NAME to a boot-declared provider — the zero-URL, name-only install shape whose descriptor deliberately carries no token_url / audience / allowed_downstream_hosts, so a DisallowUnknownFields decode rejects those sink fields by name (D-303). That shape cannot connect a NEW OAuth-fronted MCP server without a static tools.oauth_providers[] block and a runtime redeploy. To let a coordinator stand a new provider up at runtime, the descriptor MAY carry the NEW server's OAuth params over the wire (token_url, audience, scopes), while STILL naming a boot-declared credential_broker for the runtime's own credential custody — but ONLY behind a fail-closed, boot-only opt-in (tools.allow_wire_oauth_descriptor, or the global HARBOR_ALLOW_WIRE_OAUTH_DESCRIPTOR boot env; effective posture is the OR of the two, default off). This preserves the D-300 credential-plane invariant (no admin-writable field determines a credential sink) BY DEFAULT: with the opt-in off, a wire descriptor carrying any sink field is rejected exactly as D-303 rejects it today. 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 (NormalizeDownstreamHost(connection.url)), so an exchanged token can only ever be presented to the one endpoint the connection actually dials; (2) the wire token_url is dialed through the identical tokenexchange SSRF backstop the boot path uses (refuse resolved private / link-local / ULA / unspecified, no redirect, no proxy, loopback the one carve-out — D-300/D-338), and the runtime's own broker credential stays boot-declared (credential_broker names a boot-config broker; no secret ever rides the wire). When the env hatch fires, every boot prints a [DEV-ONLY WIRE OAUTH DESCRIPTOR — DO NOT USE IN PRODUCTION] stderr banner. Revisits D-303 (the name-only default stands; the wire descriptor is the gated extension); composes with D-300/D-338. (Settled — D-340.)
Per-user credential injection for receiver-style MCP servers (D-341). The southbound MCP driver's per-user credential plane is PULL-based: a connection bound to a tokenexchange provider mints a per-user identity token that the downstream server presents back to PULL the acting user's credential (RFC 8693; D-271, D-278). That covers every 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, Authorization: Basic, MCP _meta), 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, shared by every user of the agent and never per-user. The driver gains a per-user credential-INJECTION mode: 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 uses — the credential still originates from the broker, is still per-user, is fetched-not-held) and INJECTS it into the outbound request per a declared injection mapping — which credential field(s) map to which header(s) / to a Basic value / to _meta.<vendor> keys. This is a controlled exception to the pull-only posture (D-271): the runtime still PULLS the credential from the broker per acting user; it never accepts a pushed credential from a northbound client — the only new move is that the RUNTIME performs the last-hop delivery because the receiver server cannot pull. The injection mapping is NON-SECRET connection config (like allowed_downstream_hosts); only the pulled value is secret, and it follows the existing no-log / no-persist discipline — the audit redactor's coverage is EXTENDED so the non-Bearer forms (Authorization: Basic, arbitrary vendor header keys, _meta credential values) are redacted to the same bar as the Bearer path. Injection is mutually exclusive with the existing bearer/oauth mode (one auth mode per connection, the existing attach-time guard). Extends the shipped southbound per-identity injection seam (D-278) with a second declared form on the same mechanism — not a parallel implementation (§13). (Settled — D-341.)
Audit redaction lives in the audit subsystem (a single redactor over the event stream) — the canonical record is the event payload, not the Go struct. Per-descriptor Redact hooks are not the model. (Resolves brief 03 Q-5.)
Code-level tool dispatch (Settled — see brief 07). Tool calling happens at the runtime/orchestration level, not at the LLM provider level. The LLM client emits text (and optional structured JSON); the runtime parses tool intents, validates them, dispatches them in parallel, and merges results back into the next LLM prompt. Provider differences disappear: parallel tool calling works uniformly across providers because Harbor — not the provider — owns the protocol. The runtime's dispatch trio:
ActionParser(internal/runtime/planner/parser/) — extracts a typedPlannerActionfrom raw LLM text. Owns multi-action discovery and the salvage path. Knows Harbor'snext_node/argsschema; deliberately knows nothing about OpenAItool_calls, Anthropictool_use, etc.Dispatcher(internal/runtime/dispatch/) — single + parallel folded into one design unit. Validatesargsagainst the tool's input schema, runs with deadline + cancellation hooks, stamps synthetic call IDs (runtime-stamped, never model-emitted:call_{action_seq}_{step_index}for single,call_{action_seq}_parallel_{branch_index}for parallel), returns outcomes. One JSON action carries the entire parallel plan including its join spec — this is what makes parallel calling provider-independent.ObservationRenderer(internal/runtime/planner/observation/) — turns a(Trajectory, latest step)into the next chat thread, interleaving assistant + user messages from(action, observation|error|failure)pairs and applying LLM-facing redaction (heavy outputs replaced with artifact refs).
Plus two siblings:
RepairLoopdrivesparser → validator → planner-prompt-on-failurecycles up toRepairAttempts. Loud on exhaust; the regex finish-fallback is the documented last resort.SchemaSanitizer(internal/llm/correction/) lives between the runtime and the LLM client, NOT inside the client. Per-providerresponse_formatadjustments live here; the single LLM client is dumb.
Synthetic call ID scope keys are the full (session_id, run_id, action_seq, branch_index). The flatter scoping the source uses is a sharp edge Harbor closes.
Typed MCP reliability classification (settled). An MCP server may add typed control metadata to an IsError: true result without changing the standard result contract. invalid_argument, validation, and deterministic tool-domain failures are permanent for the unchanged invocation; transport and provider failures remain retryable under ToolPolicy. Missing, malformed, unknown, or legacy metadata follows an explicit compatibility fallback and is never inferred from text. Bounded content and structured content remain available to planner/model and App consumers. The same classification is projected as optional, non-secret metadata on tool.failed and tool.policy_exhausted: class, reason, attempt, and configured budget. An absent class is represented as unclassified; raw arguments and results never enter the event. The classified outcome survives the runloop's step recording and appears in the actual next ReAct prompt: the typed class, the retry-policy outcome, the bounded provider message, and the retained bounded result content are preserved, and a generic step error never masks the richer classified observation. Legacy unstructured errors may render a generic safe fallback. Canonical task/tool events and the planner observation agree on the terminal error. A typed deterministic failure in the revision_conflict shape is permanent for the unchanged invocation and carries the current revision in its bounded message so the next prompt can re-read and retry.
6.5 LLM client layer
Prepared request diagnostics (D-476). The mandatory leaf capacity check emits llm.context.prepared through the existing event pipeline: canonical input estimates by structural category, physical input/output bounds, fixed counts, numeric attempt coordinates and optional installed runtime replay coverage. No content, arbitrary identifiers or errors are copied. A capacity check is not provider success or billing; maintenance does not inherit parent history, and rejected checkpoint candidates are never reported as installed. SDK and Protocol expose the same owned projection. No storage, provider-native feature or new accounting path is introduced.
type LLMClient interface {
// One method. Streaming is signalled via opts.Stream + callbacks.
// The runtime owns prompt construction, tool semantics, parsing, and parallel dispatch.
Complete(ctx context.Context, req CompleteRequest) (CompleteResponse, error)
}
type CompleteRequest struct {
Model string
Messages []ChatMessage // role + content only (system/user/assistant)
ResponseFormat *ResponseFormat // nil | json_object | json_schema(schema)
Stream bool
OnContent func(delta string, done bool) // optional content delta callback
OnReasoning func(delta string, done bool) // optional thinking-channel delta callback
Temperature *float32
MaxTokens *int
Stops []string
ReasoningEffort string // "off" | "low" | "medium" | "high" | ""
Extra map[string]any // sanitized provider passthrough
// Native tool-calling (D-167). Nil `Tools` is the text-only
// completion the original one-method shape described, and stays
// the Deterministic planner's path.
Tools []ToolDeclaration
ToolChoice string // "" | "auto" | "required" | "none"
ParallelToolCalls bool
}
type CompleteResponse struct {
Content string
Cost Cost // tokens in/out + dollars; runtime aggregates
Usage Usage // tokens, latency, provider extras
}The client is one method. Complete is the whole surface — no per-provider entry points, no provider-specific tool-call shapes above the driver. Tool DISPATCH is the runtime's job (see §6.4 "Code-level tool dispatch"): the runtime decides what to invoke, joins parallel calls, and owns the trajectory. This is the elegance principle: provider differences disappear because the runtime — not the provider — owns the protocol.
Amended by D-167 — the client DOES carry a tool declaration. The original shape carried no Tools / ToolChoice / FunctionCall at all: the planner emitted {tool, args} JSON inside the content and a parser read it back. D-167's native tool-calling cutover moved the React planner concrete onto the provider's structured tool-call shape, so CompleteRequest now carries Tools / ToolChoice / ParallelToolCalls and ChatMessage carries a structured ToolCalls array whose elements hold Args. The reversal is targeted and its boundary is the point: the mapping lives at the driver layer (internal/llm/drivers/bifrost/translate.go), the React planner reads ToolCalls through a ToolCallProjector (§6.2), and the Deterministic planner stays text-only with a nil Tools. Declaration is the provider's; dispatch remains the runtime's. The prompt-engineered path survives as the opt-in declarative_action meta-tool for providers without reliable native tool-calling — one carve-out behind a flag, not a parallel architecture.
One consequence for the safety net below: Messages[].ToolCalls[].Args is machine-authored, offloadable content reaching the provider, so it sits on the offloadable side of the D-241 line and is subject to the same byte check as RoleTool text and binary parts (D-347).
Single architecture, no toggle. A use_native_llm=True/False mode would ship two parallel implementations of the same conceptual feature. Harbor picks one architecture and bakes the per-provider correction layer in as a SchemaSanitizer plus message-shape normalization stack — both runtime utilities called before the client request, not flags on the client. (Settled — AGENTS.md §13.)
Default driver: bifrost (github.com/maximhq/bifrost/core) — Settled — see brief 08. A pure-Go LLM gateway library. The original 2026-05-08 six-model OpenRouter study passed 23 of 24 gates, but its assumption that abandoning the chunk reader was sufficient cancellation is superseded by D-478: hard Stop must interrupt the provider connection. Harbor pins Bifrost core 1.9.0 for its context-aware transport, with Go 1.27.1; deterministic real-socket tests cover cancellation before headers and during streaming. This is not new live-model acceptance. Harbor's existing finite provider-route allowlist remains unchanged; new upstream providers, MCP execution, routing plugins and SDK capabilities are not enabled by the dependency update. The original CGo-required candidate remains rejected.
Bifrost's Tools / ToolChoice parameters were initially not used at all; since D-167 the driver maps Harbor's Tools / ToolChoice / ParallelToolCalls onto them (translate.go) for the React planner's native tool-calling path, and omits the block entirely when Tools is nil. Harbor's runtime still owns tool DISPATCH (see §6.4 "Code-level tool dispatch") — bifrost carries the declaration to the provider and returns the structured call; it never decides what runs. Bifrost is the LLM-call substrate; Harbor is the orchestration layer above it.
Inference-plane broker-pull credential source (Planned — D-333; a control-plane admin surface — the "second consumer arrived" line). Today the inference client sources its provider key from local boot config — an env var / config-file key that Harbor's Account impl hands to bifrost via Account.GetKeysForProvider(ctx, provider) (§6.15). The tool-call plane already has a richer custody model: a per-identity bearer pulled from a coordinator broker at call time, never persisted per-runtime (the tokenexchange credential_source: remote driver, D-271). The inference plane is the one credential plane with no pull path. D-333 adds the inference-plane analogue: an Account credential source that, at connect + refresh — not per hot-path call; the pulled key is cached and refreshed like the tool plane, never a per-call KEK decrypt on the inference critical path — PULLS the provider key from the coordinator's broker instead of reading local config. Same custody posture as D-271 — and its fail-loud half, carried verbatim, not just its custody half (D-271 item 2): a broker unreachable at connect OR a failed refresh raises a typed sentinel; the runtime never silently falls back to a local/boot key and never serves a stale cached key past its refresh contract (a broker revocation that fails to propagate must surface, not be masked). A source is brokered XOR local, config-declared — no dual path (§13). The pull's endpoint / 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 (bound by the D-334 write). 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. Granularity note (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). This extends D-285's remote credential source (which restricted itself to per-identity, lazy pulls because a connect-time pull "has no run identity to attribute"): the runtime-scoped variant defines its OWN attribution — every connect/refresh pull emits a runtime-scoped SafePayload llm.provider_credential_fetched audit event keyed by the runtime identity — rather than leaving the pull unaudited.
Inference-plane provider install / rotate write (Planned — D-334; parallels D-303). A Protocol admin-write — agent_config.set_llm_provider, a separate inference-plane method (NOT a relaxation of agent_config.set_oauth_provider's hard tokenexchange-only allowlist, which keeps its reflective zero-URL test intact) shipping its OWN reflective zero-URL/zero-secret decode test — binds a runtime's inference Account to a named broker-pull provider. The written descriptor is the D-303 shape exactly: zero-URL, zero-secret — it references the boot-declared inference-broker config (D-333) by non-secret name, and that config, never the wire descriptor, pins the endpoint / audience / scope, so no admin-writable field determines where the credential is sourced (D-300 preserved); authority is derived server-side from the verified session (D-219), never the request body, and gated on the auth.ScopeAdmin claim ONLY (D-066 — a control write is a strictly more elevated tier than any read). This closes the honest gap that keeps the LLM plane outside the central mint / rotate / revoke custody every other credential plane already enjoys: a central custodian can rotate a provider key into the runtimes that run on it over the Protocol, instead of touching each runtime's boot environment out-of-band. 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 (runs.set_overrides stays the model-name selector against the runtime's own key); discovered / confirmed values stay operator-gated, never auto-applied config. Composes with D-333 (the pull source this binds) and D-335 (§6.15 — broker-pulled failover, Harbor-orchestrated).
Context-bound external execution grants (Planned — HA-70 / D-434). A runtime may opt into a coordinator-signed authorization envelope for provider attempts without adding a second LLM interface. The versioned grant is content-free and binds the audience, runtime, verified (tenant,user,session) identity, logical run, provider connection and immutable connection generation, route, provider model, policy generation, opaque credential-binding handle, immutable credential-asset generation, reasoning ceiling, output ceiling, and a short-lived bounded compute lease. The runtime verifies the signature, key id, audience, expiry, policy generation, exact request model/controls, and request-edge identity before the LLM driver is reached. A grant never carries a key or secret, and the caller cannot select a provider key, authority scope, or credential asset.
Credential resolution is a separate verified-context-only seam. After verification, the Bifrost account resolves the opaque handle against the immutable asset generation and exact runtime, organization, provider, connection, identity, and logical-run context; it returns the secret only at the provider-driver boundary. Rotation or revocation advances the asset generation and fences old grants and cached resolutions. The account's existing local key path remains available when external grants are disabled; strict external-grant mode does not require a boot secret and fails closed for missing, stale, revoked, or mismatched grants.
The grant wrapper is composed inside the retry, structured-output downgrade, and Harbor-orchestrated failover paths, so every provider attempt is verified and metered. A bounded lease is checked before each attempt; a coordinator top-up may replace an insufficient grant with a newly signed lease, but the runtime never extends authority locally. Harbor's existing governance remains the local emergency ceiling and still runs for legacy and granted calls.
Each attempted provider call emits an immutable, content-free usage receipt containing stable grant/attempt/idempotency identifiers, policy and asset generations, provider/route/model dimensions, token/cost/latency usage, outcome, and canonical body hash. Receipts are durably queued through StateStore before strict-mode success is reported. The outbox uses conditional writes, response-loss-safe receipt identity, bounded exponential backoff, acknowledgement, and a circuit breaker; a coordinator delivery endpoint must deduplicate by receipt id and body hash. Receipts contain no prompt, response, tool argument, reasoning trace, secret, or credential bytes. Disabled mode preserves legacy behavior, optional mode supports mixed fleets, and required mode fails closed when grant verification or durable receipt enqueue cannot complete. This is a runtime execution-edge contract; coordinator policy and credential custody remain outside Harbor.
Stock coordinator transport and readiness (D-438). harbor serve MAY opt into one boot-pinned authenticated HTTP transport for bounded canonical receipt batches. The coordinator acknowledges each accepted fact by exact receipt id plus canonical body hash; omitted acknowledgements, response loss, and transport failure retain the durable fact for jittered bounded replay, while unknown, duplicate, or mismatched acknowledgements fail closed. The transport refuses redirects, bounds request/response size and duration, and exposes no endpoint, credential, content, or identity in logs or errors. With the block absent, Harbor starts no client, timer, outbox scan, store read, or network work. runtime_default grants continue to use the runtime's configured provider/model and need no coordinator credential or catalog. The additive runtime.info.external_grant projection distinguishes support, configured mode, accepted and ready route shapes, verifier/reservation/ credential wiring, strict receipt-parser readiness, concrete receipt transport kind and observed state, an explicit unsupported stock top-up state, and a fail-closed fully-wired strict-ready result. Lease-successor application remains outside this transport until the durable reservation epoch can advance idempotently.
Stock coordinator-bound credential resolution (D-442). harbor serve MAY also opt into one boot-pinned authenticated credential-resolution endpoint, or an embedding host MAY inject the same public resolver through sdk/server. The transport-neutral sdk/llm/credentials request contains only the complete signed external grant already accepted by Harbor's verifier; it exposes no independent authority selector. A response is accepted only when provider, opaque binding handle, provider-connection generation, and credential-asset generation equal the signed request, and its secret remains confined to the provider-driver edge. The stock client refuses redirects and unsafe endpoints, bounds bodies and duration, and never reflects secret material or coordinator response bodies in errors. Its in-memory material cache is keyed by the full canonical verified-grant digest, capped at 256 entries and 30 seconds, clamped to response/grant expiry, singleflight-coalesced only for an exact digest, generation-fenced, and cleared on close. Absent configuration performs no network, StateStore, polling, timer, or goroutine work. runtime_default continues to use the configured runtime provider/model without coordinator credentials or catalogs. Required coordinator_bound mode fails boot without an injected or stock resolver; optional mode truthfully reports that route unready until one is wired. Protocol version remains 0.1.0.
Optional grant-free external provider routes (D-444). A serving runtime may wire one Bifrost-specific route resolver independently of external execution grants. The default remains the boot-declared provider and model: without an explicit route, Harbor does not call a resolver, read route configuration, start work, or change credential selection. An explicit route is accepted only after normal JWT identity and effective-Agent reach checks. It contains only an opaque route, connection, immutable generations, and model selector. The runtime constructs the resolver request from verified tenant, user, session, logical run, effective Agent, runtime, task, and logical-call context.
The two-stage exact-bound response chooses a provider/model, non-secret key display name, immutable generations, expiry, and an optional typed endpoint, then returns one expiring credential only for the actual attempt. Harbor boots a finite chat-capable route set (unchanged from the Bifrost v1.7.4 integration) and excludes non-chat and advanced cloud-credential shapes. Azure, vLLM, Ollama, SGLang, and OpenAI-compatible endpoints use explicit typed mappings; generic endpoint or credential bundles are not representable. OpenAI-compatible egress uses a bounded immutable client pool keyed by exact tenant/runtime/route/connection/ credential generations and normalized endpoint digest. A mismatch, expiry, revocation, unsupported provider, or missing resolver fails the explicit route without falling back to the runtime key. Bifrost-internal retry is disabled for routed clients so every retry re-enters Harbor and resolves again. Credential and endpoint values are excluded from ordinary JSON, events, logs, task state, and Protocol results. The stock HTTPS (or loopback HTTP) transport is boot-pinned, authenticated, redirect-refusing, bounded, and deliberately uncached so generation changes are observed on the next attempt. An embedding host may inject the same public resolver seam.
Route-aware provider validation and discovery reuse the protected llm.posture operation, require admin scope plus signed reach to the effective Agent, and return only observed opaque generations and readiness. They never return the credential or endpoint value. This additive route contract does not authorize a run, meter usage, or duplicate JWT/reach policy, and has no dependency on ExternalGrant.
Route-bound model capabilities (D-460). A route resolver may add the optional model_profile descriptor to either exact-bound response. The descriptor contains only positive context and output ceilings plus the canonical reasoning default/levels; it carries no correction, retry, cost, sampling policy, credential, or endpoint data. The runtime advertises llm_provider_route_model_profile_v1 when the route surface is wired and sets the additive model_profile_supported bit on authenticated resolver requests. Resolvers omit the descriptor when that bit is absent, so older runtimes keep their static model-profile fallback. A descriptor is validated before policy wrappers run, is copied into one request only, and is exact-bound across the selection and resolve legs. Native request temperature, output-token, and reasoning controls remain authoritative; static policy fields for a configured model are preserved. An explicit output or reasoning control outside the descriptor is rejected before provider work, and an unknown model still fails closed when neither dynamic nor static profile exists. Protocol version remains 0.1.0.
Provider-neutral technical descriptors and runtime-origin model discovery (Planned — HA-71 / D-435). A provider control-plane consumer may need technical facts from the same Bifrost integration Harbor uses for execution, but those facts must not be re-derived in a second provider registry or mixed with presentation policy. Harbor's typed descriptor reports an opaque provider id/kind, credential modes and logical secret/url/text field kinds, custom-endpoint support, and whether bounded runtime-origin validation or model discovery is available. It carries no logo, friendly label, help copy, endpoint value, environment variable name, credential, provider response body, or identity value. Native provider endpoint handling remains manual where provider-specific semantics are not portable; declared OpenAI-compatible custom endpoints are the supported custom-endpoint fact.
The catalog is an immutable local snapshot with bounded, cancellable Validate and Discover operations. Discovery reuses the same Bifrost account construction as an LLM call, caps page size and page count, rejects malformed or duplicate model rows, and normalizes only reported provider-neutral facts: context/input/output limits, input/output modalities, tool support when a canonical parameter is reported, canonical off|low|medium|high reasoning levels when reported, deprecation, and pricing provenance. Missing facts remain unknown, absent pricing is unpriced, incomplete results are partial, cached results are explicitly stale, and configured model ids are manual when discovery is unavailable or empty. Provider errors map to stable sanitized outcomes rather than raw messages or response bodies. The offline harbor llm providers CLI is explicitly non-runtime-origin; the booted runtime also projects descriptor, validation, and discovery through the existing protected llm.posture request envelope (provider_operation=validate|discover) for admin-tier callers, with shared runtime credential state. No new Protocol method or version is added.
Structured output strategies (Settled): OutputMode = Native | Tools | Prompted. Per-provider ModelProfile selects the mode. Downgrade chain: json_schema → json_object → text on invalid_json_schema errors. Mode is observable via llm.mode_downgraded events. The Tools mode here is an LLM-level structured-output technique (asking the model to emit a single tool call shape as a workaround for providers without json_schema); it does NOT change the design — the runtime still parses and dispatches, the LLM client still emits text/JSON.
Retry with feedback (Settled): validation/parse failures feed back into the planner via the RepairLoop; observable; bounded by RepairAttempts per planner step.
Run-level structured output (shipped — D-272). The strategies above operate per LLM call; the run-level surface — a caller-supplied JSON Schema for a run's FINAL answer — is the "schema mode" run option §6.2 already enumerates, shipped as the opt-in assemble.WithOutputSchema RunOption on the embed runner. Zero default-path change; final-payload validation is runtime mechanism at the run edge for every planner (no capability ceremony), with the React driver additionally constraining the terminal completion via the existing OutputMode selection and the Validator-keyed retry-with-feedback loop. Delivery is buffered-and-validated on the additive answer_payload answer-envelope key — streaming of tool-dispatch/step events is preserved, terminal token deltas are suppressed for a schema-constrained run (the standard pairing with a validate-and-retry loop; partial-object streaming is the named follow-up). A generic typed binding (assemble.RunTyped[T], shipped — D-273) rides the same option: it derives the schema from the Go type T itself (via the shared Go-type→JSON-Schema deriver, promoted to internal/tools/schema so both the in-process tool driver and RunTyped consume ONE implementation), runs schema-constrained, and unmarshals the validated payload into T — the facade's second documented generic-func carve-out (amending D-205 item 1; sdk/ cannot express a generic function as a var forward). See D-272/D-273. The same mechanism has a per-task producer over the Protocol (shipped — D-276): an additive output_schema field on the start request rides request → task record → the per-task RunLoop drivers, which compile it once at run start and validate the terminal answer through the ONE shared run-edge envelope builder RunOnce also consumes — the validated answer_payload lands on the task envelope (surfaced via tasks.get's result_inline and a parent run's AwaitTask observation), and a schema-invalid answer after the correction budget fails the task loud with the output_invalid terminal code, never a schemaless success. See D-276.
Multimodal inputs (V1, Settled — see D-021). CompleteRequest.Messages carries multimodal content through ChatMessage.Content. The common case is text-only (Content.Text != nil); multimodal cases use Content.Parts:
type ChatMessage struct {
Role Role
Content Content
Name *string // optional, for tool / participant naming
}
type Content struct {
// Exactly one of Text or Parts is set. Text is the common case.
Text *string
Parts []ContentPart
}
type PartType string
const (
PartText PartType = "text"
PartImage PartType = "image"
PartAudio PartType = "audio"
PartFile PartType = "file"
)
type ContentPart struct {
Type PartType
Text string // when Type == PartText
Image *ImagePart // when Type == PartImage
Audio *AudioPart // when Type == PartAudio
File *FilePart // when Type == PartFile
}
type ImagePart struct {
// Exactly one of URL / DataURL / Artifact is set.
URL string // remote URL the provider can fetch
DataURL string // data:image/...;base64,...
Artifact *artifacts.Ref // canonical Harbor reference (D-022)
MIME string // image/jpeg, image/png, image/webp, ...
Detail string // "low" | "high" | "auto" (provider hint)
}
type AudioPart struct {
URL string
DataURL string
Artifact *artifacts.Ref
MIME string // audio/mpeg, audio/wav, audio/ogg, ...
}
type FilePart struct {
URL string
DataURL string
Artifact *artifacts.Ref
MIME string // application/pdf, text/csv, ...
Filename string // hint shown to the model when the provider supports it
}The bifrost driver translates Harbor's ContentPart to bifrost's per-provider content shape; bifrost handles the OpenAI / Anthropic / Gemini variations. The LLMClient interface stays one method — multimodal is just richer message content, not a new method, not a new request type.
Canonical binary representation: ArtifactRef (D-022, narrowed by D-487). Of the three supply forms (URL, DataURL, Artifact), ArtifactRef is the canonical stored form for non-trivial binary content. The LLM safety pass materializes DataURLs above the heavy-output threshold before dispatch, except a planner-marked, current-user PNG/JPEG/WebP/GIF input of at most 3 MiB. That bounded input reaches an image-capable provider as an image block; it is never copied into events, audit logs, memory, or persistence. A declared nonvision model receives a text notice that the image was withheld. URLs pass through when the provider can fetch them.
Multimodal outputs — post-V1 via tools (D-021). Image generation, speech synthesis, transcription, and video editing/generation are delivered as Harbor tools that return ArtifactRefs. The planner emits a tool.<name> action; the runtime invokes the tool via the existing dispatcher (RFC §6.4); the tool wrapper internally calls bifrost's media APIs (which already cover all 23 providers' media surfaces — see brief 08 §"What bifrost provides"). The LLMClient itself never gains an output method beyond Complete. Phase 97 ships the media-input tool wrappers; phase 98 ships media-output wrappers. The protocol and types settled here in V1 mean the post-V1 work is "implement tool wrappers," not "redesign."
Context-window safety net (Settled — D-026, narrowed by D-487). Offloadable heavy content does not reach the LLM. The bounded direct image input above is the sole exception: an image-capable provider needs its bytes to inspect it. The safety net is multi-stage; each producer respects the boundary, and a single enforcement pass at the LLM-client edge catches anything that slipped through.
Stage 1 — at the producer:
- Tool results above the heavy-output threshold (§6.10) are routed to the
ArtifactStoreby the Dispatcher; the planner sees anArtifactRef, not bytes. - Memory turns containing heavy content carry
ArtifactRefs, not the original payload (§6.6). - Multimodal inputs above the threshold are auto-materialized to
ArtifactRefatCompleteRequestconstruction, except the bounded, planner-marked current-user image input above. ObservationRenderer(§6.4) replaces heavy observation outputs withArtifactStubs when interleaving them into the next chat thread.
Stage 2 — at the LLM-client edge (the catch-all): After the planner constructs CompleteRequest and before the driver (bifrost) ships it, a single pass of the runtime walks the messages and:
- Asserts no offloadable raw heavy content survived — any string / byte slice /
DataURLwhose size ≥ threshold that isn't already anArtifactRef-shaped stub or the bounded direct image input is a bug; fail loudly withErrContextLeak(and emitllm.context_leakaudit event so operators can find the offending producer). The byte heavy-content check governs tool / MCP result text (RoleToolmessages), ordinary binaryDataURLparts (image / audio / file) of any role, and structured tool-call arguments (Messages[].ToolCalls[].Args, D-347). Conversation text (system/user/assistantmessage text, including an injected rolling summary) is exempt from this byte check; its size is governed by the token-window guard in step 2 instead (D-241). - Estimates total tokens of the assembled request against the model's configured context limit. If the estimate is within
ContextWindowReserveof the limit (default 5%), fail loudly withErrContextWindowExceeded. V1 does not auto-truncate; the planner gets a typed error and is expected to recover (drop older turns, summarize, etc.) — auto-cascade is post-V1 (an extension to memory'srolling_summaryplus aPromptAssemblerorchestrator; tracked but not on the V1 floor).
The standard ArtifactStub (Settled). When the runtime substitutes heavy content, the LLM sees a compact, model-agnostic stub:
// In-prompt rendering (text-mode JSON, model-friendly):
// {"artifact_ref":"ref-abc-def","mime":"image/png","size_bytes":65536,
// "hash":"sha256:...","summary":"User-uploaded screenshot at turn 3",
// "fetch":{"tool":"artifact_fetch","id":"ref-abc-def"}}
//
// Or in multimodal Parts: a text-only ContentPart whose body is the
// stub JSON above (the binary part is replaced wholesale).
type ArtifactStub struct {
Ref string `json:"artifact_ref"`
MIME string `json:"mime"`
SizeBytes int64 `json:"size_bytes"`
Hash string `json:"hash,omitempty"` // sha256 prefix
Summary string `json:"summary,omitempty"` // operator/runtime caption
Fetch *Fetch `json:"fetch,omitempty"` // hint: "use this tool to read the bytes"
}
type Fetch struct {
Tool string `json:"tool"` // e.g. "artifact.fetch_image"
ID string `json:"id"` // ArtifactRef ID
}The stub format is uniform across producers (tool result, memory turn, multimodal input). Operators can override Summary per-producer; the rest is runtime-stamped. The stub is the only thing the LLM ever sees in place of heavy content — operators do NOT swap formats per provider, because the rendered JSON works in every model's prompt.
What the stub is for — the read side (§6.10, D-347). The stub is a handle, not a dead end: an agent discovers artifacts by metadata, reads them by bounded byte window, and routes them to a consumer by reference — all without the bytes re-entering the context this safety net protects. Two boundaries hold in both directions. The enforcement pass above governs what reaches the LLMClient; the substitution invariant governs the return leg — when the runtime resolves a reference into a dispatched tool argument, that resolved value is dispatch-local and does not appear in the message history, the trajectory, an observation, an event payload, an audit payload, or a log. The model authors an id and continues to see an id. The read-side contracts (the isolation-triple read key, the driver-independent byte method, the truthful bound on every window, and the deliberate absence of MIME-keyed windowing) are settled in §6.10.
Multimodal interaction with adjacent subsystems (Settled — D-021):
- Audit redactor (§6.4): recognizes
DataURLand inline-base64 patterns; emits[redacted: image/<MIME> of <N> bytes]placeholders or rewrites toArtifactRef.ArtifactRefitself passes through unredacted (it's already a reference, not data). Phase 03 handles this from t=0. - Memory (§6.6): strategies handle multimodal turns.
truncationdrops them wholesale (the artifacts in the store are GC'd by the artifact subsystem's lifecycle, not memory).rolling_summaryfor V1 substitutes a[image: <ArtifactRef>, MIME=<type>, size=<N>]placeholder when summarizing; vision-aware summarization (calling a vision model to describe the image) is post-V1. - Tools (§6.4): any tool can declare
ArtifactRefin itsargsschema orresultshape. The runtime resolves refs at invocation; the tool reads bytes via theArtifactStore. No special "media tool" type — multimodal is a convention on top of the existing tool catalog. - Skills (§6.7): Skills.md attachments already settled as
ArtifactRefs (RFC §6.7); the same convention applies.
Attachment disposition is policy, not mechanism (D-189). How an uploaded attachment is handed to the model is a declared choice, never a hardcoded MIME map in the runtime. An AttachmentDisposition — ref (emit an ArtifactStub + Fetch.Tool hint; the planner/developer processes the bytes via a tool), inline (DataURL, sub-threshold), provider_native (hand the artifact to the provider's own understanding via a file_id upload), or tool:<name> (force a specific catalog tool) — is resolved with precedence per-attachment caller hint > per-agent policy map > runtime default. The layers are semantic; the carriers are adapters: the Protocol input-artifact disposition field carries the per-attachment hint for Protocol clients, and direct InputArtifactView construction carries it for library consumers embedding the runtime headless; harbor.yaml carries the per-agent map for the binary, and programmatic DispositionPolicy construction carries it for embedded runtimes. The policy core — the enum, the policy type, and the pure precedence resolver — lives in the planner package, never in the dev binary's run loop. The default is ref, so the developer-controllable tool path is first-class for the Playground, Protocol clients, third-party apps, and headless library consumers alike; provider-native upload is opt-in, never forced. The optional ProviderFileID (and FilePart.DocumentType) fields on the content parts carry the provider reference; they are additive and preserve the "exactly one of URL/DataURL/Artifact" invariant, and a file_id-only part is legal over-threshold (the §6.10 / D-026 edge guard exempts it). ArtifactStub remains the universal degradation for any provider/modality without native support.
Embedding client — a separate seam (D-189). Turning text into vectors is a distinct capability from chat, exposed as its own Embedder interface (the §4.4 driver/factory/registry seam), wired to the provider gateway — not a method on the one-method chat LLMClient. The Embedder is a standalone primitive, constructible via its factory both programmatically (ConfigSnapshot + Deps, mirroring the chat client's factory) and from config, and usable à la carte by an embedding consumer doing its own retrieval. Its first consumers are opt-in semantic retrieval modes in Memory (§6.6) and Skills (§6.7) — injected as explicit dependencies with fail-loud guards, gatekeeping nothing — that compose with, rather than replace, the default rolling_summary / token-savvy retrieval. Identity is mandatory at the Embed edge (fail closed, as at the chat edge); embedding-derived vectors are identity-scoped like every other per-session datum. D-191 pins the concrete contract: Embed(ctx, texts) ([][]float32, error) plus a lifecycle Close, in its own embeddings package with its own dependency-light Deps (an embeddings-only consumer never inherits the chat client's artifact-store/bus surface); the embedding model/provider is configured separately from chat (embeddings.{provider,model,api_key}); and the shared cosine helper is the one ranking primitive every consumer uses.
6.6 Memory subsystem
Memory is declared-policy, identity-scoped, and pluggable across persistence backends.
Cumulative session memory (D-477; RFC 002 amendment, release acceptance pending).memory becomes the single short-term session-memory owner. The standard rolling_summary strategy carries one cumulative checkpoint plus a bounded recent execution tail; recent_turns: 20 bounds detail, not the age of remembered constraints. Each compaction receives the preceding checkpoint and newly eligible evidence. memory.budget_tokens controls assembled-request compaction, with zero deriving the safe target from the effective model; output limits remain separate. Served agents can override this same target through the admin versioned memory.budget_tokens section (D-481). Omission inherits YAML; explicit zero selects automatic sizing. Advertise agent_config_memory_v1 only when the compactor and configuration service are wired. Runs freeze the value at start. An operator may select a separately authorized compaction route through memory.summarizer.provider_route (D-482). It uses the existing resolver and governed Bifrost client under the same admitted runtime/agent/task and verified user/session identity. The compactor uses that model's current profile; it never repurposes a signed grant or silently falls back to another credential. There is no fixed 512 KiB execution-evidence storage ceiling (D-480). Exact retained evidence may exceed the working-input size; token compaction does not promise bounded storage bytes. Lifetime, identity and generation fences remain. memory.strategy: none explicitly disables session memory. Replace the pair-only summary pipeline, separate sessions.retained_context_turns/WithRetainedContext activation and planner.token_budget; no compatibility layer or second memory engine. Update the memory interfaces and every served/embedded consumer together. Administrative inspection and notes share the execution owner. They do not record runtime tool receipts or run an independent summary loop. External long-term memory and the consumer transcript remain distinct.
package memory
type Strategy string
const (
StrategyNone Strategy = "none"
StrategyRollingSummary Strategy = "rolling_summary"
)
type MemoryStore interface {
Inspect(ctx context.Context, id identity.Quadruple) (Inspection, error)
Put(ctx context.Context, id identity.Quadruple, note ConversationTurn) (string, error)
Delete(ctx context.Context, id identity.Quadruple, key string) (int, error)
Close(ctx context.Context) error
}Settled:
- Two strategy names:
none(stateless) androlling_summary(standard cumulative checkpoint plus recent execution evidence under D-477).truncationis rejected. The former pair-summary background loop and drop-oldest recovery backlog are removed, not run alongside cumulative memory. - Identity is mandatory. Missing identity returns an explicit error and an audit event, never an apparently successful empty result.
- Three drivers ship at V1: in-memory, SQLite, Postgres. One conformance suite passes against all three.
llm_contextvstool_contextseparation is preserved: identifiers live intool_context(LLM-invisible); conversation state lives inllm_context. The Go analogue is "identity flows viacontext.Context, never through prompt-visible state."- The same trajectory compactor handles within-run and cross-turn summarization through the governed LLM client. Inspection and note mutations do not make model calls.
- Large administrative memory values use source-bound references through bounded
artifacts.get. Reads revalidate the exact source and scope; deletion, expiry and source replacement invalidate old references. No second artifact copy or independently presigned URL can prolong retention. - Native semantic session-memory retrieval is removed (D-477 supersedes the memory portion of D-191). Cumulative checkpoints and recent execution evidence are the short-term memory representation. There is no second vector index,
SearchTurns, retrieval-mode setting or semantic prompt-injection path. External long-term memory remains external; semantic skill retrieval and the embedding client are unchanged. Removed configuration fails explicitly rather than silently selecting another mode.
Long-term or cross-session memory remains an external integration responsibility; cumulative checkpoints do not promote session content into another retention tier.
6.7 Skills subsystem
Operator-managed agent packs and composition preview (settled). A skill pack may be selected by (tenant_id, agent_id, skill_name) in the agent configuration, but agent_id is never an isolation principal. Reads begin with the verified caller triple and signed reach to the effective agent. The pack body is durable, content-addressed, revisioned, and composed with the caller's permitted personal/session skills into one immutable next-run snapshot. Required-tool metadata filters and redacts; it never grants a tool. The same resolver serves directory injection, skill_search, skill_get, and skill_list.
That resolver also provides a read-only composition preview addressed by (tenant, user, agent). Operators with verified reach may preview a user's composition within the tenant; ordinary callers may preview only their own. The preview returns names and bounded verdicts, bodies only for the addressed caller, and typed not_found, denied, or unavailable states. It never writes a run, revision, skill row, or audit record.
Boot-declared resource-free operator skill baseline (settled — D-427). A runtime serving its resolved boot/default agent may declare a resource-free operator skill baseline directly in boot configuration, so the boot agent's skill composition is complete before any durable pack or personal-skill membership exists. The baseline loads through a config-file-relative, strict, eager, immutable loader that runs before readiness: the include root is the config file's own directory (never the process CWD), every entry must import through the ONE existing importer/validator or the boot fails loud, and the loaded set is eagerly copied and frozen for the process lifetime (restart-only, never hot-reloaded). Each declared include is one relative directory holding one case-sensitive top-level regular UTF-8 SKILL.md; the baseline is resource-free (no support-file references). Traversal, recursive discovery, symlink/hardlink/special entries, duplicate paths, and canonical-name collisions are rejected, under declaration, item, per-file, and aggregate bounds. The loader itself has no persistence, no admin verbs, no config revisions, and no lifecycle materialization: it writes no skill store row, exposes no Protocol mutation surface, advances no agent-config revision, and creates no lifecycle record — the baseline is boot-declared read-only state, node-local and reconstructed at every boot, while the durable Postgres ${SKILLS_DSN} boot_agent_packs schema persists agent revisions and personal state; no convergence between the two is claimed. Required-tool metadata is validated only after the static catalog and policy wrapping apply and against the granted-scope ceiling, with no invented identity. It binds exactly to the resolved boot/default agent for the boot tenant: the resolved (tenant, boot_agent_id) pair, never a placeholder or wildcard, and never an invented boot identity — if the deployment's default agent cannot be resolved, the runtime fails loud at boot rather than synthesizing one. The loader merges the baseline with the agent's active durable operator-pack revision into ONE combined operator tier FIRST under strict merge rules: the same canonical name with the same semantic hash dedupes as source=both; the same name with a differing hash fails loud; and the combined tier holds exactly 256 unique items. Every declared tenant-agent active revision is pre-read before readiness, and the run-start conflict defense is retained. The combined operator tier is then applied LAST over the caller's base, user, and session skills — so operator-authored content deterministically wins over caller content; a name collision with a caller-owned skill is resolved by the operator-tier-last rule, and a boot/active-revision collision is a typed conflict (never last-write-wins). Boot-declared baseline entries are boot-owned: upsert and every proposal commit (replay/prepared/ publish) and rollback/activation reject a boot-owned canonical name — even at equal hash — with a typed conflict; removal may delete an actual legacy durable revision shadow while leaving the boot baseline, and a boot-only remove is a typed read-only refusal, never false success; agent_packs.list remains durable-revision authoring only. Config removal removes the boot baseline only on the next deployment; a legacy durable revision remains, and in-flight snapshots retain their captured bytes and hash. The baseline contributes a deterministic set hash over its normalized entries to the run snapshot and to the composition preview, so an operator can verify exactly what the boot agent composes. The shipped run path is: the eager immutable index opened before readiness is handed to the run-loop driver, an exact (tenant, effective-boot-agent) run-start membership lookup binds the frozen boot entries into the run snapshot, and the concrete resolver's strict combined operator tier is frozen into the snapshot with the boot set hash, the combined hash, and per-item boot|revision|both provenance. Production and devstack use the single loader path — the devstack's synthetic boot agent resolves the same loader, never a second implementation. Headless RunOnce is explicitly unsupported and fails loud when boot_agent_packs is configured. D-414's preview is absent/incomplete on this base — it resolves durable pack membership and personal skills, but nothing declares or previews the config-file baseline — so this phase delivers ONE shared strict effective-composition resolver + preview used by boot preflight, run, and preview alike, reusing the D-411/D-414 composition path rather than inventing a parallel one, plus the exact read-only Protocol path (clients, manifest, generated docs), minimal Console and CLI consumers (D-415), config docs and example, operator skill, and smoke. The preview shows boot|revision|both and the boot_pack_set_hash under authority/reach gating, with no lifecycle materialization. EnsureBootAgentLifecycle is separate and unchanged: it still materializes the first empty agent-level revision when the lifecycle slot is absent, and it MAY write a revision; the baseline loader and composer themselves perform zero persistence, zero admin pack verbs, zero lifecycle writes, and zero config revisions — this phase never claims startup performs no revision writes whatsoever.
Verified-caller personal-skill package import and draft authoring (settled). A Protocol caller may install a complete reviewed SKILL.md package as a durable personal user skill through a two-phase, identity-mandatory import family — agent_config.user.skills.import_validate and agent_config.user.skills.import_commit (D-422). A caller-owned artifacts.put output is bounded staging input; validation invokes the ONE existing importer/validator, performs ZERO writes of any kind — no SkillStore body/package write, no agent-config membership write, and no StateStore proposal-ledger write — and returns a bounded opaque versioned sealed proposal token plus a bounded normalized review and hashes; commit re-authenticates and strictly decodes the token, re-derives identity and signed effective-agent reach, rechecks policy and ceilings, forces ScopeUser/owner and the effective AgentID server-side, and atomically materializes the approved package plus membership (durable idempotency state begins only in the commit phase, via a token-derived commit ledger and one conditional package write). The installed form is a durable personal skill package whose supporting files are copied into the package representation and addressed by immutable skillpkg://<PackageHash>/<encoded-canonical-path> references behind one mandatory authorized resolver — later sessions never dereference the staging artifact, which remains provenance only. Draft authoring is an ordinary, disabled-by-default runtime tool (skill_create_draft, D-423) that reuses the governed authoring path's safety-wrapped LLM adapter and shares the canonical semantic skill DTO, validator, deterministic serializer, and versioned PackageHash with import; it has zero mutation authority and installs nothing — installation is exclusively the import commit. Existing skill_propose(persist=true) and agent_config.user.skills.upsert, including caller-selected ScopeUser, remain unchanged and are not deprecated. Required-tool metadata is applicability information on every path; it never grants or exposes a tool.
Skills are a Runtime subsystem distinct from any external skill-distribution role. They are token-savvy, DB-backed, identity-scoped, and bring two Harbor-defining features:
- Skills.md importer — first-class. Drop a Skills.md file/pack, get an indexed Harbor skill out the other side. The predecessor's per-skill-manual-adaptation gap is closed.
- In-runtime generator with persistence — an agent can author a new skill that becomes a first-class Harbor skill discoverable by subsequent runs. The predecessor ships a draft generator with
"Do not claim to save or persist anything"hardcoded into its prompt because the runtime cannot back the claim; Harbor inverts: runtime ships persistence, prompt is updated, audit is mandatory.
type Skill struct {
ID, Name, Title, Description string
Trigger string // non-empty; planner-visible match cue
TaskType string // browser | api | code | domain | unknown
Tags, Steps, Preconditions, FailureModes []string
RequiredTools, RequiredNS, RequiredTags []string
Origin Origin // PackImport | Generated
OriginRef string
Scope Scope // Project | Tenant | Global
ScopeTenantID, ScopeProjectID string
ContentHash string
CreatedAt, UpdatedAt, LastUsed time.Time
UseCount int
Extra map[string]any
}
type SkillProvider interface {
GetRelevant(ctx context.Context, q SkillQuery, cap CapabilityContext) (Retrieval, error)
Search(ctx context.Context, q SkillSearchQuery, cap CapabilityContext) (SearchResponse, error)
GetByName(ctx context.Context, names []string, cap CapabilityContext) ([]SkillDetail, error)
List(ctx context.Context, req ListRequest, cap CapabilityContext) (ListResponse, error)
Directory(ctx context.Context, cfg DirectoryConfig, cap CapabilityContext) ([]DirectoryEntry, error)
FormatForInjection(skills []SkillDetail, maxTokens int) (text string, raw, final int, summarized bool, err error)
}Planner-facing tools (Settled): skill_search, skill_get, skill_list, skill_propose(persist=true) — registered through the regular tool catalog like any other tool.
Search ranking ladder: FTS5 → regex → exact, scoring constants matching the predecessor's calibrated values. SQLite-FTS5 is conditionally available (modernc.org/sqlite build); the regex/exact fallback is tested with FTS5=off builds in CI. (Settled.)
Semantic skill retrieval — opt-in mode (D-191, Settled). retrieval: semantic replaces the lexical ladder's ranking with embedding similarity over the identity-scoped catalog (result path semantic, scores on the same 0–1 scale), consuming the §6.5 Embedder injected as Deps.Embedder with the fail-loud no-embedder guard. Everything downstream of ranking — capability filtering, redaction, the tiered budgeter — applies unchanged, so skill_search stays token-savvy. An embedding failure fails the search loudly; the store never silently degrades to the lexical ladder.
Capability filtering + redaction: at injection time. Disallowed tool names are scrubbed from skill text; PII patterns redacted when redact_pii=true. Tiered budgeter: full → drop optional → cap steps to 3. (Settled.)
Virtual-directory pattern (Settled): Directory(cfg) returns identity-scoped, capability-filtered, pinned-then-{recent|top} entries. Up to max_entries (default 30, range 1–200).
Skills.md importer pipeline (Settled):
- Parse YAML frontmatter + Markdown body via a deterministic CommonMark-only parser.
- Normalize body sections (
## Steps,## Preconditions,## Failure modes) into structured fields. - Resolve sibling resource files; record them as
Extra.attachments. - Validate via the same
Skillvalidator the operator loader uses. - Round-trip test: any spec-compliant Skills.md imports without source edits and re-exports byte-stable.
Generator with persistence (Settled): validates the draft, stamps Origin=Generated, stamps OriginRef = "gen:{session_id}:{run_id}", scopes by operator-provided Scope (default project), inserts via the LocalDB upsert. Conflict policy: refuse to overwrite a PackImport skill of the same name; for Generated → Generated, last-write-wins gated by ContentHash change. Audit: (actor=identity_triple, action="skill.created", skill_id, content_hash, source_excerpt_hash).
Skill versioning model — Tentative — see §11 Q-5. Content-hash-as-version + OriginRef for lineage at V1; explicit semver versions are a post-V1 follow-up if cross-tenant rolling forward demands it.
Skills.md attachments — Settled. Stored as ArtifactRefs via the artifact subsystem (option (b) in brief 04 Q-5). Clean separation, survives machine moves, integrates with mandatory-artifact policy.
Same-runtime organization skill publications (HA-68, settled by D-430). An organization-owned skill is published as an immutable, content-addressed revision inside the Harbor runtime that owns it. The publication metadata, revision list, agent reference, and mutation receipt are content-free: they carry the exact publication/revision ids, generation, content hash, lifecycle state, and runtime binding, never the body. Organization administrators use the verified admin Protocol methods to publish, list, inspect, publish a successor, and retire. A caller may list available revisions, install or update an exact reference, remove its reference, and list its references only after the verified identity and signed effective-agent reach checks pass.
The durable reference is pinned to (tenant, user, agent) and the exact (publication_id, revision_id, generation, content_hash, runtime_id). The runtime's Resolve path is the sole body-bearing operation and must recheck identity, signed reach, active state, runtime id, generation, and content hash before placing the body in the immutable next-run composition snapshot. Retired, stale, foreign, or mismatched references fail closed; there is no latest-revision fallback, shared principal, global catalog, or cross-runtime portability. Persistence uses the existing StateStore SaveIf and idempotency contract, with no new driver or Protocol-version bump. The runtime composition consumer is a required phase gate, not an implied property of the metadata methods.
Conflict policy — Settled. Refuse to import (Portico-distributed cannot overwrite Generated). existing_origin != "pack" short-circuit pattern. (Resolves brief 04 Q-2.)
Generator scope default — Settled. project scope by default when skill_propose(persist=true) is invoked mid-session. (Resolves brief 04 Q-4.)
6.8 Tasks (unified foreground/background)
type TaskKind string // "foreground" | "background"
type TaskStatus string // PENDING | RUNNING | PAUSED | COMPLETE | FAILED | CANCELLED
type Task struct {
ID TaskID
SessionID SessionID
TenantID, UserID string
Kind TaskKind
Status TaskStatus
Priority int
ParentTaskID *TaskID
GroupID *TaskGroupID
Description string
Query string
Context *TaskContextSnapshot
Result *TaskResult
Error *TaskError
CreatedAt time.Time
UpdatedAt time.Time
PropagateOnCancel string // "cascade" | "isolate"
NotifyOnComplete bool
MergeStrategy MergeStrategy
}
type TaskRegistry interface {
Spawn (ctx context.Context, req SpawnRequest) (TaskHandle, error)
SpawnTool (ctx context.Context, req SpawnToolRequest) (TaskHandle, error)
Get (ctx context.Context, id TaskID) (*Task, error)
List (ctx context.Context, sessionID SessionID, f TaskFilter) ([]TaskSummary, error)
Cancel (ctx context.Context, id TaskID, reason string) (bool, error)
Prioritize (ctx context.Context, id TaskID, priority int) (bool, error)
// Group governance (lifted to a sibling interface in a later phase if needed):
ResolveOrCreateGroup(ctx context.Context, req GroupRequest) (*TaskGroup, error)
SealGroup (ctx context.Context, id TaskGroupID) error
CancelGroup (ctx context.Context, id TaskGroupID, reason string, propagate bool) error
ApplyGroup (ctx context.Context, id TaskGroupID, action GroupAction) error
ListGroups (ctx context.Context, sessionID SessionID, status *TaskGroupStatus) ([]TaskGroup, error)
ApplyPatch (ctx context.Context, sessionID SessionID, patchID string, action PatchAction) (bool, error)
AcknowledgeBackground(ctx context.Context, sessionID SessionID, ids []TaskID) (int, error)
}Settled:
- Foreground and background unify under one
TaskIDnamespace. A foreground run is a task of kindforeground. The predecessor splitstrace_id(foreground) from a separatetask_idnamespace (background) and even fakes a synthetictrace_idlikesession:<id>to fit session updates into a trace-keyed audit log; Harbor'sTaskIDwithKindcollapses that. - Lifecycle:
PENDING → RUNNING → COMPLETE, withPAUSED → RUNNING(planner-initiated, durable via planner checkpoint),FAILED | CANCELLEDterminal. - Cancellation propagation honors
PropagateOnCancel(cascade|isolate). - Idempotency:
Spawnhonors anIdempotencyKeyper(SessionID, IdempotencyKey)so a retried spawn returns the original handle. - Background tasks at V1: in-process only. The seam (
TaskRegistryinterface) is ready for a durable backend (Postgres-as-queue, NATS JetStream) post-V1.
Retain-turn timeouts and continuation hops — Settled. Per-session config (matching the predecessor's stance), with per-spawn override via SpawnRequest. (Resolves brief 05 Q-5.)
6.9 Sessions and SessionManager
Cumulative execution context (D-477; RFC 002). Root runs use the single memory policy in serving and embedding through the existing StateStore, ArtifactStore and dispatch journal. Remove the separate retained-context switch and pair-only projection, not consumer turn rows or external long-term memory. Child tasks use explicit task context and never publish their private transcripts into the root memory. Restored history is inert and must satisfy current erasure and source-expiry checks. Required terminal persistence precedes served task completion. Session deletion and lifetime remain controlled by sessions. See RFC 002 and the phase 269 plan.
Cumulative checkpoints (D-477 supersedes D-469). One versioned checkpoint has committed generation and coverage valid after covered raw detail is removed. Generate outside locks/persistence deadlines, then conditionally publish against the unchanged source prefix, generation and erasure state; preserve newer tails and unsettled admissions. Only successful publication authorizes raw-detail cleanup. Do not skip late-settling siblings or store an unbounded list of source IDs/checkpoints. Failure preserves committed state or stops with explicit capacity failure. Deletion/expiry, unlike compaction, remove information: rebuild from remaining authorized evidence or invalidate an affected opaque checkpoint. Compaction/restart never extend retention. Old private formats may be rejected explicitly; no compatibility layer is required. No new backend/provider state.
Explicit settled-journal reconciliation (D-470; RFC 002). An embedded caller with configured retained context may seal a fully settled journal as interrupted evidence through Stack.ReconcileRetainedContext. The existing conditional StateStore transaction publishes evidence and fences its source admission before future dispatch. A pending intent remains unknown and is refused; recovery does not relaunch a run, call a model/tool/hook, or extend expiry. The served consumer is defined by D-471 below.
Served reconciliation (D-471). sessions.reconcile_context accepts the source run ID under the caller's verified own-session identity. Admin claims do not widen scope. It calls the same settled-journal primitive, rejects pending external effects, and returns only a content-free reconciliation acknowledgement. The typed Go Protocol client exposes the operation. Retention must already be enabled; no new configuration, cold-run resume or automatic action retry is added.
Applied steering continuity (D-473). In retained mode, accepted user-message, redirect and injected-context content is committed as explicitly non-executable journal evidence before subsequent inference. Required persistence failures stop dependent work. Recovery and later turns preserve the observations but never replay control actions or confer old authority. Current control application and model-step/tranche accounting remain unchanged.
Retained result retrieval (D-472). The retained guard resolves structured model-facing dispatcher offload envelopes independently of narrative coverage. Its bounded metadata projection survives compaction; the existing artifact_fetch reads exact permitted ranges without repeating the original tool. Own-session reference validation runs before inference and before dependent dispatch. Missing or erased sources are unavailable, not retained as apparently valid evidence. No persistent registry, new tool, or artifact-lifetime policy is introduced.
Retained attachments (D-474): supplied attachment IDs are associated with their user turn in the same context-capable journal, atomically with the admitted query. Retained callers reject omitted input references. Current scoped metadata remains available after compaction and is revalidated before inference and dependent dispatch. Bytes stay in the ArtifactStore; a reference does not claim image inspection or extend source lifetime. Existing artifact tools perform authorized retrieval; default non-retained input disposition is unchanged.
A session is a longer-lived, multi-turn conversation that contains many runs. Identity for runtime concerns is the triple (tenant, user, session); runs are scoped within sessions.
type Session struct {
ID SessionID
TenantID, UserID string
OpenedAt time.Time
LastSeen time.Time
Closed bool
Limits SessionLimits
Context SessionContext // version, hash, llm/tool ctx, memory, artifacts
}
type SessionRegistry interface {
Open (ctx context.Context, id SessionID, ident identity.Identity) (*Session, error)
Get (ctx context.Context, id SessionID) (*Session, error)
Touch (ctx context.Context, id SessionID) error
Close (ctx context.Context, id SessionID, reason string) error
Inspect (ctx context.Context, id SessionID) (*SessionSnapshot, error)
GC (ctx context.Context, policy GCPolicy) (int, error)
}Settled session-lifetime invariants:
- A session is open until explicitly closed or GC'd.
- A closed session — explicitly closed OR GC-reaped — MAY be reopened (amended — D-312; supersedes the original "Reopen-after-close is forbidden. Clients open a new session." — implemented in Phase 176). Reopen re-activates the existing record in place: it clears
Closed/ClosedAt/ClosedReason, stamps aLastReopenedAt, refreshesLastSeen, and emits asession.reopenedlifecycle event — while preservingOpenedAtand the immutable identity triple unchanged (OpenedAtis the erasure cascade's lifecycle discriminator and MUST NOT be mutated). The conversation resumes with its history intact, because closing/GC reaps the live session record, never the stored data: the durable event log is gap-free and untrimmed in V1 (no TTL, no cap), and the StateStore / MemoryStore hold the session's scoped records until an explicit erase. The GC hard cap is measured frommax(OpenedAt, LastReopenedAt)so a reopened conversation is not re-reaped on the next sweep (the idle-TTL already resets viaLastSeen). The one terminal exception: a session that went throughsession.erase(§7 right-to-erasure) MUST NOT be reopened — reopen fails loud (ErrReopenAfterErase), never a silent empty-start, including after the erasure has fully converged and removed the session record; you do not resurrect data a user asked to be deleted. Reopen is identity-mandatory and identity-immutable — the caller's verified(tenant, user)must equal the stored record's captured identity (ErrIdentityMismatchotherwise), and cross-tenant reuse of a session id remains rejected by the invariant below (a reopen attempt under a different tenant isErrSessionIDReuse, not a reopen). - The identity triple is captured on
Openand immutable for the session's lifetime; reusing a session ID across tenants/users is rejected. TouchupdatesLastSeen; GC sweeps idle sessions per policy and never reaps a session with aRUNNINGtask.
Session GC defaults — Settled. Idle TTL 24 h, hard cap 30 days, sweep every 15 min, refuse-to-GC any session with a RUNNING task. Configurable via GCPolicy. (Resolves brief 05 Q-2.)
Session titles — Settled (D-288). The session record carries an optional human-readable Title plus a TitleSource provenance mark (unset | auto | manual), persisted through the same StateStore round-trip as the rest of the record (additive; erased with the session). The Protocol gains sessions.set_title, which ALWAYS writes manual — auto is not expressible over the wire, so a human-set name can never be forged into an auto-overwritable one. The write is scoped to the owning (tenant, user) (the scope sessions.list reads at; metadata-only, no elevation knob, no admin widening); over-bound input fails loudly, never silently clamps. The title is user-derived content and therefore never rides an event, log, or audit payload — the session.title_changed event is content-free (identity scope, session id, source) and consumers refetch the projection. Consoles display title-or-id.
Session erasure covers both session-personal-skill homes (D-400). The erasure ledger has one idempotent destructive step for the legacy shared SkillStore's exact (tenant, user, session, ScopeSession) rows, through a mandatory DeleteSessionScope operation. It neither lists nor deletes ScopeUser rows. The same erasure clears all StateStore records under the erased triple, including session overlays and agent-owned session-personal skill records; pending-ledger and terminal-tombstone fences reject a racing new write before that clear. Ledger progress records the legacy sweep before the StateStore scope clear, so an interrupted erasure resumes without claiming a local compensation or a best-effort deletion. The retained erasure event remains content-free and reports only safe aggregate telemetry.
Session auto-naming — Settled (D-289). Opt-in and default OFF: with no naming configuration the runtime's behavior is unchanged — no counters, no LLM calls, no events. When enabled (a versioned naming agent-config section riding set_revision, over a yaml runtime.naming fleet default, resolved once at run start with next-turn projection), the runtime titles the session itself: an additive sibling of the run-completion hook (§6.17's reserved additive-hook-point clause) fires at the run loop's terminal boundary and makes ONE governed Complete call on the run's wrapped LLM client — identity-carried, budget-enforced, input bounded far below the context-safety threshold, naming reasoning OFF by default, and visible output bounded to 64 tokens — then writes through an internal auto-path that refuses manual titles. Policy knobs: after_turns, repeat_every, max_repetitions (required at the config/wire edges whenever repeating, default 5 for programmatically built policies — no unlimited value exists on any path), model (empty = the run's effective model), reasoning_mode (off by default; provider_default omits provider reasoning controls without inheriting the selected model profile's planner default), max_title_len (auto output is deterministically clamped; the manual verb, by contrast, rejects — the trusted-internal vs untrusted-boundary asymmetry is intentional). The versioned naming section remains whole-section authoritative over yaml, so an omitted reasoning_mode in a present section resolves to off. A naming failure never alters the settled run outcome and is never silent: session.naming_failed carries a stable error class, never content; a failure does not burn the cap (a still-due title retries until one succeeds), and a manual clear re-arms naming by zeroing the counters, so max_repetitions is a per-cycle cap — each clear opens a fresh arming cycle.
Session lifecycle projection (settled). sessions.list and sessions.inspect accept an additive projection selector (D-424): "full" — the counter-enriched shape, the DEFAULT, unchanged — or "lifecycle", which returns session lifecycle metadata only (session id, lifecycle status, title and title source, start/update/completion/last- activity timestamps where authoritative, duration where derivable without enrichment, and the effective/default agent id only where honestly representable). The lifecycle path MUST NOT invoke event-history, task, pause, artifact, App, or counter enrichment of any kind; its work is bounded by the page size before and after restart, independent of total event/turn cardinality, and a page of N rows never runs N counter scans. Counter fields use the closed availability state current | partial | not_requested | unavailable: the lifecycle shape marks them not_requested (never merely absent, and never zero-as-not-computed); unavailable means enrichment or projection unavailable; partial remains a lower bound; full projection counters are exact at current; an omitted selector defaults to "full". A counter-dependent filter or sort (cost_above_cents, has_failed_task, has_intervention, cost_desc) paired with the lifecycle projection fails as a typed invalid request rather than silently switching to the expensive projection. The selector changes cost and shape, never authority: each method preserves its own widening claims and audit behavior exactly, and cross-identity denial stays non-oracular.
Durable tail-paged conversation turns (settled). A dedicated, runtime-owned conversation read model — sessions.turns.list (stable tail pages) and sessions.turns.get (one (session, task) bounded terminal reconciliation read) — the two named public methods — lets a Protocol consumer render the current chat from one durable projection instead of joining task/result/event/App authority itself (D-425). Bounded Activity rides inline covering at least Harbor's configured per-turn tool-call budget; a separate named activity method is NOT a v1.28 method or acceptance — if the Protocol response ceiling ever forces the exact attachment contract, a future named fallback is recorded as a deferred follow-up, not added to this phase. The projection is derived from Harbor's task, result, event, and App-context authority; it is incrementally materialized with idempotent sequence checkpoints, survives restart on durable drivers, and is erased/fenced with its session (in-memory restart loss remains explicit). Paging uses an opaque snapshot/keyset cursor anchored with an immutable task/turn tie-breaker: work is proportional to page size, independent of total event/turn cardinality, with no full task enumeration, no request-path raw event scan, and no per-row reads; appending a turn while paging older history produces neither duplicates nor omissions. One row is one root foreground user turn; background/child tasks fold into the root turn's Activity or are omitted by an explicit relationship rule, never surfaced as invented user messages. Each turn carries renderable content with explicit per-component exact/partial/unavailable state: the query, an answer union (bounded inline result OR artifact reference, or empty/evicted/unavailable — a failed read never fabricates an empty answer), ordered consumer-safe reasoning steps, ordered tool activity with a shared monotonic Activity sequence and compact cardinality-capped totals plus overflow bucket, model and token/cost usage, consumer-safe intervention metadata (the opaque action token only when the verified caller satisfies the pause's resume/approval/control tier), and durable ordered MCP App references whose replacement identity is exactly (effective_agent_id, server_id, resource_uri). The list response's exclusive live resume cursor composes with events.subscribe for a gap-free page-to-live transition under the settled page-before-subscribe handoff: the consumer folds the durable page and establishes bounded running/paused membership FIRST, then opens the stream with live_resume_seq as the initial resume_seq; the server replays events strictly newer than that snapshot through the existing bounded replay source, and a browser reconnect Last-Event-ID takes precedence (one terminal event causes one sessions.turns.get terminal reconciliation; a page retry clears stale live membership and rebuilds it from freshly read authoritative running/pending/paused rows without duplicating bubbles or re-admitting a terminal row, and a freshly read terminal durable row converges the existing bubble from that row). Chat open is two reads: one lifecycle read plus one turn-page read. The consumer conversation surface returns query, answer/ref, consumer-safe reasoning/ activity, own pause state, App refs, and compact totals; it must never return raw args/results/events, credentials, the system prompt, or a provider stack. An operator operations projection is not part of this surface, and no content-read/impersonation authority exists: admin/fleet observation implies no transcript access. events.list / state.history / tasks.get remain the raw drill-down and explicit task-detail surfaces.
An optional projection: "usage" selector on sessions.turns.get is the content-free, exact-session lane for a consumer that needs only durable turn lifecycle/timing and canonical usage facts (D-443). Its response is structurally distinct from both conversation and operations rows and contains only turn/task/session/agent identifiers, status/sealed/version/event-sequence facts, timestamps, the closed per-measure usage states, and an optional bounded reported model. It performs the same one durable indexed turn lookup, exposes no query, answer, reasoning, Activity, pause, App, attachment, run-id, message, tenant, or user content/authority, and cannot be widened by admin or fleet scope. The omitted selector keeps the conversation response unchanged. This read adds no poller, worker, receipt path, configuration, schema, or provider interaction.
The stock Runtime's root-foreground task/run relationship is part of that projection source: the foreground RunLoop uses that TaskID as its canonical RunID. Root task.spawned intentionally carries only the identity triple, while the following planner, tool-lifecycle, usage, pause, and mcp.app_available events carry RunID=TaskID. The turn materializer therefore derives exactly that task-bound RunID when both the root spawn envelope and task snapshot omit it, registers it only against the already-observed task in the same session, and persists the derived run identity on the turn. This is not a generic missing-RunID fallback for child or background executors: an explicit event/snapshot RunID remains authoritative, disagreement fails closed, and an unrelated run id never routes merely because the session has one turn. This binding is what makes the canonical rich events survive restart in the same row as the task/result lifecycle.
The persisted task lifecycle event is canonical for terminal status and its nonempty failure code. A task-record snapshot may fill a code only when a legacy event omitted one; if both carry nonempty, different codes, the snapshot's whole optional failure-metadata group is unavailable and the event-derived closed class stands. Likewise, an optional historical terminal message that cannot be represented safely (invalid UTF-8, NUL/C0/DEL controls, or exceeding the projection bound) is unavailable rather than a reason to stop the session projector. The whole message is omitted; it is never sanitized, truncated, or reinterpreted. These compatibility rules never relax the fail-closed identity, task-id, or run-id bindings.
An already-sealed turn also cannot be rewritten by a later contradictory terminal lifecycle event retained for the same task. When both event sequences are known and the contradictory terminal event is strictly newer than the event that sealed the row, projection acknowledges that later event as an immutable no-op and advances its checkpoint, preserving the first canonical terminal row and liveness for following turns. A same-sequence conflict is not historical tail compatibility: it remains a fail-closed projection error, as does any identity, task-id, or run-id disagreement.
6.10 Artifacts
MCP App callback and tool-context contracts (settled). MCP discovery preserves provider-authored _meta.ui.visibility declarations containing app in an internal per-server callback catalog alongside the planner projection. Exact app-only entries (["app"], with no model-facing entry) are not planner-visible or generically callable; mixed entries such as ["model", "app"] remain planner-visible while also being callable by the rendered App. Every App dispatch uses its host-derived server identity and remains subject to identity, agent reach, capability, approval, OAuth, state, redaction, and audit gates. Refresh, reconnect, replacement, and detach rebuild both views from one server snapshot.
Fresh render-admission contract (Settled — D-412 amendment; HA-56 stays phase 238 / D-412, Shipped v1.28). A rendered MCP App has exactly two render paths. The LIVE path — the App mounted while the originating tool-result App is in-process — may use a bounded, short-lived, provider-local binding as a compatibility convenience; that binding is never durable and is never restored. Embedded/durable reopen — the App remounted from a reopened session's durable turns — instead uses a FRESH stateless, integrity-protected, shared-KEK admission minted by the Runtime only after ALL of: verified identity, signed effective-agent reach, agent retirement, session/agent erasure, current session/agent exposure, exact server identity, exact current ui:// resource availability, paused/disabled state, and the exact CURRENT provider/catalog generation — a deterministic, replica-stable generation of the server's current discovered catalog that changes on detach, replacement, and ANY successful discovery/catalog/ resource change even when deployment descriptor configuration did not change (the existing exact registration descriptor fingerprint remains a retained input but is never alone sufficient authority; a process-local discovery counter is not acceptable, and a replica holding a different current catalog fails closed as a generation mismatch). The exact reopen order is: the durable App reference from the reopened session's turn rows, a successful mcp.apps.tool_context replay (a failed / unavailable / evicted / foreign replay mints no authority), the current ui:// read explicitly requesting one fresh admission (request_render_admission: true — the only minting read; ordinary and AppBridge-secondary resource reads never mint), the iframe/AppBridge mount, and then same-server app-only callback dispatch through the existing wrapped invocation echoing the fresh admission as the distinct render_admission authority. The fresh admission is distinct from, never aliases, and never coexists with the legacy live binding; neither is persisted or restored. The admission claim binds claim schema, mint time, the (tenant, user, session) triple, effective agent, server, resource, and current provider/catalog generation — and carries no raw tool arguments, secrets, provider output, callback name, or general capability. Ordinary resource reads never mint an admission; only the explicit admission-requesting read path does. The surface is STRICTLY opt-in — sealer availability alone never enables render admission, even when an OAuth broker already supplies the shared KEK — and every mint/verify reads the reach-admitted effective agent stamped in the request context, never a fixed boot/default fallback. The app-only callback stays absent from planner context, tools.list, search, and generic resolution, and dispatches only via the same-server ResolveAppTool path followed by the existing approval/OAuth/policy/redaction/retry/audit gates. Durable turn rows (the HA-64 / D-425 projection) retain metadata and component availability only — never an admission token; mcp.apps.tool_context replay is unchanged and never reruns the originating tool. Unavailable and expired admissions answer typed unavailable/expired, and refresh re-runs every fresh check — nothing is silently extended. Production and devstack share ONE implementation and one immutable shared sealer instance; the surface is enabled by tools.mcp_app_render_admission.enabled (default false) and seals with the existing tools.oauth_token_kek_env KEK-backed AES-256-GCM sealer — no second authority field and no literal key; an enabled surface with an empty env name, a missing/unset/invalid KEK, or a sealer construction failure fails readiness loud even when no OAuth provider or credential broker is configured, and restart/multi-replica deployments verify against the shared KEK and succeed only when the same current provider/catalog generation is present — a replica holding a different current catalog fails closed as a generation mismatch. Pinned non-goals: no generic capability framework, no persisted callback authority, no arbitrary origins, no provider exceptions, no hot registry, and no transcript impersonation.
Broker-bound manual App operations (D-486, implementation candidate). An external trusted host may supply an opaque broker-owned operation selector on these existing resource/tool methods. It grants no authority. The runtime binds it to the admitted agent/source/resource and exact outgoing input; callbacks still require fresh render admission and the existing wrapped invocation. The signed-capability broker pull must acknowledge the complete binding, mint a bounded credential from current policy, and bypass shared caching/singleflight. App-bound callbacks do not automatically retry unknown effects. Trusted planner invocations may use the same lane after the signed broker explicitly requires a governed operation: they bind the reach-admitted effective agent, resolved source, qualified tool and bounded canonical outgoing arguments after artifact egress (or an exact App resource read). Browser render admission remains mandatory for browser callbacks. Planner bindings carry fresh correlation references, grant no authority, and likewise stop automatic retries after promotion. See App operation v1 for the wire contract, local acceptance and activation requirements. The external host/broker lane requires its own product acceptance before production activation.
MCP App tool-context records have an explicit session-lifetime contract by default: they survive session reopen and are removed by session erasure. not_found therefore means unknown or cross-identity unless a separately measured, bounded, identity-scoped eviction policy is adopted. The Console renders the existing honest placeholder for a typed miss; no silent blank or implicit TTL is claimed.
type ArtifactScope struct {
TenantID, UserID, SessionID, TaskID string
}
type ArtifactRef struct {
ID, MimeType string
SizeBytes int64
Filename, SHA256 string
Scope ArtifactScope
Namespace string
Source map[string]any
}
type Store interface {
PutBytes(ctx context.Context, data []byte, opts PutOpts) (ArtifactRef, error)
PutText (ctx context.Context, text string, opts PutOpts) (ArtifactRef, error)
Get (ctx context.Context, id string) ([]byte, bool, error)
GetRef (ctx context.Context, id string) (*ArtifactRef, bool, error)
Exists (ctx context.Context, id string) (bool, error)
Delete (ctx context.Context, id string) (bool, error)
List (ctx context.Context, filter ArtifactScope) ([]ArtifactRef, error)
}Settled:
- Heavy outputs MUST route through the ArtifactStore. There is no opt-in flag and no
NoOpfallback. An in-memory driver is the floor; production drivers (filesystem, SQLite-blob, Postgres-blob, S3-style) ship as additional drivers behind the same interface. - IDs are content-addressed:
{namespace}_{sha256[:12]}. Re-uploading identical bytes returns the existing ref. - Access goes through a
ScopedArtifactsfacade per task that auto-stamps the identity triple on writes and scope-checks on reads. Tools never see raw scopes.
Heavy-output threshold — Settled at 32 KB default, runtime-configurable, per-tool overridable. (Resolves brief 05 Q-1.)
Protocol-native binary tool content (Settled — D-447). A tool result that contains a standard typed binary content block is materialized into the identity-scoped ArtifactStore regardless of the heavy-output threshold. Each binary block becomes its own content-addressed artifact with bounded size, canonical MIME metadata, a safe filename, full hash, producer provenance, and deterministic content index. The tool result returned to the dispatcher, trajectory, LLM projection, and MCP App tool-context capture contains only the corresponding artifact metadata/ref; text, structured content, and ResourceLink metadata remain intact, and ResourceLink is never fetched automatically. Missing identity/store, empty or oversized content, cancellation, and storage/projection failures are loud and never fall back to raw bytes or a truncated JSON value. The materialization seam is transport-neutral so MCP and future protocol adapters implement the same contract; the dispatcher does not branch on a server or driver name. The ArtifactStore interface is not transactional, so a later multi-part write failure may leave earlier identity-scoped, content-addressed artifacts in the session manifest; retries deduplicate them.
The read side — by reference, by window, and by routing (Settled — D-347). Offload is only half the contract. An agent that receives an ArtifactStub must be able to act on it without pulling the bytes back through the model, and the read side is settled in four parts.
Discovery is metadata-only, and the read key is the isolation triple. ArtifactScope carries (TenantID, UserID, SessionID, TaskID), and the fourth field is a provenance annotation, not an isolation principal — the boundary is and stays (tenant, user, session), exactly as ArtifactScope.Validate already declares by requiring the three and accepting an empty TaskID. A task runs within the tuple and does not widen it, the same reading §6.16 gives agent_id. Two consequences: reads (Get / GetRef / Exists / Delete) resolve on (tenant, user, session, id), so discovery and read agree by construction and an enumerable ref is a fetchable one; and List keeps TaskID as a filter — where an empty field stays a wildcard — because a predicate over a result set and an identity are different things. List carries the same mandatory-identity precondition as every sibling method; a wildcard tenant is reached only through the audited elevation verb (§4, §5.4), never by omission. Because IDs are content-addressed, narrowing the read key cannot merge distinct content — it can only stop an artifact from being hidden from itself.
Bytes are served by a Protocol method, on every driver. artifacts.get (§5.2) is the driver-independent read: it resolves through ArtifactStore.Get and is therefore mandatory on every registered driver, the in-memory floor included. The presigned-URL resolver (artifacts.get_ref) is not a second implementation of it — it is a transport optimisation for stores that can hand bytes off their own edge, so a large media download need not transit the Runtime. Both resolve the same ref under the same verified identity and the same scope posture; they differ in who serves the bytes, not in who may read them. A store without native presigning has a path rather than a refusal.
Windows are byte-offset and MIME-agnostic. A read may be bounded and resumed by byte offset, and every bounded response is truthful about its own bound (total_size_bytes, returned_bytes, truncated, eof) — a clamp is never silent, whether the bound came from the caller, the configured default, or the artifact fetch ceiling. That ceiling is operator policy alongside the heavy-output threshold rather than a compile-time constant: what goes out and what comes back are tuned as the pair they are. Its guarantee is bounded to one fetch; aggregate consumption stays the Governance layer's concern (§6.15). Row- or schema-addressed windowing is deliberately not part of this, because a stored MIME is not revisable — it is not part of the content-addressed identity, a re-Put of identical bytes returns the existing ref unchanged, and the interface exposes no metadata revision. Keying behaviour on a value that cannot be corrected converts a loud failure into a permanent one.
A window is a contract, not a cost claim. The Store read above returns whole bytes, so a window is correct before it is cheap; a ranged read — a range-aware driver method that serves an offset without materialising the blob — is a separate, conformance-parity change across every driver (§9), and no claim about which drivers serve it incrementally belongs in the interface's godoc until each has earned it.
Pass-by-reference routing keeps bytes out of the model. A tool may declare an artifact-reference parameter; the model supplies an id, and the runtime resolves the ref at dispatch so the bytes flow store → consumer. The resolution is performed under the dispatching run's identity, which descends from the transport-verified triple (§4) — a tool reaches exactly the bytes its own run reaches, and tool drivers hold no identity logic. A resolved value is dispatch-local: it does not re-enter the message history, the trajectory, an observation, a canonical event payload, an audit payload, or a log. The model authored an id and continues to see an id.
Optional MCP artifact-egress parameters (HA-67, settled by D-429). The existing flat artifact-egress mapping accepts one trailing ? marker on a parameter name. The shared compiler strips it before schema validation and ParamsFor, preserving the bare remote property name. Missing, null, or empty / whitespace-only string optional values omit substitution without requiring a resolver. The empty-string rule accommodates MCP/schema adapters that materialize an omitted optional string property as ""; a non-empty supplied value follows the existing strict type, non-empty-id, identity-scoped resolution, digest, encoding, and byte-ceiling checks. Required parameters are unchanged. The marker is a local config/mapping concern, not a new Protocol shape or a remote capability, and resolved bytes remain dispatch-local.
Bytes are stored as authored; the redactor governs what is emitted. An ArtifactRef passes the redactor unredacted precisely because it is a reference (D-022), and an artifact exists to hold the content the event stream and the prompt must not carry. The redactor's role on the upload path is admission — it may refuse a payload — not rewriting. The read surface inherits that boundary and does not relax it: bytes are returned to the identity that already reaches them, and are placed nowhere the redactor governs.
6.11 StateStore
Separate configuration persistence (D-485). Runtime assembly may select an optional configuration_state driver/DSN independently of execution state. Omission aliases the existing state handle. The separate handle owns agent registrations, versioned agent/user configuration, lifecycle fences, configuration operation journals and session-specific settings; execution records, cumulative context, tasks and events continue using their existing stores. Agent lifecycle and personal-setting conditional writes stay on one store. Session erasure also fences and clears session-scoped configuration. This reuses the StateStore interface, driver triad and PostgreSQL schema/pool; it creates neither a second transcript nor automatic migration of old settings.
// EventID is a ULID supplied by the caller; the store keys idempotency on it.
type EventID string
// StateRecord is the unit of persistence. Bytes is opaque to the store —
// callers serialize their domain types and run them through audit redaction
// upstream of Save (the store does not redact).
type StateRecord struct {
ID EventID
Identity identity.Quadruple
Kind string // caller-namespaced, e.g. "session.lifecycle", "task.checkpoint"
Version int // optimistic-concurrency hint for typed wrappers
Bytes []byte // pre-redacted, caller-serialized payload
UpdatedAt time.Time
}
// SlotExpectation compares the current record in one identity-and-kind slot.
// An empty EventID requires the slot to be absent.
type SlotExpectation struct {
Identity identity.Quadruple
Kind string
EventID EventID
}
type StateStore interface {
Save(ctx context.Context, r StateRecord) error // idempotent on EventID; ErrIdempotencyConflict on same-ID-different-bytes
SaveIf(ctx context.Context, expectations []SlotExpectation, next StateRecord) error // atomically compare every slot, then save one record; ErrConditionFailed on mismatch
SaveBatchIf(ctx context.Context, expectations []SlotExpectation, writes []StateRecord) error // atomically compare every slot and save every conditioned record; all-or-nothing
Load(ctx context.Context, id identity.Quadruple, kind string) (StateRecord, error)
LoadByEventID(ctx context.Context, eventID EventID) (StateRecord, error)
Delete(ctx context.Context, id identity.Quadruple, kind string) error
DeleteScope(ctx context.Context, id identity.Identity) (int, error)
ListKind(ctx context.Context, scope ListScope, kindPrefix string) ([]StateRecord, error) // unordered global maintenance scan; explicit elevation claim (D-207)
ScanKindForTenant(ctx context.Context, scope ListScope, tenantID, literalKindPrefix string, limit int, continuation string) (StateScanPage, error) // deterministic tenant-bounded maintenance page (D-400)
ListKindForIdentity(ctx context.Context, id identity.Quadruple, kindPrefix string) ([]StateRecord, error)
Close(ctx context.Context) error
}Conditional save (D-398). SaveIf is mandatory on the full driver triad, not an optional durable-driver capability. Its expectation list is non-empty, unique by (Identity, Kind), and must include the target slot of next exactly once. Every expectation compares the slot's exact current EventID; an empty expected ID means the slot must be absent. The driver atomically checks every condition and saves the single next record, or returns ErrConditionFailed with no partial write. Ordinary Save idempotency on next.ID is evaluated only after the conditions match and cannot turn a stale condition into success. In-memory holds one mutex across compare/write; SQLite uses one write transaction; Postgres locks/predicates both present and absent slots so two independent clients have one winner. The shared StateStore conformance suite pins matching, stale, absent, multi-slot, identity, cancellation, close, and concurrent-reuse behavior on every driver.
Conditional atomic batch (D-431). SaveBatchIf is mandatory on the same driver triad. Expectations and writes are non-empty and unique by (Identity, Kind); every write slot must be explicitly conditioned. Duplicate write EventIDs are rejected. Predicate mismatch, idempotency conflict, cancellation, or driver failure commits no record. In-memory holds its mutex across complete validation and application; SQLite and Postgres use one transaction, with Postgres taking deterministic advisory locks for absent and present slots. Durable event publication uses this operation to advance the shared sequence authority and commit the immutable body and conditional session head together, preserving gap-free visibility across runtimes. The authority and durable session fences occupy a StateStore-reserved internal Kind namespace at one exact coordination identity which external records and scope deletion cannot reach. Prefix-shaped legacy rows at ordinary identities remain ordinary erasable session data. Every durable history/index/projection reader observes the persisted fence and rechecks before exposing results. Drivers report only genuinely ambiguous transaction commit acknowledgement as ErrCommitOutcomeUnknown; callers do not poison themselves on definite conflicts, cancellation, validation, or known rollback. Every authority-absent durable store, including an empty one, requires the explicit events.legacy_writers_drained adoption acknowledgement after every old event writer sharing the scope has stopped; an ordinary rolling/zero-downtime upgrade is not compliant, while migration-only non-writers may overlap. Already-authority restarts require no repeated acknowledgement.
Session-owned records and fence composition (D-400). SaveIf compares many slots but writes exactly one next slot. SaveBatchIf is reserved for contracts that genuinely require several conditioned records to become visible together; neither operation by itself defines collection cardinality. A session-overlay or agent-owned session-personal-skill mutation therefore composes four exact expectations: its target record, the agent lifecycle slot, the pending session-erasure ledger, and the terminal session-erasure tombstone. The lifecycle expectation prevents a retirement that won the race from admitting a later session write; the two erasure expectations require absence and prevent a write once erasure has begun or converged. The successful record is still keyed by the caller's (tenant, user, session) triple. For new owned personal records, agent_id appears only in an encoded caller-namespaced Kind as ownership metadata, never as a storage identity or WHERE principal; the raw schema-1 overlay Kind remains the separate compatibility form described below. Per-record payload and per-request result limits are validated at the typed wrapper boundary; no claim of a hard stored-record count is made without a StateStore primitive that can enforce one.
The agent-owned personal record is both body and membership: one successful personal upsert or logical delete performs exactly one SaveIf of that record and never mutates the overlay as a second durable slot. The schema-1 Overlay.PersonalSkills field becomes read-only legacy migration-eligibility input. A resolver enumerates current owned records through the exact per-agent Kind prefix. Every overlay, personal-record, and composite read loads the lifecycle and both erasure fences before and after its point loads or enumeration. It returns only when the three exact EventID generations are stable and non-terminal; a changed generation retries at most MaxSessionSkillReadAttempts = 3, checking ctx.Err() before every attempt, then returns ErrSessionSkillReadUnstable rather than returning a mixed generation. This before/after fence is the read linearization proof for later inaccessibility. When that typed exhaustion reaches an external session skill or overlay endpoint, it maps to the new canonical session_skill_read_unstable Protocol code (HTTP 409); direct runtime callers receive the Go sentinel. The Protocol addition receives the same error-registry and Console lockstep as every other canonical code.
Uncertain SaveIf convergence is write-class-specific. An overlay or personal body write rereads its target, lifecycle, and pending/tombstone erasure pair; a cutover target/progress/final write rereads only the cutover record and exact epoch/digest/generation preconditions; a retirement tombstone/progress write rereads the lifecycle/tombstone target and operation/progress expectations; a cleanup item rereads its item target plus only applicable session fences. Each accepts success only when its exact intended EventID and content landed; none compensates unconditionally.
StateStore adds one mandatory, conformance-tested maintenance primitive for this use: ScanKindForTenant(ctx, scope, tenantID, literalKindPrefix, limit, continuation). The driver filters tenant and literal prefix in storage, returns no more than the bounded limit, orders by the stable composite slot key (tenant, user, session, run, kind), and emits an opaque validated continuation. It is a resumable sequence of pages, not a restart-survivable database snapshot. ListKind remains for existing unordered maintenance work; cutover and retirement use the tenant-bounded scan. After an agent lifecycle tombstone wins, its owned keyset is frozen, so retirement can use the same scan without inventing a collection transaction.
The schema-1 legacy transition is fleet-wide and operator-controlled without inventing a runtime-membership subsystem. Static boot configuration contains a bounded, unique tenant declaration list: {tenant_id, epoch, roster_digest, legacy_writers_drained}. Syntactically or structurally invalid configuration (an empty or invalid field, duplicate tenant, or over-bound list) fails boot loud; it is not an implicit dual_read declaration. An unlisted tenant, or a valid declaration whose legacy_writers_drained is false, is dual_read. Boot iterates only admitted tenants and CASes each declaration into agentcfg.session_personal.cutover.<base64url(epoch)> under CutoverScope(tenant), exactly {TenantID: tenant, UserID: "__agentcfg__", SessionID: "__session_personal_cutover__", RunID: ""}; there is no unknown-tenant discovery. A malformed or declaration-mismatched durable cutover record never authorizes state_only: readers stay mutation-refusing dual_read and surface a bounded loud diagnostic/error. The reserved user is already rejected for verified real user-scope config by ErrReservedUser; an agent ID equal to the cutover session sentinel cannot alias this record because the cutover Kind namespace is disjoint from lifecycle/config Kinds, which tests pin. The cutover record is bounded to mode, epoch, digest, current scan continuation, counters, and generation. It never embeds per-overlay results: the owned personal record itself is the per-name copy marker (epoch plus legacy content hash), and retirement/erasure status is derived from their durable terminal fences.
After the drained assertion, migration walks paged ScanKindForTenant results for the common schema-1 overlay prefix. It copies each currently eligible legacy reference under the overlay record's own identity with the target, lifecycle, pending-erasure, and terminal-erasure fences. The old writer source is quiescent because operators drained it and new code never mutates schema-1 PersonalSkills; that is not a snapshot claim. Restart resumes at the stored continuation. A final fresh paged verification pass proves every currently eligible reference is either marked copied by its owned record or terminally fenced before one final CAS can enter state_only.
The legacy overlay Kind is exactly raw agentcfg.session_overlay. concatenated with agent_id, with no delimiter or encoding. It is not prefix-safe: the legacy migration and retirement scan the tenant-bounded common overlay prefix, then require record.Kind == LegacyOverlayKind(agentID) before any load or mutation. Only new encoded personal-record Kinds use collision-safe exact per-agent prefixes. The a/ab adjacency case is a mandatory adversarial test: neither migration nor retirement may overmatch the other raw Kind.
Until that completion CAS, resolvers re-read the cutover record and stay dual_read: old shared ScopeSession bodies are authoritative, copies are non-authoritative, and session-personal mutations fail loud. Operators may set the declaration only after draining old replicas; it is an administrative precondition, not a guessed roster. Thus an old writer after an early copy remains visible in dual_read, never silently ignored. The default mutation-refusing state is an intentional compatibility and deployment change: before v1.26 it requires explicit release-review acceptance and same-change coverage in docs/CONFIG.md, CHANGELOG.md, the matching operator skill, its docs-site stub, and example configuration.
Settled (revised — D-027):
- Generic key-value-of-typed-bytes surface.
StateStoreis one mandatory interface keyed on(identity.Quadruple, Kind string, Bytes []byte), with idempotency on a caller-providedEventID(ULID) and conditional save on exact current slot event IDs. Consuming subsystems (sessions, tasks, planner checkpoints, memory snapshots, steering events, distributed bindings, trajectories) land their typed wrappers at their own layer atop this surface — not insideinternal/state. Example:SessionRegistry.Save(s Session)reduces toStateStore.Save(StateRecord{Identity: s.Identity, Kind: "session.lifecycle", Bytes: marshal(s)}). This keepsinternal/statea leaf with no upstream Harbor deps beyondinternal/identityandinternal/config. - One mandatory interface, three V1 drivers (in-memory, SQLite, Postgres), one conformance suite. The predecessor's eight optional
Supports*capability protocols +hasattrduck-typing are explicitly rejected — if all V1 drivers implement everything, optional capabilities are ceremony. - Forward-only migrations, per-driver migration directories. Each migration ends with
INSERT OR IGNORE INTO schema_migrations(version) VALUES (N);(or driver equivalent). - Postgres store startup has two explicit migration modes (D-428).
applyis the backward-compatible default: it retains the session advisory lock and may create or advance the schema, so it requires a direct or otherwise session-affine endpoint.verifyis the steady-state transaction-pool mode: it validates the embedded migration set, reads the existingschema_migrationsledger, and fails startup if the ledger is absent, unreadable, or missing any embedded version. It performs no DDL, writes, transaction, advisory lock, or pinned-connection acquisition. Deployment is deliberately two-step: apply through a direct endpoint before switching the store DSN to a transaction pooler withverify; Harbor never falls back from one mode to the other. - WAL journal mode for SQLite.
- Idempotency:
Savekeys onEventID; same-ID + same-bytes is a no-op, same-ID + different-bytes returnsErrIdempotencyConflict(caller-controlled retry semantics — the store never silently overwrites). - Identity-mandatory at the API boundary: empty tenant / user / session in the
Quadruplerejected withErrIdentityRequired. EmptyRunIDis acceptable for session-scoped state. - Explicitly-elevated maintenance scans —
ListKindandScanKindForTenant(amended, D-400).ListKind(ctx, scope, kindPrefix)remains the unordered global literal-prefix scan existing maintenance loops use (first consumer: the pause sweeper crash-orphan rescan).ScanKindForTenant(ctx, scope, tenantID, literalKindPrefix, limit, continuation)is the mandatory ordered page surface for resumable tenant maintenance: drivers filter tenant and literal prefix in storage, bound the page, order by the composite slot key, and validate opaque continuation. Both requireListScope{MaintenanceScoped: true}, reject an empty prefix, and grant only visibility: callers mutate every returned record under its own identity.ScanKindForTenantis a page sequence, not a cross-restart snapshot; identity-scoped reads stay onLoad/LoadByEventID. - Audit redaction is upstream of
Save. The store stores opaque bytes; mixing redaction into the persistence layer would couple a leaf package to the audit subsystem and split responsibility (D-020).
Earlier typed sketch (superseded by D-027 — kept for history): an earlier draft listed 21 typed methods (SaveTask, SaveTrajectory, SaveBinding, SaveSteering, SaveMemoryState, etc.) keyed on domain types from unshipped phases. That shape would have inverted the dependency graph (a leaf persistence interface importing types from its consumers); the generic surface is strictly more general and lets each consumer ship its typed adapter at the right layer.
Build-tag strategy — Settled. Both SQLite and Postgres drivers ship in the default binary; operators choose at config time. Distros that need a smaller binary use build tags to drop one. (Resolves brief 05 Q-3.)
6.12 Distributed contracts (V1: contracts only)
type BusEnvelope struct {
Edge, Source, Target string
TaskID TaskID
Payload json.RawMessage
Headers map[string]any
Meta map[string]any
}
type MessageBus interface {
Publish(ctx context.Context, env BusEnvelope) error // at-least-once
}
type RemoteTransport interface {
Send (ctx context.Context, req RemoteCallRequest) (RemoteCallResult, error)
Stream(ctx context.Context, req RemoteCallRequest) (RemoteEventStream, error)
GetTask (ctx context.Context, taskID, contextID string) (*RemoteTaskSnapshot, error)
Subscribe(ctx context.Context, taskID, contextID string) (RemoteTaskEventStream, error)
Cancel (ctx context.Context, taskID, contextID string) error
}Settled:
- V1 ships the interfaces, an in-process
MessageBus(loopback), and aRemoteTransportcapable of speaking A2A to remote agents. - No durable distributed bus driver (NATS, Redis Streams, Postgres-as-queue) at V1. Post-V1 phases (
Distributed-2,Distributed-3, …) add those. - Delivery semantics:
MessageBus.Publishis at-least-once; handlers must be idempotent on(TaskID, Edge, EventID).RemoteTransport.Sendis request/reply;Streamyields ordered events with a finaldone=true. (Resolves brief 05 Q-4.)
6.13 Typed event bus
The event bus is the canonical projection of runtime state. One bus, protocol-grade. Used both for live UI streaming and for telemetry — logging and OpenTelemetry derive from the same events rather than being parallel paths.
package events
// EventType is a string-typed exhaustive enum. Each canonical type
// is declared as an exported constant + registered in init() so the
// registry stays the single source of truth.
type EventType string
// EventPayload is sealed via an unexported method on Sealed (an
// embedded struct any caller can compose into its concrete payload
// type). Bus-internal payloads compose SafeSealed instead, marking
// them as SafePayload — the bus skips the audit redactor for these
// (no secrets by construction; preserves typed access on the
// subscriber side). External payloads default to NOT-SafePayload;
// the bus runs their value through audit.Redactor and the
// subscriber-side payload becomes a RedactedMap when the redactor
// reflects a struct into a map.
type EventPayload interface {
isEventPayload()
}
type Sealed struct{}
type SafePayload interface {
EventPayload
isSafePayload()
}
type SafeSealed struct{ Sealed }
type RedactedMap struct {
Sealed
Data map[string]any
}
type Event struct {
Type EventType
Identity identity.Quadruple // tenant + user + session + run, mandatory triple
OccurredAt time.Time // assigned by Publish when zero
Sequence uint64 // monotonic per-bus, gap-free; assigned by Publish
Payload EventPayload
Extra map[string]string // bounded, low-cardinality; reserved for Phase 56 metric labels
}
type Filter struct {
Tenant, User, Session string
Types []EventType
Admin bool
}
type EventBus interface {
Publish(ctx context.Context, ev Event) error
Subscribe(ctx context.Context, f Filter) (Subscription, error)
Close(ctx context.Context) error
}
// Optional additive capability for present-tense animation. SafePayload
// values retain the existing audit-redactor bypass; other values follow the
// audit boundary. The capability leaves Sequence == 0 and never persists or
// enters replay history.
type LivePublisher interface {
PublishLive(ctx context.Context, ev Event) error
}Settled:
- One bus, not two. Telemetry, durable semantic/lifecycle events, and present-tense chunk animation share this typed bus from t=0. The bus has two explicit publication contracts:
Publishis durable/replayable where the configured driver supports replay, whilePublishLiveis the bounded, non-durable animation lane described below. EventBus(the Go-level name shipped asinternal/events.EventBus) ships withPublish/Subscribe/Close. Present-tense animation is the additiveLivePublishercapability; the shipped drivers implement it, while a legacy/custom EventBus may omit it for source compatibility. In that case the completion publisher emits no chunk event, logs one explicit warning per run, and disables animation rather than converting chunks into durablePublishwrites; the terminal result remains authoritative. TheReplay(ctx, Cursor, Filter)method is a separate concern and lives in Phase 06's replay-equipped driver — when that driver lands, callers will type-assert the returnedEventBusto aReplayercapability interface, keeping the core surface lean.- Drop policy on backpressure: drop-oldest, with a
bus.droppedevent describing the dropped sequence range. Notices are windowed at most once perDropWindowper subscriber. - Server-enforced isolation filter:
Subscriberejects empty-triple non-admin filters withErrIdentityScopeRequired. EveryAdmin: trueSubscribe additionally emits anaudit.admin_scope_usedevent so abuse is retroactively detectable. Cryptographic verification of the admin claim is wired in Phase 61 (Protocol auth); Phase 05 trusts the boolean. - Audit-before-emit boundary. Every
Publishruns the payload throughaudit.Redactorbefore enqueueing — except forSafePayload-marked types, which bypass the redactor (their declarer guarantees no secret-shaped fields; preserves typed access for bus-internal events and well-known metadata). On redaction failure: the bus emits a siblingaudit.redaction_failedevent (with NO original payload bytes) AND returns the wrapped error to the caller. The original event is NOT enqueued (D-020). - Non-durable live publication (D-452). The additive
LivePublishercapability applies the same event validation, audit-boundary policy, identity filtering, and bounded drop-oldest fan-out asPublish, then immediately delivers a present-tense event withSequence == 0. Non-SafePayloadvalues follow the audit redactor;SafePayloadvalues retain their existing bypass. It does not touchStateStore, the replay ring, the global sequence authority, or projection watermarks, so reconnect is intentionally lossy for live animation. SSE omitsid:for these events; the terminalAnswerEnvelopeand durable task/session lifecycle events are authoritative and reconcile a client after a missed animation frame. Only LLM completion chunks use this lane in the current release. A failed or cancelled run never gets a synthetic answer from this lane. - Identity-mandatory:
Publishrejects events whose Quadruple lacks tenant/user/session withErrIdentityRequired. EmptyRunIDis acceptable for session-scoped events. - Sequence numbering: durable
Publishis per-bus monotonic viaatomic.Uint64; gap-free. Caller-prefilledSequence != 0is rejected withErrSequenceProvided.PublishLiveretainsSequence == 0as its explicit no-replay sentinel. - Replay-from-cursor: ring buffer (default 10k events) when no durable log; exact replay when the durable log driver (StateStore-backed, Phase 57) is configured. Replay capability lives in Phase 06 and contains durable events only; live animation is not replayed.
- Indexed exact/admin fan-out (planned — D-453). Non-admin subscriptions are indexed by mandatory full identity triple. Publication selects only the event's exact bucket plus a distinct
Admin: truebucket, then applies the unchanged run/type predicate. Admin remains widened fan-in even when identity fields are present. Limits, audit, drops, reaping, cancel, and close share the same indexed membership; unrelated exact subscribers are not scanned. - Atomic individual-record batches (planned — D-454). Additive
BatchPublisheris implemented by both shipped drivers while legacy/custom buses remain source-compatible. All members are validated/redacted before mutation. The durable driver assigns consecutive global sequences and commits authority, individual bodies, and affected session heads in oneStateStore.SaveBatchIf; failure exposes no prefix. Replay, history, projections, subscribers, and SSE cursors retain exact individual records, never a flattened batch envelope or shared cursor. - One ordered bounded async observability FIFO (planned — D-455). Each bus asynchronously accepts only driver-neutral
llm.cost.recordedand the five universal tool-lifecycle events. Validation/redaction precedes acceptance. OrdinaryPublish/PublishBatchuses the same FIFO and waits, so a successfultask.completedcannot overtake prior accepts. Orderly Close drains/joins. Abrupt process loss before commit may lose accepted telemetry: no outbox, WAL, recovery synthesis, or exactly-once claim is added. This does not extend to authority/security paths and creates no second transcript;sessions.turns.*,live_resume_seq, SSE IDs, completion chunks, Console, and Protocol0.1.0remain unchanged. - Cardinality safety: future metric derivation (Phase 56) will draw labels from
Event.TypeandEvent.Extraonly — neverRunIDorTraceID. A static lint check enforces this in CI; the script ships as a Phase 05 stub atscripts/check-event-cardinality.shand tightens in Phase 56.
Identity-indexed fan-out (settled — D-453). The shipped in-memory and durable drivers retain a canonical subscriber lifecycle set and maintain an exact (tenant, user, session) index for non-admin subscriptions plus one explicit Admin bucket. Non-admin subscriptions require the full identity triple, so tenant- or user-wildcard buckets are not valid. Fan-out selects from the event's exact bucket and the Admin bucket, then evaluates the full Filter.Matches predicate before each bounded enqueue. Subscribe cap checks, Subscribe insertion, Cancel removal, and Close teardown update the canonical set and secondary indexes under the same driver lock. This is an internal selection optimization: event ordering, drop-oldest notices, cancellation, fences, admin auditing, and every EventBus/Protocol/SSE/event-payload contract remain unchanged.
Event taxonomy is Settled and lives in internal/events/events.go. V1 starter set: runtime.error, runtime.warning, bus.dropped, bus.subscription_idle_closed, audit.redaction_failed, audit.admin_scope_used, governance.budget_exceeded, governance.rate_limited. Adding new types is at-the-seam: declare an exported constant and register it in init(). The TestEventTypes_Exhaustiveness smoke gate runs in preflight.
Default subscription filters in harbor dev: (tenant, user, session) of the active run by default. Multi-run debugging requires an explicit operator opt-in. (Resolves brief 06 Q-3.)
Schema versioning — Settled. Best-effort additive: new EventTypes and new optional fields are non-breaking. Strict semver for the bus-wire schema once third-party Consoles exist (V1.5+). (Resolves brief 06 Q-4.)
Earlier sketch (superseded by D-028 — kept for history): an earlier draft of §6.13 carried flat identity fields (TenantID, UserID, SessionID, RunID) plus EmittedAt, plus optional metric-shaped fields (LatencyMs *float64, TokensIn *uint32, TokensOut *uint32, CostUSD *float64, QueueDepth *QueueDepthSnapshot), and called the bus interface Bus. The shipped surface uses identity.Quadruple (re-using Phase 01's type), OccurredAt, no inline metric fields (Phase 56 derives labels from Extra), and renamed Bus → EventBus. The earlier draft also ranged the bus interface over Replay directly; replay is now a Phase 06 capability layer. D-028 captures the reconciliation.
Durable observability rollups (settled — D-426, a narrow D-296 amendment). A first-class durable observability rollup projection — a rebuildable, indexed materialization of aggregate measures over successfully persisted canonical Harbor events, behind its own typed interface and §4.4 driver seam with in-memory, SQLite, and Postgres drivers — answers administrative cost/token/outcome questions without scanning the raw event log at read time. Rollups are BEST-EFFORT aggregates over successfully persisted canonical events, never a billing-exact ledger; the durable event log and governance's cost accumulator remain the exact per-call authority. The projection consumes the EXISTING local durable event sequence incrementally, with a durable applied-through watermark: there is no outbox, no new canonical event ID, and no active-active exactly-once claim — the fail-loud LLM publication contract is unchanged and projection application failures are best-effort; replay of the same source event is idempotent, restart catch-up and a crash between source persistence and projection application converge, and concurrent replica application is at-least-once idempotent on the local sequence with the absent exactly-once property stated, not claimed. The storage base grain is exactly the fixed UTC MINUTE bucket plus the authoritative dimensions (tenant_id, user_id, session_id, model); agent_id is not a rollup dimension (not even conditionally), no other entity dimension is added, and a query MAY coarsen the bucket. Measures are EXISTING source-backed payloads only: the llm.cost.recorded successful-completion count; exact cost as an integer or decimal; prompt/completion/reasoning/ cache-read/cache-write/total tokens; latency count/sum/min/max; and task completed/failed/cancelled counts. Attempts, failed LLM calls, retry/ downgrade, task-spawned, and user-message counts are unsupported and reported unavailable — never mandated, never inferred, and never backed by new canonical events. Unsupported measures are omitted or marked unavailable, never synthesized. Every query response carries an observed watermark and an explicit completeness state — current, catching_up, unavailable (plus rebuilding and retention-quality signals) — and never returns zero as a substitute for unavailable. The session enricher (§6.9 counters, D-309) becomes projection-backed with the honest raw partial-scan fallback (CountersPartial). Session erasure removes or tombstones every aggregate attributable to that session and reconciles parent user/tenant totals; a rebuild never resurrects erased aggregates, and a rebuild over a pruned log exposes that historical incompleteness. ONE bounded Protocol-owned administrative query surface (observability.query) carries a mandatory time window, server-authorized filters, a closed group_by set, bounded buckets, pagination with deterministic sorting, exact-or-explicitly-partial results, and a maximum result/bucket budget that fails loudly; ordinary callers query only their verified identity scope, and widened queries require verified admin or console:fleet claims from the request context (never the body) with the established widened-read audit evidence. The Console remains a pure Protocol client. This AMENDS D-296 narrowly and only as recorded there: the projection is derived, rebuildable state whose source of truth stays the canonical event log — it is not a general-purpose Harbor TSDB, does not hold the only copy of derived history, and does not relax the cardinality firewall; identity-labelled OTel metrics remain forbidden.
6.14 Telemetry
Slog + OpenTelemetry from t=0. The Runtime emits events; the events drive both slog records (via the Logger wrapper) and OTel spans/metrics (via Tracer and MetricsRegistry). No retrofit.
Settled:
- One logger:
log/slog. JSON in production, text in dev. No toggle inside the library; the slog handler is selected at process start. - Standard attribute set on every logger:
tenant_id,user_id,session_id,run_id,task_id,trace_id,span_id,tool(when present). Logger.Erroremits both an slog record AND a pairedruntime.errorbus event so logs always have an event peer. (Settled.)- OTel propagation:
traceparentfor HTTP southbound;_meta.traceparentfor stdio MCP per-request;HARBOR_TRACEPARENTenv var on stdio spawn for the initial trace. - Metrics exporter: OTLP default. A built-in Prometheus
/metricsendpoint ships at V1 for self-hosted setups (popular operator preference). (Resolves brief 06 Q-2.)
6.15 Governance subsystem
Governance is Harbor's middleware between the Runtime and the LLMClient driver. It owns identity-scoped policies — cost accumulators + ceilings, rate limits, per-call token budgets, and (post-V1) key rotation, model swap, failover chains, circuit breakers — that the LLM-call substrate (bifrost) doesn't and shouldn't know about, because it doesn't know Harbor's identity triple.
package governance
type Subsystem interface {
// PreCall is invoked before each LLMClient.Complete.
// Returns a typed sentinel error to gate the call:
// ErrBudgetExceeded, ErrRateLimited, ErrMaxTokensExceeded, ErrKeyUnavailable.
// Returning an error fails loudly; the runtime emits the corresponding event and
// can route to the unified pause/resume primitive when configured.
PreCall(ctx context.Context, ident Identity, req llm.CompleteRequest) error
// PostCall is invoked after each LLMClient.Complete (success or failure).
// Accumulates cost / tokens / latency; emits events; updates rate-bucket state;
// drives circuit-breaker bookkeeping (post-V1).
PostCall(ctx context.Context, ident Identity, req llm.CompleteRequest, resp llm.CompleteResponse, err error) error
}
type Identity struct {
TenantID, UserID, SessionID, RunID string
Tier string // "free" | "team" | "enterprise" | custom
}
// Policy interfaces (each lives behind the §4.4 seam pattern with multiple drivers):
type CostPolicy interface { /* check + accumulate budgets */ }
type RatePolicy interface { /* token-bucket + bookkeeping */ }
type KeyResolver interface { /* per-call key selection (wraps bifrost.KeySelector) */ }
type ModelOverride interface { /* mid-session model swap (post-V1) */ }
type FailoverPolicy interface { /* orchestrated provider chain (post-V1) */ }
type CircuitBreaker interface { /* per-(provider, key) health (post-V1) */ }What bifrost gives us free (just by using it as library):
- Multi-key load balancing per provider (
Key.Weight). - Per-key model whitelist / blacklist (
Key.Models,Key.BlacklistedModels). - Per-request
KeySelectorhook — Harbor's identity triple flows here viactx. Bifrost.ReloadConfig(...)for non-realtime config swap.Account.GetKeysForProvider(ctx, provider)invoked per request — keys can change withoutReloadConfig.- Cost reporting passthrough (
Usage.Cost.{TotalCost, InputTokensCost, OutputTokensCost, ReasoningTokensCost, ...}). - Connection pooling + drop-excess-requests backpressure.
LLMPlugin/MCPPluginpre/post hook architecture (available; intentionally NOT used for identity-scoped policies — see boundary note below).
V1 scope (Settled). See master plan phases 36a + 36b.
- Cost accumulator, identity-scoped. Aggregates
Usage.Cost.TotalCostper(tenant, user, session)and per model. StateStore-backed (in-mem / SQLite / Postgres conformance). - Per-identity cost ceilings. PreCall checks; emits
governance.budget_exceededevent; fails loudly withErrBudgetExceeded. - Per-identity rate limits. Token bucket per
(identity, model). PreCall checks; emitsgovernance.rate_limited; fails withErrRateLimited. - Per-call MaxTokens per identity tier. PreCall enforces a configured ceiling before the request goes out.
- Live events on the bus.
llm.cost.recorded,llm.tokens.recorded,governance.budget_*,governance.ratelimit_*. Console subscribes via Protocol once Console lands.
Post-V1 (deliberately tracked — see master plan phases 91–96).
| Phase | Capability | Why post-V1 |
|---|---|---|
| 91 | Console-driven key rotation (Protocol governance.rotate_key) | Operator workflow; needs Console to land first |
| 92 | Console-driven mid-session model swap (Protocol governance.swap_model) | Operator workflow |
| 93 | Failover chains as Harbor policy | Has policy + audit implications best done with Console visibility |
| 94 | Provider circuit breakers per (provider, key) | Cleaner once we have failover |
| 95 | LLM cache (exact-match + semantic) | Big complexity; not a V1 floor item |
| 96 | PII redaction at the LLM boundary | Audit subsystem owns the redactor; post-V1 |
Two governance writes are added by the v1.17 Gate-0 amendment, outside the 91–96 set (phase numbers assigned when the v1.17 wave is scoped): the identity-tier policy write (D-332, below) and broker-pulled Harbor-orchestrated failover (D-335, below — it realizes the phase-93 FailoverPolicy seam over broker-pulled keys).
Boundary with adjacent subsystems.
- LLM client (§6.5): Governance wraps the
LLMClientinterface. The LLMClient stays one method; the bifrost driver underneath is unaware of identity scopes. - Audit: Governance emits events; Audit redacts and persists. Audit owns PII redaction at the LLM boundary; Governance owns thresholds. (Settled — D-020.)
- Pause/resume (§6.3): A
BudgetExceededorRateLimitedevent can trigger a pause via the unified pause/resume primitive, surfacing in Console as a steering event withINJECT_CONTEXT("you're at budget — pause for operator approval"). - Bifrost layer: Governance does NOT use bifrost's
LLMPluginarchitecture for identity-scoped logic — that would couple Harbor's governance to bifrost's plugin lifecycle and hide it from Harbor's audit + event bus. Bifrost plugins remain available for low-level transforms (provider-quirk normalization that doesn't depend on identity). - Failover (post-V1): Harbor orchestrates failover at the Governance layer; it does NOT push a per-call
Fallbacksarray into bifrost. Each fallback hop is a Harbor event with cost + identity attached. (Settled — D-018.)
Key rotation (post-V1, Settled mechanism). Console pushes a new key value via Protocol → Harbor's Account impl swaps keys atomically (atomic.Pointer over the live key set) → bifrost picks it up on the next call via Account.GetKeysForProvider(ctx, ...). No ReloadConfig race. Old keys are invalidated immediately. (Settled — D-019.)
Persistence. Governance accumulators (cost, tokens, rate-bucket state) live in StateStore (in-mem / SQLite / Postgres drivers). Forward-only migrations per §9. Conformance test asserts identical behavior across backends. Cross-session isolation test asserts one session's accumulator doesn't bleed into another.
Hot-reloadable fields (operator-facing). Ceilings, rate limits, MaxTokens tiers, key set. Other Governance config remains restart-required per §10. (These are the config-default layer; under D-332 (Gate 0, below) the identity-tier POLICY TABLE additionally becomes Protocol-administrable and StateStore-backed, layered over exactly these config defaults.)
Identity-tier policy write (Planned — D-332; a control-plane admin surface — the "second consumer arrived" line). The identity-tier policy table Governance enforces — per tier: cost-ceiling (USD) / max-tokens cap / rate-limit capacity, plus the default-tier assignment — is Protocol-visible today via governance.posture (read) but Protocol-immutable: editing tiers is a runtime-config concern (a redeploy / out-of-band edit). A control-plane consumer whose job is policy administration renders a Governance page from governance.posture and can SHOW the enforced policy but has no honest control to CHANGE it over the Protocol without reaching around the wire into runtime config — which a pure Protocol client must not do. governance.set_posture (the write sibling of the existing read) writes that table as a full replace through the projected shared validator (the D-302 pattern), never a partial merge — 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 across all three drivers). It carries the same admin-write posture as the already-writable governance.set_tenant_overrides (per-tenant LLM defaults): authority is 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). The runtime stays 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. It round-trips faithfully with the read — what set_posture writes is what the next posture returns. Persistence. The tier policy graduates from hot-reloadable boot config to a StateStore-backed policy record layered over the config-declared defaults (in-mem / SQLite / Postgres conformance, forward-only migrations per §9); a runtime with no written override enforces exactly its config defaults, so the write is additive and backward-compatible. Explicitly NOT part of this: no consumer-side policy engine or re-enforcement (the runtime stays authoritative); no new identity axis; no change to how a tier is resolved for a caller (that stays the runtime's); no scope-gate relaxation. This is the missing write for a table the runtime already exposes read-only — the honest close of the gap between a policy an operator can see and one they can administer from the same surface.
Broker-pulled failover chains stay Harbor-orchestrated (Planned — D-335; extends, does not reverse, D-018). A control-plane consumer that centrally custodies provider credentials (§6.5's inference-plane broker-pull, 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) that would do this inside the SDK. Harbor does not use it — D-018 stands: Harbor orchestrates failover at the Governance layer through the FailoverPolicy seam (post-V1 phase 93), so every fallback hop is a Harbor event with cost + identity attached and passes through the audit + event bus + per-identity cost accumulator; a bifrost-internal Fallbacks array would hide the hops from all three. 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, re-runs Governance PreCall (budget / rate-limit / MaxTokens) before re-issuing — not merely PostCall accounting, so an N-provider chain cannot push a run past its per-identity ceiling across hops — 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, it does not silently continue down the chain). Cross-provider fallback is therefore fully expressible (a heterogeneous chain) without delegating orchestration to the SDK. Priority MEDIUM; composes with D-333/D-334 and the shipped governance.rotate_key (D-019).
6.16 Agent Registry
The Agent Registry is the runtime subsystem that owns the registration identity of agents. It is an in-process, per-runtime-instance component — there is no central Harbor service, and there must not be one. Every harbor process (and every embedding of the Harbor library) has its own registry, persisted via that instance's configured StateStore driver (in-memory / SQLite / Postgres — the §9 triad, behind the §4.4 seam pattern).
agent_id is a registration identity, not an isolation principal. Harbor's isolation boundary is and stays the tuple (tenant, user, session) (+ run for the quadruple — §4, §6.1). An agent is a runtime entity — it has a planner, tool bindings, memory bindings, policies, and operational health — but it runs within (tenant, user, session); it does not widen the isolation boundary. Memory drivers, state drivers, and event subscribers continue to scope by the tuple, never by agent_id. (Settled — D-059. This dissolves a recurring ambiguity: "agent as a registered, runtime-tracked entity" and "agent as an isolation boundary" are orthogonal; the first is this subsystem, the second is explicitly rejected for V1.)
The three-ID model. Each registered agent carries three identifiers, each answering a different question:
| ID | Question | Lifecycle |
|---|---|---|
agent_id | "which logical agent" | Minted once at first registration, persisted, rehydrated on restart. Runtime-instance-local, collision-free by construction (ULID); never assumed globally unique. |
incarnation | "which boot of it" | Ephemeral; bumps on every process start. |
version_hash | "which configuration" | Deterministic content hash over (prompt set, tool set + schemas, planner config, model policy); bumps only when configuration content changes. |
A plain restart yields the same agent_id + same version_hash + a new incarnation; a restart after a configuration edit bumps both incarnation and version_hash. version_hash is the free V1 precursor to the post-V1 Evaluations / agent version-control program (§12) — success-rate-over-version_hash needs no retrofit if the hash is present from V1.
Two creation cases. Locally-hosted agent — the runtime instance is running the agent; it mints a local agent_id. Connect-to-remote agent — the agent runs in another Harbor instance (or is any A2A-speaking peer); the local runtime assigns a handle (an agent_id local to this instance), and the canonical identity of the remote agent is its A2A AgentCard (§6.12), owned by the remote operator. This mirrors a DNS resolver's relationship to a remote host: the local entry is a handle, the authoritative record lives elsewhere. (Settled — D-060.)
restart rehydrates; restart ≠ recreate. With a durable StateStore driver, a process restart rehydrates the registry and an agent returns with the same agent_id — a stable fleet view depends on this. The in-memory driver loses the registry on restart and is documented as dev-only. Teardown-and-recreate is distinct: recreate genuinely mints a fresh agent_id because it is a new logical entity; restart keeps the StateStore record.
Agent-config retirement is terminal and replayable (D-398/D-399). A retirement operation atomically replaces the mutable agent-config active slot with a durable tombstone by comparing the exact current StateStore event ID. The tombstone records a caller-supplied operation ID, retirement time, the pre-retirement revision ID and content hash, a fixed cleanup manifest, and the durable progress of each cleanup action. Immutable revision history is never rewritten or deleted. The same operation ID resumes incomplete cleanup after a timeout, lost acknowledgement, or process restart; a different operation ID conflicts and cannot replace the replay identity. All agent- and user-tier config mutations and rollbacks fail closed once the tombstone wins. Session overlays and session-personal skills are durable StateStore records, not process-local state. Their mutators use the four-slot conditional-save composition in §6.11, so a lifecycle tombstone or either session-erasure fence wins before the one target record is written. A record that preceded retirement is inaccessible on every later read; after the tombstone freezes the owned keyset, retirement cleanup uses the paged tenant-bounded ScanKindForTenant seam. Its fixed manifest uses a collision-safe exact per-agent Kind prefix only for new encoded personal records; for raw schema-1 overlays it records the common overlay prefix and requires exact record.Kind == LegacyOverlayKind(agentID) equality before any mutation. It then mutates each returned record only under that record's own full identity. Re-creation mints a fresh agent_id; there is no implicit unretire.
Retirement makes the agent unresolvable for every new run, including explicit and omitted selection of a configured default. A start that acquired its immutable run snapshot before the tombstone may finish; retirement never rewrites an in-flight run's projection. Cleanup is limited to live resources whose durable owner is the retired (tenant, agent_id) and whose identity is captured in the tombstone manifest. Boot-declared/global resources and identity-scoped credentials without an agent ownership record are not swept. User-tier revisions and ScopeUser skills remain immutable/durable history; cleanup never retires or deletes shared or unattributable legacy SkillStore bodies. The sole legacy shared-body deletion is session erasure's exact ScopeSession sweep. agent_config.retire is an admin control-plane lifecycle verb, not a data-plane bearer operation, so admin scope is its authority and it does not consume agent_reach. agents.deregister continues to remove only the fleet registry record and neither creates nor removes the agent-config tombstone.
The retired result is distinct only after signed reach has authorized the effective target: a reach-authorized control.start, every active/current or mutating agent-config/session/user/skills projection, and all session methods return canonical agent_retired (HTTP 409). agent_config.retire returns canonical agent_retirement_conflict (HTTP 409) for an expected-hash mismatch, a different operation ID, or an incompatible same-slot retirement; an exact same-operation retry instead returns and resumes its stored status. The generic unresolvable refusal remains the non-oracle result. Admin agent_config.list_revisions and agent_config.diff, and exact immutable revision reads, remain available after retirement under their existing admin authority. agent_config.user.list_revisions and agent_config.user.diff remain available only under their existing verified user scope plus signed reach. This preserves history without broadening any claim; active/current getters and skill-list projections do not become historical read doors.
Retirement lifecycle facts are canonical, redacted, and identity-scoped: agent_config.retirement.started, .progress, and .completed. Their payloads carry only the identity, agent ID, a hash of the operation ID, and bounded stage/class, counters, and generation; they never carry a raw operation ID, descriptor, or credential. Each durable transition first records a pending event checkpoint. Cleanup cannot advance beyond it; the runtime emits the event and then exactly CAS-acknowledges the checkpoint. An emit or ack failure fails loud and the same-operation retry resumes this sequence. Delivery is therefore at-least-once (duplicates are permitted; silent loss is not).
Signed MCP capability registration is atomic, production-safe, and boot-authorized (D-401, extended by D-404, D-405, and D-406). A runtime may boot one generic OAuth credential broker/trust anchor: it alone retains the fixed exchange endpoint, credential-pull endpoint, runtime broker credential, KEK, true scope ceiling, and signed-capability authority verifier material. This production path is usable without a development flag only when that broker/trust anchor explicitly opts into signed capability authority; an absent anchor/opt-in remains fail-closed. An administrator then registers one new OAuth-fronted or receiver-style MCP capability with exactly one agent_config.register_oauth_mcp_capability operation. The operation prepares an unpublished provider and connection, CAS-persists one agent-config revision, and publishes the pair together; two public writes must never be composed to manufacture this state. Its writable request contains only a provider name, broker name, audience, normalized requested scopes, SignedOAuthMCPConnectionDescriptor, expected_content_hash, and a signed authority envelope. It accepts no general MCP descriptor: its closed shape is exactly {name, url, tool_allowlist, tool_denylist, connect_timeout_ms, request_timeout_ms, injection, artifact_byte_eligible, artifact_params}. injection, when present, is the existing bounded non-secret receiver mapping: its provider must equal the enclosing private provider name; the signer binds every mapping field; the target header/meta key must be redaction-covered; and the credential sink remains derived from the canonical connection URL and checked against the boot broker's downstream-host ceiling. The private provider then pulls the acting user's credential per call and is owned by the connection, while initialize/discovery remains unauthenticated. The final artifact fields retain D-404's signed, schema-validated trusted-egress semantics. Strict decode plus reflection continue to reject OAuth/provider/token URL, discovery, stdio command/env/cwd, arbitrary headers, credential/secret, and host/sink-list fields.
The envelope is authority, not administrator input. Its durable operation key is tenant-scoped (tenant_id, trust_anchor_name, issuer, kid, jti); a reserved tenant-control-scope record uses a canonical length-prefixed tuple-hash Kind and stores bounded tuple fields/hashes, exact pair fingerprint, expiry, phase, and revision identity. One pair-lifetime operation record has exactly one normal graph: claimed -> revision_committed -> published -> removal_admitted -> removal_revision_committed -> catalog_unpublished -> teardown_receipted -> removed. Every phase transition compares its exact operation EventID. There is no generic aborted phase: a prepared-but-incomplete claim retries its current phase until it becomes terminal. Only claimed or revision_committed may transition to terminal expired_incomplete, after safe close/compensation and the activation fence preserve prior/no-active; that tombstone is retained until expiry plus bounded skew before cleanup. Exact tuple+fingerprint resumes the recorded phase; same key/different fingerprint rejects. claimed retries prepare; uncertain revision writes exact-reread; revision_committed re-prepares/re-publishes after restart; publish checkpoint errors verify the exact live pair; an exact published replay restores the same locally attached generation when absent and then returns the original response; and removed never recreates. A published record is retained for its pair's full immutable-history lifetime even after the registration authority expires or its verifier key is revoked: this is a recovery/replay constraint, not continued bearer authority, which still requires current exchange entitlement and exact binding. removed is a durable anti-replay tombstone retained with that pair history (and never less than the accepted authority expiry-plus-skew horizon), so no retry or replay can recreate the pair. This recovery state machine explicitly does not claim cross-record ACID with the revision.
One shared canonical URL-byte helper serves signing, matching, fingerprinting, transport, and reconcile: absolute HTTPS only; IDNA2008 lower-case ASCII host without root dot; RFC5952 compressed lower-case IPv6 in brackets; no IP zone/userinfo/fragment; numeric port with leading-zero explicit port rejected and omitted 443; uppercase percent hex and unreserved decode before RFC3986 dot-segment removal (so %2e participates), and / empty path. Query order and duplicate pairs stay intact; + is literal; absent query omits ?, while an explicit empty query retains a terminal ?. Bytes are https://host:port/path[?query], sink is https://host:port, and redirects are refused.
The signed provider is pair-owned and outside general ProviderSet; private MCP prepare binds directly to it, and only catalog source swap makes data-plane dispatch visible. Protocol projections come from immutable pair revision; generic provider resolution cannot bind it. A pair-owned registry holds only close/reconcile receipts. Generic writers remain closed against pair halves. Each local publication is additionally bound to one opaque, durable publisher epoch CAS-minted only after the desired revision commits. The epoch is internal: it appears in no Protocol request/response, revision, broker actor assertion, or audit payload. A restart or second runtime must CAS-take a new epoch before it prepares/publishes; every prior provider, cached bearer, and MCP handle then fails closed. Token exchange authorizes the exact operation phase+epoch before cache lookup, after exchange, and before cached return, while the bearer RoundTripper repeats the check immediately before downstream send. The only pre-publication exception is an internal preparation marker, accepted solely in revision_committed; ordinary dispatch cannot carry it.
Paired removal is the latter five transitions of that same pair-lifetime record, not a second operation. It is a durable SaveIf recovery sequence: removal_admitted (all publisher epochs durably denied before local teardown), removal_revision_committed (desired pair absent by revision CAS), catalog_unpublished, teardown_receipted (transport/provider close+revoke for the exact local epoch when present; absence is safe because durable revocation already made every remote/stale epoch inert), then removed. A runtime with no local handle may therefore advance teardown from the durable receipt; stale processes can retain only non-dispatchable handles and clean them on reconcile. A commit-then-error or unknown outcome exact-rereads the operation EventID/phase, desired revision, catalog source, and teardown receipt, and resumes only the missing phase. Expiry, key revocation, or a lost verifier never block teardown; retirement invokes this same sequence. Before a first-install candidate is semantic active, a durable agent-scope pending-activation/compensation fence binds exact operation/content fingerprint, candidate revision, and prior active revision/EventID or no-active. 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/no-active and never authorizes the candidate. A foreign operation rejects with typed pending/conflict; only the same operation can serialize and resume. Exact-EventID SaveIf commits or aborts the fence; uncertainty remains pending cross-runtime until reread proves its phase. DeactivateIfActive is post-fence pointer compaction, never this security fence. (Phase 233b.)
Immediate user-profile reconciliation (D-450). The user-tier agent_config.user.reconcile_live_profile method is the single Protocol recovery/read operation for a caller whose durable ConfigScopeUser signed MCP pair may no longer be materialized in the process-local registry. Its request is limited to the verified IdentityScope echo and agent_id, and it requires the existing agent_config:user scope and signed reach for that agent. The service invokes the same SignedOAuthMCPReconciler.ReconcileSignedOAuthMCPCapabilityForScope path used at run start with ConfigScopeUser, then reads the caller's fresh active revision and returns that projection. It has no provider, endpoint, authority, JTI, tenant/user selector, idempotency key, ledger, or alternate lifecycle; register, removal, revision CAS, physical owner, and teardown-fence semantics remain those of D-448. The active read follows the reconciler, so a concurrent user removal or revision fence cannot be reported from a stale pre-reconcile projection. Missing identity, authorization, reach, wiring, or profile state fails closed, and the Protocol version is unchanged.
Events. The registry emits agent.registered, agent.restarted, agent.health, agent.drained, agent.deregistered on the typed event bus (§6.13), carrying the registration agent_id. The Console Agents page (§7) is a lens over these events plus a registry state snapshot — the Console never holds the agent list itself (D-061).
Fleet privilege tiers. A Console managing one or more Harbor runtimes is a control plane. Fleet observation (reading events, viewing topology, listing agents) and fleet control (pause / drain / restart / force-stop of agents) are distinct privilege tiers — control requires a more-elevated scope claim than observation, extending the elevated-scope-claim concept (§6.13 admin subscriptions). Every fleet-control command is audit-redacted (§6.4) and emitted. A leaked read-only Console token must not be able to force-stop a fleet. A runtime-side enrollment allowlist of authorized control-plane clients is a stronger-than-JWT-scope option, deferred as a "decide later" item — per-request JWT scope (§5.5) covers the V1 need. (Settled — D-066.)
Consumers. Phase 30 (tool-side OAuth) keys agent-bound tokens by the registration agent_id. The Console Agents page renders the three-ID model and the fleet-control surface. Briefs: 09-mcp-oauth-from-bifrost.md (agent-as-actor), 11-console-feature-surface.md (operator mockup).
Tenant-owned MCP source admission amendment (D-457)
Boot-declared MCP infrastructure remains process-global in the shared registry and catalog. Runtime-added sources use an exhaustive ownership discriminator: boot_global only for the fully empty owner, tenant_agent for complete tenant and agent ownership, and tenant_user for complete tenant, agent, and user ownership. Partial owners fail closed. The discriminator is computed from immutable server-derived ownership, not persisted as another grant.
Runtime-owned source visibility requires the verified tenant, admitted effective agent, and current owning agent/user revision; user sources additionally require the verified owner user. Signed pair ownership is checked separately from ordinary connection descriptors, and signed publisher-epoch authorization remains the final invocation fence. Effective agent is configuration authority, not an addition to the (tenant, user, session) identity or storage partition. Identical source labels across tenants or derived agents do not imply authority.
Tenant-owned physical source names are deterministic owner-qualified catalog keys; durable descriptors remain logical. Protocol tool rows expose logical_id for stable policy keys and MCP server rows expose logical_name from registry metadata; physical id/name remain invocation identifiers. Legacy agent source/resource/App and paused tool references resolve only through the current verified tenant and admitted effective-agent view, with fresh generation checks for Apps and no ambiguous or personal-source alias fallback. Explicit low-level operator inventory remains available; ordinary identity-scoped reads never acquire fleet visibility from an empty owner user. This supersedes D-301/D-448 only for runtime-added MCP source admission and naming, retaining shared boot transport and registry/catalog architecture.
The read-only tools.list and tools.describe configuration inventory (D-459) uses explicit view: configuration, verified admin authority, and a mandatory admitted effective agent_id. The default remains execution exposure. The configuration view preserves identity, source ownership, current revision, signed reach and auth-scope admission; it only includes both loading modes and omits exposure exclusions. A physical tool ID remains subject to those filters when describing its schema. Configuration reads never authorize invocation.
Clients negotiate tools_configuration_view_v1 before sending the configuration view selector. It is advertised only when the owned-source configuration backend is wired. An older runtime receives no selector; clients do not retry a refused configuration request by downgrading its view.
6.17 Run-completion hook
The run-completion hook is the Runtime's one run-lifecycle egress point: an operator-configured hook, fired exactly once at the run loop's terminal boundary, that delivers the run's transcript to a named catalog tool. The motivating consumers are memory, audit, and analytics sinks that need the full conversation at completion for runs no client observes — background and disconnected runs have no observer to pull it, and a completed foreground embed run emits no generic completion event a subscriber could ride (only tasks-engine runs emit task.completed / task.failed). The hook is therefore runtime mechanism on the run loop — the single seam every run type (embed one-call, foreground task, background task) terminates through — never planner policy: no planner concrete knows the hook exists, and a swapped planner inherits it unchanged (§3.2).
Firing contract (Settled — D-280). The hook fires once per run, at the terminal exit, for every terminal outcome — goal, no_path, constraints_conflict (including a rejected HITL gate and a pause that timed out), cancelled (including cancel-while-paused), and terminal runtime error — classified error or deadline_exceeded — with the outcome carried in the payload, not encoded in whether the hook fires. It never fires mid-run and never on pause: a pause is not an exit; the run loop does not return while a pause is outstanding. The hook outcome is the run-loop terminal outcome: post-run processing outside the run loop — the run-level output-schema backstop that can fail a goal-finished run's answer after the loop returns, and the driver-level task-status mapping — is not reflected in the payload (a schema run whose terminal answer later fails the runtime-edge validation delivers goal to the sink while the task lands failed). The hook runs after the run's terminal result is settled and MUST NOT alter it — a hook failure (unknown tool, transport error, timeout) emits the canonical run.hook_failed event (§6.13) plus a Warn log and nothing else; a successful dispatch emits run.hook_dispatched. Both payloads carry metadata only (identity, tool name, outcome, duration, transcript size) — never transcript content. Silent degradation is forbidden (§3.4); so is escalating a sink failure into a run failure.
Egress is the tool catalog — one path, not two. The hook's target is a tool name resolved in the runtime's catalog (an MCP tool on a connection, an in-process tool, or a config-declared HTTP tool), dispatched through the same executor path planner tool calls take. This is deliberate: provenance stamping, mandatory identity capture, the per-tool policy shell (timeout, bounded retries), and the args-free tool.invoked / tool.completed / tool.failed audit events all come from the existing machinery. A bespoke webhook/HTTP-callback subsystem was considered and rejected: it would re-implement transport, auth, retry, provenance, and audit in parallel with the catalog — the "two parallel implementations" smell. The catalog is the egress. Because the executor resolves against the full catalog while the planner's prompt sees the filtered per-run view, a hook target can be dispatchable without ever being exposed to the LLM. The dispatch is not a planner tool invocation: it appends no trajectory step and advances neither tool_calls_seen nor the Console tool_count. Retries beyond the target tool's own policy shell do not exist at the hook level — one attempt, bounded, no queues.
Cancellation bridge. For a cancelled run the run's own context is already dead when the hook fires. The dispatch runs under a bounded detached context — cancellation detached from the run, context values preserved (the identity quadruple keeps flowing; the bridge is never a bare background context, which would drop identity), bounded by an explicit configurable timeout. This is the same documented bridge pattern the tool-auth subsystem uses for post-cancellation token work.
Hard-Stop exception (D-478 amends D-280/D-289). An accepted hard Stop suppresses the external completion-hook dispatch and auto-naming trigger: Stop must not launch a new tool or model call during teardown. Durable internal settlement and completion-chunk sealing still run using bounded cleanup contexts. Soft cancellation and other terminal outcomes retain the firing contract above.
The transcript payload is a public contract. The hook delivers a typed, versioned payload (format_version: 1, golden-pinned JSON — it leaves the process to operator servers): run metadata (the identity quadruple; the registration agent_id when the wiring layer knows it — metadata per §6.16, never an isolation key; outcome; timings; the true tool-invocation count) plus the faithful ordered conversation — the initial goal, every steering-injected USER_MESSAGE and REDIRECT in arrival order with step indices, the assistant's per-step prose and compact tool lines, and the final answer. Steering text is captured from live run state at completion: the run loop accumulates applied user messages per run (they are otherwise consumed per step and never durably recorded — the applied-control history deliberately drops payloads). Steering entries are bounded by the §6.3 Protocol-edge payload caps; raw tool observations are excluded (the transcript is conversation-shaped, not a trajectory dump — trace consumers use the observability surface). The payload travels only as tool arguments to the target's transport: it never traverses the LLM edge (the §6.5 context-window safety net does not apply) and never rides a bus event or log line.
Configuration: static yaml paired with the versioned agent-config surface. The static home is runtime.hooks.run_completion: {tool, timeout} in the operator config; the durable, versioned home is a hooks section on the agent-config payload (§6.16's content surface), riding the existing revision machinery — content-hash, set_revision with sibling-section preservation, server-side diff, rollback, agent.config.revised. Resolution happens once at run start with next-run projection semantics (the per-run immutable snapshot, §3.5): agent-config section over yaml over no-hook; an in-flight run keeps its snapshot; an edit is invisible to it by construction. No new Protocol method ships for this: the section rides the existing agent-config verbs, and the wire impact is additive types only (the per-section wire schema plus its diff arm, kept in lockstep with the Console client mechanically). On the embed path the WithCompletionHook run option overrides (or explicitly disables) the resolved hook per call.
Identity. The dispatch carries the run's (tenant, user, session) + run quadruple; a hook target on an MCP connection with a per-identity credential binding resolves the caller's bearer automatically from the context identity — no hook-specific auth path exists.
Non-goals (V1.10, settled). No pre-run hook point (the next-message override surface covers pre-run shaping; further hook points are additive future decisions against D-280, which is why the firing contract is outcome-carried rather than outcome-encoded). No durable store capture of steering turns (the hook reads live state; durable capture is a named follow-up with its own retention/erasure questions). No generic webhook subsystem — one egress path. (Settled — D-280.)
7. Console layer
The Console is its own product, in its own repository. It is a SvelteKit + adapter-static SPA that talks to the Runtime exclusively over the Harbor Protocol. (Settled — AGENTS.md §4.5.)
7.1 The runtime-lens principle
The Console does not own execution — the Runtime does. The Console connects through the Protocol and renders state snapshots + realtime events + control commands. Every Console page is therefore a runtime lens: a projection over canonical Runtime state, never a standalone app feature and never a privileged hook. This principle has a binding consequence — see §7.3.
7.2 Information architecture
The Console is a 14-page observability + control plane, organized in five clusters:
Harbor Console
├─ Runtime — Overview, Live Runtime
├─ Execution — Sessions, Tasks, Agents, Tools, Events, Background Jobs
├─ Resources — Flows, Memory, MCP Connections, Artifacts
├─ Evaluation — Evaluations (post-V1, §12)
└─ Settings — SettingsThe canonical Agents-page mockup is docs/rfc/assets/console-agents-page.png — fleet management, not an assistant gallery.
Two distinctions are settled and load-bearing (D-062):
Live Runtime ≠ Sessions. Live Runtime is the present-tense interactive execution workbench — initiate, observe, and steer live executions through the same Protocol surfaces used in production; the chat/testing interface is one panel among many. Sessions are the past-and-active durable execution records — replay, continue, clone, convert-to-evaluation. Conflating them produces two half-built versions of the same surface.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 a lens over the Agent Registry (§6.16).
7.3 Binding conventions
- No Console page phase ships without its feeding Protocol-surface phase landing first or in the same wave. This is the "no primitive without its consumer" rule (
AGENTS.md§13) read backwards — it keeps the Console honest as a Protocol client. Cross-cutting Console needs (anotification.*event topic for the Overview intervention queue,search.*Protocol methods for global search) land as named acceptance criteria of their consuming page phases, not as free-floating primitives. - A Console DB, if one exists, holds Console-local state only — saved views, dashboard layouts, per-operator preferences, annotations. It is never a source of truth for runtime entities (agents, sessions, tasks, tools, events, artifacts); those flow exclusively through the Protocol. (Settled — D-061;
AGENTS.md§13 forbidden practice.) - MCP Apps
DisplayModeis a Protocol-level concern. An MCP app declares its preferred rendering mode —inline(a widget in the chat scroll),fullscreen(a new tab within the agent/session view; multiple fullscreen apps yield multiple tabs), orpip(split-screen between chat and app, default 50/50, resizable). The runtime forwards the declared mode; the Console honours it.DisplayModelives ininternal/protocol/types/, not in Console-only state. - The MCP Apps host emits theme + design tokens and delivers tool data into the rendered app (D-342). The Console MCP Apps host renderer populates the
ui/initializehost-context with the operator's live theme (color-scheme) and structural design tokens (styles.variables, the ext-appsMcpUiStyleVariableKeynamespace), and patches them via ahost-context-changednotification when the host theme changes — so a renderedui://app adapts to the host's light/dark mode and native tokens instead of shipping a fixed palette. It also delivers the originating tool call's INPUT + RESULT into the app afterui/notifications/initialized(the app renders from host-pushed data; it never re-invokes the tool). Both are host obligations of theio.modelcontextprotocol/uiext-apps dialect the host already speaks — Console-only, no Harbor-Protocol version bump. Critical lifecycle rule: the bridge is constructed ONCE with the final host-context, and every host→app send (theme patch, tool push) is gated behind the bridge'sinitializedcallback; the host lifecycle must never be torn down and rebuilt for a theme/data change (a reactive theme in the renderer's bridge-owning effect once tore down the transport mid-handshake and timed outui/initialize— the re-land isolates the lifecycle from theme reactivity and is gated by a real-iframe end-to-end handshake test AND a live real-agent render check against a real ext-apps App). Completes the reverted Console halves of D-226 (data delivery) and D-227 (live theme). (Settled — D-342.) - Progressive tool-input streaming into a rendered app is deferred (D-343 reserved). An app that assembles LIVE as a tool streams — relaying
tool-input-partial/ progressivetool-resultacross the bridge — needs a runtime streaming SOURCE that does not exist today: partial tool-call arguments are merged inside the LLM driver and never leave it as a canonical event. A future wave adds allm.toolcall.partialstreaming Protocol event at the driver's fragment-assembly site, then a thin Console relay ontosendToolInputPartial— Protocol-additive, not a version bump. Reserved as D-343 until that wave.
7.4 Out of scope (V1)
- Evaluations is a post-V1 subsystem, not a V1 page (§12, D-064).
- Authoring agents in the Console — the dev-loop scaffolding lives in
harbor dev+ CLI, with the Console as the inspector, not the editor. The Flows page is a viewer over engine graphs in V1; flow authoring/versioning is post-V1 (D-063). - Hosting the Console in the Harbor Runtime binary. (Even when
harbor devboots a local Console, the Console is spawned as a separate static-file server or embedded via a thin static-file handler that talks to the Runtime via the Protocol — not via direct package imports.)
The Console repo and its phase plans land in a separate sequence. Some Console-related phases live in this repo (Protocol surface evolution, e2e Playwright tests against harbor dev); the Console itself does not. The current phase plan (phases 72–75) covers a subset of this IA; re-decomposition against the full IA is tracked in docs/plans/README.md. Brief: 11-console-feature-surface.md.
8. CLI layer
The Harbor CLI is a single binary harbor with subcommands. (Settled.)
harbor dev Boot local Runtime + embedded Console + hot reload + draft-save scaffolding
harbor scaffold Generate a new agent skeleton from a template
harbor validate Validate config / skills / agent definitions without booting
harbor inspect-events Tail or filter the event bus of a running Runtime
harbor inspect-runs List recent runs; show a run's trajectory
harbor inspect-topology Render a run's node graph as ASCII
harbor tui Attach the native terminal test/control client to a Runtime
harbor serve --tui Serve the Protocol and co-launch that same terminal client
harbor token keygen Generate a signing keypair + public JWK Set (bring-your-own-issuer)
harbor token mint Mint a Harbor JWT signed with that key (no-IdP self-issuing on-ramp)
harbor version Print version, build hash, supported Protocol versionSettled:
- All subcommands are Protocol clients of the Runtime; they use the same client SDK a third-party tool would.
harbor tui --attach <url>is the native terminal test/control client. It is not a coding agent or IDE: it renders sessions, turns, tasks, tools, interventions, artifacts, events, Runtime posture, and controls exclusively through the authenticated Protocol. Git, repository trees, source editing, patches, shell/PTY execution, LSP, and worktrees are outside its scope. The first release is a single-operator development surface: one TUI process has exactly one active session at a time. It may switch sessions explicitly, but it is not a multi-user/fleet dashboard. The active identity remains the mandatory(tenant, user, session)triple and a switch reacquires credentials for the selected session rather than rewriting request identity under an old JWT. A lifetime-scoped token source is consulted before every REST request and SSE connection so file/issuer rotation can keep a long-running terminal attached; expiry still fails visibly when no replacement credential exists. The client restores its last durable session reference on restart and reuses that same session. If the record was closed/GC-reaped, the next submitted turn reopens it through the canonicalstart→session.reopenedpath; an erased session remains terminal and requires Start Fresh. Its binding minimum is OpenCode-level or better perceived terminal quality in hierarchy, composer editing, commands/dialogs, responsive behavior, streaming stability, accessibility, and lifecycle safety; the mechanical acceptance contract isdocs/design/tui/CONVENTIONS.md(D-317).harbor serve --tuiis an opt-in co-launch convenience, not an internal Runtime view. It waits for the ordinary authenticated listener, then dials it through the same REST/SSE client as remote attach. A scaffolded serving binary may expose the same opt-in through the curatedsdk/tuifacade. No mode gets an anonymous loopback exception, dev signer, TUI-private endpoint, or direct Runtime handle access.harbor devboots the Runtime headless on127.0.0.1:<port>, opens the Protocol, starts the embedded Console, watches the project directory for changes, hot-reloads on Go-source changes (graceful-stop in-flight runs first; configurable), and exposes a draft-save scratchpad endpoint for dynamic agent scaffolding.- The dynamic scaffolding flow: a developer iterates on an agent in the dev loop, saves drafts (project-local
.harbor/drafts/), and only commits to a final scaffold when satisfied. deployandpackagesubcommands are NOT V1. They land with Harbor Cloud's shape. (Resolves brief 06 Q-5.)harbor tokenis the bring-your-own-issuer on-ramp for an operator who runs no external identity provider.harbor token keygengenerates an asymmetric keypair (ES256 default, RS256 opt-in — both on the §5.5 allowlist) and emits the public JWK Set;harbor token mintself-issues a Harbor JWT signed with that key, with--issuer/--audiencemandatory so they match the operator'sidentity.issuer/identity.audience.harbor serveis unchanged: it accepts a minted token only because the operator pointsidentity.jwks_fileat the emitted JWK Set — identical to pointing at an external provider. serve still mints nothing. This is a single-issuer / small-production posture; multi-user SSO graduates to a real IdP. (D-264.)harbor scaffold --with-serveris the opt-in serving scaffold: alongside the agent skeleton it generates acmd/<agent>/main.gothat serves the Protocol via thesdk/serverfacade (§3.6 item 6, §5.6), with theharbor tokenloop above as its local-dev credential path; the flagless default scaffold stays headless. (D-292.)
CLI subcommand additions are an RFC update, not a casual change.
9. Persistence triad
V1 ships three drivers behind every persistence-shaped interface (StateStore, ArtifactStore, MemoryStore, SkillStore):
- In-memory — zero dependencies; default for embedded use, dev, tests.
- SQLite —
modernc.org/sqlite(CGo-free); single-binary deployments. - Postgres —
pgx; multi-node production.
All three pass the same conformance suite. Designing the interface against three backends from t=0 forces clean abstractions; designing against one tends to leak that backend's assumptions into the contract.
Settled:
- One mandatory interface per subsystem. No optional
Supports*ceremony. - Forward-only, per-driver migrations.
- WAL journal mode for SQLite.
- Both SQLite and Postgres drivers ship in the default binary; operators choose at config time.
- Conformance test approach:
conformance.RunSuite(t, factory)driven against any factory; CI runs all three drivers.
Cross-driver tests are mandatory. A new optional capability is a new method on the interface plus a new conformance scenario — no per-driver hand-waving.
10. Stack decisions
| Area | Decision | Status |
|---|---|---|
| Language | Go 1.27.1+ | Settled; Bifrost context-aware transport requires Go 1.27 |
| Module path | github.com/hurtener/Harbor | Settled |
| License | Apache-2.0 (MIT acceptable; see License subsection) | Settled |
| Build | CGO_ENABLED=0, static binary, -ldflags='-s -w' | Settled |
| SQLite | modernc.org/sqlite (CGo-free) | Settled |
| Postgres | pgx | Settled |
| LLM client | github.com/maximhq/bifrost/core (pure Go) wrapped behind LLMClient interface (one method, code-level tool dispatch in runtime); SchemaSanitizer between runtime and client | Settled — see Q-3 + brief 08 |
| Logger | log/slog (JSON prod, text dev) | Settled |
| Tracing | OpenTelemetry SDK | Settled |
| Metrics | OTel + built-in Prometheus /metrics | Settled |
| HTTP | stdlib net/http | Settled |
| JSON | stdlib encoding/json (consider goccy/go-json if perf-bound; not V1) | Settled |
| JSON Schema | santhosh-tekuri/jsonschema | Settled |
| ULID | oklog/ulid | Settled |
| YAML | goccy/go-yaml | Settled |
| CLI | cobra | Settled |
| Native TUI | Bubble Tea v2 + Lip Gloss v2 + selected Bubbles v2 + charmbracelet/x/ansi | Settled — attach-first Protocol client; quality floor D-317 |
| Console | SvelteKit + adapter-static + Skeleton | Settled |
| Console MCP Apps host | @modelcontextprotocol/ext-apps + peer @modelcontextprotocol/sdk — the official framework-agnostic AppBridge for the sandboxed MCP Apps renderer (core + app-bridge entry points ONLY, never the /react entry, so not the forbidden React surface). Consumed in manual-handler mode (D-173). | Settled — see D-172/D-173 |
| Protocol wire | SSE + REST (event stream + control surface) | Settled — Q-1 RESOLVED 2026-05-14 |
| Goroutine-leak testing | go.uber.org/goleak (test-only; VerifyTestMain in the goroutine-bearing packages) | Settled |
Additions to this surface require an RFC PR (see AGENTS.md §13).
License (Settled — Apache-2.0)
Harbor is published under Apache License 2.0. The full text lives in /LICENSE at the repo root.
Rationale. Two permissive open-source licenses were considered: MIT and Apache-2.0. Both are "open" in the OSI sense and broadly compatible with the dependency surface (bifrost, modernc/sqlite, pgx, all stdlib-equivalent transitive deps). The choice is Apache-2.0 because:
- Patent grant. Apache-2.0 §3 includes an explicit, irrevocable patent license from contributors to users. For a runtime that companies will build agents on top of — and contribute back to — a clean patent grant materially reduces adoption friction. MIT is silent on patents; that silence is fine for small libraries but creates ambiguity for infrastructure.
- Notice and attribution discipline. Apache-2.0 §4(d)'s NOTICE-file mechanism makes attribution requirements explicit and machine-readable, which fits the "many third-party drivers, many providers" surface Harbor will accumulate.
- Positioning consistency. Harbor frames itself as infrastructure-grade ("Kubernetes for agents"). Apache-2.0 is the dominant license in that neighborhood: Go itself, Kubernetes, Docker, Terraform, OpenTelemetry, gRPC, Containerd, Bifrost (our LLM client), Cobra (our CLI). MIT is more common for libraries-as-libraries (Gin, Fasthttp, Chi); Apache-2.0 is more common for platforms-and-runtimes.
MIT remains acceptable. If the maintainer prefers MIT (lighter, fewer obligations on contributors, matches some sibling projects), the flip is mechanical: replace /LICENSE, update this RFC entry, update the stack table row, update README.md. No code changes needed because Harbor's dependencies are MIT-or-Apache-compatible either way. This is recorded so a future re-read knows MIT was a real alternate, not an oversight.
License compatibility with dependencies. Bifrost (github.com/maximhq/bifrost/core) is the only non-stdlib LLM-related dependency at V1; its license must be Apache-2.0 or MIT-compatible — to be verified in the Phase 33 PR by reading its LICENSE file at the pinned version. (Sanity check: large-org Go projects with similar ancestry are universally one of these.)
Contributor License Agreement (CLA): not used. Apache-2.0's §5 ("Submission of Contributions") establishes the contribution license inbound by default. Harbor does not require a separate CLA for V1. If commercial contribution patterns later require one, that is a separate RFC.
11. Open questions
These must be resolved before the relevant phase ships. Each Q-N is referenced inline in §5/§6/§10 above.
- Q-1 (Protocol wire transport) — RESOLVED (2026-05-14). The Protocol wire transport is SSE for the event stream + REST/JSON for the control surface (see §5.4). WebSocket + JSON-RPC and gRPC server-streaming were considered and not chosen for V1: SSE+REST is the lowest-cost, browser-native option needing no proxy/shim or extra dependency, and the
internal/protocol/transports/seam keeps an alternate transport additive rather than a v1→v2 migration. Phase 60 is no longer a decision gate. Owner: hurtener. - Q-2 (A2A northbound at V1). Is exposing Harbor as an A2A server in V1 scope, or V1.1? Lean: V1.1 unless an early adopter demands it. Owner: hurtener.
- Q-3 (LLM client choice) — RESOLVED (2026-05-08). The original CGo-required candidate is rejected (conflict with
AGENTS.md§5/§13). Replacement:github.com/maximhq/bifrost/core— pure Go, 23 first-class providers, empirically validated against six OpenRouter-routed models (23 of 24 gating items pass; the lone non-pass is a cancellation-timing measurement artifact, not a defect). Validation harness and full results indocs/research/08-llm-client-validation.md. The L-2 phase is no longer a decision gate; it is a normal implementation phase. - Q-4 (Episodic memory tier). Is a durable summaries-promoted-to-user-scope tier a V1 feature or post-V1? Lean: post-V1 unless V1 user feedback demands otherwise.
- Q-5 (Skill versioning model). Content-hash-as-version +
OriginRefat V1; explicit semver versions at V1.5 if cross-tenant rolling-forward demands. Owner: hurtener. - Q-6 (Second V1 planner concrete). Settled here as
deterministic(smallest concrete that exercises a non-LLMDecisionshape). The choice is recorded for grep-ability.
These open questions are tracked as GitHub issues once the RFC is approved; the issue references replace the inline Tentative markers.
12. Out of scope for V1 / Future work
- Harbor Cloud. Managed execution plane. Separate product, post-V1.
- Durable distributed bus drivers (NATS, Redis Streams, Postgres-as-queue). Post-V1 phase set (
Distributed-2,Distributed-3, …). - Additional planner concretes beyond
reactanddeterministic. PlanExecute, Workflow, Graph, Supervisor, MultiAgent, HumanApproval all wait on V1 evidence that the interface holds. - Reflection / critique loops in the reference planner. Optional per concrete; not on V1's critical path.
- Auto-sequence detection (deterministic single-tool transitions skip the LLM call). Optional optimization, off by default.
- Cross-process tool-context handle directory. V1 keeps the registry process-local; a distributed handle directory is post-V1.
- A2A northbound server (Harbor as an A2A endpoint). V1 candidate but de-prioritized; revisit at V1.1.
- An
episodic memorytier aboverolling_summary. - Visualization editor in the Console. V1 ships read-only topology visualization; an editor is later.
- Code-mode as a Harbor primitive. A sandboxed (
go.starlark.net) code surface over the tool catalog, with meta-tools and pause/resume composition. Detailed design indocs/research/10-code-mode-as-harbor-primitive.md; correctly post-V1. Its cross-impact touches many V1 phases compositionally, not structurally — revisit at V1.1 planning. - Evaluations subsystem + agent version-control. Eval suites, golden sessions, replay-based evaluation, regression diffs, baseline promotion — and, built on top, agent version-control (success-rate-over-
version_hash, prompt evolution, tool evolution). It is a subsystem, not the Console page alone. Settled constraints (D-064): built as a §4.4 extensibility seam so a premium/hosted variant is a driver and not a fork; hard dependency on fully-replayable sessions, which makes the durable event log (Phase 57) load-bearing for it. Brief:11-console-feature-surface.md.
13. Appendix A — subsystem summary cross-reference
| Subsystem | RFC § | Briefs |
|---|---|---|
| Core runtime (engine, messages, streaming, routers, concurrency, playbooks) | §6.1 | 01-core-runtime.md |
| Planner interface, Trajectory, RunContext | §6.2 | 02-planner-and-control.md, 07-code-level-tool-calling.md |
| Steering and unified pause/resume | §6.3 | 02-planner-and-control.md + cross-fork synthesis |
| Tool catalog and transports | §6.4 | 03-tools-and-llm.md, 07-code-level-tool-calling.md |
| LLM client | §6.5 | 03-tools-and-llm.md, 07-code-level-tool-calling.md, 08-llm-client-validation.md |
| Memory | §6.6 | 04-memory-and-skills.md |
| Skills | §6.7 | 04-memory-and-skills.md |
| Tasks | §6.8 | 05-state-tasks-artifacts-sessions.md |
| Sessions | §6.9 | 05-state-tasks-artifacts-sessions.md |
| Artifacts | §6.10 | 05-state-tasks-artifacts-sessions.md |
| StateStore | §6.11 | 05-state-tasks-artifacts-sessions.md |
| Distributed contracts | §6.12 | 05-state-tasks-artifacts-sessions.md |
| Typed event bus | §6.13 | 06-events-observability-devx.md |
| Telemetry (slog + OTel) | §6.14 | 06-events-observability-devx.md |
| Governance (cost / rate / key rotation / failover) | §6.15 | 03-tools-and-llm.md, 08-llm-client-validation.md (cross-cutting) |
| Agent Registry (registration identity, three-ID model, fleet control) | §6.16 | 09-mcp-oauth-from-bifrost.md, 11-console-feature-surface.md |
| Console (separate repo) | §7 | 06-events-observability-devx.md, 11-console-feature-surface.md |
| CLI | §8 | 06-events-observability-devx.md |
14. Appendix B — the seven explicit upgrades baked in from t=0
These are the architectural decisions Harbor takes against the broader design space. Each is specified above; the appendix lists them here so phase plans can reference the doctrine in one place.
- Swappable planner. A
Plannerinterface from t=0; runtime owns mechanism, planner owns policy. The runtime never depends on a specific reasoning strategy. (See §3.2, §6.2.) - Pause/resume as a runtime primitive, not a planner return type. One coordinator serves HITL approval, tool-side OAuth, A2A
AUTH_REQUIRED/INPUT_REQUIRED, and steeringPAUSE. (See §3.3, §6.3.) - Native background tasks under unified
TaskID. Foreground and background are kinds of the same task; identity is unified. The runtime is task-keyed at the schema level. (See §6.8, §6.11.) - One typed event bus. Telemetry, streaming, and protocol emission share one canonical model. Logging and OTel derive from it; no parallel channels. (See §6.13, §6.14.)
- Tool transport unified at the type level. Every
Toolis the same struct regardless of source (in-process, HTTP, MCP, A2A). Dispatch is one switch in one place; visibility is filtered by the identity triple. (See §6.4.) - Mandatory artifacts for heavy outputs. No opt-in flag; no
NoOpfallback. The router is always-on and the size threshold is configurable. (See §6.10.) - Console as a Protocol client. The Runtime is headless and emits canonical events; the Console renders projections. The Runtime never imports the Console; the Console never reads Runtime internals. This is what unlocks remote attach, fleet view, IDE/TUI clients, and observability-vendor adapters. (See §3.1, §5, §7.)
These are the doctrine. Phase plans cite them by number when justifying design choices.
This RFC is the source of truth for V1 architecture. Updates land via PRs labeled rfc. Phase plans defer to it; if a phase plan and this RFC drift, the RFC wins and the plan is updated in the same PR.
Tenant authority for tool broker client credentials (D-458)
Remote tool broker client credentials resolve under the verified execution tenant, or the immutable authenticated signed connection tenant after caller validation. The versioned scoped GET and strict tenant echo are defined in docs/CONFIG.md. Runtime authentication plus coordinator-side runtime/tenant mapping authorizes the selection; a wire tenant field alone never does. Cache, flight and revocation state preserve tenant authority. Explicit deployment credential compatibility remains available for ordinary brokers; signed capabilities never downgrade to that path.
Atomic per-task model and thinking selection (D-461)
control.start accepts llm_settings with optional native model, reasoning_effort and max_tokens. Routed models use provider_route in the same Start request; a native model and provider route cannot be combined. The bundle is copied into the existing task record and idempotency identity, then applied to that run without a session-preference write or runtime restart. Present bundles bypass the old pending-session slot; omitted bundles preserve legacy behavior. Supplied values override runtime/agent/virtual-agent defaults, while governance and provider capability checks still apply. Changed settings with one idempotency key conflict. The advertised capability is run_llm_settings_v1; Protocol remains 0.1.0.
Run-scoped tool restriction
control.start may carry tool_allowlist, an array of physical catalog tool names that narrows the already authorized tool set for this task. An omitted field keeps ordinary visibility; a present empty array closes tool access. The runtime applies the same fence to prompt listing, deferred discovery, invocation, completion hooks, and planner-spawned child tasks. Aliases resolve to physical names before the fence is checked, so an alias cannot authorize a different tool. A changed allowlist under one idempotency key conflicts. The response acknowledges an accepted restriction with tool_allowlist_applied, and runtime.info advertises run_scoped_tools_v1 when supported.
The operation-bound opt-in resource response also returns app_operation_generation, the exact fingerprint sealed into its available render admission. This lets a trusted host create an exact next-operation intent without decoding the sealed token. Ordinary reads and unavailable admissions omit it. The fingerprint is not authority; fresh sealed admission remains mandatory.