Skip to content

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:

  1. Harbor Runtime — the orchestration kernel: tasks, planner runtime, tools, memory, sessions, events, skills, artifacts, the unified pause/resume primitive.
  2. Harbor Protocol — the canonical event/state contract that the Runtime exposes to any client. Versioned independently.
  3. Harbor Console — the observability and control-plane UI. A Protocol client; ships with the ecosystem; architecturally decoupled.
  4. Harbor CLI — the harbor binary. harbor dev boots 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 react planner; the Planner interface; 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 dev feel 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 react and deterministic. The Planner interface 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

text
                                +-----------------------+
                                |    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-process

The 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:

go
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_REQUIRED task 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.routeThroughGategate.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 ErrUnserializable naming the offending field path. There is no "silently set to nil/None" path.
  • Identity is mandatory. No require_explicit_key=False knob, 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:

  1. No data racesgo test -race ./... is the gate; CI runs it.
  2. No context bleed — run A's input/state never reaches run B; verified by per-run identity assertions in the test.
  3. No cancellation cross-talk — cancelling run A's ctx MUST NOT affect run B; verified by parallel-cancel tests.
  4. 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:

  1. 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.
  2. 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).
  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 (+ the react/deterministic registration import paths), sdk/tasks, sdk/steering, sdk/dispatch, sdk/runctx, sdk/assemble, and sdk/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 the ErrorClass vocabulary on sdk/tools. The pause/resume Coordinator stays deliberately private (D-205's curation call) — the assembled stack is its supported construction path.
  4. 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.
  5. harbortest/ remains the test kit; its parameter vocabulary becomes externally satisfiable through the sdk/ aliases rather than kit-local forks.
  6. 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 stock harbor serve. sdk/server is a thin alias/forward over the promoted internal serve constructor (§5.6): server.Open(ctx, cfg, server.Options{RegisterCatalog}) returns a handle with Serve/Close. It is production-only by constructionOpen always builds the JWKS validator from cfg.Identity and 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 uses harbor token keygenidentity.jwks_fileharbor token mint (§8), exactly as an external harbor serve operator would. This is the only sdk/ 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.)

go
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.Context

4.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

SurfaceDescription
Streaming eventsThe typed event bus from §6.13, server-filtered by identity.
Task controlstart, cancel, pause, resume, redirect, inject_context, approve, reject, prioritize, user_message (the nine taxonomy entries from §6.3).
State snapshotssessions.inspect, tasks.get, state.history, state.list_trajectories, state.load_planner_checkpoint.
Topologytopology.snapshot events; static graph + live queue depth.
Artifactsartifacts.list, artifacts.get, artifacts.get_ref, artifacts.delete — all scope-checked. Heavy bytes always go by ArtifactRef, never inline.
Traces / metricstraceparent 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, and an explicitly agent-projected tools.describe. Omitted control.start.agent_id resolves to the configured default before the gate; an omitted tools.describe.agent_id retains its distinct boot-effective projection and is not an agent choice. 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.

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.

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:

  1. One promoted serve constructor. The config→listener composition (bootDevStack + its options + the devStack serve/close lifecycle) is promoted out of cmd/harbor into an importable internal package (internal/runtime/serve), the same re-homing move §3.6/D-197 made for assemble.Assemble. harbor serve, harbor dev, and harbor console all 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 in cmd/harbor and 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.
  2. One curated sdk/server facade (§3.6 item 6) over that constructor, exposing server.Open(ctx, cfg, Options{RegisterCatalog}) → a handle with Serve/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).

go
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. RunID is Harbor's term for what the predecessor called trace_id; Harbor reserves TraceID for OpenTelemetry-style traces (which may span multiple runs). (Resolves brief 01 Q-1.)
  • DeadlineAt is 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-stream Seq, terminal Done).
  • Routers: PredicateRouter, UnionRouter, RoutePolicy.
  • Concurrency: MapConcurrent, JoinK.
  • Subflows: Subflow(factory, parent, opts...) runs a child engine with the parent's RunID, 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.

go
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

go
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:

  • Decision is a sum type. Runtime opcodes (parallel, spawn, await, pause, finish) are different shapes from tool calls. The predecessor's "magic strings as next_node" pattern is rejected.
  • Batch composes one native tool-calling response's heterogeneous intent: N catalog tool calls (joined per JoinSpec, exactly as CallParallel) plus M non-blocking spawns (each RetainTurn=false; ≥2 unbound spawns auto-join one task group). Only _finish, _await_task, _task_status, _cancel_task, _steer_task, _pause_task, and _resume_task remain 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-branch Batch is never constructed — one representation per semantic. Observation results key by the provider call_id and reply in original call order.
  • TaskStatusQuery / CancelTask (planner-package shapes — TaskStatusQuery is deliberately NOT named TaskStatus, 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 (isolate detaches 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-expressible isolate lands 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 SAME isOwnDescendant guard and ErrTaskNotOwnDescendant sentinel as TaskStatusQuery / 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).
  • RunContext is 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, and Control signals.
  • The reference react planner 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 RunID is the pattern.

Trajectory:

go
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.)

6.3 Steering and the unified pause/resume primitive

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:

go
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:

  1. A serializable half: IDs, configs, plain values. Serializes via standard JSON.
  2. 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.)

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.

go
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 Tool is the same struct regardless of source. The dispatch is one switch in one place.
  • CatalogFilter keys on the full identity triple plus GrantedScopes. The predecessor filters by tenant only; Harbor goes further from t=0.
  • Argument validation runs at the catalog edge; failures are typed tool.invalid_args events (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.

go
// 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-RPC message/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 aggregate flow.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, plus flow.budget_exceeded mapped to ErrFlowBudgetExceeded).

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 is a prompt-layer control, not an executor-edge gate (Settled — D-281 amendment). The pause / disable / loading-mode exposure controls (§6.16) act on the projected catalog view a planner is offered each turn — disable hides a tool from both the prompt-time list and the planner-facing Resolve, while defer hides it from the prompt only (the tool stays tool_search-discoverable and Resolve-callable, preserving the two-turn discovery cycle). Neither is re-checked at the executor edge: ToolExecutor.ExecuteDecision resolves a CallTool name against the FULL catalog, so a planner-originated dispatch of a remembered name (one the model carries from a prior turn's prompt, no longer in the projected view) is not blocked there, and the run-completion hook (§6.17) deliberately resolves its target against the full catalog — a disabled tool named as a hook target still dispatches. Exposure narrows the view the planner is offered; it is not a capability revocation at the executor. A run-loop pre-dispatch membership check of planner-originated names against the projected view (closing the remembered-name gap at the executor edge) is a named follow-up.

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.

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:

  1. ActionParser (internal/runtime/planner/parser/) — extracts a typed PlannerAction from raw LLM text. Owns multi-action discovery and the salvage path. Knows Harbor's next_node / args schema; deliberately knows nothing about OpenAI tool_calls, Anthropic tool_use, etc.
  2. Dispatcher (internal/runtime/dispatch/) — single + parallel folded into one design unit. Validates args against 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.
  3. 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:

  • RepairLoop drives parser → validator → planner-prompt-on-failure cycles up to RepairAttempts. 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-provider response_format adjustments 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.

6.5 LLM client layer

go
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 with first-class drivers for 23 providers (OpenAI, Anthropic, Google, Vertex, Bedrock, Azure, OpenRouter, XAI, Mistral, Ollama, Groq, Cohere, Cerebras, Fireworks, Perplexity, Replicate, ElevenLabs, HuggingFace, Nebius, Parasail, SGL, vLLM, Runway). Empirically validated on 2026-05-08 against six OpenRouter-routed models: 23 of 24 gating items pass (six models × four checks: basic chat, json_object response_format, streaming with content callback, ctx cancellation; plus token usage and cost reporting on every model). The one cancellation FAIL is a measurement artifact for long streams, not a functional defect — Harbor's runtime can abandon the channel reader on ctx.Done() without consequence. Adopting bifrost requires Go 1.26+ (matching its go.mod); Harbor's go.mod is bumped accordingly. The original CGo-required candidate is 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).

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:

go
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). Of the three supply forms (URL, DataURL, Artifact), ArtifactRef is the canonical form for non-trivial binary content. Inline DataURL is convenient for small images but carries the bytes through every layer (events, audit, memory, persistence) — so it's bounded by the heavy-output threshold (32 KB default, RFC §6.10). Above the threshold, the runtime automatically materializes DataURL content into ArtifactRefs and rewrites the message before persistence and event emission. URLs pass through unchanged 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). A runtime-wide invariant: no message reaching the LLM carries raw heavy content. 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 ArtifactStore by the Dispatcher; the planner sees an ArtifactRef, not bytes.
  • Memory turns containing heavy content carry ArtifactRefs, not the original payload (§6.6).
  • Multimodal inputs above the threshold are auto-materialized to ArtifactRef at CompleteRequest construction (D-022 above).
  • ObservationRenderer (§6.4) replaces heavy observation outputs with ArtifactStubs 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:

  1. Asserts no raw heavy content survived — any string / byte slice / DataURL whose size ≥ threshold that isn't already an ArtifactRef-shaped stub is a bug; fail loudly with ErrContextLeak (and emit llm.context_leak audit event so operators can find the offending producer). The byte heavy-content check governs offloadable content — tool / MCP result text (RoleTool messages), binary DataURL parts (image / audio / file) of any role, and structured tool-call arguments (Messages[].ToolCalls[].Args, D-347) — which is exactly what the producers above route to an ArtifactStub. Arguments belong on this side of the line for the same reason results do: they are machine-authored, they reach the provider, and they have a by-reference offload path. Conversation text (system / user / assistant message text, including an injected rolling summary) is not offloadable to a stub and is therefore exempt from this byte check; its size is governed by the token-window guard in step 2 instead (D-241).
  2. Estimates total tokens of the assembled request against the model's configured context limit. If the estimate is within ContextWindowReserve of the limit (default 5%), fail loudly with ErrContextWindowExceeded. 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's rolling_summary plus a PromptAssembler orchestrator; 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:

go
// 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 DataURL and inline-base64 patterns; emits [redacted: image/<MIME> of <N> bytes] placeholders or rewrites to ArtifactRef. ArtifactRef itself passes through unredacted (it's already a reference, not data). Phase 03 handles this from t=0.
  • Memory (§6.6): strategies handle multimodal turns. truncation drops them wholesale (the artifacts in the store are GC'd by the artifact subsystem's lifecycle, not memory). rolling_summary for 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 ArtifactRef in its args schema or result shape. The runtime resolves refs at invocation; the tool reads bytes via the ArtifactStore. 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 AttachmentDispositionref (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.

go
package memory

type Strategy string
const (
    StrategyNone           Strategy = "none"
    StrategyTruncation     Strategy = "truncation"
    StrategyRollingSummary Strategy = "rolling_summary"
)

type Config struct {
    Strategy           Strategy
    Budget             Budget
    Isolation          IsolationPolicy   // RequireExplicitKey: true (mandatory)
    SummarizerModel    string
    IncludeTrajectory  bool
    RecoveryBacklogMax int
    RetryAttempts      int
    RetryBackoffBase   time.Duration
    DegradedRetryEvery time.Duration
}

type Store interface {
    AddTurn(ctx context.Context, id identity.Identity, turn ConversationTurn) error
    GetLLMContext(ctx context.Context, id identity.Identity) (LLMContextPatch, error)
    EstimateTokens(ctx context.Context, id identity.Identity) (int, error)
    Flush(ctx context.Context, id identity.Identity) error
    Health(ctx context.Context, id identity.Identity) (Health, error)
    Snapshot(ctx context.Context, id identity.Identity) (Snapshot, error)
    Restore(ctx context.Context, id identity.Identity, snap Snapshot) error
}

Settled:

  • Three strategies: none (no-op), truncation (recent-window + budget enforcement), rolling_summary (background summarization, health states healthy → retry → degraded → recovering → healthy).
  • Identity is mandatory. The predecessor's require_explicit_key=False knob is removed from Harbor. Missing identity = empty result + audit event. (Settled.)
  • Three drivers ship at V1: in-memory, SQLite, Postgres. One conformance suite passes against all three.
  • llm_context vs tool_context separation is preserved: identifiers live in tool_context (LLM-invisible); conversation state lives in llm_context. The Go analogue is "identity flows via context.Context, never through prompt-visible state."
  • The summarizer is an injectable callable; the LLM call lives in the LLM-client subsystem; memory consumes a Summarizer interface.
  • Semantic retrieval is an opt-in mode, not a strategy (D-191). retrieval: semantic layers embedding-similarity search ON TOP of the configured strategy: turns are embedded at AddTurn and a SearchTurns(ctx, id, query, limit) surface ranks them by cosine, while GetLLMContext keeps its strategy-shaped patch unchanged — composition, never replacement. The embedder is injected as Deps.Embedder with the same fail-loud rule as the summarizer (semantic mode without an embedder fails at Open; no stub fallback). Vectors persist as identity-scoped records through the same StateStore floor the memory records use — all three drivers inherit vector persistence with conformance parity, brute-force cosine at V1 scale (an ANN index is post-V1 if scale demands). Vectors are derived data: snapshots/restores carry the strategy state, not the index, and an embedding-model change requires re-embedding (a dimension mismatch fails loudly). SearchTurns on a non-semantic store fails loudly (ErrSemanticDisabled), never an empty success.

Memory budget at very long sessions — Tentative — see §11 Q-4. rolling_summary covers hours; an episodic memory tier (durable summaries promoted from session to user scope) is post-V1 unless V1 user feedback demands it earlier.

6.7 Skills subsystem

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:

  1. 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.
  2. 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.
go
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):

  1. Parse YAML frontmatter + Markdown body via a deterministic CommonMark-only parser.
  2. Normalize body sections (## Steps, ## Preconditions, ## Failure modes) into structured fields.
  3. Resolve sibling resource files; record them as Extra.attachments.
  4. Validate via the same Skill validator the operator loader uses.
  5. 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.

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)

go
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 TaskID namespace. A foreground run is a task of kind foreground. The predecessor splits trace_id (foreground) from a separate task_id namespace (background) and even fakes a synthetic trace_id like session:<id> to fit session updates into a trace-keyed audit log; Harbor's TaskID with Kind collapses that.
  • Lifecycle: PENDING → RUNNING → COMPLETE, with PAUSED → RUNNING (planner-initiated, durable via planner checkpoint), FAILED | CANCELLED terminal.
  • Cancellation propagation honors PropagateOnCancel (cascade | isolate).
  • Idempotency: Spawn honors an IdempotencyKey per (SessionID, IdempotencyKey) so a retried spawn returns the original handle.
  • Background tasks at V1: in-process only. The seam (TaskRegistry interface) 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

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.

go
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 a LastReopenedAt, refreshes LastSeen, and emits a session.reopened lifecycle event — while preserving OpenedAt and the immutable identity triple unchanged (OpenedAt is 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 from max(OpenedAt, LastReopenedAt) so a reopened conversation is not re-reaped on the next sweep (the idle-TTL already resets via LastSeen). The one terminal exception: a session that went through session.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 (ErrIdentityMismatch otherwise), and cross-tenant reuse of a session id remains rejected by the invariant below (a reopen attempt under a different tenant is ErrSessionIDReuse, not a reopen).
  • The identity triple is captured on Open and immutable for the session's lifetime; reusing a session ID across tenants/users is rejected.
  • Touch updates LastSeen; GC sweeps idle sessions per policy and never reaps a session with a RUNNING task.

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 manualauto 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 — 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), max_title_len (auto output is deterministically clamped; the manual verb, by contrast, rejects — the trusted-internal vs untrusted-boundary asymmetry is intentional). 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.

6.10 Artifacts

go
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 NoOp fallback. 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 ScopedArtifacts facade 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.)

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.

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

go
// 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
    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.

Session-owned records and fence composition (D-400). SaveIf compares many slots but writes exactly one next slot; it is not a multi-record transaction and cannot atomically enforce a 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. StateStore is one mandatory interface keyed on (identity.Quadruple, Kind string, Bytes []byte), with idempotency on a caller-provided EventID (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 inside internal/state. Example: SessionRegistry.Save(s Session) reduces to StateStore.Save(StateRecord{Identity: s.Identity, Kind: "session.lifecycle", Bytes: marshal(s)}). This keeps internal/state a leaf with no upstream Harbor deps beyond internal/identity and internal/config.
  • One mandatory interface, three V1 drivers (in-memory, SQLite, Postgres), one conformance suite. The predecessor's eight optional Supports* capability protocols + hasattr duck-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).
  • WAL journal mode for SQLite.
  • Idempotency: Save keys on EventID; same-ID + same-bytes is a no-op, same-ID + different-bytes returns ErrIdempotencyConflict (caller-controlled retry semantics — the store never silently overwrites).
  • Identity-mandatory at the API boundary: empty tenant / user / session in the Quadruple rejected with ErrIdentityRequired. Empty RunID is acceptable for session-scoped state.
  • Explicitly-elevated maintenance scans — ListKind and ScanKindForTenant (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 require ListScope{MaintenanceScoped: true}, reject an empty prefix, and grant only visibility: callers mutate every returned record under its own identity. ScanKindForTenant is a page sequence, not a cross-restart snapshot; identity-scoped reads stay on Load/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)

go
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 a RemoteTransport capable 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.Publish is at-least-once; handlers must be idempotent on (TaskID, Edge, EventID). RemoteTransport.Send is request/reply; Stream yields ordered events with a final done=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.

go
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
}

Settled:

  • One bus, not two. The predecessor's split of telemetry vs chunked-output channels is unified on this single typed bus from t=0.
  • EventBus (the Go-level name shipped as internal/events.EventBus) ships with Publish / Subscribe / Close. The Replay(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 returned EventBus to a Replayer capability interface, keeping the core surface lean.
  • Drop policy on backpressure: drop-oldest, with a bus.dropped event describing the dropped sequence range. Notices are windowed at most once per DropWindow per subscriber.
  • Server-enforced isolation filter: Subscribe rejects empty-triple non-admin filters with ErrIdentityScopeRequired. Every Admin: true Subscribe additionally emits an audit.admin_scope_used event 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 Publish runs the payload through audit.Redactor before enqueueing — except for SafePayload-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 sibling audit.redaction_failed event (with NO original payload bytes) AND returns the wrapped error to the caller. The original event is NOT enqueued (D-020).
  • Identity-mandatory: Publish rejects events whose Quadruple lacks tenant/user/session with ErrIdentityRequired. Empty RunID is acceptable for session-scoped events.
  • Sequence numbering: per-bus monotonic via atomic.Uint64; gap-free. Caller-prefilled Sequence != 0 is rejected with ErrSequenceProvided.
  • 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.
  • Cardinality safety: future metric derivation (Phase 56) will draw labels from Event.Type and Event.Extra only — never RunID or TraceID. A static lint check enforces this in CI; the script ships as a Phase 05 stub at scripts/check-event-cardinality.sh and tightens in Phase 56.

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 BusEventBus. The earlier draft also ranged the bus interface over Replay directly; replay is now a Phase 06 capability layer. D-028 captures the reconciliation.

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.Error emits both an slog record AND a paired runtime.error bus event so logs always have an event peer. (Settled.)
  • OTel propagation: traceparent for HTTP southbound; _meta.traceparent for stdio MCP per-request; HARBOR_TRACEPARENT env var on stdio spawn for the initial trace.
  • Metrics exporter: OTLP default. A built-in Prometheus /metrics endpoint 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.

go
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 KeySelector hook — Harbor's identity triple flows here via ctx.
  • Bifrost.ReloadConfig(...) for non-realtime config swap.
  • Account.GetKeysForProvider(ctx, provider) invoked per request — keys can change without ReloadConfig.
  • Cost reporting passthrough (Usage.Cost.{TotalCost, InputTokensCost, OutputTokensCost, ReasoningTokensCost, ...}).
  • Connection pooling + drop-excess-requests backpressure.
  • LLMPlugin / MCPPlugin pre/post hook architecture (available; intentionally NOT used for identity-scoped policies — see boundary note below).

V1 scope (Settled). See master plan phases 36a + 36b.

  1. Cost accumulator, identity-scoped. Aggregates Usage.Cost.TotalCost per (tenant, user, session) and per model. StateStore-backed (in-mem / SQLite / Postgres conformance).
  2. Per-identity cost ceilings. PreCall checks; emits governance.budget_exceeded event; fails loudly with ErrBudgetExceeded.
  3. Per-identity rate limits. Token bucket per (identity, model). PreCall checks; emits governance.rate_limited; fails with ErrRateLimited.
  4. Per-call MaxTokens per identity tier. PreCall enforces a configured ceiling before the request goes out.
  5. 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).

PhaseCapabilityWhy post-V1
91Console-driven key rotation (Protocol governance.rotate_key)Operator workflow; needs Console to land first
92Console-driven mid-session model swap (Protocol governance.swap_model)Operator workflow
93Failover chains as Harbor policyHas policy + audit implications best done with Console visibility
94Provider circuit breakers per (provider, key)Cleaner once we have failover
95LLM cache (exact-match + semantic)Big complexity; not a V1 floor item
96PII redaction at the LLM boundaryAudit 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 LLMClient interface. 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 BudgetExceeded or RateLimited event can trigger a pause via the unified pause/resume primitive, surfacing in Console as a steering event with INJECT_CONTEXT ("you're at budget — pause for operator approval").
  • Bifrost layer: Governance does NOT use bifrost's LLMPlugin architecture 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 Fallbacks array 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:

IDQuestionLifecycle
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 OAuth MCP capability registration is atomic, production-safe, and boot-authorized (D-401, extended by D-404). 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 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, artifact_byte_eligible, artifact_params}. The final two non-secret fields are the same trusted artifact-egress declaration a generic HTTP MCP connection carries: the signer binds them exactly, the mapping is bounded and validated against discovered string schemas before publication, and neither the MCP provider nor an unsigned caller can opt itself in. Strict decode plus reflection reject OAuth/provider/token URL, injection, discovery, stdio command/env/cwd, 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; published 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.)

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).

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.

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:

text
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     — Settings

The 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 (a notification.* 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 DisplayMode is 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), or pip (split-screen between chat and app, default 50/50, resizable). The runtime forwards the declared mode; the Console honours it. DisplayMode lives in internal/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/initialize host-context with the operator's live theme (color-scheme) and structural design tokens (styles.variables, the ext-apps McpUiStyleVariableKey namespace), and patches them via a host-context-changed notification when the host theme changes — so a rendered ui:// 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 after ui/notifications/initialized (the app renders from host-pushed data; it never re-invokes the tool). Both are host obligations of the io.modelcontextprotocol/ui ext-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's initialized callback; 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 out ui/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 / progressive tool-result across 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 a llm.toolcall.partial streaming Protocol event at the driver's fragment-assembly site, then a thin Console relay onto sendToolInputPartial — 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 dev boots 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.)

text
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 version

Settled:

  • 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 canonical startsession.reopened path; 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 is docs/design/tui/CONVENTIONS.md (D-317).
  • harbor serve --tui is 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 curated sdk/tui facade. No mode gets an anonymous loopback exception, dev signer, TUI-private endpoint, or direct Runtime handle access.
  • harbor dev boots the Runtime headless on 127.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.
  • deploy and package subcommands are NOT V1. They land with Harbor Cloud's shape. (Resolves brief 06 Q-5.)
  • harbor token is the bring-your-own-issuer on-ramp for an operator who runs no external identity provider. harbor token keygen generates an asymmetric keypair (ES256 default, RS256 opt-in — both on the §5.5 allowlist) and emits the public JWK Set; harbor token mint self-issues a Harbor JWT signed with that key, with --issuer / --audience mandatory so they match the operator's identity.issuer / identity.audience. harbor serve is unchanged: it accepts a minted token only because the operator points identity.jwks_file at 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-server is the opt-in serving scaffold: alongside the agent skeleton it generates a cmd/<agent>/main.go that serves the Protocol via the sdk/server facade (§3.6 item 6, §5.6), with the harbor token loop 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):

  1. In-memory — zero dependencies; default for embedded use, dev, tests.
  2. SQLitemodernc.org/sqlite (CGo-free); single-binary deployments.
  3. Postgrespgx; 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

AreaDecisionStatus
LanguageGo 1.26+Settled
Module pathgithub.com/hurtener/HarborSettled
LicenseApache-2.0 (MIT acceptable; see License subsection)Settled
BuildCGO_ENABLED=0, static binary, -ldflags='-s -w'Settled
SQLitemodernc.org/sqlite (CGo-free)Settled
PostgrespgxSettled
LLM clientgithub.com/maximhq/bifrost/core (pure Go) wrapped behind LLMClient interface (one method, code-level tool dispatch in runtime); SchemaSanitizer between runtime and clientSettled — see Q-3 + brief 08
Loggerlog/slog (JSON prod, text dev)Settled
TracingOpenTelemetry SDKSettled
MetricsOTel + built-in Prometheus /metricsSettled
HTTPstdlib net/httpSettled
JSONstdlib encoding/json (consider goccy/go-json if perf-bound; not V1)Settled
JSON Schemasanthosh-tekuri/jsonschemaSettled
ULIDoklog/ulidSettled
YAMLgoccy/go-yamlSettled
CLIcobraSettled
Native TUIBubble Tea v2 + Lip Gloss v2 + selected Bubbles v2 + charmbracelet/x/ansiSettled — attach-first Protocol client; quality floor D-317
ConsoleSvelteKit + adapter-static + SkeletonSettled
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 wireSSE + REST (event stream + control surface)Settled — Q-1 RESOLVED 2026-05-14
Goroutine-leak testinggo.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:

  1. 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.
  2. 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.
  3. 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 in docs/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 + OriginRef at 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-LLM Decision shape). 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 react and deterministic. 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 memory tier above rolling_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 in docs/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

SubsystemRFC §Briefs
Core runtime (engine, messages, streaming, routers, concurrency, playbooks)§6.101-core-runtime.md
Planner interface, Trajectory, RunContext§6.202-planner-and-control.md, 07-code-level-tool-calling.md
Steering and unified pause/resume§6.302-planner-and-control.md + cross-fork synthesis
Tool catalog and transports§6.403-tools-and-llm.md, 07-code-level-tool-calling.md
LLM client§6.503-tools-and-llm.md, 07-code-level-tool-calling.md, 08-llm-client-validation.md
Memory§6.604-memory-and-skills.md
Skills§6.704-memory-and-skills.md
Tasks§6.805-state-tasks-artifacts-sessions.md
Sessions§6.905-state-tasks-artifacts-sessions.md
Artifacts§6.1005-state-tasks-artifacts-sessions.md
StateStore§6.1105-state-tasks-artifacts-sessions.md
Distributed contracts§6.1205-state-tasks-artifacts-sessions.md
Typed event bus§6.1306-events-observability-devx.md
Telemetry (slog + OTel)§6.1406-events-observability-devx.md
Governance (cost / rate / key rotation / failover)§6.1503-tools-and-llm.md, 08-llm-client-validation.md (cross-cutting)
Agent Registry (registration identity, three-ID model, fleet control)§6.1609-mcp-oauth-from-bifrost.md, 11-console-feature-surface.md
Console (separate repo)§706-events-observability-devx.md, 11-console-feature-surface.md
CLI§806-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.

  1. Swappable planner. A Planner interface from t=0; runtime owns mechanism, planner owns policy. The runtime never depends on a specific reasoning strategy. (See §3.2, §6.2.)
  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 steering PAUSE. (See §3.3, §6.3.)
  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.)
  4. 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.)
  5. Tool transport unified at the type level. Every Tool is 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.)
  6. Mandatory artifacts for heavy outputs. No opt-in flag; no NoOp fallback. The router is always-on and the size threshold is configurable. (See §6.10.)
  7. 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.

Apache-2.0 licensed — see LICENSE.