Changelog
All notable changes to Harbor are documented in this file.
The format is based on Keep a Changelog, and Harbor adheres to Semantic Versioning.
Two versions move independently in Harbor (RFC §5.3):
- The product release version — the
harborbinary's own semver, reported byharbor versionand the subject of the headings in this file. - The Harbor Protocol version — the Runtime↔Console wire-contract version, pinned in
internal/protocol/types/version.go(currently0.1.0). A breaking Protocol change is an RFC change and carries its own deprecation window.
Unreleased
1.26.6 — 2026-08-03
Added
- Signed OAuth MCP capability registration now accepts the same trusted artifact-egress declaration as a generic HTTP MCP connection:
artifact_byte_eligibleplusartifact_paramsbounded to 32 methods, 8 parameters per method, 128 bytes per name, and 8 KiB canonical JSON. Both fields are covered by the signed immutable binding, replay fingerprint, persisted revision, applied echo, and restart reconciliation. Mappings are rejected atomically when unsigned, tampered, over-bound, or inconsistent with the server's discovered string input schema. The closed descriptor still accepts no headers, arbitrary hosts, credential sinks, or secrets. This is an additive Protocol field change;ProtocolVersionremains0.1.0. A schema-rejected committed candidate is durably compensated and terminally non-replayable across restart. Existing signed pairs with both fields omitted preserve their prior replay fingerprint across upgrade and restart.
1.26.5 — 2026-08-03
Fixed
- Identity-scoped OAuth MCP connections no longer open the optional connection-level standalone SSE stream, which could retain a short-lived preparation bearer beyond its lifetime. Streamable JSON-RPC calls continue resolving fresh tenant/user credentials per invocation. Per-entry-only OAuth now injects those credentials on calls and refuses redirects when no single provider can authorize the hop. Unbound and static-header MCP connections retain standalone SSE. No Protocol wire or runtime configuration surface changed.
1.26.4 — 2026-08-03
Fixed
- Run-start connection reconciliation now preserves an active signed OAuth MCP capability instead of treating it as an undeclared generic MCP connection. Generic stale-connection cleanup remains intact, while recovery of a missing signed connection stays exclusively with the dedicated signed lifecycle. No Protocol wire or runtime configuration surface changed.
1.26.3 — 2026-08-03
Fixed
- Signed OAuth MCP capability dispatch now keeps immutable registrar identity for the broker actor assertion, removal, and audit while exchanging and caching for the verified live run subject. Reconciliation reattaches through the frozen owner receipt for a later same-tenant subject. An authenticated durable
control.startadmission receipt now gates the exact effective agent across restart and child spawns, so denied, tampered, or bare SDK-created tasks cannot use a cached bearer. No Protocol wire or runtime configuration surface changed.
1.26.2 — 2026-08-03
Fixed
sessions.listnow applies lifecycle-only filters, ordering, and cursor pagination before counter enrichment. Alast_activity_descrequest withlimit=50enriches only its returned rows instead of scanning counters for the full matching catalog; counter-dependent filters andcost_descretain their exact results with bounded (eight-worker) enrichment concurrency. No Protocol wire change.
1.26.1 — 2026-08-03
This patch restores authority-enabled serving composition for signed OAuth MCP capability recovery. The Protocol version remains 0.1.0; there is no wire surface change.
Fixed
- Signed OAuth MCP capability authority is now wired through the shared serving composition. A valid boot-declared authority no longer fails before recovery because the run-loop projection detacher was selected in place of the MCP attacher's exact signed-pair teardown seam. This restores authority-enabled boot for
harbor serve, dev/Console compositions, and the production server facade without changing the Protocol version or making any credential, verifier, or sink field writable.
1.26.0 — 2026-08-02
This release strengthens the authority and lifecycle boundaries for agent-addressed Runtime work: bearer reach is explicit and bounded, OAuth MCP capabilities have a durable production registration path, and agent-config retirement is terminal and recoverable. The Protocol version remains 0.1.0; new methods, fields, and error codes are additive, but the deployment and client actions below are required for a safe upgrade.
Before you deploy
- Mint bounded signed bearer reach for every agent-addressed data-plane request.
control.start, the session and useragent_configmethods, and an agent-namedtools.describenow require a validagent_reachclaim. Add--agent-reach <id>[,<id>...]when usingharbor token; missing or empty reach is denied and malformed reach fails authentication. The defaultharbor devbearer reaches only its boot agent. Update any issuer or client that previously relied on an unbounded bearer before deploying. - Plan the session-personal skill cutover. New session-personal skill mutations default to
session_skill_cutover_pending(HTTP 409) while a tenant is indual_read; eligible legacy skills remain readable. An operator must declare the tenant's bounded static cutover, drain older writers, and allow Harbor's resumable verification to finish atstate_only. Do not retry the refusal by writing a legacy shared SkillStore body. - Treat retirement outcomes as part of normal client handling. The additive
agent_config.retireoperation makes an agent unavailable for new runs while retaining immutable revision history and resumable cleanup. A retired agent now returns the additiveagent_retired(HTTP 409) outcome; a conflicting retirement operation returnsagent_retirement_conflict. Clients that distinguish error codes should handle both before enabling retirement automation.
Security
Stable-JTI signed OAuth MCP registration now durably admits expiry before compensation and can resume the same exact operation with a later freshly verified envelope. Recovery preserves the original registrar and receipt, restores only the frozen prior/absent authority, and cannot reopen published, removal, removed, or widened bindings.
Signed OAuth MCP capability registration now recovers only exact durable signed pairs at boot and run start. Recovery verifies the pair descriptor, owner, operation receipt, and activation fence before it can attach or withdraw a connection; real SQLite and two-runtime Postgres restart paths are covered.
Agent-addressed data-plane calls now require signed, bounded
agent_reachauthority. This applies tocontrol.start, the session and useragent_configdata-plane methods, andtools.describeonly when it names anagent_id. Add--agent-reach <id>[,<id>...]when minting a bearer withharbor token; the defaultharbor devbearer reaches only its boot agent. Missing or empty reach denies these calls, while malformed reach rejects authentication. Omittedtools.describe.agent_idremains the boot-effective projection.Session-personal skill mutation now defaults to a deliberate read-only
dual_readdeployment posture. Existing eligible legacy session skills stay readable, but new/update/delete requests returnsession_skill_cutover_pending(HTTP 409) until an operator declares the tenant's bounded static cutover, drains every older writer, and Harbor completes a fresh durable verification pass tostate_only. This is not a transparent fallback and clients must not retry by writing a legacy shared SkillStore body. The declaration requires a configured SkillStore; malformed, duplicate, over-bound, or unwired declarations fail boot loud.
Fixed
The completed, independently reviewed authority and lifecycle checkpoint composes signed-agent reach, conditional configuration writes, signed-capability publisher takeover and removal, session erasure, inflight-run retirement drain, cleanup restart, reasoning durability, and mixed-identity cancellation isolation. Isolated-schema Postgres races cover the conflicting authority, lifecycle, overlay, and personal-record transitions. This is checkpoint evidence; tag, release assets, checksums, and provenance publication are recorded separately after they complete.
Bifrost reasoning now preserves the selected response's exact observed bytes. Any non-nil raw reasoning delta, including an empty one, makes the raw stream authoritative; details-only responses coalesce fragments by stable block identity without trimming or rewriting whitespace. Only choice index 0 contributes content, reasoning, tool calls, or callbacks. The same bytes are verified through live callbacks, completed responses, planner decisions, task trajectory, durable restart history, and Console history rendering. This is an internal fidelity correction with no Protocol type, method, event, error, manifest, or version change.
1.25.0 — 2026-08-01
One release-closure slice: the original prompt-composition surface, the transaction and declared-tool corrections found by adversarial review, and the external-oracle/reliability gates needed to trust the candidate. No Protocol wire-version change (ProtocolVersion stays 0.1.0) and no migration. Every wire addition is an optional field or an additive error code; two changes are nonetheless breaking for a deployed client, and they are the first thing to read below.
The defect this wave closes is not the one that was reported. The upstream ask was "Harbor offers no additive path for contributing prompt content." It does — two of them, both shipped and both documented. What was true is narrower and worse: the safe paths were not reachable over the Protocol, so the surface steered consumers toward the unsafe one. planner.MemoryBlocks carries recalled content behind an anti-prompt-injection preamble and appeared nowhere in internal/protocol/; planner.LLMOverrides.ExtraInstructions renders additively into a trusted position and was absent from RunOverrides. A consumer needing either reached for system_prompt_override, which replaces the whole base+user spine, silently suppresses the operator's durable user layer, and seats caller content in the trusted base position.
Before you deploy
Two changes break clients that worked against 1.24. Both are in Action required at the end of this entry with the full reasoning; this is the short form, and neither is optional reading.
- The control transport now decodes strictly, so an unknown request member is a
400instead of a silent drop (D-374). If your client folds extra keys into request bodies, it will start failing. Seven request types across eleven methods declare noidentityfield — the fiveartifacts.*,SearchRequest(shared by the fivesearch.*), andgovernance.set_posture— and anidentityobject sent to any of them is now refused. This is not hypothetical: Harbor's own Console and its own smoke corpus both broke on it, and both are fixed in this release. GovernancePostureRequest.tenant_idandLLMPostureRequest.tenant_idare removed from the Protocol (D-374). The Runtime never read either field. A request carryingtenant_idtogovernance.postureorllm.postureused to return a200with the wrong tenant's posture; it now returns a400. Drop the field and setidentity.tenantinstead — the cross-tenant read has always lived there.
Three further changes need a decision from you, not a code change — the conditional-write guarantee is exact within one Runtime process and absent across processes; extra_instructions remains available to ordinary users for same-session personalization but is now escaped and structurally separated from operator guidance; and every model-visible tool name longer than 44 bytes is now rewritten (D-377), so anything that string-matches tool names outside a live run — stored transcripts, eval fixtures, dashboards — sees new strings.
Upgrade recommended for all deployments. This release also strengthens the containment of untrusted memory and turn text in the prompt (see Security below).
Added — a caller can hand a run its own recalled memory, in the UNTRUSTED tier
starttakes an optionalcaller_memory. It is ajson.RawMessage(StartRequest.CallerMemory,caller_memory,omitempty) composed into the run's External memory tier — the one the ReAct planner renders as a system message behind a five-line anti-prompt-injection preamble, in the documented most-stable-first order that preserves KV-cache prefixes. This is the slot that already existed for exactly this content; it is now reachable from a Protocol client. An omitted field produces a byte-identical wire shape and byte-identical run behaviour, golden-compared rather than asserted.The caller names no key. It supplies a value; the runtime writes it under one fixed runtime-owned map key,
caller_supplied(runctx.CallerSuppliedKey). There is therefore no reserved-key deny-list to maintain and no future collision surface: runtime producers may add sibling keys forever and can never collide with a caller, and a caller can never shadow, rename or displace a runtime key. Runtime semantic recall (recalled_turns) and caller-supplied content COMPOSE in the same tier rather than competing for it.The
Conversationtier is NOT caller-writable. The runtime writes that slot unconditionally whenever the memory patch is non-empty, so a caller writing it would be two producers on one slot with silent last-writer-wins. The reported need is served by External; widening buys nothing and costs the collision.The bound sits at the Protocol edge, before a task exists. A payload over 32 KiB is refused
invalid_requestand no task is created — the refusal precedesSpawn. (It does not precede session creation: thestarthandler ensures the session row before it validatescaller_memory. Only the named-agent check is ordered ahead of the ensurer.) The refusal text namescaller_memory, which is load-bearing: the control transport caps the whole body at 64 KiB with the same error code, so a field cap that ever rose to meet the envelope cap would become unreachable dead code while every status-code test kept passing. The ordering is pinned mechanically. An explicit"caller_memory": nullis refused rather than read as absent, and a malformed document is refused rather than dropped.That cap is a resource bound and a wire-size guard, not a security boundary (D-375). It exists because nothing downstream re-checks these bytes — the LLM-edge leak guard byte-exempts system-role text — so without it an oversized document reaches the token-budget guard and fails the whole run late instead of costing one cheap refusal. Nothing may be inferred from it about how much content a caller can put in front of the model: the same principal can send more through the uncapped
query(landing in the unframed conversation position) and through the claim-freeagent_config.session.set_user_prompt(1 MiB body, landing inside the system prompt). What contains this payload is positional, never its size.It is still deliberately not an operator knob, on the operational argument rather than a security one: a byte dial buys nothing an operator can act on, and a configurable value is a foot-gun against the cap-ordering invariant above. If a legitimate caller is ever refused, the answer is an additive optional config key defaulting to the constant and validated strictly below the envelope cap.
Negotiate
caller_memorybefore you rely on it, and unknown members are now refused rather than dropped (D-374). A runtime that predates the field would have discarded it and answered 200 — the run then proceeds without the memory the caller believes it supplied, and nothing says so. Two changes close that:runtime.info.capabilitiesnow advertisescaller_memory(the ninth Protocol capability, and the first to advertise an additive optional request field rather than a method cluster — a client reads its absence to identify an older runtime), and the control transport now decodes strictly, so a member no wire type declares comes back400 {"code": "invalid_request"}with the member named. Every other Harbor Protocol handler already decoded strictly; the control transport was the outlier. No deprecation window applies —Deprecationcan only describe a method, error code, wire field or capability being retired, and unknown-member tolerance is none of those. Action for clients: strip members Harbor does not declare. In particular theartifacts.*methods scope byscope, and a strayidentityobject beside it is now a 400 rather than a silent drop.Seven request types, reached by eleven methods, declare no
identityfield — the fiveartifacts.*types (which scope byscope), the singleSearchRequestshared by all fivesearch.*methods (which scopes byfilter), andGovernanceSetPostureRequest. Audit all eleven methods if your client folds identity into request bodies, as Harbor's own Console and smoke corpus both did:- The Console sent it on all five
artifacts.*and onsearch.query— its transport folds the triple below the typed client surface, where caller-side typing cannot see it. Those call sites now suppress the fold, and a lockstep check (npm run lint) enforces the rule in both directions: a call site suppresses the fold iff its request type has noidentity. - The smoke corpus sent it to four
search.*methods. A second guard (scripts/check-smoke-body-identity.sh, run bymake drift-audit) covers that corpus.
Clients built on the Go Protocol client are unaffected: they marshal typed wire structs, which have no
identityfield to populate.- The Console sent it on all five
Removed:
GovernancePostureRequest.tenant_idandLLMPostureRequest.tenant_id— a Protocol field the Runtime never read. Both request types were orphans: the posture family is decoded into the sharedRuntimeInfoRequestenvelope, sotenant_idwas discarded and an admin naming another tenant silently received its OWN tenant's posture with a 200 — wrong data on an admin audit path, with no signal. The strict decode turned that into a loud 400, which is how it surfaced.The cross-tenant read is not lost — it never lived here. The selector is
identity.tenant: a body tenant differing from the verified tenant requires theadmin/console:fleetclaim and emitsgovernance.posture_read_admin. That gate is implemented, audited and tested in both directions. The field was a second, never-implemented spelling of it, so it was removed rather than implemented — two selectors for one concept would have to answer "what if they disagree?" for no benefit.If you send
tenant_idto these methods, drop it and setidentity.tenantinstead. Both types are gone from the Protocol reference and the generated TS wire types; a newTestManifest_NoOrphanWireTypesgate fails the build if a wire type is ever again published without a method that decodes it.memory.caller_block_admitted— a new canonical event recording the FACT of an admission, never the content. It carriesbytes,tierandkeyand no fragment of the payload.bytesis the exact pre-redaction wire length, even when the persisted redacted representation has a different size. It fires at admission, which precedes planning, so it lands whether or not the run subsequently succeeds. A Console that cannot tell caller-asserted memory from runtime-retrieved memory can audit neither.A documentation correction that changes where the bound had to live.
internal/runtime/runctx/memory_fetch.goclaimed the LLM-edge context-leak guard was "the authoritative backstop" for an oversized memory tier. It never has been.findContextLeakapplies its byte check to text only when the message isRoleTool, and memory tiers render asRoleSystem. The real backstop is the token-budget guard, which fires after the whole prompt is built and fails the run late. The comment is corrected, and that correction is why this bound sits at the Protocol edge rather than being left to a downstream guard that does not cover it. Both halves are pinned by greps, so a future author who re-adds the claim — or who changes the exemption the argument rests on — is forced to re-derive rather than silently invalidate it.The honest residual, stated rather than implied. This makes it possible to put untrusted content in front of a model over the wire. That is the point; the mitigation is positional (the untrusted tier and its framing), not filtering. An operator who pipes third-party content through
caller_memorywithout redacting it has a data-leakage path no prompt wrapper closes. The 32 KiB cap is the only thing that re-checks these bytes before the token-budget guard — it is not the only path by which a caller's content reaches the model, and it bounds one block rather than the aggregate: a caller block plus a largeretrieval_top_kplus a long trajectory can still exhaust a context window, and there is no per-tenant rate or volume accounting on admitted caller memory — spend is metered at the LLM edge, admission is not.
Added — extra_instructions on run overrides, contained as normal-user personalization
runs.set_overridestakes an optionalextra_instructions. The product capability is not admin-only: a verified non-admin user can personalize the next run in their own session. What was missing is reach —RunOverridesexposedsession_id,reasoning_effort,temperature,max_tokens,system_prompt_overrideandmodel, and no additive field, so the only producer was the admin-gated tenant-override record. The wire addition needs no new error code or Protocol version move.The run-level value is structurally separate from the tenant value and can NEVER clear or impersonate it. The admin-owned tenant contribution remains in
<additional_guidance>; the session contribution lands in the escaped, runtime-labelled<user_personalization>tier. Prompt ordering is not an authorization boundary: tool exposure, identity, governance, and policy checks remain authoritative. The personalization tier survives asystem_prompt_overridewithout being promoted into the replacement spine.There is no run-level clear, and an empty value is accepted rather than refused. An empty or whitespace-only run-level value contributes nothing and returns the tenant value untouched, so a run with no session contribution resolves byte-identically to before. That is what stops "clear" being reachable by the back door of "set it to empty".
The two contributions remain distinguishable. Runtime-owned section framing makes their provenance explicit while the durable per-agent surface below provides name-addressed attribution for admin-owned blocks.
The event carries a FLAG, never the text.
events.RunOverridesSetPayloadgainsSetExtraInstructions bool. The value is caller-supplied free text and never rides the bus.Binding non-goal — this is NOT the home for recalled memory. The field is for user-authored preferences that personalize a response. Recalled or retrieved content belongs in
start.caller_memory, with its stronger read-only external-memory framing.
Added — ordered, named prompt blocks on the agent-config payload
A new agent-config payload section,
extra_system_blocks, carrying an ORDERED list of{name, body}blocks, written by one admin verb (agent_config.set_extra_system_blocks) as a whole-section desired-state replace. The prompt surface was two flat*strings (Base,User), so N independent capability sources that each want to contribute one block collapsed into one opaque string and removing one contributor's text meant re-deriving the whole composition from prose. Blocks exist so each contributor can find and replace exactly its own.It lands on the durable payload, not on the per-run bundle — which is where the upstream ask put it. A per-run bundle is reconstructed by whoever assembles the next request, so a per-run block list inherits the same "who reconstructs the rest" problem it is meant to solve. On the config payload the list is durable state the registry owns, readable by name through
agent_config.get, and mutable by a name-addressed read-modify-write that the expected-revision token below makes safe against a concurrent second contributor. That token is why this ships one section-replace verb rather than per-item upsert and delete verbs.Order is the declared slice order, and normalization MUST NOT sort it. This is a deliberate asymmetry with two sibling sections: skill names are sorted and de-duplicated, and OAuth providers are sorted by name, both because a re-ordering of a SET must not perturb the content hash. For blocks a re-ordering DOES change the rendered prompt, so it MUST change the hash, mint a real revision, and appear in the diff — which gains a
Reorderedflag its sorted siblings have no analogue for. The carrier is a slice, never a map — map iteration order is not a composition order — and nothing on the write → normalize → hash → project → render path sorts it. (Localseenmaps do exist on that path, for duplicate-name detection; they are membership sets over an already-ordered slice and determine nothing about order.)Admitted block bodies are byte-faithful. Blank detection may inspect a trimmed view, but normalization, hashing, persistence, projection, and prompt rendering preserve every byte of a nonblank body, including surrounding whitespace. This matters for operator-authored structured prompt fragments.
Rendered VERBATIM, in declared order, each behind a plain-text
[name]label, into the same<additional_guidance>position — after the binary's baked operator guidance and before the additive extra-instructions. Blocks survive a sessionsystem_prompt_override, for the same structural reasonextra_instructionsdoes. Names are unique and charset-restricted (^[A-Za-z0-9._-]{1,64}$); a duplicate is refused withinvalid_requestnaming both offending positions rather than silently de-duplicated, because uniqueness is what makes remove-by-name well defined.Trust is argued from the WRITE DOOR, not assumed. The section has exactly one dedicated write verb, and every door that reaches it —
set_extra_system_blocks, plusset_revisionandrollback, which write or republish the whole payload — sits in the admin method set: the same tier that writesPromptLayers.Base, which is already verbatim and strictly more powerful. Escaping a block while leavingBaseunescaped would defend against a writer who can already replace the entire prompt, and would mangle an operator's angle brackets. The obligation that creates is stated rather than engineered away: a capability that wants to surface user-authored or model-authored text MUST NOT put it in a block — it uses the untrusted memory tier above, orPromptLayers.User, which is escaped precisely because it has a lower-tier write path. Two tested guards make a future reopening loud.The
[name]label is for a human reading a transcript. It is not a security boundary and must not be described as one. To the model, two blocks from two capabilities are one contiguous run of trusted guidance. A<block name=…>prompt tag was rejected: the attribution the ask needs is a data-model property, and minting a tag from config data would make the prompt's structural taxonomy a function of caller input.Absent ⇒ byte-identical. A stored revision written before this release unmarshals to nil, normalizes out of the canonical form entirely, and does not perturb its content hash; the renderer returns nothing, so the composed system prompt is byte-equal to before. Pinned by a byte-equality test rather than by inspection. No
harbor.yamlkey, no new error code.No cap on block count or body size, deliberately. The two real bounds are the agent-config wire door's 1 MiB body cap (
maxAgentConfigBodyBytes) and the LLM edge's token-budget guard. The byte-leak check does not cover this content — verified rather than assumed, because the obvious assumption is wrong: system-role text is exempt from the leak detector. A per-section cap would be operator policy with no consumer asking for it, andPromptLayers.Base— unbounded on the same surface today — would make it pure asymmetry.
Added — an optional precondition on every agent-config write
expected_content_hash, one optional string on all seventeen spine-writing request types. Every write onto the durable agent-config revision spine was unconditional last-writer-wins with no conflict detection anywhere on the path: two writers composing into one agent's config silently reverted each other and both were told200. Present and matching ⇒ the write proceeds. Present and not matching, or present with no active revision ⇒ refused and nothing is persisted — no revision record, no active-pointer move, noagent.config.revisedevent. Absent ⇒ byte-for-byte the previous behaviour on every door.The count is asserted, not documented. The section shipped at sixteen doors and the ordered-blocks verb above made it seventeen — caught by the exact-count guard, which is what it is for. A future eighteenth spine writer added without the field fails three ways, including a behavioural table that drives each door with a stale token (the only one of the three that catches a door which declares the field and then drops it on the floor).
A content hash, not a revision id.
agent_config.rollbackrepoints the active revision without necessarily changing the content, so rolling back to the content a writer already read leaves the content identical and the revision id different. A revision-id token would refuse that write — turning "the operator restored exactly what you read" into a false positive on Harbor's own recovery path. Accepting either token was rejected: two fields answering one question means four combinations to specify and a client that can silently pick the weaker one.revision_conflict— a new error code, mapped to HTTP 409. Nothing existing fits: the body was well-formed (so notinvalid_request) and the server did not fault (so notruntime_error, which would additionally make the conflict unbranchable — a client could not tell "re-read and retry" from a server bug). Nodatafield is added to the error envelope; a conflicted client re-readsagent_config.get, which already returns bothrevision_idandcontent_hash.The evaluation order is load-bearing. The precondition runs before the existing idempotent-re-set short-circuit. The other order would convert a stale token into a
200whenever the caller's payload happens to equal the current content — a success that misleads the caller into believing its base was still valid. A transposition is the one mutation that leaves every grep-for-presence guard green, so it is pinned twice.The token is a PRECONDITION, never an AUTHORITY. It is compared strictly after the identity and scope gates, it can only ever cause a write to be refused, and no value of it widens what a caller may write. A valid token with an incomplete identity triple is refused by the identity gate; a valid token with no admin scope is refused by the scope gate; neither is reported as
revision_conflict.The first write is protectable too —
expected_content_hash: "-"(agentcfg.ExpectNoActiveRevision, D-370). The composition protocol had no expressible form at its own base case: on an agent with no config the only token a first contributor could send was the empty one, which means unconditional, so two contributors composing a fresh agent silently reverted each other and both were told200. The reserved sentinel succeeds only while the agent has no active revision and is refusedrevision_conflictthe moment one exists, on both the content-write door and the pointer-move door. A real token is 64 lowercase hex characters, so"-"can never collide with one — asserted by a conformance row rather than argued. Three conformance rows carry it (one of which reproduces the lost update and then shows it closed), and the conditional-write smoke's exact-count guards moved 4 → 7 with them.What the token still does not do, named rather than discovered later. An unconditional writer can still clobber a conditional one — the token constrains the writer that supplies it, not the ones that do not.
Fixed — the preflight inert-smoke gate, and the classifier that measured it
scripts/smoke/inert-baseline.txtdrains to zero, and thirteen of its twenty-four entries were the measuring instrument's own false positives. The v1.24 release switched on a gate: a smoke belonging to a shipped surface that reportsOK: 0andFAIL: 0asserted nothing. Twenty-four scripts violated it and were parked as declared debt. Measurement showed the count itself was wrong — the shipped/not-shipped classifier failed two independent ways, and thirteen of the twenty-four did not belong to shipped surfaces. Its row regex could see 233 of 339 master-plan rows and treated the other 106 as shipped; its status vocabulary named two of the eight not-shipped words in use and defaulted the rest to shipped. The correction can only relax, never tighten, and that is verified rather than argued: both classifiers were run over all 360 plan entries — 345 unchanged, 15 relaxed, 0 tightened.The eleven genuinely-inert smokes got real assertions, and the helper they use closes a trap.
go test -run NoSuchTest ./pkgprints "no tests to run" and exits zero, so a smoke that names a test and asserts only the exit code reports OK forever after that test is renamed or deleted. The newassert_go_tests_passhelper greps a--- PASS:line per name, so a rename is a FAIL. Every one of the eleven repairs was mutation-verified, and the captured output showsgo testexiting 0 in each case — the exit-code guard would have been a false OK on all eight of the pure-Go repairs.Three residual holes in the gate itself, each closed rather than deferred. A smoke that exits 0 without printing a summary was invisible to both gates and is now a FAIL naming the missing call. A baseline line naming a deleted script rotted forever and is now asserted as a property of the file. An unparseable master-plan row was indistinguishable from a missing one and is now reported by name — scoped to the classification call site rather than to every smoke, because reporting the twenty-one rowless smokes on every run would be noise an operator learns to scroll past, which is the failure mode this exists to close rather than reproduce.
scripts/drift-audit.shno longer writes its markdownlint output to a fixed/tmppath.make preflightruns the audit internally, so two concurrent audits clobbered each other's diagnostic and an operator could read someone else's violations. The verdict was never wrong — it comes from the exit code — but the file the failure message named was.The drift-audit's own guards are now mutation-verified (D-376).
scripts/drift-audit.shis the mechanical instrument behind the contributor workflow and half the rejection-on-sight list, and nothing verified the instrument — its guards had been mutation-verified by hand, with the results recorded in code comments that no automated check re-ran. A guard that cannot fire is indistinguishable from a corpus with no violations. The mutation harness now builds a throwaway corpus per guard, applies the defect that guard names, runs the real audit against it, and asserts that guard's own FAIL line — never merely the exit code, which would report "caught" when a different guard fired. 18 guard units, 18 covered, 22 mutations, and a census that fails when a new guard ships with no case.It found two live defects on its first run, both fixed here:
brief NNreference resolution could not fail (an unmatched glob undernullglobdegenerated to a barels, exit 0 — every brief citation in every implementation plan had been unverified), and a smoke with noPREFLIGHT_REQUIRESheader aborted the whole audit underset -euo pipefail, silently skipping six later guards.
Security — untrusted memory and turn text are structurally contained
Content inside an untrusted prompt section cannot escape that section. The JSON-encoded memory tiers (
read_only_external_memory,read_only_conversation_memory), the skills-context block and the session-artifacts manifest are embedded inside tag-framed wrappers the model reads positionally. Harbor now guarantees structurally that no byte of the content inside those wrappers can be read as the wrapper's own framing: the JSON tiers encode<,>and&to their\u003c/\u003e/\u0026JSON escapes (loss-free, JSON-native, and deterministic, so the compact-JSON KV-cache discipline is unaffected), and the plain-text sections run through the same neutralisation. Untrusted content therefore stays untrusted and can never open a trusted position —<additional_guidance>, the operator base layer, or the admin-written named blocks.This covers content nobody had to mark as untrusted. It is not only about the new
caller_memoryfield:recent_turns[].userandrecalled_turns[].userare ordinary user turn text that the runtime itself recalls into these tiers, and an uploaded artifact's filename, MIME type and provenance string land in the session-artifacts manifest. Every one of those is contained by the same mechanism.Trusted positions still render VERBATIM, deliberately.
PromptLayers.Baseand the admin-writtenextra_system_blocksare authored by principals who can already replace the entire prompt; neutralising them would defend against nobody and would mangle an operator's angle brackets. The asymmetry is the design, and it is tested in both directions.Pinned end-to-end — over the real wire, through the real
runctx.ComposeCallerMemory→ planner render path — for every wrapper in the prompt, plus the inverse assertion that trusted positions are unaffected. Recorded as D-386, with the property named in the encoder's godoc as load-bearing rather than incidental, so a future author cannot switch it off as a formatting preference.Upgrading is recommended for every deployment. The containment is structural and needs no configuration.
Changed — model-visible tool names are bounded, and a dropped declaration is announced
A tool's model-visible name is now bounded at 44 bytes and shortened TAIL-FIRST (D-377). Catalog keys are
<sourceID>_<tool>and the catalog is flat and process-global, so a source id must be globally unique — operators reach for long, high-entropy ids to get that, and the id is then paid on every turn, twice per tool (thereq.Tools[]declaration and the<available_tools>prompt section). Over-budget names now render as<retained tail>_<8-hex digest of the full sanitized name>. The catalog key does not move and no isolation property changes. Each run constructs one immutable projection table from the declarations it actually exposed; reverse resolution accepts only a declared winner and never treats model input as a raw catalog key. The projection is a pure function of the declared catalog snapshot, which is why a short per-source alias was rejected (an alias assigned from catalog composition shifts when the catalog grows mid-run, and the catalog grows mid-run by design).Measured effect on a 30-tool server, declared-name bytes per turn across both surfaces: a 6-byte key is unchanged (in-budget names pass through byte-for-byte), a 36-byte key drops 3124 → 2640 (−15%), a 64-byte key drops 3840 → 2640 (−31%). Characters are measured exactly; the token figure is an estimate and is labelled as one — no tokenizer is vendored, and dividing the character deltas brackets the long-key saving at roughly 300–480 tokens per turn. Do not quote that range as measured.
Operator-visible consequence: anything that string-matches a model-visible tool name outside a live run — stored transcripts, eval fixtures, dashboards — sees new strings for names that were over 44 bytes. Live runs are unaffected: every surface re-derives the name from the catalog key each turn.
<available_tools>was showing the model a name it could not call, and that is fixed in the same change. The prompt section rendered the RAW catalog key whilereq.Tools[]declared the sanitized form (clock.nowlisted,clock_nowdeclared). Both surfaces now go through one transform.Builtin discovery, lookup, and declarative dispatch use that same projection.
tool_search,tool_get, anddeclarative_actionno longer resolve a model-authored name as a raw catalog key. Reserved planner controls claim the namespace first, collision losers are neither advertised nor callable, and authorization scopes are applied before the projection is built. An unknown or raw alias fails before dispatch; a rejected serialized batch leaves no partial pending-call state. This closes #654 without renaming the internal catalog.planner.tool_declaration_collision— a new canonical event (D-378). Two catalog tools that collapse onto one model-visible name are ambiguous to provider-side dispatch, so one declaration is dropped. The drop was correct; its failure mode was not — it was a barecontinuewith no error, no event and no log. Combined with the old head truncation that meant, measured on a 64-byte source id with a 30-tool server, 30 catalog tools in, 1 declaration out, while<available_tools>still listed all 30. The drop is kept and made observable: a typedSafePayloadnames the run identity, the colliding model-visible name, the tool that KEPT the declaration and the tool that was DROPPED, because the remedy is operator-side. An event and not an error, because failing every run of an agent with two ambiguously-named tools would escalate a config problem into a total outage.A benign re-discovery of an already-loaded tool stays quiet —
seenmaps the model-visible name to the real catalog name that claimed it, so the drop can tell "same tool" from "lost surface". Residual collisions remain reachable by construction (clock.nowandclock/nowboth sanitize toclock_nowat any length), which is why the diagnostic is load-bearing rather than theatre.
Fixed — agent-config mutations preserve one coherent visible state
Runtime-added MCP connections now prepare, persist, then activate (D-390, closing #653). Dial, authentication, handshake, and discovery happen privately; no provider, catalog entry, live registration, or
onlinestate is published before desired state is durable. Activation holds a private, reversible registry reservation while direct reads continue reaching the prior provider, then uses the catalog swap as the dispatch linearization point. A collision restores the exact prior registry and catalog; displaced resources close only after successful publication.A write error no longer triggers unconditional detach. An exact active-pointer read distinguishes confirmed landing, confirmed absence, and an unreadable answer. Confirmed landing activates while returning the storage error loud; absence closes only unpublished resources; unreadable state preserves the existing live connection and reports the ambiguity. An exactly landed auth-required write returns
auth_requiredwith the reread revision and the producer-owned pause token rather than rejecting a durable continuation over a lost acknowledgement. Inline OAuth is likewise private until activation and carries an exact install receipt for rollback.Auth-required preparation is restart-safe through the production callback, not only through a fake continuation. A mandatory typed
FlowStoreseals the pending PKCE verifier, client material, identity, expiry, and pause token over the existing StateStore/KEK seam. Reconstructed providers claim the high-entropy state exactly once, validate its identity and provider owner, and resume the same durable pause without a second exchange. Retryable failures release the claim; a spent code whose token cannot be persisted records a durable terminal-rejection stage and rejects the pause through bounded uncancelled cleanup. A transient rejection failure retains a retry path without re-exchanging the spent code. The access token, refresh token, and encrypted exact flow marker persist in one atomic StateStore record. Before pause resume or destructive cleanup, a second sealed record keyed by the exact OAuth state preserves the original identity, source, subject, pause, expected decision, and retry horizon. Callback retry therefore remains exact even after a later flow replaces the current credential or a cleanup delete lands without its acknowledgement. Partial cleanup reclaims residual PKCE and claim records; later flows prune only expired tombstones for their exact identity, with the tombstone deleted last. Already-resumed cleanup verifies the exact terminal decision. Upstream OAuth response bodies and redirect error text never enter callback logs, HTTP detail, pause records, or canonical events: seven standard denial codes are accepted and every other value becomes staticauthorization_denied. Mixed resume decisions share one first-winner gate, so reject/timeout cannot overtake accepted continuation work.Conditional skill mutations coordinate their body and revision effects (D-388). The expected-content check runs under the owner lock before all four admin/user SkillStore doors. A later revision failure restores the exact prior body or deletes only a body created by that call; a stale expectation changes no body, revision, pointer, or success event.
Ambiguous revision saves clean only proven orphans (D-388, superseding the affected D-373/D-380 behavior). When a revision-record save reports an error, an exact scoped point-read may delete only a byte-identical, unreferenced candidate. A missing record needs no cleanup; a mismatched or unreadable answer is retained and reported. Compensation uses a bounded uncancelled context and preserves both the original and cleanup errors. This is coordinated compensation, not cross-store ACID or cross-process CAS.
Fixed — caller memory is redacted at rest, like its siblings
caller_memorywas persisted verbatim whiledescriptionandquerywere redacted (D-370). Proven against the realpatternsredactor: the durable driver's whole-record marshal — the bytes that land in the StateStore on disk — contained the raw secret. The wire godoc documented the prompt-injection residual and said nothing about at-rest persistence, so an operator who sawqueryredacted would reasonably infer the same ofcaller_memory. The inconsistency is the part that could not survive.Redaction was chosen on measured evidence, not assumption. Driving the canonical rule set over representative payloads first: objects, arrays, numbers, booleans,
nulland nesting all survive structurally intact, and only secret-shaped keys and inlineBearer …/Basic …values are replaced. It is not a text redactor on this path —redactRawJSONdecodes, walks the value and re-encodes, which is the path the engine already takes for a structured tool result.Two consequences handled rather than absorbed. The idempotency compare against the stored bytes would now raise a false conflict on an honest retry, so it is removed — the content hash already folds the pre-redaction bytes and is strictly stronger. And a malformed document is now refused loudly (
tasks.ErrInvalidRequest, naming the field) before the marshal, rather than being re-quoted into a valid-but-unusable row whose failure surfaces at whatever reads it later.The residual is restated honestly rather than dropped. The audit redactor is a pattern redactor, not a sanitiser: it does not detect PII, does not detect a credential that looks like prose, and cannot make hostile text safe. An operator piping third-party content through
caller_memorystill has a leakage path neither a prompt wrapper nor a pattern redactor closes.
Fixed — two isolation and availability defects on already-shipped surfaces
The spawn idempotency index is keyed by the full identity triple (D-371). It was keyed on
(SessionID, IdempotencyKey)—tenant_idanduser_idappeared in none of the six sites, including the hydration rebuild that decides whether the shape survives a restart. Stated at its true size: this was not a handle disclosure, because the divergence compare rejects a foreign entry; the reachable damage is a cross-tenant denial plus an existence oracle, and session ids are high-entropy so it is not reachable by guessing. It is fixed anyway, because an isolation boundary is held by the shape of the key and not by the entropy of one component.No migration. The index is derived state that is never written to any store — the engine rebuilds it at
Hydratefrom each persisted task's ownIdentity+IdempotencyKey, andTask.Identityalready carriesTenantID. Existing rows are byte-identical; an upgrade rebuilds the narrower key from what is already on disk.RunIDdeliberately does not join the key: a run-scoped key would defeat dedup across exactly the retries it exists to collapse.The pending-override slot map is bounded (D-372).
runs.set_overridesrecords a one-shot next-message override into an in-process map keyed by the identity triple; a session that records one and never sends a message left its slot behind. The package godoc called that "dropped" — nothing dropped it. With no TTL, no eviction and no size cap, any authenticated caller reached unbounded growth by recording an override under a fresh session id in a loop. The map now holds at most 4096 entries and evicts the oldest-recorded slot at capacity — evict rather than refuse, because a capacity refusal lets one caller deny the surface to every other tenant, which is strictly worse.Eviction is loud, once per eviction, with no first-in-window suppression: below the bound an eviction is impossible, so a line is never routine. There is deliberately no TTL — the slot already has a lifetime ("until the next message") and a second time-based axis would be a second mechanism answering one question. A non-positive bound is ignored rather than honoured, so there is no way to configure the bound away. The same append-only shape in the agent-config session-overlay store is fixed in the same change, refcounted rather than capped, because patching one and leaving its twin is how a defect class survives its own fix.
Verified — a settled decision gains the instrument it never had
HA-47 (keying the live MCP registry by
(owner, name)) is REFUSED, and D-301 is reaffirmed unchanged (D-379). The re-key is inert for its own motivating case: the token tax is levied on the CATALOG key, not the registry key, and with the registry conflict removed by construction two owners attachinggithubstill fail atcatalog.Register("github_add"). It would also move a clean pre-dial refusal to after the transport is live.What lands instead is the missing instrument. D-301's namespace half — "a collision fails loud", the bounded guarantee traded for the process-global catalog — was pinned nowhere, so anyone re-attempting this had to rebuild the probe to learn what the decision already knew. It is now pinned as behaviour: the refusal is loud, typed, and pre-dial; it leaves the first owner's catalog and registration untouched; a distinct-name control arm proves the harness measures the name; and the catalog is pinned as the independent second gate.
A latent credential-plane hazard is recorded rather than quietly fixed. The tool token cache Kind carries no agent component under
ScopeUser, so two agents in one tenant sharing a user and a connection name would share one bearer cache row. It is unreachable today only because the same-name refusal above prevents the precondition. Any future work that relaxes cross-owner connection-name uniqueness must address this first; it is filed as issue #638 rather than fixed in a test-only change.
Fixed — release oracles and scoped convergence
Canonical event names now have a handwritten external oracle. The oracle is bidirectional over the entire canonical registry; it is not generated from the emitter constants it checks. Adding, removing, or renaming an event requires an explicit reviewable oracle edit. The current registry contains 143 canonical names.
A shipped smoke that exits zero without a canonical summary is red in a black-box fixture. The mutation harness derives its declared cases and guard signatures from one registry, so deleting a whole registered case leaves either an unexecuted declaration or an unclaimed audit guard.
Docs PR validation no longer competes in the global Pages concurrency group. Validation supersedes only an older run for the same workflow/ref; the main-only deploy job alone owns the global
pagessingleton.Agent-config revision enumeration is identity-scoped inside every StateStore driver. The mandatory
ListKindForIdentityinterface method is implemented by in-memory, SQLite, and Postgres drivers;ListRevisionsno longer performs a maintenance-wide scan followed by Go-side filtering.A stale erasure ledger converges before it is discarded. The older lifecycle's completion is published before checkpoint deletion; publish/delete failures retain retryable state and never touch the current lifecycle. Retried delivery is best-effort deduplicated from retained history, with duplicates preferred to lost compliance records when that oracle cannot verify.
Fixed — deterministic reliability gates
Tool conformance failure decisions are per invocation rather than derived from a scheduler-shared counter; OAuth refresh callers synchronize behind one observable single flight; parallel-cancel start signals are buffered; and cancelled Protocol-client calls cannot race a successful server response.
The OAuth choreography now distinguishes the tool OAuth pause from the planner's run pause and waits until both tokens exist before completing the callback. Callback completion resumes the tool pause after durable token persistence; one steering
RESUMEthen releases the distinct run pause. This removes the fast-callback race without sleeps or retries.The authenticated PTY workflow is unquarantined. It sends real Kitty CSI-u function-key codepoints, establishes persisted or canonical state before a full visual repaint, and keeps a newer action modal open when an older same-scope inspection completes. It also sends one failed-followup retry command; a duplicate while dispatch is in flight cannot start again or mutate queued intent. Failure diagnostics report child liveness and capture SIGQUIT goroutine stacks. The frozen correction passed 10 local race repetitions, a 20-run two-CPU Linux calibration, and then 100/100 race-instrumented workflows in the same Go 1.26 Linux profile (
ok, 392.617s).
Changed — dependencies
github.com/maximhq/bifrost/corev1.5.21→v1.7.4, tracking the upstreamSecretVarrename. No Harbor-facing API change.
Action required
The first two items break a client that worked against 1.24. The rest ask for a decision, not a code change.
BREAKING — strip request members Harbor does not declare. The control transport now decodes strictly (D-374): a member no wire type declares is refused
400 {"code": "invalid_request"}with the member named in the message. Every other Harbor Protocol handler already decoded this way at this sameProtocolVersion 0.1.0; the control transport was the outlier, and the asymmetry was an omission rather than a policy. No deprecation window applies —Deprecationcan only describe a method, error code, wire field or capability being retired, and unknown-member tolerance is none of those; a window would also be a grace period on a data-loss bug.What to do today: audit any place your client adds keys to a request body below its typed surface. The concrete trap is identity folding — seven request types reached by eleven methods declare no
identityfield (the fiveartifacts.*,SearchRequestshared by the fivesearch.*, andGovernanceSetPostureRequest). Harbor's own Console folded the triple below its typed client on six call sites, and Harbor's own smoke corpus sent it to four more; both are fixed here and both are now held by a lockstep guard. Clients built on the Go Protocol client are unaffected — they marshal typed wire structs with noidentityfield to populate.BREAKING — drop
tenant_idfromgovernance.postureandllm.posture, and setidentity.tenantinstead.GovernancePostureRequestandLLMPostureRequestare removed (D-374). Nothing ever decoded them: the whole posture family decodes into the sharedRuntimeInfoRequestenvelope, sotenant_idwas discarded and an admin naming another tenant silently received its OWN tenant's posture with a 200 — wrong data on an admin audit path, with no signal. The strict decode turned that into a loud 400, which is how it surfaced.The cross-tenant read is not lost — it never lived here. The selector is
identity.tenant: a body tenant differing from the verified tenant requires theadmin/console:fleetclaim and emitsgovernance.posture_read_admin. That gate is implemented, audited and tested in both directions. The removed field was a second, never-implemented spelling of it. A newTestManifest_NoOrphanWireTypesgate now fails the build if a wire type is ever again published without a method that decodes it.The conditional-write guarantee is exact within ONE Runtime process and ABSENT across processes. Do not read this release as "Harbor now prevents lost updates." The atomicity comes from one thing: the agent-config service's 256-way striped per-owner write lock, taken by every door as its first act after identity validation and held across its whole read-modify-write. It does not come from the store, and the store says so in its own interface godoc — it stores and returns a version integer and enforces no compare-and-set. Two Runtime processes sharing one Postgres or SQLite StateStore can still silently lose an update, with both writers told they succeeded.
That residual is written into the field's godoc, the error code's godoc, the generated Protocol reference row and the operator skill, and it is pinned by a test that asserts it as absent: two independently-constructed registries over one real file-backed SQLite store, one writer suspended between its precondition read and its save, the other completing an entire conditional write under the same token — and the lost update still occurs. If a future change ever does make the write cross-process safe, that test fails and all four texts must be corrected. The fix that would close it is named rather than hinted: a conditional-write primitive on the
StateStoreinterface across the in-memory / SQLite / Postgres triad, with conformance rows. That is its own release.What to do today: if you run more than one Runtime process against a shared store, treat
expected_content_hashas a defence against concurrent writers within a process and not as a distributed lock. Single-process deployments get the full guarantee.extra_instructionsremains a normal-user personalization capability. Any verified caller ofruns.set_overridescan personalize its own session; no admin-only gate was added. The runtime now escapes that prose inside a dedicated<user_personalization>section and keeps the admin-owned tenant guidance in<additional_guidance>. A user cannot clear the tenant tier or forge its structural boundary. What to do today: treat the field as preferences, not recalled data; usecaller_memoryfor retrieved content.Model-visible tool names longer than 44 bytes are rewritten (D-377). A live run is unaffected — every surface re-derives the declared name from the catalog key each turn, and the transform is a pure function, so a name cannot shift mid-run. What changes is any string comparison made outside a live run: stored transcripts, eval fixtures that assert on a tool name, dashboards that group by it. What to do today: if you pin tool names anywhere durable, re-capture them after upgrading. Names already within 44 bytes pass through byte-for-byte and need nothing.
Everything else in this release is additive and needs no action. Every remaining wire change is an optional field or an additive error code; an omitted field reproduces the previous behaviour byte-for-byte on every affected path, and each of those claims is pinned by a golden or byte-equality test rather than asserted. The at-rest redaction of
caller_memory, the idempotency-key rekey and the override-slot bound all land without a migration and without a config change.
[1.24.0] — 2026-07-30
Seven phases: artifact read-path byte correctness, a heavy-content threshold split by purpose, the MCP arm of pass-by-reference routing, caller-named agent selection, run-start connection re-establishment, _meta path nesting for MCP annotations, and a search user-axis isolation gate. No Protocol wire-version change (ProtocolVersion stays 0.1.0) and no migration anywhere in the release.
Upgrading — four behaviour changes an operator can experience without touching a config file. Two of them narrow what a caller sees and can read as a regression; both are deliberate. See Action required at the end of this entry, and read the heavy_output_threshold_bytes and search entries below in full before upgrading.
Added — an MCP tool can be handed artifact BYTES without them entering the model's context
A mapped parameter on an MCP tool call is resolved from the artifact store and sent as base64, so a large document reaches a remote tool without ever transiting the model's context. When the model authors an artifact id into a parameter the operator has mapped, the runtime resolves the artifact itself and places the bytes into the outbound tool-call body (RFC 4648 §4 standard base64, padded). No address is published, no grant is minted, and no reusable handle exists. This is the MCP counterpart of the in-process artifact-reference parameter that shipped in 1.23.0.
The raw argument JSON is never rewritten, so the substituted bytes reach none of the seven places a tool call is otherwise recorded: the observation, the LLM observation, the serialised trajectory, event payloads, audit payloads and logs, the per-invocation content hash, and the durable MCP-App tool-context record.
Two additive, optional connection fields configure it — on
tools.mcp_servers[]inharbor.yamland on theagent_config.add_mcp_connection/agent_config.set_revisiondescriptor:artifact_byte_eligible(bool, default false) — the containment boundary. With it unset, declaringartifact_paramsis refused at the door, not silently ignored.artifact_params(map[string][]string) — this server's bare server-side tool names mapped to the parameter names that carry artifact bytes, in the same shapetool_policiesandtool_oauth_providersuse.
Both are http-only and refused on stdio (explicit, or an
autotransport carrying only acommand). Each mapped parameter is validated at attach against the server's own discoveredinputSchema— it must be declared there and declared string-typed. An absent or non-string parameter fails the attach loudly rather than failing the next call silently. The check is point-in-time: a server that mutates its schema without emittingtools/list_changeddrifts out from under a validated mapping and surfaces as a server-side argument-validation error on the wire.tools.mcp_artifact_egress_max_bytes(default 8388608 — 8 MiB) bounds one substituted parameter. It is deliberately independent ofheavy_output_threshold_bytes: substituted bytes never enter a model's context, so the budget is network and memory, not tokens. An oversize artifact fails loudly naming the artifact, its size and the ceiling — it is never truncated, because a partial document delivered to a remote ingester is a corruption rather than a bounded read. A negative value is refused at config load rather than read as unbounded. Restart-required.mcp.artifact_egressed— a new canonical event recording the FACT of a substitution, never the bytes. It carries the identity quadruple, the server id, the tool name, and one content-free record per substituted parameter (artifact id, parameter name, byte count,sha256:digest). It is fail-closed, not best-effort: a publish failure, a missing bus or a missing identity aborts the call before any wire request is issued. A substitution that could not be recorded does not happen.Read the security posture, not just the feature. Byte-eligibility is wire-configurable and admin-writable — deliberately not the fail-closed boot gate its siblings
tools.allow_wire_oauth_descriptorandtools.allow_wire_injectiontake, because those gate where a credential is sent and this gates where a user's own content is sent. Stated plainly: a tenant admin can attach a server they control, map a parameter, declare it byte-eligible, and receive a user's artifact bytes on the next run that names an id. Artifacts are stored as authored, unredacted, so a byte-eligible connection can move a secret. That sits inside the co-tenant-admin trust boundary Harbor already accepts and documents, whose remedy is one runtime per tenant. What does not widen is the reachable artifact set — resolution runs under the dispatching run's own(tenant, user, session), the same seated resolver the in-process arm uses.Known limit: an MCP App's tool callback (
mcp.apps.call_tool) cannot use a byte-mapped parameter. There is no run, so no resolver is seated, and the call fails loudly rather than degrading.
Added — a caller can name the agent a run should use
The
startmethod takes anagent_id. It was an explicit argument on roughly thirty request types — the wholeagent_config.*family, the user-layer and skills families,tools.describe— and absent from exactly one surface: the one that STARTS a run. So a caller could write a config revision under a new agent id, read it back, diff it, roll it back, and no run would ever use it. Orphan configs were the normal outcome of driving the control plane as documented.The field is additive and optional (
agent_id, omitted by default) onStartRequestand on the internal spawn path, and the resolved value is persisted on the task and surfaced asagent_idon the task row.A named agent is accepted on two cheap checks and is NEVER silently substituted. It is accepted if it equals the runtime's configured default agent id, or if a config revision exists for
(caller's tenant, agent id). Anything else is refused at the Protocol edge before the session ensurer and before the task is spawned, so no session row and no task materialise. A caller that named A, silently got B, and was told it succeeded is the exact defect this closes.An unresolvable id answers
invalid_request; a resolver error answersruntime_error— never a fall-through to the default. The refusal text is one constant that names neither the rejected id nor the reason, so the edge is not a cross-tenant existence oracle. On a Runtime built without an agent resolver, a non-emptyagent_idis refused rather than ignored; an omitted one behaves exactly as before.The credential plane is untouched. The RFC 8693 acting principal and the MCP
_meta.agent_idprovenance stamp continue to derive from the boot value. A caller-named agent changes prompts, tools, skills and the other configuration projections only. Two carriers, different provenance, deliberately not conflated.An absent
agent_idon a task row means DEFAULTED, not "unknown". Every historical row predates the field and every such run bound the runtime's default; render absence as "the runtime default". The session row deliberately carries no agent — a session may run several — so the agent is persisted on the task only.ACTION for anyone using idempotency keys. The named agent is folded into a task's content identity, so reusing an idempotency key while naming a different agent is now a loud conflict rather than a silent adoption of the original task's agent. A spawn that names no agent hashes byte-identically to one made before the field existed.
Added — a declared MCP connection is re-established at run start
Connection reconciliation attaches as well as detaches. It was detach-only, and nothing on the run path attached, so a connection an active config revision DECLARED but the live registry did not carry never came back — not after a restart, not after a rollback that re-declared one. The only way back was an admin re-running
agent_config.add_mcp_connectionby hand.At every run start, for the reconciling
(tenant, agent)owner, a declared connection the registry is missing is now re-attached through the same attach lifecycle the admin verb drives — never a second implementation — so every gate is re-applied against current boot policy, including the fail-closed stdio command allowlist and the fail-closed credential-injection opt-in. Detach still runs first; the attach pass re-reads the owner view afterwards.The claim is bounded, and the bound matters. It is not "connections survive restarts". It is: a declared connection whose descriptor is self-sufficient is re-established at run start. A connection whose live transport depended on operator-supplied static
Headersis not restart-survivable — headers are secret and are never persisted — so the re-dial receives a401and is reported astransport_failed.The credential plane is untouched structurally: the attach path has no token step, so this leg mints, holds, refreshes and exchanges nothing, never initiates a consent flow, and never parks a run. An auth shortfall surfaces where it always did — on the first tool call, through the existing typed auth-required pause cycle.
Two additive canonical events,
mcp.connection.reattachedandmcp.connection.reattach_failed. The failure event carries a stable class from a closed set of six:transport_failed(the only retryable one),stdio_not_allowed,injection_disabled,oauth_binding,owner_conflict,ambiguous_server_id. A reconcile is distinguishable from an admin add by the author's run id — an admin add carries none.Failure is loud, non-fatal and bounded: a refused or unreachable third party never fails the run and never aborts the sweep. Retries back off per
(owner, name), the first failure emits, suppressed attempts are counted and the count rides the next event, and an operator edit to the descriptor resets the window. The window is in-memory, so a crash-looping deployment re-dials a dead server once per boot.
Changed — the heavy-content threshold rises to 128 KiB, and the config key's meaning NARROWS
Read this one even if you have never set the key. Two things move independently and both are visible.
artifacts.heavy_output_threshold_bytesnow defaults to 131072 (128 KiB), up from 32768 (32 KiB). An ordinary 40 KiB JSON tool result now reaches the planner inline instead of being routed to the artifact store and read back on a second turn — which was the point. The consequence to size: leaving the key unset now permits roughly 32k tokens of a 200k context window for a single 32–128 KiB tool result. On a small-context model that is a real regression risk. Setting the key explicitly to32768restores the previous behaviour of this arm.ACTION: the key no longer governs Console-facing Protocol replies. It now governs the LLM-context arm only — the tool dispatcher's promote-to-stub boundary, the LLM-edge leak guard and auto-materialization, the trajectory-compaction payload budget, and the
tools.content_statsheavy_threshold_bytesfigure that reports it.Six Protocol-visible sites now select inline-versus-reference at a pinned 32 KiB that deliberately does not track the key:
pause.list,memory.getandmemory.list, the flow catalog, and themcp.servers.read_resource/mcp.apps.call_tool/mcp.apps.tool_contextreads. Two further arms that were never Protocol replies also pin their own 32 KiB and stop tracking the key: the search preview's ref-versus-inline selection and the terminal-UI scrollback fold.Who this bites. On a default configuration the pins are a byte-for-byte no-op — 32 KiB before, 32 KiB after. The narrowing is only observable to an operator who had set an explicit non-default value: their Console-facing bounds now decouple from it and revert to 32 KiB. There is deliberately no compatibility flag; the reopening path, if it is ever wanted, is an additive
artifacts.console_inline_payload_byteskey rather than re-coupling two different questions to one number. The rationale is that those payloads are rendered by a browser and never placed in a prompt, and that the arm a reply selects is Protocol-visible: raising this key should widen what a planner sees inline and leave every browser-facing reply unchanged.docs/CONFIG.mddocuments the split in full.tools.content_stats.heavy_countis not comparable across this upgrade. It counts historical offload events against the current threshold, so the number drops discontinuously after upgrade without anything having changed.One residual, named rather than buried.
tool_calls[].argumentshas no offloader anywhere in the tree, so the leak detector guarding that class moved from 32 KiB to 128 KiB — a straight 4× loosening of the only guard on it. The alternative, a detector held below the offload boundary, would kill runs on content the runtime had just declared acceptable and that no producer could have avoided.examples/harbor.yaml,examples/dev.yamlandexamples/serve.yamlno longer pin32768.
Changed — a dotted meta_annotations key now NESTS in the MCP _meta map
tools.mcp_servers[].meta_annotationskeys are annotation PATHS. A key with no.still sets a top-level_metakey. A DOTTED key now nests:vendor.account_id: acct-42lands at_meta.vendor.account_idinstead of as the literal flat key_meta["vendor.account_id"].Why. Two mechanisms write into the same outbound
_metamap on the same call and they disagreed about what a dot meant.injection.meta_keyhas always been a path (it walks and creates intermediate maps);meta_annotationsmerged flat. So a receiver-style MCP server reading one nested namespace could be handed a per-user credential but had no route at all to its non-secret companion value — it could not rideinjection(one mapping per connection, and a non-credential leaf is correctly refused by the redaction-coverage predicate) and it could not rideheaders(a documented secret field, never persisted). One namespace now has one meaning regardless of which mechanism wrote into it, through the same helper.ACTION: read this if you declared a dotted annotation key. No config rewrite is required — the nested shape is what a dotted key was already asking for — but the connection sends a DIFFERENT
_metaon its next call after upgrade, with no error and no config edit. If a downstream server matches on the literal flat key, either rename the key to remove the dot or update the server. Flat keys are completely unaffected, and no shipped Harbor example or config declared a dotted key.Three declarations that were accepted before are now refused, loudly, at boot and at every runtime door (
agent_config.add_mcp_connection,agent_config.set_revision, and attach). Each error names the offending key and the rule:- A key whose WHOLE value or any dot-segment is reserved.
tenant.foois now refused; it previously passed because only the whole key was checked. (The whole-key check is retained, not replaced — it is the only arm that sees the spec-reservedio.modelcontextprotocol/namespace, whose segments carry no prefix.) - A path that COLLIDES with another declared path on the same connection — equal to it, or a prefix of it — including the
injection.meta_keypath.{vendor: x, vendor.id: y}is refused, and so is a flatvendorannotation alongsideinjection.meta_key: vendor.api_key. That second case previously discarded the operator's annotation SILENTLY at merge time. A connection persisted before this rule now fails its calls loudly rather than picking a non-deterministic winner. - A path deeper than 16 segments, or one containing an empty segment (
a..b).
ACTION: a
harbor.yamlthat validated before can now fail to boot. All three rules apply at boot, not only on the wire. Runharbor validateagainst your config before upgrading.- A key whose WHOLE value or any dot-segment is reserved.
The 16-segment
_metapath cap now applies at every door. It previously existed only on theagent_config.add_mcp_connectionwire path, so a boot-declaredinjection.meta_keydeeper than the cap was accepted where the identical wire-declared one was refused. Both now consult one constant. (No migration: no shipped config nests past two segments.)Audit note — over-redaction, not a leak. The redactor replaces a matched key's WHOLE value without recursing, and its receiver-injection rule matches the last key segment. A flat
token.envannotation was not redacted (last segmentenv); nested, the node key istoken, which matches, so the entiretokensubtree collapses to***— non-secret siblings included. Redaction coverage is preserved; the cost is audit readability under a credential-named namespace. No audit rule changed.
Fixed — search results are scoped to the caller's own user
A search that does not name a user now returns YOUR rows, not every user's in the tenant.
internal/searchgated by tenant and never by user. An emptyuser_idsfilter was read as a wildcard, so the DEFAULT request — with no caller input at all — returned rows for every user in the tenant acrosssearch.sessions,search.tasksandsearch.artifacts. A NAMED foreign user was honoured unexamined on all five methods. Both shapes are closed: an omitteduser_idsfolds to the caller's own user, and a named foreign user — or any multi-user set — is refused withscope_mismatch(403) rather than silently emptied.This is a security fix. The
(tenant, user, session)triple is the isolation boundary and the user axis was not being enforced on this surface.ACTION: the Console's ⌘K palette silently returns fewer rows for an ordinary operator. The palette sends no user filter, so it lands on the folded path with no Console change: an operator now sees only their own sessions, tasks and artifacts. A deployment that had come to rely on the palette as an unofficial fleet view loses that, and there is deliberately no compatibility flag — an identity-downgrading knob is not something Harbor ships.
What restores the wider view: the
adminorconsole:fleetclaim — the same claim that already widens the tenant axis on this surface and the same oneevents.listandartifacts.listtake. Under either, naming a user reads that user and omitting one fans across the tenant.harbor token --scopes console:fleetmints a read-only one.Two known partials, stated rather than implied: a GRANTED cross-user crossing emits no audit event (pre-existing, and symmetric with the tenant axis), and a widened multi-user
search.eventsread still returns only the first user's rows.
Fixed — artifact_fetch refuses a window it cannot return intact
A binary artifact read through
artifact_fetchused to arrive corrupted, silently, at a length the same response contradicted. The window went out through a string-typedcontentfield and JSON encoding rewrote every invalid UTF-8 byte toU+FFFD— an 8-byte PNG header arrived as 10, whilereturned_bytesstill said 8.The read is now UTF-8-admissible or it refuses. On a refusal the response still carries
ref,mime,size_bytesandtotal_size_bytesso the caller knows what it asked about;contentis empty and the windowing fields (offset,returned_bytes,truncated) are zero-valued, because a refusal reportingtruncated: truewould invite endless paging into the same wall. The error names the stored MIME and the failing byte offset, and points at the route that does work: pass the artifact id to a tool that declares an artifact-reference parameter, and the runtime hands that tool the bytes directly.The gate is content-driven, not MIME-driven — it tests the actual window. In practice every binary type refuses (PDF, images, audio, archives), and so does a text artifact carrying one invalid byte mid-window rather than dropping it silently. Windowing artefacts are NOT refused: leading continuation bytes and a truncated trailing rune are trimmed, the window is admitted, and
offsetreports where the returned content actually begins rather than echoing what was requested.ACTION: a caller that was tolerating corrupted binary from
artifact_fetchnow receives an error object instead of bytes.artifacts.getis deliberately unchanged and stays byte-exact. Itscontentis[]byte(base64 on the wire), so it is correct for every MIME at every offset with no rune trimming — it is the byte-exact read, and a new test pins that against a future "restore consistency" refactor.A
max_bytesof 1–3 no longer pages forever. The effective maximum floors at 4 bytes, so a window smaller than a single rune is served at 4 rather than trimming to empty whiletruncatedstayed true and the documented paging rule returned the same offset indefinitely. The floor also applies to the operator-resolvedartifacts.fetch_default_max_bytes.A failed artifact-reference resolution is now machine-distinguishable. Two classes ride the observation under an
error_classkey —artifact_ref_not_found(model-recoverable) andartifact_resolver_unavailable(operator misconfiguration, explicitly not model-recoverable) — and the same value lands on the parallel-branch observation aserror_class. An ordinary tool error is byte-identical to before; no class key is written.
Fixed
- A hung MCP server no longer blocks a connection attach indefinitely. The SSE/HTTP transport's session-termination request ran on a context the runtime does not own and the driver's HTTP client had no timeout at all, so a server that accepted the TCP connection and then answered nothing wedged the caller after its own handshake context had already expired. This affected the already-shipped
agent_config.add_mcp_connectionadmin verb, so deployments on 1.23.0 and earlier are exposed. A request that carries no deadline of its own and is not a server→client event stream now gains a 15-second bound at the driver's shared choke point; a request the runtime already bounded keeps its own budget untouched, so an operator who raised a slow tool'stimeout_msis never pre-empted, and a streaming response is not truncated.
Internal
The scaffold module pin and its guard.
scaffold.FallbackModuleVersionhad drifted five releases behind — it is whatharbor --versionreports on an un-stamped source build and whatharbor newwrites into a scaffoldedgo.mod. Thedrift-auditcheck meant to catch that compared the pin against the SAME branch'sCHANGELOG.mdand declined to consult tags, so a branch whose release bookkeeping had been discarded by a release-heal merge satisfied it self-consistently. The check now reads the PUBLISHED tags (remote first, local tags, thenorigin/main's changelog), reports honestly when no source is reachable instead of printing an unearned OK, and separately fails when the newest published release has no section in this branch's changelog.Six inert smoke guards repaired, and the harness-level hole that let them hide. Preflight failed on one condition only — a smoke exiting non-zero — so a script whose every check was a SKIP read as green. Two of this wave's guards had never once reported OK or FAIL: one posted to a
/v1/search/*route that has never existed in the tree (the fivesearch.*methods dispatch throughPOST /v1/control/{method}), and another posted to a/v1/mcp/*route that does not exist WITH an identity triple the dev bearer is not minted for, so it skipped two independent ways. Four more had their pass value and their can't-tell value collapsed together: two absence guards that skipped as "pre-215 build" on a shipped phase, a task-counter that returned0for a 401 and for a malformed body alike, anokarm accepting200|401across five methods, and a file guard that reported OK when the file was missing. All are now live and mutation-verified. Preflight itself now records each smoke's counters and FAILS when a script belonging to a SHIPPED phase produces zero OKs and zero FAILs; a pending phase's skeleton is reported and tolerated, which is the 404→SKIP convention working as designed. Switching that gate on found 24 shipped-phase scripts already asserting nothing — they are recorded inscripts/smoke/inert-baseline.txtas known debt, so a NEW inert guard fails immediately while the inherited backlog is visible rather than silent.
Action required
artifacts.heavy_output_threshold_bytesdefaults to 128 KiB and no longer governs Console-facing Protocol replies (pause.list,memory.*, the flow catalog, the threemcp.*reads), which pin at 32 KiB. A default config is unaffected; an explicit non-default value now applies to the LLM arm only. Set it to32768to restore the previous LLM-arm behaviour.- Search is scoped to the caller's own user. The Console ⌘K palette returns fewer rows for a non-admin operator, and a request naming a foreign user is refused rather than emptied. Mint
adminorconsole:fleetfor a fleet view. - A dotted
meta_annotationskey now nests, and three_metadeclarations that were accepted before are refused at boot as well as on the wire. Runharbor validateagainst your config before upgrading. artifact_fetchrefuses a non-text window instead of returning corrupted bytes. A caller that consumed binary through it must move toartifacts.getor to a tool taking an artifact-reference parameter.- Reusing an idempotency key while naming a different
agent_idis a conflict, not a silent adoption of the original task's agent.
[1.23.0] — 2026-07-27
Minor: artifact bytes become readable on every store driver, the read-back bound becomes operator policy, in-process tools can take content by reference without that content ever re-entering the model's context, and an artifact listing is scoped to the caller's own user. No Protocol wire-version change (ProtocolVersion stays 0.1.0).
Upgrading: two artifact store drivers migrate their key on first start and the S3 object layout changes — see Action required at the end of this entry.
Added — artifact bytes are readable on every store, and the read-back bound is yours
artifacts.get— a Protocol method that returns an artifact's bytes, served by every artifact driver.POST /v1/control/artifacts.gettakes the scope and the artifact id and returns the bytes plus the reference metadata.Why it matters. The only byte path the Protocol had was
artifacts.get_ref, which returns a presigned URL — something only an S3-compatible store can mint. On theinmemdefault (and onfs,sqliteandpostgres) it answerspresign_unsupported/ 501. So a deployment could offload heavy content correctly and then have no supported way to read it back.artifacts.getresolves through the store's mandatory read, so it works everywhere.The two are not alternatives to pick between.
artifacts.getis the read every client can rely on.artifacts.get_refis an optimisation for stores that can hand a large download off their own edge, so the bytes need not transit the Runtime. Both resolve the same reference under the same identity; reach forget_refonly when you know your deployment runs an S3-compatible driver.Every artifact read now reports its own bound.
artifacts.getand theartifact_fetchbuilt-in both answer withtotal_size_bytes,returned_bytesandtruncatedalongside the window, so a partial read is never mistakable for a complete one.offset— page a large artifact instead of only reading its head. Both the method and the built-in take a byteoffset; read from 0, then re-read at the previousoffset + returned_byteswhiletruncatedis true. Windows are byte ranges, not lines or rows, so a window can begin and end mid-line and the caller splits the text itself.
Added — two optional artifacts config keys
artifacts.fetch_default_max_bytes(default 65536) andartifacts.fetch_hard_max_bytes(default 1048576). The read-back bound becomes yours to tune, alongside theheavy_output_threshold_bytesthat governs what goes out. Both keys are optional and both default to the values the previous build hardcoded, so no existing configuration changes behaviour.A request above the ceiling is served at the ceiling, not refused, and the response says so through
truncated— a caller has no way to discover the ceiling before asking, so a refusal would cost it a round trip and teach it nothing.The ceiling bounds one read, not a sequence. It is not a budget over repeated calls; aggregate consumption stays the governance layer's concern (cost ceilings and rate limits).
Added — in-process tools can take content by reference
A Go tool can now declare an artifact-reference PARAMETER and read the stored bytes without the content ever passing through the model. Declare a field of type
sdk/tools.ArtifactRef; the derived JSON Schema renders it to the model as a plain string, so the model supplies an artifact id — the same id the runtime already quotes to it in a truncated tool result or the session artifact block — and the runtime resolves it at dispatch and hands the tool the bytes viain.Doc.Bytes().gotype SummarizeArgs struct { Doc tools.ArtifactRef `json:"doc"` MaxWords int `json:"max_words"` }Why it matters. Harbor's heavy-content safeguard was one-directional: a large tool RESULT routes to the artifact store so it never reaches the context window, but a tool that needed to READ something large could only be given it through its arguments — which meant through the model. This closes that leg. Bytes flow store → tool.
What the runtime guarantees. The resolved value is dispatch-local: it does not appear in the argument JSON, the trajectory, the observation the next prompt renders, any event payload, an audit payload, or a log. The reference resolves under the run's own
(tenant, user, session), so a tool reaches exactly what its run reaches — an out-of-scope id answers not-found through the same soft pathartifact_fetchuses, with no way to tell "not yours" from "does not exist". Every failure is loud: an unresolvable reference is the step's error, and reading a reference the argument never carried returnstools.ErrArtifactRefUnresolvedrather than an empty slice a tool would measure as an empty artifact.Scope. In-process tools only. A tool behind a process boundary (HTTP, MCP, A2A) cannot be handed a Go value, and handing it something dereferenceable instead is a separate design that has deliberately not been built — it needs an externally-reachable address Harbor does not have, a grant whose single-use semantics a presigned URL cannot provide, and an answer for a grant URL being a bearer capability to the content.
Worked example:
examples/tools/artifactstats/. Guidance: theadd-an-in-process-toolskill.
Changed — the LLM-edge heavy-content guard also inspects tool-call arguments
A tool call whose arguments exceed the heavy-output threshold now fails loudly instead of reaching the provider. The guard that already refused oversize tool RESULTS and oversize inline binary content now applies the same byte check to
tool_calls[].arguments— the field the prompt builder replays turn over turn and the provider drivers map onto the wire. The failure is the existing one (ErrContextLeakplus allm.context_leakevent naming the offending call by index); nothing else about the guard moves — same threshold, same exemption for ordinary conversation text, same error, same event.Who this affects. Almost nobody: a tool call is an id and a few parameters. If it fires, it is naming a real bug — a producer putting a payload into an argument that should have been a reference, which is now something Harbor supports directly (see above).
Changed — artifact reads now resolve on the session, and two stores migrate
An artifact is read by
(tenant, user, session)plus its id; the producing task is provenance.artifacts.get_refandartifacts.delete— and theartifact_fetchbuilt-in the model uses — no longer require the caller to name the sametaskthe artifact was stored under. Naming a different one, or omitting it, reaches the same artifact.Why it changes. The session-artifact block the planner injects lists every artifact in the session and invites the model to fetch any of them, while the three writers that produce them stamped three different task shapes. So the model was shown references a read then answered "not found" for, and it had no way to tell that from a deleted artifact. Nothing crosses a session, a user or a tenant: the session was always the innermost isolation scope, and this makes the key match it.
The provenance stamp becomes first-writer-wins. Identical bytes stored by two tasks in one session were two records, one stamp each; they are now ONE record carrying the first writer's stamp. A
taskfilter onartifacts.listtherefore answers "which artifacts is this task the recorded producer of", not "which did it write". This is inherent to a content-addressed store — the id is derived from the bytes, so the question has no single answer once two tasks produce them.artifacts.listnow requires a tenant. A filter with no tenant is refused at the store instead of listing across tenants. Emptyuser,sessionandtaskremain wildcards within the tenant, so no existing request shape changes: the Protocol edge already refused a tenant-less list.An artifact listing is scoped to the caller's own user unless an administrative claim widens it.
artifacts.listnow decides its two identity axes separately:useris an isolation principal. Omitting it lists YOUR artifacts — it is no longer read as "every user in the tenant". Naming another user is refused403 identity_scope_requiredunless the caller holdsadminorconsole:fleet; with either claim, naming a user reads that user and omitting it fans across the whole tenant, which is the fleet view.sessionis a filter, not a boundary. Omitting it still spans every session of your own, and naming one of your own sessions still narrows. No claim is involved either way — theuserdecision above already settles whose artifacts are in play.
This is the same line
events.listdraws on the same two axes. The everyday call —{"scope": {"tenant": "..."}}— keeps working and needs no claim, and the Console Artifacts page and Background Jobs artifact card are unaffected because both already send the caller's own user. ACTION for anyone whose client relied on a tenant-only listing to enumerate a whole tenant: mint the token withconsole:fleet(read-only) oradmin.harbor token --scopes console:fleetdoes it.ACTION: the SQLite and Postgres artifact stores run a schema migration on first start after upgrade.
0002_read_key_is_the_triple.sqlre-keysartifacts_blobsonto(tenant, user, session, namespace, id). It is forward-only and transactional, and pre-existing duplicate rows (the same bytes stored by two tasks in one session) collapse onto the row with the lowesttask. On a large artifacts table the rebuild is not instantaneous — size the restart window rather than meeting it. No action is needed for the in-memory or filesystem stores.ACTION: the S3-compatible artifact store writes a new object key layout. Objects are now stored at
<prefix>/<tenant>/<user>/<session>/<namespace>/<id>— the task segment is gone, because an object store has no compare-and-set and keying on it let concurrent writers of identical bytes create one object each. Objects written by earlier versions stay readable and deletable: the driver falls back to a session-prefix scan and nothing is rewritten in place, so a bucket migrates by being read. The cost is one extra listing on a read that misses the new key, and one per delete. Buckets whose lifecycle rules or external tooling match on the old key shape need those rules updated.
Action required
- SQLite and Postgres artifact stores run a forward-only
0002migration on first start, re-keying the artifacts table onto(tenant, user, session, namespace, id). Rows that differ only by the producing task collapse onto a single row carrying the first writer's stamp. Take the usual backup before upgrading a Postgres deployment. - The S3 artifact object key drops the task segment — new objects are written under
<tenant>/<user>/<session>/<namespace>/<id>. Objects written by earlier versions stay readable and deletable via a session-prefix fallback and are never rewritten in place, so a bucket migrates by being read. Buckets whose lifecycle rules or external tooling match on the old key shape need those rules updated. artifacts.listis scoped to the caller's own user unless the request carriesadminorconsole:fleet. A caller that omitted the user field and relied on receiving every user's rows in the tenant now receives its own; an operator tool that needs the wider view must present one of those claims.
[1.22.0] — 2026-07-25
Minor: one shared body-identity gate across every Protocol surface, owner-scoped MCP connection writes, MCP App replay on session reopen, and the re-landed MCP-Apps host obligations. No Protocol wire change (ProtocolVersion stays 0.1.0), no config schema change, no migration.
Upgrading: one boot-time validation is newly enforced — see Action required at the end of this entry.
Added
- A reopened session replays its rendered MCP Apps. A
ui://App that rendered during a turn now survives a session reopen instead of vanishing from the transcript:HistoryTurncarriesapp+serverID(reusing the liveMCPAppRefViewrather than a second wire shape), and history reduction folds the durablemcp.app_availableevent with the same guards, display-mode normalisation, and last-wins semantics the live decoder applies. Re-mount resolves the persisted tool context by its deterministictool_call_id— no new storage and no new Protocol method. Resolution happens before the iframe mounts, so an unresolvable context renders an honest placeholder rather than a half-mounted frame; a resolution failure stays a loud error and is never laundered into the eviction copy. A non-emptyToolCallIDnow promises a fetchable record: the MCP driver stamps the id only when a tool-context record actually persisted, and runtime-added connections capture exactly as boot-config ones do. (D-348) - Five MCP-Apps host obligations.
ui/notifications/size-changedis consumed, with the inline frame bounded in CSS between the existing minimum and a new--size-app-inline-maxtoken so any reported height is bounded by construction (fullscreen and picture-in-picture are unaffected);ui/resource-teardownandrequest-teardown; host-contexttoolInfoandcontainerDimensions; andresources/templates/list. (D-351)
Changed
- Every Protocol surface reconciles its request-body identity scope through one shared gate.
internal/protocol/bodyscope.Reconcilereplaces thirteen near-duplicate helpers across both transports. The posture is a registry row, not a policy value — a call site can name a posture but never invent one — and each row declares its per-component rules (Pinned/PinnedOrEmpty/AdminScoped), the claims that grant a tenant crossing, a deny code, and a prose reason a reviewer can disagree with. A policy that permits a crossing must be handed a non-nilAuditor; a nil sink is a runtime error rather than an unrecorded crossing. The MCP Apps and MCP-Connections gates moved insideDispatch, which makes those surfaces' transport-agnostic godoc true. (D-349) identitycarries provenance. A third context key holds the transport-established triple (WithVerified/FromVerified); plainidentity.Withrefuses to move the working identity past it, andWithElevated(ctx, id, reason)is the one named crossing, scoped to the audited tenant.SessionCountersgainsPartial, distinguishing "we could not look" from "there were none" — including at the filter branches — so a cross-tenant admin listing never presents an unreadable scope as an empty one.- App→tool dispatch is confined to the App's own server. An app-supplied bare tool name is qualified with the host-derived
serverIDbefore dispatch. Because the catalog key<sourceID>_<tool>is a string join over unconstrained charsets,Registry.Registernow refuses — under the same write lock that installs the entry — a server id that sits inside another registered id's tool-name namespace, in both directions so registration order does not matter.harbor validateapplies the identical rule. - MCP accessor errors are classified by sentinel, not by matching error text, so a remote server's wording cannot decide a Harbor verdict and an App can distinguish "no such tool" from "you may not".
Fixed
- A request with no established identity is refused with
identity_requiredinstead of falling back to the caller-supplied body scope. - Live MCP connection writes are owner-scoped.
Registry.SetOAuthDiscoveryOriginsresolves through a newownedEntry(name, owner), replacing the live allow-list only on the connection the caller's(tenant, agent_id)owns; a name another owner attached, a boot-declared name, and an unregistered name all refuse identically, and a zero or partial owner is refused at the choke point rather than at each call site. The boot-declared guard now applies on every path, before any revision read, so it is a property of the name rather than of the branch. Registry reads are unchanged (D-287/D-301). (D-350) agent_config.set_revisionvalidates connection descriptors before persist, reusingadd_mcp_connection's shape authority — one authority, two doors — including the fail-closed stdio allowlist. Rejection leaves the active revision untouched, and the normalized descriptor is what persists, so both doors write identical bytes for identical input.OAuthDiscoveryAllowedOriginsround-trips through the revision spine. Both the domain and wire converters carry the field, soset_revision→get/list_revisions/diffreflects what the allowance write recorded.- Two cross-tenant admin reads no longer degrade silently.
sessions.list/sessions.inspectreturned fabricated zero counters andsearch.taskssilently dropped foreign rows; both now perform an audited re-scope.artifacts.deletemoved to its own admin-only registry row so the transport grants exactly what the surface honours.
Internal
- A three-part lockstep gate keeps the body-identity reconciler the only one, in the
protocol-ts-gen-checkidiom, each part with a non-vacuity pin: coverage (every scope-carrying canonical request type joins to a registered surface, both directions — a new type or a deleted row failsgo test), enforcement (an AST scan refusing any hand-written body-identity comparison outside the reconciler, reading through hoisted locals and aliased imports, with a reasoned allow-list), and minting (a reviewed call list for the verified-identity and elevation writers, with stale entries reported). - 21 inert smoke guards repaired.
assert_grep_absentmatched with basic regex while its siblings used extended, and swallowed grep's exit-2, so 21 absence assertions across the suite were silently passing without running — among them hand-rolled-fetchbans on seven Console pages, a Protocol single-source check, a spawn-depth check, and one asserting absence from a file that no longer exists. All are live and passing; a malformed pattern or unreadable target now fails loudly with "the guard did not run". Smoke assertions in this wave are anchored to call sites rather than identifiers — an identifier appears in prose, a call site does not — and are mutation-verified to FAIL rather than SKIP. - Two smoke guards could only ever pass on macOS. One asserted a tab-indented field with
\tinside agrep -Epattern — BSD grep reads that as a tab, GNU grep as a literalt, so on Linux it could never match and reported a present field absent. The other ranCGO_ENABLED=0 go test -race, which on Linux fails to build with "-race requires cgo" rather than running anything. Both are fixed, anddrift-auditnow fails on\tor\dinside agrep -Epattern anywhere underscripts/, naming file:line; those two are the only escapes that diverge between the greps. - The TUI PTY end-to-end gate is hermetic. The co-launch tests inherited the operator's real
HOMEand so read back a composer draft that an earlier run had persisted to~/.harbor/tui-state.json, making the suite non-idempotent. Each PTY launch now gets its ownHOME, and the wave's env-key lookup walks to the filesystem root instead of five parents — previously, running from a nested working tree put the repository.envout of reach and three subtests skipped silently.
Action required
A deployment whose configuration declares two MCP server ids where one sits inside the other's tool-name namespace — for example github and github_enterprise — is now refused at boot and by harbor validate. Remediation is a rename, which moves every <name>_<tool> catalog key referenced by agent YAML, disabled_tools, paused_servers, and persisted revisions.
An MCP App must send the bare server-side tool name; an App that previously sent an already-qualified Harbor catalog key will report a missing tool.
[1.21.1] — 2026-07-25
Patch: Protocol body-identity reconciliation and the dev bootstrap endpoint are tightened to the same contract the rest of the surface already applies. No Protocol wire change, no config schema change, no migration.
Fixed
- Body-identity reconciliation validates the full
(tenant, user, session)triple on the MCP surfaces. The MCP Apps and MCP-Connections Protocol adapters reconcile a request-body identity scope against the auth-verified identity on all three components; an empty body triple is still backfilled from the verified identity, exactly as before. Neither surface has an admin-elevation path, so a body triple that disagrees with the verified identity fails closed withidentity_required. artifacts.get_refreconciles the body scope's tenant before the store read, so the artifact driver is never consulted for another tenant's scope.Bootfails closed on a nil auth validator. A validator factory that returns no validator and no error is now rejected withErrAuthValidatorRequired. Identity is mandatory; the serve band never takes the test-kit validator-less path, and that path is unchanged for test kits that callBuildMuxdirectly.- The dev bootstrap endpoint serves requests addressed to a local authority (
localhostor a loopback IP literal, with an optional port) and emits no CORS allow-headers, so the connection envelope is same-origin local only. The Console's one-click attach fetches it fromwindow.location.origin;curlagainst127.0.0.1, the CLI, and every smoke script are unaffected. A request addressed to some other authority — including an external name resolved to a loopback address — is refused; address it to127.0.0.1orlocalhost.
[1.21.0] — 2026-07-24
Minor: durable-by-default per-user skills, and wire-carried per-user credential injection for dynamically-added receiver-style MCP servers.
Added
- Durable-by-default per-user skills. A new claim-free
agent_config.user.skills.{list,upsert,delete}verb family lets an end user author personal skills that persist across all of their sessions, via a newScopeUserrung keyed(tenant, user). Durability follows the store driver — ephemeral on the in-memory driver, durable on SQLite/Postgres. The run-start projection unions durable user skills so they survive an admin membership pin, and a personal skill cannot widen capability (the default-deny capability filter scrubs any tool a skill names outside the run's allowed set), which is why the verbs need no elevated scope claim. (D-345) - Wire-carried per-user credential injection for runtime-added MCP servers. The
add_mcp_connectionconnection descriptor (andagent_config.set_revision) may now carry an optionalinjectionmapping for receiver-style MCP servers, behind a fail-closedtools.allow_wire_injection/HARBOR_ALLOW_WIRE_INJECTIONboot opt-in — so a coordinator can attach a receiver-style server at runtime and deliver each acting user's own broker-pulled credential without a boot redeploy. The injection provider names a boot-declared broker (no secret rides the wire), the reachable sink is derived from the connection URL, and every target key is redaction-covered. Composes with the wire-carried OAuth descriptor (D-340) and reuses the receiver-injection engine (D-341). (D-346)
[1.20.0] — 2026-07-24
Minor: MCP Apps in the Console now adapt to the host — live theme + design tokens and real tool data delivered into the rendered app — re-landed handshake-safe.
Added
MCP Apps host theme + design tokens. When a
ui://MCP App mounts in the Console, the host now hands it the viewer's light/dark preference (from the OSprefers-color-scheme) plus the Console's structural design tokens (styles.variables, the ext-appsMcpUiStyleVariableKeynamespace, mapped fromtokens.css) in theui/initializehost-context, and relays a live OS theme flip mid-session viahost-context-changed— the app re-themes without a reload. A spec-conformant app themes itself to match the Console instead of booting to a fixed palette.MCP Apps Data Delivery (Console half). After the app sends
ui/notifications/initialized, the host delivers the originating tool call's input then result into it (official AppBridgesendToolInput/sendToolResult, through the D-173 injected client readingmcp.apps.tool_context) — so the app renders its real content rather than an empty shell, and never re-invokes the tool (which would double a side effect).This re-lands the two Console halves reverted in #346 (D-226 Data Delivery, D-227 live theme) as one coherent, handshake-safe slice: the bridge is constructed once with the final host-context, the lifecycle effect is isolated from theme reactivity, and every host→app send is gated behind
oninitialized— never a teardown-rebuild that would breakui/initialize. Gated by a real-iframe Playwright handshake test and a binding live-render of a real Dockyardanalytics-widgetsapp under a real agent. Console-only; no Runtime/Protocol change. (D-342)
Fixed
- MCP-App re-render resource storm. On a turn that produced no final answer (the task fails and the transcript re-renders), a single widget render could fire hundreds of
mcp.servers.read_resourcecalls and log repeated "Not connected" tool-context delivery failures. The app-document fetch now dedups on the resource URI (an app-prop identity change to the same document no longer refetches and tears the bridge down), and tool-context delivery re-checks the transport is still connected after its async fetch — a bridge closed mid-fetch drops its stale delivery instead of throwing. A failed-turn render is now bounded to a handful of requests with zero delivery errors.
[1.19.1] — 2026-07-24
Patch: config-validation hotfix for the v1.19.0 skills Postgres driver.
Fixed
- Skills
postgresdriver rejected by config validation. The v1.19.0 skillspostgresdriver was rejected by config validation (config.skills.driver: must be one of localdb) despite the driver shipping — the static driver allowlist in the config validator was not extended.skills.driver: postgresnow validates and boots; a config-validation regression test guards it.
[1.19.0] — 2026-07-24
Minor: a Postgres driver for the skills subsystem — durable, shared skill storage for multi-instance deployments.
Added
- Skills Postgres driver. The skills subsystem gains a
postgresstorage driver alongside the existinglocaldb(SQLite) default, bringing it to the §9 three-driver persistence parity (in-memory / SQLite / Postgres). Skills now persist in shared, durable Postgres for multi-instance / production deployments instead of only a per-instance SQLite file. The driver sits behind the existingSkillStoreinterface (no interface change, noSupports*ceremony), is identity-triple-scoped on every query, carries its own forward-only migrations, and passes the existing skills conformance suite unchanged.localdbremains the default — fully backward-compatible.
[1.18.1] — 2026-07-24
Patch: test-only hardening — eliminate a class of load-sensitive flaky goroutine-leak tests. No Runtime, Protocol, or Console behaviour changes.
Fixed
- Load-sensitive goroutine-leak tests hardened to a bounded drain-poll. A class of concurrent-reuse and leak tests sampled
runtime.NumGoroutine()a SINGLE instant right after their workers joined — after a fixedtime.Sleep, a loneruntime.GC(), or a tightruntime.Gosched()spin — and compared against a baseline. Under the fullgo test -race ./...matrix (andmake preflight) shared background teardown goroutines (event/notification consume-loops, HTTP handler unwinds, SSE keepalives, server-close readers) can still be draining at that instant, so the count reads transiently over tolerance and the test FAILs on one platform while passing on the other and in isolation — a spurious failure that forced CI re-runs on otherwise-clean PRs. Each such site now polls a real-time bounded eventually-assertion (AGENTS.md §17.4): re-sample afterruntime.GC()(which reaps finished-but-unscheduled goroutines and parks the poller so exiting goroutines get scheduler time under load) until the count settles within tolerance or a deadline elapses, failing only if it never drains — the leak-detection intent is preserved. Twenty-three single-instant sites across the protocol, protocol/transports, protocol/client, tui/app, tui/conversation, runtime/engine, runtime/pauseresume, search (+ four index sub-packages), tools/drivers/mcp, and thetest/integrationwave suites were converted; the twoprotocol/clientsites'runtime.Gosched()spin (which can starve the very goroutines it waits on under load) was replaced with the same GC-parked poll. Test files only.
[1.18.0] — 2026-07-23
Minor: runtime-time MCP connection management + per-user credential delivery — an idempotent live re-attach, a dev-gated wire-carried OAuth-provider descriptor, and per-user credential injection for receiver-style MCP servers.
Added
agent_config.add_mcp_connectionis idempotent at the live layer. A same-name attach against a still-live in-memory registration now synchronously REPLACES it — deregister the old server's tools + close its transport, then register the new one — an atomic, owner-scoped upsert, instead of failing with a duplicate-tool-name collision. This closes the synchronous-attach / deferred-detach asymmetry that otherwise stranded a coordinator re-establishing a connection on a running runtime with ZERO tools until a process restart. A same-name collision owned by a DIFFERENT tenant / agent is rejected loud (ErrConnectionNameOwnerConflict) and never tears down another owner's live registration; the fix also closes a transport leak in the registry's same-name overwrite path.- A dev-gated, wire-carried OAuth-provider descriptor.
agent_config.set_oauth_provider/add_mcp_connectionmay carry a new server's OAuth binding (token_url,audience,scopes, naming a boot-declared credential broker) so a new OAuth-fronted MCP server can be connected at runtime without a statictools.oauth_providers[]block and a redeploy — but ONLY behind a fail-closed, boot-only opt-in (tools.allow_wire_oauth_descriptor, or theHARBOR_ALLOW_WIRE_OAUTH_DESCRIPTORboot env; default off). With the opt-in off (all production), a descriptor carrying any credential-sink field is rejected exactly as before. When an operator opts in, the downstream allow-list is DERIVED from the connected server's own URL (never wire-chosen), the wire token endpoint is dialed through the same SSRF backstop as the boot path (private / link-local / unspecified refused, redirects refused, no proxy), and the runtime's own credential custody stays entirely boot-declared — no secret ever rides the wire. - Per-user credential injection for receiver-style MCP servers. The southbound MCP driver can source the acting principal's credential from the broker per outbound tool call and inject it in a server's declared form (arbitrary headers,
Authorization: Basic, or MCP_meta), reaching a server that RECEIVES its credential directly instead of pulling it via RFC 8693. The injection mapping is non-secret connection config; the pulled value is per-user, fetched-not-held, and never logged — the audit redactor covers every injected form. Injection is mutually exclusive with the bearer OAuth mode (one auth mode per connection).
[1.17.4] — 2026-07-23
Patch: fix the Console's posture-edit 400 and a silent-park on an unresolvable MCP OAuth binding.
Fixed
- The Console's Governance-Posture edit card 400'd on every submit. The shared Console transport folds the identity triple into every request body, but
governance.set_posturedecodes an identity-LESS request type withDisallowUnknownFields, so the foldedidentitykey was rejected withunknown field "identity"(HTTP 400) before the validator ran.setPosturenow opts out of the body fold (identity still rides theX-Harbor-*headers); a corrected JSDoc no longer claims the transport skips the fold. - A runtime-added MCP connection whose
oauth_providerdid not resolve parked silently atauth_requiredinstead of failing loud. The auth-required classifier is a message heuristic that matches the markeroauth; a binding/config error (mcp: invalid oauth_provider binding: …) containsoauth_provider, so an unknown-provider / missing-or-mismatched downstream-allow-list error was misclassified as auth-required and parked, hiding the diagnostic. The attacher now checks the typedErrOAuthBindingsentinel first, so a binding misconfiguration surfaces its real cause loudly (§13). The genuine transport auth-required path is unchanged.
[1.17.3] — 2026-07-23
Patch: dev-only, fail-closed opt-in to permit a private-IP tokenexchange dial.
Added
- Dev-only opt-in to reach a private-IP credential broker. The
tokenexchangecredential-bearing RFC-8693 exchange POST is hardened with a post-DNS dial backstop that refuses private-range / link-local resolved addresses (the DNS-rebinding defence). That backstop also refused the ordinary containerized local-dev topology — a coordinator behind a private-IP TLS sidecar whose operator-declaredtoken_urlresolves into RFC 1918 space. A new fail-closed, boot-only opt-in relaxes ONLY the private / link-local / ULA branch of the guard, scoped to that provider's own boot-declaredtoken_url: the per-providertools.oauth_providers[].allow_private_token_urlconfig flag, or the globalHARBOR_DEV_ALLOW_PRIVATE_EXCHANGE=1boot env (effective posture is the OR of the two, default off). When the env hatch fires, every boot prints a[DEV-ONLY PRIVATE-IP TOKEN EXCHANGE — DO NOT USE IN PRODUCTION]stderr banner. It is never Protocol-writable or derived from a discovered / wire descriptor.
Changed
- Production posture is unchanged. The dial backstop stays default-armed; the opt-in is off unless an operator explicitly sets the config flag or the banner'd env. The unspecified-address block (
0.0.0.0/::) stays refused unconditionally, the loopback carve-out stays allowed, and the token-endpoint redirect refusal (the credential-form-replay defence) stays absolute — none of them are touched by the opt-in (see D-300, D-338).
[1.17.2] — 2026-07-21
Patch: fix tool_search output validation when a discovered tool has no tags.
Fixed
tool_searchfailed its own output schema whenever a searchable MCP tool had no tags. An MCP-discovered tool withtags: nullsurfaces in Go as a nil slice, which JSON-marshals tonull; but thetool_searchresult schema requirestagsto be an array, so the whole result failed validation (/tools/N/tags: got null, want array) — breakingtool_searchfor any agent whose catalog held an MCP tool without tags. Thetool_searchresult builder now normalizes a nil tag slice to an empty array ([]) at the emit boundary.
[1.17.1] — 2026-07-21
Patch: fix runtime-added MCP connections on SDK-facade servers.
Fixed
- A compiled
sdk/serveragent could not accept a runtime-added MCP connection. The SDK facade exposes no knob forMCPDefaultIdentity, so it was left empty; the runtime-add attacher then passed that empty triple to the MCP provider, which rejected it at construction (DefaultIdentity must be fully populated). Every runtime-added connection on such a server died before dialing.serve.Bootnow fills an unsetMCPDefaultIdentitywith the same fully-populatedassemble.DefaultMCPIdentityfallback the boot path already applies to config-declared servers. The default only stamps transport-side events; per-call isolation continues to ride the inflight caller identity, so multi-isolation is unchanged. Agents launched viaharbor serve/harbor dev/ the test devstack were unaffected (they set the identity explicitly).
[1.17.0] — 2026-07-21
The control-plane admin-write release. Harbor's admin/control plane gains WRITE surfaces that were previously read-only or absent, plus the parallel-intent primitives that complete the v1.16 task-control story: a planner can now steer, pause, and resume the children it spawned; operators can rewrite the governance identity-tier policy live; and the LLM credential plane learns to pull provider keys from a broker and orchestrate failover — all fail-closed, all identity-scoped.
Every new surface is additive on the wire — one new canonical event (governance.failover), two new Protocol methods (governance.set_posture, agent_config.set_llm_provider), new optional wire fields, and new config keys — so the Harbor Protocol holds at 0.1.0 (no methods removed, no breaking changes). Nine decisions land: D-329 (task-group-cancelled conversational mirror), D-330 (planner steer/pause/resume of a spawned child), D-331 (per-tool OAuth on resource/prompt paths + owner-scoped uninstall), D-332 (governance.set_posture tier-policy write), D-333 (inference broker-pull credential source), D-334 (agent_config.set_llm_provider), D-335 (broker-pulled Harbor-orchestrated failover), D-336 (ephemeral inference-provider rebind), D-337 (governance.failover as a canonical event).
Added — control-plane admin-write surfaces
notification.task_group_cancelledconversational mirror with a cancel-origin (operator / cascade / fail-fast); operator-driven cancels are suppressed, only unprompted cancels surface (D-329).- Planner-facing steer / pause / resume of a spawned child (
_steer_task/_pause_task/_resume_task), dispatched onto the existing per-sub-run steering inbox and the unified pause/resume primitive; descendant-scoped, human-supremacy-preserving (D-330). - Per-tool OAuth binding extended to MCP resource/prompt paths, plus owner-scoped provider uninstall (defense-in-depth on D-303) (D-331).
governance.set_posture— admin identity-tier policy write (per-tier budget / max-tokens / rate + default tier), fail-closed against any budget-widening write,admin-scope only, hot-swapped into live enforcement with no restart (D-332).- Inference-plane broker-pull credential source (per-runtime, fail-loud, no stale-key-past-refresh) plus a zero-URL admin
agent_config.set_llm_providerinstall/rebind (D-333, D-334). - Broker-pulled Harbor-orchestrated failover — on a retryable provider error, advance the chain, emit
governance.failover, re-run governancePreCall(budget is never bypassed), re-issue through the one-methodLLMClient; the provider SDK's native fallback array stays unused (D-018) (D-335, D-337).
[1.16.0] — 2026-07-18
The parallel-intent release. Harbor's React planner and runtime gain the ability to express and execute concurrent intent in a single turn, a task-management control surface with a real cancel hierarchy, honest background-wake and turn-failure signals on the conversation surfaces, prompt-cache telemetry, a default-agent row, and three additive OAuth broker legs. The wave closes a long-standing limitation from the native tool-calling migration: a planner could no longer say "do these things at once."
Every new surface is additive on the wire — new event classes, new optional wire fields, new config keys — so the Harbor Protocol holds at 0.1.0 (no methods removed, no breaking changes). Seven decisions land: D-322 (Batch decision), D-323 (Batch executor), D-324 (task-management meta-tools + cancel hierarchy), D-325 (background wake + failure honesty), D-326 (cache-token capture), D-327 (default-agent row), D-328 (OAuth broker legs).
Added — parallel intent + task management
- The
Batchdecision (D-322). A fourth sealedplanner.Decisionshape,Batch{Tools, Spawns, Join}, lets a single planner step dispatch multiple tool calls and/or spawn multiple background tasks at once. The fossilized "_spawn_taskmust stand alone" rule (a leftover from the pre-native-tool-calling era) is lifted: only the genuinely terminal / blocking controls (_finish,_await_task,_task_status,_cancel_task) remain standalone;_spawn_taskis now batchable alongside ordinary tool calls.SpawnTaskgains an additiveCallID. - The Batch executor with auto-grouping (D-323). The runtime dispatch path executes a
Batchwith non-atomic per-branch semantics (a branch failure is that branch's error result, not a whole-run abort); two or more unbound spawns are auto-grouped so the planner can await them as a unit. Aplanner.max_batch_spawnsconfig key (default5) caps spawn breadth per batch. - Task-management meta-tools + a cancel hierarchy (D-324). New reserved controls
_task_statusand_cancel_task(sealedTaskStatusQuery/CancelTaskdecisions) let a run inspect and cancel the tasks it spawned — descendant-scoped, so a run can never observe or cancel a sibling run's tasks. A model-expressiblepropagate_on_cancel: isolatelands in the same phase as its brake. The cancel hierarchy is explicit and tested end-to-end: operator > agent (own descendants) > cascade — there is no uncancellable task; a human always has the last word. - Background-wake notifications + turn-failure honesty (D-325). Two new conversational-mirror event classes,
notification.task_group_resolvedandnotification.task_completed, surface background resolution on the conversation surface (ref-shaped member outcomes; the typedWatchGroupplanner path is untouched). The TUI renders muted lifecycle one-liners (a new conversationalnotificationblock kind) and a dedicated× Turn failed · <ErrorCode>status-strip line for a failed foreground turn that previously went silently idle. The Console Sessions and Tasks docks render the same family. - A default-agent row on
agents.list(D-327). The agent registry projection synthesises a default-agent row (Agent.IsDefault), so a zero-registration Runtime still presents an agent to operators; a real registration suppresses the synthetic row. - OAuth broker legs (D-328). Three additive, report-not-act legs on the broker-pull spine: (HA-26) a downstream 403 +
WWW-Authenticate: insufficient_scopebecomes typedtools.ErrInsufficientScopeon the tool-result error path andMCPServerView.LastScopeShortfallon the connection view, andClassifyErrornow returnsErrClassPermanentfor it (closing a retry-storm where a scope shortfall was retried); (HA-27a) an RFC 8707resource_indicatorrides the RFC 8693 exchange with best-effort audience verification; (HA-27b) a per-tooltool_oauth_providersbinding overrides the connection-leveloauth_providerfor a shared MCP server fronting multiple downstream resources; (HA-28) aninclude_actor_tokenopts the run's verified acting principal into the exchange as an RFC 8693actor_token. Custody, acquisition, refresh, and consent stay coordinator-side; no leg widens a binding or runs a flow, and D-300's boot-declared credential-sink invariant is preserved.
Changed
- Prompt-cache telemetry (D-326). The bifrost translator no longer drops the provider's cache accounting:
llm.UsagegainsCacheReadTokens/CacheWriteTokens, threaded through the cost surface and its consumers (the TUI reducer, the Console run-events reader, and the sessions enricher). Cache tokens are informational first — governance ceiling math is intentionally untouched this wave. - New configuration keys (all additive, optional, restart-required):
planner.max_batch_spawns; and on atokenexchange-bound MCP provider,resource_indicator,include_actor_token, and the per-servertool_oauth_providersmap. Documented inexamples/harbor.yaml.
Fixed
- Native-path structural rejections are now repairable, not fatal. A native tool-calling response the React projector cannot turn into an actionable decision — malformed tool-call args JSON, a
retain_turnspawn riding a multi-call batch, a terminal/blocking control co-occurring with other calls, an out-of-enum spawn arg — previously killed the run, while the same class of failure raised at dispatch was fed back to the planner and re-planned. The runloop now feeds a projector structural rejection back as a repairable step observation (bounded by a consecutive-failure budget so a persistently-malformed model still terminates loudly). Genuinely-fatal errors (context cancellation, missing identity, unserializable state, LLM transport failure) stay fatal. A real streaming model emitting imperfect JSON now recovers instead of dying.
Known follow-ups
- A cascade / fail-fast-cancelled batch-spawned task has no conversational mirror while its successful siblings wake — tracked in #532 (a
notification.task_group_cancelledclass), accepted-by-plan per D-325's scope for this wave.
[1.15.0] — 2026-07-17
The native terminal client release. Harbor gains a first-party TUI — a pure Protocol client that attaches over authenticated REST/SSE and never holds a Runtime handle — and the distribution surfaces to launch it. On top of the foundational wave (the Go Protocol client, the typed projection reducer, the Bubble Tea terminal foundation, and the conversation / runtime-inspection / control surfaces), a deep level-up pass rebuilt the conversation to product quality: message hierarchy, a full-screen alternate-screen session surface with line-granular scrolling, honest chrome sourced from canonical events, a 76× faster layout path, and a correctness pass over the interactive command/modal/ session workflows.
The TUI reads only canonical Protocol events, state snapshots, topology, artifacts, traces, and metrics — never a Runtime internal — so the Harbor Protocol holds at 0.1.0 (no new methods, events, or breaks). Two operational decisions land: D-320 (TUI distribution) and D-321 (dev-only .env loading).
Added — the native terminal client (D-320, D-321)
harbor tui --attach— the native terminal client. A pure Protocol client (no Runtime handle) carrying the full(tenant, user, session)identity through authenticated REST/SSE, with the runtime routes (tasks / tools / artifacts / events / posture / interventions / diagnostics) behind F-keys and the F9 action matrix for canonical control (cancel / pause / resume / redirect / inject / user-message / prioritize, and intervention approve / reject / resume through the unified pause primitive). TTY discovery falls back to/dev/ttywhen stdin is redirected.harbor dev --tuico-launches the local Runtime + the terminal client in one process, one terminal — the one-command dev loop.harbor devalso loads a dev-only./.envat boot (D-321): a names-only stderr line, environment-wins precedence, no secret ever echoed in an error,--no-env-fileto opt out.harbor serve --tuico-launches the native terminal client after the server is ready. The TUI attaches through authenticated REST/SSE — it receives no Runtime handle. The operator supplies the token (HARBOR_TOKENor~/.harbor/token); there is no anonymous loopback, automatic token minting, or mock fallback. Quitting the TUI drains the owned server. Runtime logs go to a captured sink so Bubble Tea frames are never overwritten; on server failure the terminal is restored before the captured log is printed.sdk/tui.Run(ctx, Options)— a curated connection-only facade withBaseURL,Token protocolclient.TokenSource, andSession. No Runtime/stack/event-bus handle. The facade forwards to the sharedinternal/tui/entryattach flow (harbor tui --attachandharbor serve --tuishare one implementation).(*server.Handle).WaitReady(ctx) (string, error)— race-safe one-shot readiness throughsdk/server. Returns the actual bound address or the bind/cancellation error. No polling, no second listener lifecycle.harbor scaffold --with-server --with-tuigenerates a binary whose opt-in--tuiflag co-launches the TUI viasdk/server+sdk/tui. Flagless behavior remains headless and unchanged.- A dependency-free, streaming-stable markdown renderer (
internal/tui/markdown) with a span API (RenderSpans) that emits plain styled runs for the cell-grid canvas — deferred delimiter matching keeps a mid-stream token from flickering formatting. - The Harbor lighthouse mark and reconciled palette in the session banner, with a real version readout (
v1.15.0-devon un-stamped builds, neverv0.0.0). - Wave-end PTY E2E (
test/integration/wave_v115_tui_test.go) covers standalone attach, stock co-launch, and generated co-launch with identity propagation, session isolation, conversation, shutdown ordering, goroutine baseline, and N≥10 concurrent cycles under-race.
Changed — the conversation surface level-up
- Rebuilt around message hierarchy. The session surface owns the whole alternate screen — banner at the top, the conversation flowing top-down, the composer pinned at the bottom — with line-granular scrolling that follows the tail and never yanks the reader back, reflow-stable across resizes, and the mouse wheel translated to scroll without capturing the mouse so native text selection keeps working over the conversation only.
- Honest chrome. The per-turn duration and the context / cost / token readout come only from the canonical
llm.cost.recordedstream (never estimated); the operator's own message is echoed on a live turn; honesty states surface as chrome. The conversation carries the conversation — audit records, cost accounting, planner decisions, and session/task lifecycle stay on the diagnostics and events routes, never interleaved with the answer. - 76× faster transcript layout — a per-block layout cache keyed on a content signature plus a structured projection clone replacing a JSON round-trip, so scrolling a long conversation costs map lookups instead of a full re-render.
- Interactive-workflow correctness. A single command-dispatch path with a submit-time slash guard keeps
/commanddrafts out of the model (they execute or toast, never send as chat) and makes shell-owned commands reachable from the composer; interrupting a run takes a deliberate double-esc;ctrl+cquits from every input mode. Free-text dialogs (rename, attach, credential, action input) now carry real labels, masked credential echo, and inline validation instead of a mislabeled "Search:" prompt. Start Fresh mints a fresh session instantly (with per-session dev-token minting underharbor dev), a failed session switch is non-destructive (the previous session stays live), mid-run steering targets the active run from the conversation, an explicit theme choice locks against terminal auto-detect, and closing search always clears its filter.
Fixed
- Streaming transcript segmentation. A turn's assistant text and reasoning were keyed per run, so a turn's preamble and its post-tool answer merged into one block anchored at its first appearance — reordering the intervening reasoning and tool blocks. Streaming blocks now key per
(kind, run, step), advancing on each stream'sDoneterminator (content and reasoning carry independent terminators), so every message and reasoning segment is a distinct, correctly-ordered block while consecutive same-step deltas still merge.
(Next up: the run-start connection reconcile landed DETACH-ONLY in v1.11 (D-287) — only the attach / OAuth-resume leg of issue #375 remains parked with the 92k–92q MCP-OAuth band; identity-scoped StateStore enumeration to bound the user-scope revision scan — issue #396; and the shared chat-module extraction to web/shared/chat/ — the D-091 follow-on. New this wave: hardening the Protocol-installed OAuth provider's Uninstall(owner) blast radius — issue #507; the tools Annotator's read-side metrics path may grow a cache off the durable bus if the per-projection events scan proves expensive (D-314), and its DisplayModes reader awaits per-MIME MCP negotiation. A HARBOR_LIVE_LLM-gated smoke guards Anthropic reasoning-chunk surfacing; the internal/runtime/dispatch parallel-cancel test flakes under full-suite -race CPU oversubscription — issue #480.)
[1.14.0] — 2026-07-14
The credential-plane + Protocol-edge-honesty release. Two arcs land together.
The credential plane gets an invariant, and MCP OAuth becomes Protocol-drivable. An adversarial review of the v1.14 credential surface found three exfiltration paths in shipped Harbor and generalized the rule that closes them: no admin-writable field may determine where a credential is sent — every credential sink (token endpoint, allowed downstream hosts, token audience, scope ceiling) is now boot-declared. On top of that hardened edge, an operator can now grant an MCP server's OAuth-discovery origins live, install a zero-URL broker-pull OAuth provider over the Protocol, and bind it to a connection — the discovery walker shipped in v1.13 is now reachable on the ordinary self-hosted (localhost / compose / private-VPC) posture it could never complete against before.
The Protocol read edge stops lying. A recurring silent-absence defect class — a surface that declares a wire field, ships a facet/sort/aggregate over it, never populates it, and returns believable-but-false emptiness — is closed across events.aggregate, sessions.list/inspect, runtime.health retention, and the tools catalog, and a build-time projection-completeness gate now makes the class mechanically un-reintroducible. events.aggregate works on the durable driver for the first time (it 500'd on every call), gains an addressable/cacheable bucket grid and opt-in per-tenant attribution, and widened admin reads finally fan in across the axes they were authorized to read instead of folding to an empty own-scope result.
Nearly all additive: the Harbor Protocol holds at 0.1.0 — three new methods, one new event, one new error code (session_erased), and a handful of optional wire fields, none of them a break. The one wire removal — a structurally-dead memory TTL facet that was always empty — is a deliberate within-0.1.0 removal justified under RFC §8 (no live consumer). Thirteen phases plus a checkpoint audit; fifteen new decisions (D-300…D-314).
Security
- Credential-sink hardening (D-300) — the v1.14 credential-plane invariant: no admin-writable field may determine where a credential is sent. Three exfiltration paths reachable in shipped Harbor are closed in-band: (1) a boot-declared
tools.oauth_providers[].allowed_downstream_hostsallow-list is now enforced in the MCP southbound binding (resolveOAuthBinding) — a binding whose connection host is not listed is refused fail-closed, covering both thetokenexchangeandoauth2drivers; (2) the token-exchange HTTP client (which POSTs the org's OAuthclient_id/client_secret) is hardened to refuse private-range/link-local dials post-DNS (the DNS-rebinding backstop; loopback stays allowed for a boot-declared localhost-sidecar broker), disable the proxy, and refuse every redirect (Go replays the POST body on 307/308); (3) the MCP bearer client re-validates every redirect target against the provider's allow-list so an exchanged downstream bearer never egresses via a redirect. A boot-declared audience/scope ceiling decouples the token audience from the caller-chosen provider name and intersects requested scopes. Themcp.servers.set_raw_html_trustaudit ordering is corrected to a genuinely fail-closed (apply-then-emit-then-compensate) posture. - MCP OAuth discovery now works on private networks without weakening the SSRF posture (D-304). The v1.13 discovery walker had two SSRF guards that disagreed: the per-hop policy permitted a same-origin protected-resource fetch, but the dial-time backstop unconditionally refused every private/loopback IP with no production override — so discovery could never complete against an MCP server on
localhost, a compose network, or a private VPC (the ordinary self-hosted posture), dying one hop before the operator could even be asked to grant an origin. The dial backstop is aligned to the per-hop policy, relaxed for exactly the same-origin protected-resource hop and pinned to the connection's operator-declared dial target (resolved IP set + port) — the DNS-rebinding vector stays closed by pinning to the resolved IP, not the origin string, and plain-HTTP is permitted only to that pinned same-origin target (compose/k8s service names). Cross-origin and authorization-server hops keep the full private-IP refusal; there is no production private-network knob. - Run-start connection reconcile is owner-scoped in a shared runtime (D-301). In a deployment running one runtime for many tenants, a run-start reconcile enumerated the process-global MCP registry and could detach boot-declared servers or another owner's runtime-added connections. Runtime- added connections and Protocol-installed providers now carry an owner tag
(tenant, agent), and a reconcile only ever touches its own owner's runtime- adds. The owner tag is a reconcile-view filter, never an isolation principal or a storageWHEREkey (the boundary stays(tenant, user, session)); the shared catalog, bare-name resolution, and dispatch model are unchanged (extends D-287, does not reverse it). The bounded honesty this states plainly: a shared runtime does not claim hard cross-tenant isolation of runtime-added tool dispatch — a name collision fails loud, and a deployment needing hard isolation runs one-runtime-per-tenant.
Added
agent_config.set_mcp_discovery_origins— live MCP OAuth discovery-origin grant (D-302). The per-connection origins that the discovery walker's cross-origin authorization-server hop needs were reachable only as a restart-required yaml field, unusable to a Protocol-driven console. One narrow admin verb now writes them (full replace) as revisioned agent-config state — diffable, rollback-able — and applies them live to the process-global registry. Revoke is symmetric and live (dropping an origin refuses the next hop and prunes the recorded requirement's entries from that origin); rollback /set_revisiontake effect through the owner-scoped run-start reconcile. A granted origin is still refused at dial if it resolves private/loopback (D-304). Server-derived admin authority, no new scope. The Console/mcp-connectionsneeds_allowanceaffordance deep-links to the/agent-configeditor.- Protocol-installable OAuth providers, zero-URL broker-pull shape (D-303).
agent_config.set_oauth_provider(upsert + live install) andagent_config.remove_oauth_provider(drop + live uninstall) let an admin manage OAuth providers over the Protocol without a restart. Honoring D-300, the writable descriptor carries zero URLs —{name, credential_broker, scopes?}; the token endpoint, allowed downstream hosts, and audience/scope ceiling are all pinned at boot on a named credential broker the descriptor references by name (aclient_secret_env/token_url/remotefield is rejected by name via strict decoding). Uninstall closes the provider and fails its bound calls loud; rollback past an install runs the same uninstall through the owner-scoped reconcile (safe cross-tenant only because of D-301). The Console Add-connection card gains anoauth_providerselect from the installed set, closing a silent binding drop. events.aggregateaddressable bucket grid — theanchorfield (D-306). The aggregator laid its bucket boundaries from the wall-clock instant of the call, so abucket_startwas never re-requestable and no consumer could cache a bucket. An optionalanchor(a*time.Time; absent ⇒ today's clock-anchored behavior) floors boundaries onto the fixed gridanchor + k·bucket; passing the Unix epoch yields a globally shared grid, so two calls at two instants with the same anchor/window/bucket share boundary instants and a cold N-bucket backfill becomes one cacheable call.events.aggregateper-tenant attribution — theby_tenantflag (D-307). An aggregate bucket was a bag of scalars with no tenant attribution, so an admin-widened count could not be verified against thetenant_idsthe caller asked for. Opt-inby_tenantreturnscounts_by_tenant(tenant → event_type → count) alongside the totals, only for admin-/console:fleet-widened reads (server-derived), scoped by construction to the authorized tenant set (Σ counts_by_tenant == counts). Existing callers see byte-identical responses.- Session reopen — a closed conversation resumes with its history intact (D-312). RFC §6.9's "reopen-after-close is forbidden" is amended: a
start/EnsureOpenon a session whose record is closed or GC-reaped now re-activates it in place — the immutable identity andOpenedAtare preserved, aLastReopenedAtis stamped (and the GC hard cap now measures from it, so an old conversation stays resumable), and because close/GC never reaped the durable events/state/memory, the conversation resumes with its full history. A new content-freesession.reopenedevent fires. The one terminal exception is an erased session: reopen fails loud with a new machine-branchablesession_erasedcode (HTTP 409), gated fail-closed on both the closed-record and fresh-create paths against a durable erasure tombstone — a deleted conversation is never silently resurrected. The Console Playground/Sessions pages resume a closed session and branch onsession_erasedfor the deleted-conversation path. - Tools facets and aggregates carry real per-tool data (D-314). The catalog projector read OAuth status, approval policy, last-used, error-rate, and content-size through an
Annotatorseam that shipped with no production implementation — so in prodfilter.oauth_statuses/filter.approval_policies, the version search axis, and the catalog aggregates all operated over structural defaults (D-313 honestly gated them behind a capability toggle in the interim). A productionAnnotatoris now assembled and wired, reading identity-scoped raw data from the owning subsystems (OAuth fromtools/auth, policy fromtools/approval, metrics/last-used/content-stats from the events stream). This also lights up the previously-inert admin write path:tools.set_approval_policyandtools.revoke_oauthnow persist (routed back through the owning subsystems with audit, never a Console shadow store).DisplayModesreads honestly empty pending per-MIME MCP negotiation.
Fixed
events.aggregatereturned HTTP 500 on the durable driver, on every call (HA-18/HA-20, D-305). The aggregator replayed through a per-session path with a session-less admin filter that the durable driver correctly refuses — so the method worked in dev (in-memory) and 500'd in prod. It now reads through the same cross-session windowed fan-in substrateevents.listuses; a window too wide to count within the memory bound returns the partial buckets with an additivetruncatedflag (uniformly on both drivers), never a status-code fork. The events-driver conformance matrix that let this ship — it never exercised the session-less admin read and never coveredevents.aggregateat all — gains a driver-parametrized aggregate scenario and a four-method parity leg, plus a build gate that fails if any registered driver has no conformance run wired.- Widened admin reads folded away the axes they were authorized to read (D-308/HA-21). Both
events.aggregateandevents.listunconditionally folded every elided identity axis onto the caller's own triple — so a real admin/console:fleetread namingtenant_ids:[T2]with elided user/session narrowed to the caller's own (empty) scope, authorizing and auditing a fan-in the fold immediately defeated (a silently-blank Console fleet sparkline). The fold is now asymmetric: a widened, scope-gated, audited read leaves the elided user/session axes wildcard and fans in; only a non-widened own-scope read folds. The tenant axis stays name-to-widen (D-284 parity). This also closes the session-less fan-in case (HA-21). sessions.list/sessions.inspectcounters were declared but never assigned (HA-22, D-309). EightSessionRowfields — cost, tokens, task/event counts, failed-task and pending-intervention flags, agent id/name — shipped on the wire permanently zero, while the Service ran facets, a sort, and a keyset cursor over them: "sessions over $5" returned empty on a fleet full of them, and "most expensive first" silently degraded to an id tiebreak. The six numeric counters are now populated at the source through a read-timeEnricherseam (identity-scoped, no shadow store), with an honestcounters_partialmarker when a bounded scan truncates — never a plausible-but-low number. The two agent fields (no single-valued session→agent binding exists in V1) take the class rule instead: representable absence, andfilter.agent_idsover the unpopulated binding loud-rejects rather than returning a false-empty page.runtime.healthretention horizons were invisible to the one caller that observes the fleet (HA-23, D-310). Thetasksandsessionsretention horizons were measured at the caller's own triple/tenant scope, so a fleet coordinator polling under a service identity (owning no sessions/tasks) received only theeventshorizon and silently under-trusted its windowed view. A verified admin/console:fleetcaller now reads both horizons at runtime-wide scope (server-derived), and each horizon carries ascopemarker so an unobservable scope is distinguishable from a genuinely empty surface — the consumer degrades honestly instead of trusting an absent horizon as runtime-wide truth.
Changed
- Migration — the downstream-host allow-list is MANDATORY for a bound OAuth provider (D-300). Any
tools.oauth_providers[]entry that atools.mcp_servers[]connection binds viaoauth_providerMUST now declare a non-emptyallowed_downstream_hoststhat lists the connection's host (default-port equivalence and case-insensitivity apply). A previously-valid config that bound a provider without listing its downstream host now fails at boot (not at first call), naming the missing field. Addallowed_downstream_hosts: ["<connection-host>"]to the provider entry. A new boot-declaredtools.oauth_credential_brokers[]list pins named credential sinks (token endpoint + allowed downstream hosts + broker credential env); it is additive and the inlineoauth_providers[].remoteblock stays valid. Seeexamples/dev.yamlanddocs/CONFIG.md. - A build-time projection-completeness gate, plus one dead memory wire field removed (D-311, D-313). The silent-absence class is now closed mechanically: a registry-gated gate (
internal/protocol/projectioncheck) fails the build when a filtered/sorted/aggregated wire field is never assigned by its production projector (never-assigned) or when the production constructor never wires the seam that populates it (never-wired), and asserts every projection surface is registered. Fixing the surfaces it caught, the structurally-dead memoryhas_ttl_expiringfilter and bothexpiring_in_1haggregate fields (MemoryAggregates,MemoryHealthAggregate) are removed from the wire — V1 memory has no TTL, so these were always empty. This is a within-0.1.0wire removal taken under an explicit RFC §8 exemption (always-empty fields, no live consumer), not a silent drop. A client reading either field should stop; no replacement is needed.
[1.13.1] — 2026-07-13
A scaffold patch release. Both bugs were reported by an external adopter integrating the harbor scaffold --with-server path — the first is a broken golden path (the scaffolded binary could not boot), the second made the generated project unbuildable as emitted.
Fixed
--with-serverscaffold died at boot whenharbor.yamldeclared anytools.built_inentry. The generatedRegisterToolsregistered the declared built-ins at the pre-policy catalog seam; the runtime then registered the same names fromtools.built_in(with their backing SkillStore / ArtifactStore / Bus / Redactor), and the catalog rejected the duplicate:open server: tools/builtin: builtin: failed to register built-in tool: "clock.now": tools: duplicate tool name. Built-ins are config-driven and the runtime owns them — the generated registrar now carries this module's compiled tools (tools.custom) and nothing else, which is the registrar seam's whole purpose. Listing a built-in in the yaml is the complete opt-in; no Go wiring accompanies it. This also closes a latent second defect: the generatedbuiltin.RegistryContextwas Catalog-only, so a stateful built-in (artifact_fetch, theskill_*set) registered that way would have been store-less. A project declaring only built-ins now gets aRegisterToolsthat returnsnil— the seam stays emitted socmd/<name>/main.gokeeps a stable shape. Corrections appended to D-154 and D-267.- The generated
go.modwas unbuildable as emitted. It requiredgithub.com/hurtener/Harbor v0.0.0-devwith thereplacedirective commented out, and told the reader Harbor "has not yet published a tagged module release" — untrue since v1.0.0. The scaffold now emits a real, resolvablerequire github.com/hurtener/Harbor vX.Y.Z— the release version of theharborbinary that scaffolded the project (link-stamped version → the binary's embedded build info → the last published release), sogo mod tidy && go build ./...works with no manual edit. A clearly-labelled commentedreplaceremains for contributors building against a local Harbor checkout. The version is threaded through both seeding paths —harbor scaffoldand theharbor devdraft store (devdraft) — so a promoted draft pins the same release a scaffolded project does. Corrects D-087 item 6, which deferred this pinning to release-engineering and shippedv0.0.0-devin the meantime.make drift-auditnow gates the pin: it must name a released CHANGELOG section, and may trail the newest by at most one release (the deliberate merge→tag window, since a release's CHANGELOG section lands before its tag is cut).
Testing
- The scaffold-serve smoke now boots a scaffolded binary that declares a built-in. Its probe config carries
clock.nowalongside the custom tool, so the scaffold → build → boot leg exercises the mixed configuration, and the authenticated discovery probe asserts BOTH tools reach the served catalog. This is the end-to-end gate whose absence let the bug ship; it is verified to FAIL against the pre-fix template with the adopter's exact error. The serve-parity integration test declares a built-in on the scaffolded side too, in both the in-process parity legs and the live (HARBOR_LIVE_SERVE) leg. cmd/harbor/scaffold— new unit gates: the renderedagent.gofor a config declaring both built-ins and custom tools must not referencebuiltin.RegisterWith(and must still register the custom tool), and the renderedgo.modmust name a published release with thereplacecommented. The scaffold-template and serve-facade smokes gain matching absence pins, so a built-in registration cannot creep back into the emitted registrar.internal/devdraft— new gates on theharbor devdraft-seeding path: a stamped binary's release version reaches the seededgo.mod, and an un-stamped one falls back to a published release (never the dev sentinel).make drift-audit— a new mechanical check onscaffold.FallbackModuleVersion(verified to bite on both a phantom pin and a two-release trail), so the release-time bump is prompted rather than left to prose.
[1.13.0] — 2026-07-11
The adopter-serving + observability-history release. Two arcs land together.
Any agent can now serve the Protocol. An external Go module that assembles its own runtime with compiled in-process tools was previously headless-only — it could RunOnce but could not mount the northbound Protocol surface, so it was not a first-class Protocol server without forking the runtime. That gap is closed: the serve composition (config → subsystems → auth → transports → listener), formerly trapped in package main, is promoted to one importable constructor; harbor serve / dev / console become thin callers of it, and the dev-only surfaces (dev-token mint, bootstrap endpoint, draft scaffolding, Console embedding) compose caller-side through explicit injection seams. A curated, production-only sdk/server facade exposes it, and harbor scaffold --with-server emits a cmd/<agent>/main.go that serves the full Protocol with the project's own compiled tools — at parity with harbor serve, enforced by a both-binary integration gate that boots each from one config and compares the whole method surface. The compiled-tool registrar rides the existing pre-policy catalog seam, so an in-process tool inherits every declared approval / OAuth / policy wrapping.
The Console stops losing history, and fleet observability gets a durable read. Reopening a Playground session no longer drops what a run produced: per-turn tokens / cost / latency / model and the tool-call badges rehydrate, and the ordered reasoning-step ↔ tool-call interleaving is reconstructed from the durable event stream — both by relocating the cost and tool-lifecycle emissions to driver-neutral seams so the read-back carries what the live stream always did. A new durable, time-ranged, cross-session events.list lets a Protocol client render historical raw events over a {since, until} window without holding a shadow copy; flows.runs.list gains the same time window; and each durable surface reports its observed retention horizon so a windowed view degrades honestly at the edge. The MCP southbound driver discovers a connected server's advertised OAuth requirement (RFC 9728 → RFC 8414) and surfaces it as inert data on the connection view — the runtime never runs the flow or holds a token; discovery fetches are SSRF-guarded with a post-resolution dial check.
All additive: the Harbor Protocol holds at 0.1.0 (no error-code or version change), and existing configs stay byte-compatible (a config-free runtime is byte-identical to v1.12). Seven phases, eight new decisions (D-291…D-298).
Added
- Serve-band promotion — one importable serve constructor. (runtime) The
bootDevStackcomposition that turned a validated config into a running Protocol server was reachable only frompackage main, so no external module or test kit could mount it. It is promoted tointernal/runtime/servewith a required auth-validator factory (identity is mandatory; a nil factory fails loud); the dev signer never promotes, and dev-only routes/surfaces are composed caller-side viaExtraRoutes/BuildAuthSurface/BuildLLMSnapshot/PostBootseams.harbor serve/dev/consoleandharbortest/devstackall become callers of the one constructor, deleting the kit's hand-mirrored mux (net −900 lines). Purely internal wiring; no wire change. (D-291) sdk/serverfacade +harbor scaffold --with-server+ parity gate.(sdk) A curatedsdk/server.Open(ctx, cfg, {RegisterCatalog})mounts the promoted server behind a production-only JWKS posture — it always builds the operator's verifier fromidentityand fails loud when absent; no dev signer, no mock knob, no dev surface is reachable.harbor scaffold --with-server(opt-in; the default scaffold stays headless) emits acmd/<name>/main.gothat loads yaml, blank-imports the production driver aggregator, passes the project'sRegisterToolsto the facade, and serves. The compiled-tool registrar fires at the catalog's pre-policy point so an in-process tool is wrapped by declaredtools.entriespolicy exactly like any other. Atest/integrationgate boots stockharbor serveand a scaffolded binary from one config and asserts method-surface parity + custom-tool dispatch; the wire-level leg runs env-gated against a real LLM. The headless-embed path stays the default (D-091); external Protocol serving is a decided contract with a deprecation window (RFC §5.6). (D-292)- Session rehydration carries per-turn metadata. (runtime, console) A reopened Playground session recovered message text but lost every per-turn stat, the model chip, and the tool-call badges — because
llm.cost.recordedwas emitted only by the bifrost driver andtool.*only by the in-process transport, so the durable read-back was missing them. The cost emission moves to the mandatory LLM-edge safety wrapper (one event per driver completion, every driver) and the tool-lifecycle emission moves to a catalog-build descriptor shell (every transport, every dispatch path — single, parallel, MCP-Apps, declarative), each carrying the full run quadruple (closing a latent empty-RunIDattribution bug). The Console reducer folds the now-present metadata so reopen renders identically to live. Content-free throughout (D-026 / §7 preserved). Zero wire change. (D-293) events.list— durable, time-ranged, cross-session raw-event read.(protocol) A Protocol client rendering historical fleet observability had only a forward-only live tail and a counts-only aggregate — no way to read the actual event rows over a{since, until}window without a forbidden shadow store. The additiveevents.listreuses the existingEventFilter(itssince/untilaxes finally consumed) plus a tail-first sequence cursor mirroringstate.history, returning the same bus-redacted rows the SSE projects. Fleet-widening derives server-side from the verified session and requires the closedadminORconsole:fleetscope set; the per-readtruncatedflag marks the retention edge. Both event drivers implement the windowed read (in-memory honest-ring, durable real-window). The Console Events page gains the historical window as its same-phase consumer. Full lockstep + smoke. (D-294)flows.runs.listtime window + observed retention horizons.(protocol)flows.runs.listgains optionalsince/until(inclusive, mirroringTaskFilterfor cross-method consistency), bounded onStartedAtbefore pagination.runtime.healthgains aretentionblock reporting each durable surface's observed oldest-retained timestamp (surfacing what is actually retained — V1 has no prune knob — via an optional cross-driver capability), so a consumer promising "the last N days" degrades honestly at the window edge, pairing withevents.list's at-readtruncatedflag. Counters/metrics deliberately do NOT become a time-series substrate. (D-295, D-296)- MCP OAuth requirement discovery, surfaced as data. (tools) When the MCP southbound driver meets the MCP-auth-spec challenge (401 +
WWW-Authenticate resource_metadata) or an operator probe, the runtime walks the RFC 9728 protected-resource-metadata → RFC 8414 authorization-server chain and surfaces the discovered endpoints/scopes verbatim, as inert data on the MCP-connection view — a proposal an operator confirms, never auto-trusted config. The runtime never runs the OAuth flow, never holds or refreshes a token (the broker-pull custody model, D-271, is preserved); RFC 7591 registration is reported, never invoked. Discovery fetches are SSRF-guarded: same-origin default on the resource hop, explicit per-connection allowance + private-range/IP-literal refusal at post-resolution dial time (DNS-rebind defence), bounded redirects, size/time caps, https-only, no credentials. Conformance fixtures derive from the real RFC example documents. The one discovery chain is single-homed for the parked flow-execution siblings to reuse. (D-297) - Structured reasoning-step rehydration on session reopen. (console) The ordered reasoning ↔ tool-call interleaving (which thinking preceded which tool call) survived only in the live stream; reopen collapsed it to a flat blob. The Console history reducer now reconstructs the per-step structure from the same durable
planner.decision+tool.*events, folding a reasoning step only for trajectory-appending decision kinds (CallTool/CallParallel/SpawnTask/AwaitTask) so the reopened index is byte-identical to the live enricher's —Finish/RequestPauseemit a decision but no step and are correctly excluded. Zero wire change; the reasoning trace rides aSafePayloadand is persisted raw. (D-298)
Fixed
- The Playground Controls panel silently broke every override. (console) The panel composed a
top_pfield the runtime'sRunOverridesnever had, so the strictruns.set_overridesdecoder rejected the whole request — reasoning effort, temperature, max-tokens, and system-prompt overrides all failed. The unsupported field is removed andRunOverridesis promoted to a named, lockstep-gated TS interface so a phantom key is now a compile error rather than a silent runtime rejection. - A latent trajectory data race between the serve enricher and the steering run loop's step append (predating this wave) is closed by a per-run mutex + snapshot, surfaced and fixed under the rehydration work.
- A DNS-rebinding hole in the MCP OAuth discovery guard (the private-IP check ran on the pre-resolution hostname) is closed with a post-resolution
net.Dialer.Controlcheck. - A cross-user event-disclosure gap —
events.list/events.aggregate/events.subscribegated the tenant axis but not a foreign same-tenant user — is closed in the shared filter path (§6 multi-isolation). - ~35 internal phase-number references in godoc-visible source are rewritten to name the feature, and the drift-audit pattern is tightened to catch the hyphenated form that let them through (§13).
[1.12.0] — 2026-07-09
The name-your-sessions release: sessions stop displaying as raw ids. The canonical session record gains an optional human-readable title with provenance, a new Protocol verb lets a caller rename any session they own — with the Console Sessions page and Playground switcher shipping the rename affordance in the same wave — and the runtime can now title a session itself: an opt-in, default-off auto-naming policy that makes one governed LLM call at a run's terminal boundary and can never overwrite a human's name. A latent lost-update race on the session record, predating this wave, is closed by serializing every whole-record writer. One new Protocol method, two new canonical events, one new config surface; all additive — the Harbor Protocol holds at 0.1.0 (no error-code or version change), and existing configs stay byte-compatible (a config-free runtime is byte-identical to v1.11).
Added
- Session titles —
Title+TitleSourceon the record,sessions.set_title, Console rename. (protocol) Sessions displayed as raw ids everywhere, and a coordinator consumer must not shadow-store a human-readable name (D-061) — so it lands framework-side. The session record gainsTitleplus aTitleSourceprovenance mark (unset | auto | manual), persisted as an additive JSON round-trip through the existingsession.lifecyclekind (zero migration) and erased with the record by the existingDeleteScopecascade. The newsessions.set_titleverb ALWAYS writesmanual—autois not expressible over the wire, so the internal auto-namer is its sole producer and manual-wins is structurally unforgeable. The write is scoped to the owning(tenant, user)— the scopesessions.listreads at — so a Console-style "rename any of my sessions" flow needs no elevation; metadata-only, no admin widening, and a cross-identity target isnot_found(existence is never revealed). Empty-after-trim clears; over-bound (> 200 runes), multi-line, or invisible-only (format-character) input fails LOUD with400 invalid_request— never a silent clamp. The title is user-derived content and never rides an event, log, or audit payload:session.title_changedis content-free ({session_id, source}), and consumers refetch —sessions.list/sessions.inspectprojecttitle/title_sourceon every row. The §13 consumers ship same-wave: the Sessions page renders the truncated title (full title + id in the tooltip, id fallback) with an inline rename, and the Playground switcher renderstitle || session_idwith an active-session rename and event-driven list refresh. (D-288.) - Session auto-naming — opt-in policy + terminal-boundary titling.(runtime) Opt-in and DEFAULT OFF: with no
namingconfig anywhere the runtime is byte-identical to v1.11 — zero counters, zero LLM calls, zero events (test-pinned). Policy home pairs a versioned agent-confignamingsection riding the existingset_revision(no new verb; joins the D-283 rebuild-completeness guard) with a yamlruntime.namingfleet default — precedence agentcfg › yaml › off, resolved once at run start (next-turn projection). A PRESENT section is authoritative either way: a bare{auto: false}revision is an explicit per-agent opt-out that wins over a yaml-on fleet default (section presence is the signal — never dropped as inert). Knobs:after_turns(default 1),repeat_every(0 = name once),max_repetitions(required ≥ 1 whenever repeating — no unlimited value exists, so unbounded periodic re-naming is unrepresentable; default 5 for programmatically built policies),max_title_len(default 80; auto output is deterministically clamped — the manual verb, by contrast, rejects; the trusted-internal vs untrusted-boundary asymmetry is intentional), andmodel(empty = the run's effective model; point it at a cheap profile — the spend is deliberately the tenant's). The mechanism is a sibling of the run-completion hook at the run loop's terminal boundary — it fires after(fin, err)settle and never alters them, with the hook firing FIRST so a slow naming call can never inflate the hook payload's timing — making ONE governedCompletecall on the run's already-wrapped LLM client (governance outermost: a ceiling/rate block SKIPS naming loudly and the run is untouched) over a ≤ 4 KiB bounded transcript digest, then writing through the internalSetTitleAutopath, which refusesmanualtitles and updates title + counters in one record save. Failure is loud but contained:session.naming_failedcarries a stable error class (llm_error/timeout/empty_title/governance_blocked/manual_title/internal), never content — and a failure does not burn the cap: a still-due title retries on every completed run until one succeeds (the naming call is bounded by a fixed 10s runtime timeout; worst-case post-run latency is hook + naming timeouts, serialized). (D-289.)
Fixed
- Lost-update race on the session record. (runtime) The registry's whole-record writers — the pre-existing
Touchand GC-reap, joined by the newSetTitle— did load→mutate→save without holding the registry lock across all three steps, so a write racingClosecould re-persistClosed=false(resurrecting a closed session as a GC-invisible zombie, violating reopen-after-close), and one racingsessions.deletecould re-persist the record after the irreversible clear (empirically confirmed pre-fix). Every whole-record writer (Touch/Close/SetTitle/ the GC reap) now flows through ONE serialized read-modify-write path, and the erasure cascade'sDeleteScoperuns under the same mutex — pinned by interleave tests verified to fail against the pre-fix code. Surfaced by D-288's verb widening the writer set; the fix covers the pre-existing writers too. (D-288.) - Lost-update race on the session-discovery catalog. (runtime) The per-
(tenant, user)discovery catalog — the index that letssessions.listsurvive a restart, since the StateStore has no List — did its own read-modify-write WITHOUT the registry lock held across all three steps: an erasure's catalog remove racing a concurrentOpenof a sibling session could save a stale set-difference that dropped the freshly-opened session's catalog entry. The session's record survived but became invisible tosessions.listand was never re-discovered after a restart (nor GC-swept). Both catalog writers (theOpenadd and the erasure remove) now flow through ONE serialized read-modify-write path, and the erasure's catalog remove is folded into its in-memory-clear critical section under the registry lock — pinned by an Erase-vs-Open interleave test asserting a fresh registry over the same store re-discovers every surviving session. Surfaced by D-288'sset_titlewidening the writer set alongside the record race above. (D-288.) - Auto-naming re-arm after a manual clear. (runtime) Clearing a session's title via
sessions.set_titlereset the title + provenance but NOT the naming counters, so once any auto-name had landed a clear→re-arm never fired under a name-once policy (repeat_every: 0) — the documented unqualified re-arm was a silent dead-end. A manual clear now zeroes the naming counters (AutoNameCount/LastAutoNamedTurn) in the same record save, opening a fresh arming cycle; themax_repetitionscap is therefore per-cycle, not per-session-lifetime. A no-opset_title(clearing an already-unset title, or an identical manual re-set) now also suppresses the redundantsession.title_changedevent. (D-289.) - Agent-config
hooks-section presence footgun. (runtime) Aset_revisioncarrying a barehooks: {}(or a run-completion section with an empty tool) returned 200 but was silently normalised away, so a per-agent opt-out of run-transcript egress fell through to the yaml fleet hook and kept dispatching. A PRESENT hooks section is now authoritative (mirroring the naming-section presence rule): an empty section is the explicit per-agent NO-HOOK that overrides the yaml default,NormalizePayloadpreserves any non-nil section, andagent_config.diffgains a presence dimension — carried to the wire as the additiveAgentConfigHooksDiff.section_present_*fields (TS mirror, wire manifest, and generated Protocol reference regenerated) — so the opt-out is a visible revision end to end. The request shape is unchanged. (D-290.)
Internal
- One additive Protocol method (
sessions.set_title) with its request/response wire types; two additive canonical events (session.title_changed,session.naming_failed); the additiveSessionRow.title/title_sourceprojections and theAgentConfigNamingsection + its diff arm. The generated Protocol reference and the TS wire manifest were regenerated for every wire change — all additive, soProtocolVersionholds at0.1.0, and no error code was added (an invalid naming policy maps to the existinginvalid_request/400 class). The wave-end checkpoint additionally closes the pre-existing session-discovery-catalog lost-update, re-arms auto-naming after a manual clear (per-cyclemax_repetitions), and makes an agent-confighooks-section presence authoritative (D-290) — the only checkpoint wire change is the additiveAgentConfigHooksDiff.section_present_*diff fields, soProtocolVersionstill holds at0.1.0— and lands the wave E2E (test/integration/wave_v112_test.go) composing the title lifecycle, identity-scoped event discipline, own-vs-foreign refusal, governance interplay, live-revision precedence, and a cross-tenant concurrency stress under-race.
[1.11.0] — 2026-07-05
The fleet-and-lifecycle release: the control plane a coordinator drives over many runtimes grows the surfaces it was missing — an admin observer can now enumerate tasks and agents across every session on a runtime, a broker credential minted after boot reaches a running runtime without a reboot, a runtime-added MCP connection can actually be removed (not just paused), and the right-to-erasure audit record becomes part of erasure's success criteria instead of a best-effort afterthought. A latent silent-degradation bug in the agent-config revision model — any section edit erased a pinned run-completion hook — is closed and made mechanically impossible to reintroduce. One new Protocol method, three new canonical events, one new config surface; all additive — the Harbor Protocol holds at 0.1.0 (no error-code or version change), and existing configs stay byte-compatible.
Added
- Admin-widened fleet enumeration for
tasks.list+agents.list.(protocol) Both list methods projected only the caller's own(tenant, user, session)triple, so a coordinator's synthetic observer session saw nothing. An additivefilter.tenant_idsselector widens the read to every task / agent across all sessions of the named tenants — riding the SAME verifiedauth.ScopeAdminclaim assessions.list(no new "fleet" scope vocabulary). Atenant_idsrequest without the claim fails LOUD with403 scope_mismatch, never a silent narrow; every widened row carries full per-identityattribution and every widened call emitsaudit.admin_scope_used. The widening rides a SEPARATE explicit tenant-scoped enumeration seam on each registry (ListTenant, with conformance parity across the inprocess and durable drivers) — never an optional/blank session on the identity-scoped read, so no identity-downgrading knob (§13). Cross-runtime federation stays coordinator-side, the same division as sessions/events. This lands the "future cross-runtime aggregating projector" the tasks projector godoc reserved, behind the unchangedProjectorinterface. (D-284.) - OAuth provider credential source —
envor coordinator-served pull.(tools) Atools.oauth_providers[]client credential was resolved once at boot from the process env, so a broker credential a coordinator mints AFTER a runtime booted could never reach it — forcing a one-reboot provisioning step. A §4.4credential_sourceseam on the provider entry closes it:env(today's boot-time, fail-loud resolution — the DEFAULT, existing configs byte-compatible) orremote(the runtime PULLSclient_id/client_secretfrom a coordinator endpoint at first need, authenticated by its own service token, memory-only TTL cache, single-flight, strictformat_version-ed parse). Fail-loud everywhere: boot validates the declared source's shape; a fetch failure fails the tool call with a typed sentinel plus a SafePayload audit event — NEVER a fallback to env, to an unauthenticated call, or to the interactive flow. Theremoteendpoint must be https (loopback the one carve-out) and redirects are refused. Declaring both sources on one entry is a validation error;remoteis valid only for the non-interactivetokenexchangedriver. Push over the Protocol stays rejected (the credential-passthrough shape D-271 forbids). Defense-in-depth: withremote, the broker secret never enters the runtime's environment at all. Two new canonical events (tool.provider_credential_fetched/tool.provider_credential_fetch_failed); no Protocol wire-type change. (D-285.) agent_config.remove_mcp_connection+ detach-on-reconcile. (protocol) Pause could disable a runtime-added MCP connection but never remove it (the descriptor persists forever; resume resurrects the server) — so a coordinator's delete flow had no path. A new admin revision verb drops the named descriptor AND prunes that server's tool-exposure residue atomically (sibling-safe: an entry a remaining server's<name>_prefix also claims is never pruned); unknown-name and boot-declared-yaml-name each fail loud with distinct typed errors. Run-start reconciliation gains the detach leg: the declared-vs-attached diff deregisters an undeclared server from the catalog + MCP registry and closes its transport at a run-start reconcile boundary — never mid-run; a rollback past an add detaches through the SAME path (one mechanism, §13). Honest in-flight semantics: exposure correctness is next-turn per session; teardown is process-global, so a different session's in-flight run that next calls the detached server fails LOUDLY (typed catalog-not-found / closed-transport) — never a hang or silent success. Agent-bound sealed tokens are NOT deleted on remove (re-add reuses completed consent; revocation is provider-side). New canonicalmcp.connection.removedevent. This supersedes D-240 decision 5's detach-on-rollback deferral via that entry's own recorded revisit clause; the 92k–92q MCP-OAuth band stays parked, so the run-start reconcile mechanism ships DETACH-ONLY (the attach leg remains deferred with the parked band). (D-287; supersedes D-240 §5.)
Fixed
- Agent-config section setters no longer erase a pinned run-completion hook. (runtime) Each of the five section-scoped setters (
set_tool_exposure,add_mcp_connection,set_skills,set_prompt_layers,set_llm_params) rebuilds theConfigPayloadby hand, enumerating the sibling sections known when it was written — so when theHookssection landed (v1.10, D-280), none of them carried it forward, and any tool-exposure / connection / skills / prompt-layer / LLM-params edit silently erased a pinned hook (deterministic, same-surface, no error — the §13 silent-degradation shape a coordinator pinning an auto-save hook trips on its golden path). All five now carryHooksforward, and a reflection-backed rebuild-completeness guard makes the invariant mechanical: a seed constructor populates every payload section, reflection-asserts full field coverage, then asserts each setter preserves every non-target section byte-identically — adding a future section without extending every setter failsgo test, naming the field. The omission class is closed, not just this instance. No wire change. (D-283.) - Session-erasure audit record is now part of erasure's success criteria (closes #409, #410). (runtime)
session.erased(the right-to-erasure record-of-fact, D-262) was emitted best-effort AFTER the irreversible clear: one bus/redactor failure lost the only audit record while the call reported success, and a re-invoke returnednot_found— no second chance. Separately, a retried mid-cascade erasure re-ran idempotent deletes that found fewer records, under-reporting deletion counts. Now the ordering invariant is binding — a durable compliance checkpoint (an erasure ledger) is persisted BEFORE the irreversible clear, and the final record-of-fact is part of the success gate: a record/emit failure failssessions.deleteLOUD with the new typed sentinel (ErrErasureRecordFailed→ HTTP 500) with the session still re-invokable, and a re-invoke converges (skips every destructive step, re-attempts only the record). Deletion counts accumulate across converging attempts, so the response and event report true totals — the #410 "document the undercount" alternative was rejected (compliance counts must be accurate, not documented-as-inaccurate). No wire-shape change; field docs state the cumulative semantics. (D-286.)
Internal
- Three additive canonical events (
mcp.connection.removed,tool.provider_credential_fetched,tool.provider_credential_fetch_failed); one additive Protocol method (agent_config.remove_mcp_connection) with its request/response wire types; the additivefilter.tenant_idsselector ontasks.list/agents.list. The generated Protocol reference and the TS wire manifest were regenerated for every wire change — all additive, soProtocolVersionholds at0.1.0and no error code was added.
1.10.0 — 2026-07-02
The reach-the-wire release: the typed answers and brokered credentials v1.9 shipped for the embed path now reach the Protocol and the MCP wire, and the runtime grows its first run-lifecycle egress. Any task can return a schema-validated answer, a shared MCP server receives a per-identity bearer plus call provenance on every request, the tools.http_manifests knob goes from documented-but-rejected to live at boot, an operator-named catalog tool receives every run's full transcript at completion, and tool loading modes become runtime-controllable per agent. Governance also stops undercounting: every retry and downgrade attempt now reaches the cost ceilings — see Fixed. Additive wire types + two new canonical events; the Harbor Protocol holds at 0.1.0 (no method, error, or version change this release).
Added
output_schemaonstart— per-task structured output. (protocol) The run-level mechanism v1.9 shipped forStack.RunOncegains its Protocol producer: one additive field on thestartrequest asks any task for a schema-conforming final answer. The schema is compile-rejected at the edge (400 invalid_request, before any task spawns), compiled once at run start, steers the React driver through the existing per-turn mechanism with zero planner change, and the terminal answer is validated through the ONE shared envelope builderRunOncealso uses — the validated object lands as the task envelope'sanswer_payload, readable viatasks.get'sresult_inlineand a parent run's AwaitTask observation (the heavy-output offload applies unchanged). A schema-invalid answer after the correction budget fails the task loud with the newoutput_invalidterminal code — never a schemaless success — and schema-constrained tasks suppress assistant token deltas (the validated answer arrives once, on completion). A reused idempotency key carrying a different schema is a loudErrIdempotencyConflict. Per-run granularity by design — deliberately not agent config. (D-276.)- MCP southbound per-identity OAuth bearer +
_metaprovenance.(tools) A non-secretoauth_providername on an MCP connection (yaml, agent-config descriptor, and wire) binds a declaredtools.oauth_providers[]entry — the v1.9tokenexchangebroker credential included — and every identity-stamped per-call RPC injects a fresh per-identityAuthorization: Bearervia a context-aware RoundTripper: the token rides the per-call ctx, so one shared transport serves N concurrent identities with no bleed. Fail-closed is the load-bearing invariant — a bound provider whose token fetch fails aborts the call with NO wire request, never an unauthenticated fallback; aconsent_requiredrefusal parks on the unified pause primitive._metagains provenance: the registrationagent_id(attribution metadata, never an isolation principal) plus operator-declared non-secretmeta_annotations, with reserved keys rejected at validation. A staticAuthorizationheader alongside a binding, or a binding on a connection that would select stdio, is rejected at validation. (D-278.) tools.http_manifestsgoes live — the HTTP-manifest boot loader.(tools) The documented-but-dead knob is wired: assembly loads each UTCP-style manifest at boot and registers its tools by name — after built-ins, beforetools.entries[]middleware — so the existing by-nameoauth/approval/loading_modebindings apply to manifest tools with zero new machinery. This is the first config-only, black-box path through catalog OAuth wrapping (token exchange included): config in, brokered-credential pre-check + HTTP round-trip out.harbor validateflips from rejecting a populated list to validating it; relative entries resolve against the config file's directory under the path-safety posture; a missing, unparseable, or invalid manifest — or a tool-name collision — fails boot naming the file and the config key, never a silent skip. Boot-only (restart-required). (D-279.)- The run-completion hook — transcript egress through the tool catalog.(runtime) The runtime's first run-lifecycle hook (new RFC §6.17):
RunLoop.Runfires it exactly once at its terminal boundary — every terminal outcome, carried in the payload; never mid-run, never on pause — dispatching a typed, versionedRunCompletionPayloadtranscript (initial goal, mid-run steering messages in arrival order, assistant steps, final answer, identity + the true tool-invocation count) to an operator-named catalog tool through the existing executor path, so provenance, identity, per-tool policy retries, and args-free audit events come free (a bespoke webhook subsystem is the rejected parallel implementation). A hook failure never alters the settled run outcome —run.hook_failed+ a Warn; success emitsrun.hook_dispatched(both metadata-only, never transcript content). Cancelled runs fire under a bounded detached ctx with identity values preserved. Config pairs yamlruntime.hooks.run_completion.{tool,timeout}with a versioned agent-confighookssection on the existing revision surface (no new verb); embedders get a per-callWithCompletionHookRunOption. One seam covers embed, foreground, and background runs uniformly — including the runs no client observes. (D-280.) - Runtime loading-mode control on tool exposure. (protocol)
agent_config.set_tool_exposuregainsserver_loading_modes(per MCP source id, tool-form descriptors only via the new additiveTool.Formclassification) andtool_loading_modes(exact per-tool name), valuedalways/deferred, with ONE pinned precedence order — per-tool > per-server > boottools.entries[].loading_mode> driver default. Applied next-turn at the shared run-start projection via a newLoadingOverrideView: a deferred tool drops out of the prompt-time catalog but staystool_search-discoverable and callable (disable stays strictly stronger — hidden from prompt AND dispatch); in-flight runs keep their snapshot. Admin tier only — loading is not capability-narrowing. An unknown mode value fails400 invalid_requestbefore any revision is recorded;agent_config.diffrenders structured loading arms.tools.describegains an optionalagent_idreporting the projected effectiveloading_mode. (D-281.)
Fixed
- Governance now accounts every LLM attempt — the in-band attempt-cost tap. (governance) Governance composes outside the retry wrapper (deliberate: the ceiling check must run before any spend), so every intermediate corrective re-ask and downgrade attempt was a real provider call invisible to the
CostAccumulator— worst case(MaxRetries+1)×3uncounted calls per planner turn, live since v1.9's validator loop went production-real (the gap D-272 recorded).governance.Wrapnow installs a per-call attempt-cost tap afterPreCallpermits; the retry and downgrade wrappers synchronously report each attempt they consume;PostCalldrains the tap once and folds it with the final response's cost under the existing identity key — exactly-once by the propagate-or-report invariant, with the compose order,PreCallshort-circuit semantics, andresp.Costmeaning all unchanged. Operators take note: recorded spend now includes retry/downgrade attempts, so per-identity totals rise and ceilings trip sooner — by design; attempt spend accumulates even when the outer call ultimately errors. (D-275.)
Internal
- The events subsystem gained its conformance-suite home (
internal/events/conformancetest— 20 pinned scenarios plus a fail-loud capability gate, spanning fence semantics, Bounds/Window history, replay cursors, subscribe scoping, and close lifecycle), and both bus drivers consume it from their own tests, folding the hand-copied per-driver twins; six scenario cells are coverage gains on the driver that lacked them. Zero production change. (D-277.) - The distributed durable-bus conformance flood test is bounded to the subscriber buffer, deflaking
Concurrent_Publish_NoRace. - Two additive canonical events (
run.hook_dispatched/run.hook_failed); the generated Protocol reference and the TS wire manifest regenerated for every wire change (start.output_schema, the MCP descriptor'soauth_provider/meta_annotations,AgentConfigHooks+ its diff arm, the exposure loading maps,tools.describe.agent_id) — all additive, soProtocolVersionholds at0.1.0.
[1.9.0] — 2026-07-02
The typed-output-and-brokered-credentials release: the embed path now answers in your types, and a fleet can hold tool credentials in one place instead of N runtimes each acquiring their own. Stack.RunOnce gains an opt-in run-level JSON Schema whose validated answer lands on a new envelope key, RunTyped[T] derives that schema from a Go type and hands back the unmarshaled struct, and the new tokenexchange OAuth driver pulls downstream tool credentials from an external broker at token-miss time — never persisting them. Two pre-wave integrity fixes land with it, one of which changes the meaning (not the shape) of an SDK-visible envelope field — see Changed. Additive public API +one new canonical event; the Harbor Protocol holds at 0.1.0 (no method, error, or wire-type change this release).
Added
WithOutputSchema— run-level structured output. (embed) One run option asksStack.RunOncefor a schema-conforming final answer: the terminal payload is validated at the run edge for every planner (no capability ceremony), delivered as the additiveanswer_payloadenvelope key (AnswerEnvelope.AnswerPayload; the pinned three-key byte shape is untouched andAnswerkeeps the string rendering), and a schema-invalid answer after the profile's bounded retry budget is a typedplanner.ErrOutputInvalid— never silent unvalidated text. The React driver additionally constrains the terminal completion through the existingOutputModestrategy and theValidator-keyed retry-with-feedback loop — no new knob.WithStreamcomposes:step/tool_dispatchedevents stream as today; token chunks are suppressed on a schema-constrained run (a validate-and-retry loop cannot retract streamed tokens), and the validated answer arrives once, in the envelope. Partial-object streaming is the named follow-up. (D-272.)RunTyped[T]— the typed embed binding. (embed) A generic free function,assemble.RunTyped[T](ctx, stack, goal, id, opts...), that derives the output schema fromT(via the same reflection deriverRegisterFuncuses, promoted to a shared neutral package), runs schema-constrained, and returns the validated answer already unmarshaled intoT. All failure modes loud: an unsupportedTfails at call time before any LLM spend; a caller-suppliedWithOutputSchemaalongside it isErrRunTypedSchemaConflict; a validated-but-unmarshalable payload isErrRunTypedUnmarshal. Deliberately a free function over the shared immutableStack— no stateful binding object; thesdk/assembleforward is the facade's second (and gate-enumerated) generic-func carve-out. (D-273.)tokenexchange— pull-based external tool credentials. (tools) A new non-interactive driver on the OAuth flow-strategy registry (tools.oauth_providers[].driver: tokenexchange): at token-miss time the runtime performs an RFC-8693-shaped exchange against an operator-configured credential broker (a fleet orchestrator, a token vault, an STS), presenting its own env-indirected broker credential plus the verified ctx identity triple — so one central grant serves N runtimes instead of N consents and N sealed copies. Brokered tokens are TTL-cached in memory only, single-flighted, and never persisted (TokenStore.Putis never called — the broker stays the single source of truth). Broker failure fails the run loudly (typedErrExchangeFailed, no silent interactive fallback); aconsent_requiredrefusal parks on the unified pause primitive via the existing typedErrAuthRequired; interactive-flow methods return the typedErrNonInteractive. Every actual exchange emits the new canonicaltool.credential_exchangedaudit event (zero token bytes). Push-style credential injection over the Protocol is rejected as credential passthrough — recorded so it is not re-litigated. (D-271.)
Changed
AnswerEnvelope.ToolCallsSeennow counts true tool invocations — embedders take note. (embed) The field previously reported the trajectory step count: a parallel tool call (N branches) counted 1, and a task spawn/await counted 1 despite dispatching no tool. It now sums real dispatches per decision (CallTool= 1,CallParallel= N, spawn/await = 0) via the sharedplanner.CountToolInvocations. The JSON key, type, and envelope byte shape are unchanged — only the value's meaning — but the field is SDK-visible, so embedders who assumed step-count semantics should re-check consumers. The same rule now drives the Console tasktool_countandWithStream, which emits onetool_dispatchedevent per dispatched tool (N events for a parallel call, none for spawn/await); the two counters deliberately diverge on failures (ToolCallsSeencounts attempted invocations,tool_countsuccessful dispatches). (D-274.)
Fixed
- Session erasure fails loud when the event-bus fence fails. (protocol)
sessions.delete's cascade eraser documented "a Fence error fails the erasure loud" but actually logged and reported unqualified success with the late-event window still open. A fence error now failsErasebefore the first destructive step runs — nothing is deleted, and a retry after the fault clears converges to a full erasure. A bus that doesn't implement fencing at all remains the documented warn-and-proceed capability downgrade. Both branches gained their first tests. (D-274.)
Internal
- The Go-type→JSON-Schema deriver moved from the in-process tool driver to the neutral
internal/tools/schemapackage (golden-pinned byte-identical), also fixing a pre-existing seam violation where the flow engine imported the concrete driver directly. - One additive canonical event (
tool.credential_exchanged) — the generated Protocol reference regenerated; no method, error, or wire-type change, soProtocolVersionholds at0.1.0. - Wave-end checkpoint: composing E2E (
test/integration/wave_v19_test.go) across the wave's surface — schema-constrained runs dispatching real tools through the OAuth-wrapped catalog, park/resume under an output schema, per-branch invocation counting on the envelope, and N-way mixed typed/plain concurrency against one shared Stack.
[1.8.0] — 2026-06-27
The adopter-path release: make all three advertised ways into Harbor — embed, CLI, and Protocol — work end to end, and make the public surface honest about what ships. v1.7 advertised the paths; only one was honest end-to-end. This release closes the serve-attach cliff with two on-ramps, delivers a production one-call runner with first-class streaming, makes a scaffolded tools agent actually invoke a tool against a real provider, and ships discoverable examples plus a vendorable TypeScript wire-type generator. Purely additive public API + docs; the Harbor Protocol holds at 0.1.0 (no method, error, event, or wire-type change this release).
Added
Stack.RunOnce— the production one-call runner. (embed) One blocking call turns a goal + the(tenant, user, session)identity into the terminal answer envelope, replacing ~15–27 lines of hand-builtRunContext/RunSpecceremony per run. It is built on a sharedrunctx.NewRunContextfactory that composes the same memory / skills / artifact / streaming projections the dev drivers use (parity-tested, never a third construction site), withsdk/assemble+sdk/runctxfacade aliases and an N≥100 concurrent-reuse guarantee. (D-265.)WithStream— first-class streaming onRunOnce. (embed) One run option observes token / tool / step events as they happen, on the same blocking method — wired to the synchronous plannerOnChunk+ steeringOnToolDispatchedseam, so streamed chunks deterministically precede the final envelope. New publicStreamEventtype. (D-266.)harbor token— the no-IdP self-issuing on-ramp. (protocol) A new CLI subcommand for an operator with no identity provider:harbor token keygengenerates an asymmetric keypair (ES256 default / RS256 opt-in) and the matching public JWK Set (itskidis the RFC-7638 thumbprint);harbor token mintself-issues the JWTsharbor serveverifies. serve's verifier is unchanged — it trusts the key only because you pointidentity.jwks_fileat the emitted JWK Set, identical to pointing at a real IdP, so serve still mints nothing. Least-privilege defaults (no scopes unless--scopes, a short ttl),private.pemwritten0600, and mandatory--issuer/--audiencethat must matchserve.yamlor attach 401s. (D-264.)- Production-identity guide, skill, and worked OIDC client. (protocol)
docs/site/protocol/production-identity-setup.mddocuments both attach on-ramps (a real IdP and theharbor tokenself-issuing path), lifting the JWT claim shape — including theiss/audexact-match contract serve hard-rejects — from the authoritative parser; theconfigure-production-identityoperator skill operationalizes it; andexamples/protocol-clients/oidc-client-example/is an SDK-free worked client that obtains a JWT via the OAuth2 client-credentials grant and attaches. (D-263.) - External-client TypeScript wire-type generator. (protocol)
cmd/harbor-protocol-ts-typesreflects over the canonical Protocol surface and emits a vendorable, dependency-free TypeScript wire-type module for third-party clients (consumed by a workedevent-viewer-ts), under its ownmake protocol-ts-types-gen[-check]targets — distinct from, and non-interfering with, the reserved Console generator and its lockstep gate. Partially retires the TypeScript-generation deferral for external clients. (D-269.) - Runnable
sdkexamples and an in-tree conformance worked example. The firstExample_functions undersdk/— the facade's first contact on pkg.go.dev — and ago test-compiledconformance-forkharness wiring a customFactory+RunSuite.
Changed
harbor devis honest about.goedits. (cli) A.gochange now warns and guides a manual rebuild instead of driving an in-process reboot that reporteddev.hot_reload.completed{Success=true}without recompiling the binary — a loud false success. Config / YAML reload still rebuilds in place. (D-268.)- The scaffolded tools agent actually invokes a tool. (cli) When an agent declares tools, the scaffold's golden test now registers and dispatches a declared tool through the executor (not merely that
RegisterToolsis defined), closing a compile-only false-green. (D-267.) - Native tool-calling sanitizes catalog tool names. (runtime) The React planner maps a tool name to the provider-safe form (
^[a-zA-Z0-9_-]{1,64}$) for native tool-calling and resolves it back on dispatch, so Harbor's dotted convention — every built-in (clock.now) and the default scaffolded custom name (inventory.check) — no longer 400s against OpenAI-compatible providers. Transparent: the catalog name, the config, and the public API are unchanged. (D-270.) - A public surface that tracks the shipped reality. The marketing surface now states the current canonical method count, the genuine config-reload capability, and the v1.8.0 release surfaces.
Fixed
- Dotted tool names against real providers. Any built-in or default-named custom tool previously failed the first LLM call on an OpenAI-compatible provider with a
400, because the catalog name was sent verbatim as the function name. The 107c/107d native-tool-calling tests and the scaffold gate all used a scripted LLM, which bypasses provider name-validation — surfaced by building a real scaffolded agent against a live provider. (Fixed by the native-tool-calling change above.) - The
embed-runonceworked example runs, not just compiles. It now declares aModelProfilesentry, so an operator who copies it gets an answer instead of a runtime error. Surfaced by live verification.
Internal
- A composing wave-end end-to-end test (
wave_v18_test.go) exercises all three adopter paths together on real drivers under-race: embedRunOnce+WithStream, both Protocol on-ramps (the hermetic mock-OIDC issuer andharbor token) against a realharbor servewith a mismatched-iss401, in-process MCP tool dispatch, identity propagation, and an N≥16 concurrency stress. A net-new integration test also proves a planner invoking an MCP-sourced tool through the executor. - Three pre-existing, load-induced CI flakes were hardened (a
t.Parallelgoroutine-leak check, a durable-bus drain deadline, and a spawn/await settle window), and two long-committed root build artifacts were removed. - Purely additive public API + docs: no Protocol method / error / event / type change, so
ProtocolVersionand the committed wire-surface digest both hold.
[1.7.0] — 2026-06-26
Protocol-edge hardening: capability negotiation, key-revocation safety, and data-lifecycle erasure. This release lets a generic Protocol client learn which conditionally-mounted surfaces a runtime serves at attach (instead of probing for a 404), bounds how long the JWKS validator will honor a possibly-revoked key during an IdP outage, and adds the Protocol's first identity-scoped session erasure verb with a real three-store cascade. The Harbor Protocol's wire surface grows (two new capabilities + one method + one error code + wire types) but stays semver 0.1.0 — every change is additive or corrective, and no prior INTENDED behavior changes.
Added
- Agent-config capability negotiation.
runtime.infonow advertises anagent_configcapability — but only when the agent-config control plane is actually mounted — so a Protocol client (Console, IDE/TUI, SDK) gates theagent_config.*surfaces at attach instead of method-probing for a501/unknown_method. The advertisement is wired from the same source-of-truth as the mount in every boot path, so it can never claim a surface the runtime does not serve. (D-260.) - Session erasure —
sessions.delete. A new identity-scoped, own-session-only Protocol method that erases a session and cascades deletion of its scoped State, Memory, and Artifacts — Harbor's first canonical right-to-erasure surface, satisfiable through the wire contract instead of a privileged back-door. It refuses fail-loud withsession_running(409) when the session has a running task (mirroring the GC never-reap-running invariant), emits a redacted, content-freesession.erasedaudit event under the actor's observability scope (never the erased identity), and advertises asession_lifecyclecapability when an eraser is wired. Backed by a new mandatoryStateStore.DeleteScopecascade primitive with in-memory / SQLite / Postgres conformance parity. (D-262.) - JWKS max-stale / revocation ceiling. A configurable
identity.jwks_max_stalebound on the production JWKS validator: past the ceiling, with refreshes failing, it fails closed with a distinctjwks_stalerejection reason instead of serving a possibly-revoked signing key indefinitely during an identity-provider outage. The bound is identity-agnostic (it gates before a verified identity exists), has no opt-out knob, and defaults to a safe 1h. No new wire type — it reuses the existing401auth-rejected envelope. (D-261.)
Fixed
- Erasure is durable under concurrency. A session erased while a task was still publishing lifecycle events could leave orphaned durable events readable via
state.history(the events landed on the durable bus after the cascade's scope-delete). A new identity-scoped event fence — taken by the cascade before the sweep and lifted when the session id is reused — closes the race: a late publish is either swept or dropped, so a post-erasurestate.historyfor the erased session is genuinely empty. Surfaced by live testing.
Internal
- A composing wave-end end-to-end test exercises all three surfaces together with real drivers — both capabilities coexisting in one
runtime.infoprojection (a self-consistent 7-capability universe), the full erasure lifecycle, the JWKS staleness fail-closed-then-recover path, cross-tenant isolation, and an N≥10 concurrency stress — under the race detector. - Only the wire-surface digest in the committed manifest moves (it hashes the capability / method / error / type names);
ProtocolVersionholds at0.1.0, consistent with the precedent that capabilities and additive methods are advertised, not version-gated.
[1.6.0] — 2026-06-25
Session hydration and user-scope agent configuration. This release adds a windowed session-state history surface backed by a durable event bus that rehydrates across a Runtime restart, a per-user durable agent-configuration tier that layers between the admin config and the ephemeral session overlay, and a connect-time wire-surface drift signal for Protocol clients. The Harbor Protocol's wire surface grows (a new state.history surface and an additive runtime.info field) but stays semver 0.1.0 — every change is additive or corrective, and no prior INTENDED behavior changes.
Added
- Session-state history. A new Protocol surface (
POST /v1/state/history) for windowed, identity-scoped replay of a session's event history: tail-first paging that scrolls back to the head, with offloaded heavy payloads surfaced by artifact reference. Cross-tenant reads return404(no existence leak) and an unidentified read returns401. - Durable event-bus rehydration across restart. The durable EventBus rehydrates its monotonic sequence counter from the persisted log on construction, so a Runtime restart continues a session's event sequence above its pre-restart high-water mark instead of resetting to 1 — the foundation that lets
state.historyreturn a gap-free, strictly-monotonic window across a restart boundary. - User-scope agent configuration. A per-user durable config-variant tier layered between the admin/agent config and the ephemeral session overlay. A user owns a standing variant via
agent_config/user/set_revision(set / list / diff / rollback), keyed by the real(tenant, user)and isolated per user — never byagent_id, which is a key, not an isolation principal.- Prompt layers compose at run start with the precedence admin Base > admin User > user-durable > session User.
- Tool exposure projects as a grow-only three-set union (admin ∪ user ∪ session): a user can disable more tools/servers, never re-widen past the admin-provisioned palette.
- Wire-surface drift detection.
runtime.infonow returns awire_surface_digest— a coarse, stablesha256:fingerprint of the Protocol's name-level wire surface (version + method / error / capability / wire-type names; field shapes and event-type names are deliberately excluded). The committed wire manifest is stamped with the same digest, so a Console (or any client that vendors the manifest) raises a loud drift signal at connect-time when it was built against a different Protocol surface. Additive field; the Harbor Protocol stays0.1.0.
Changed
- Agent-config write serialisation is memory-bounded. The per-owner read-modify-write lock is striped across a fixed shard array instead of an unbounded per-owner map, so the lock memory stays constant regardless of how many users ever write a durable variant. Same-owner writes still serialise.
Fixed
- Agent-config keying fails closed on an unrecognised scope. The config scope discriminator is matched explicitly; an out-of-range value now returns an error instead of defaulting to the more-privileged agent tier.
Internal
- A composing wave-end end-to-end test exercises the durable-bus-restart →
state.historyrehydration seam together with the user-scope projection (real drivers across every seam, identity propagation, ≥1 failure mode per track, and an N≥48 concurrency stress under the race detector) — closing the one cross-phase seam no per-phase test covered. - godoc hygiene on operator-visible surfaces, a scan-cost note on the user-scope
ListRevisionspath (tracked for an identity-scoped enumeration in #396), and test coverage tightened to the phase-plan claims (prompt-layer subsets, the tool-exposure union's order-independence, and the Console wire-drift match branch).
[1.5.1] — 2026-06-23
Runtime hardening, observability, and a memory-context fix. This release bounds the runtime's long-lived state, adds first-class observability (pprof + standard collectors + runtime gauges, surfaced in the Console), and fixes a memory context-budget bug that could poison a long-lived session. Every change is additive or corrective: the Harbor Protocol stays at 0.1.0, all new configuration is optional, and no prior INTENDED behavior changes.
Added
- Runtime observability foundation. Standard Go + process collectors on a per-instance Prometheus registry (
/metrics), plus Harbor runtime gauges (harbor_runtime_{active_runs,engine_capacity_entries,governance_cache_entries,events_dropped}) registered on theMetricsRegistryso they reach/metrics, OTLP, AND the already-shippedmetrics.snapshotprojection — no new Protocol method. A pprof debug listener is available for live profiling, gated to a loopback address viaserver.debug_addr/HARBOR_DEBUG_ADDRand NEVER mounted on the Protocol/Console surface. - Console runtime gauges. The Live Runtime health panel surfaces the runtime gauges through the existing
metrics.snapshot(typed Protocol client; no new method or page). - Operator-tunable memory compaction.
memory.recent_turnssets the verbatim recent-turn window forrolling_summary(default 4);memory.summarizer.modelruns compaction on a chosen model (unset → the main LLM);memory.summarizer.promptappends operator instructions to the baseline summariser prompt (extends, never replaces it — so the role framing and conciseness guarantees are preserved).
Changed
rolling_summarynow enforces its configuredbudget_tokens. The strategy keeps the assembled context within the token budget — on write it compacts oldest-first into the rolling summary (threading the prior summary so it is never discarded), and on read a deterministic clamp guarantees the emitted patch fits the budget even if the summariser is degraded.budget_tokens: 0preserves the prior unbounded behavior. (D-242.)- The context-window safety net's byte check is scoped to offloadable content. The 32 KiB heavy-content check now governs tool/MCP results and binary inputs (which offload to an
ArtifactStub); legitimate conversation text — including a long rolling summary — is governed by the token-window guard instead, not the byte threshold. (D-241.) - Governance cost ceilings and rate limits are keyed by identity, not per-run (RFC §6.15) — closing a per-run ceiling-bypass and bounding the governance cache. (D-249.)
Fixed
- Long-lived sessions no longer poison themselves. A
rolling_summarysession's accumulated context could cross the heavy-content byte threshold and then fail EVERY subsequent run at planner step 0 with a context-leak error; the budget enforcement + safety-net scoping above resolve it. Surfaced by runtime profiling. - Bounded runtime retention. The engine's per-run streaming-capacity map and the governance cache are now reaped on a true run-end idle-TTL sweep instead of growing for the process lifetime. (D-248.)
- Clean shutdown under a hung summariser. The
rolling_summaryrecovery loop runs under a cancellable context cancelled onClose(), so a hung or degraded summariser can no longer pin runtime shutdown. (D-250.)
Internal
- The seven per-driver SQL migration runners (SQLite + Postgres across state, memory, artifacts, skills) are consolidated behind one shared
internal/persistence/sqlmigraterunner — no behavior change; migrations stay forward-only and append-only. (D-253.)
[1.5.0] — 2026-06-22
The agent-config control plane: live, audited, versioned control of an agent's configuration over the Protocol — prompt, skills, MCP policy, connections, and model/sampling — plus the governance LLM-control surface it builds on (provider key rotation, tenant-default and per-agent LLM overrides) and the durable runtime infrastructure underneath (a StateStore-backed distributed bus and a durable TaskService). Every change is additive: the Harbor Protocol stays at 0.1.0, all new configuration is optional, and no prior behavior changes. The defining property is next-turn, snapshot-immutable semantics — a config edit affects only an agent's NEXT run; in-flight runs keep the immutable view they snapshotted at run start (the D-025 concurrent-reuse contract), so there is no mid-flight mutation, draining, or forcible teardown.
Added
- Agent-config control plane (the headline). A durable, identity-scoped, VERSIONED desired-state registry: every edit is an immutable, content-addressed revision with a parent pointer; the active config is a revision pointer; rollback is a repoint (never a mutation); and a server-side diff between revisions is a read method plus an
agent.config.revisedevent. The admin Protocol familyPOST /v1/agent_config/*coversget/set_revision/list_revisions/diff/rollbackand the per-section convenience verbs — each verb replaces only its own section and preserves its siblings (the bidirectional section-merge invariant). All writes are admin-scoped and identity-mandatory; authority derives from the verified ctx, never the request body. Persisted across the in-memory / SQLite / Postgres driver triad with conformance parity. - Layered system prompt. An operator-owned
baselayer plus an optional session-scopeduserlayer that composes ABOVE the base without weakening it (agent_config.set_prompt_layers) — the composition order is the security boundary. - MCP pause/resume + per-tool policy. Pause / resume an agent's MCP servers and disable individual tools as next-turn projection (
agent_config.set_tool_exposure); the live transport stays warm while paused. A paused server's App callbacks are rejected against CURRENT desired state (the planner-snapshot / app-call-current asymmetry), with the operator-legible "paused by an administrator" advisory driven by the canonical event. - Runtime add-connection. Add a NEW MCP server connection live (
agent_config.add_mcp_connection) — async dial →initializehandshake → discover → register, recorded as a non-secret connection revision; a half-attached server is never registered (fail loud). Adding a stdio server (an RCE surface) is allowlist-gated beyond admin, argv-form only. (An OAuth-required server currently PARKS on the unified pause/resume primitive; completing the resume-to-online attach + connection run-start reconciliation is tracked in #375.) - Per-agent LLM parameters. A versioned
model/temperature/max_tokens/reasoning_effortsection (agent_config.set_llm_params), resolved at run start as session › per-agent › tenant-wide baseline › config. A pinned model with no configuredModelProfileis rejected at set time, never silently. - Skills control. Manage an agent's skill set over the Protocol (
agent_config.skills.{list,upsert,delete}) — each membership change is a versioned revision (so skills inherit diff + rollback); a pack-origin skill is never silently overwritten. - Session-user safe subset. A non-admin, session-scoped lower tier (
agent_config.session.*): set the user prompt layer, NARROW (never widen) source/tool enablement within the admin-allowed set, and manage ephemeral personal skills — the tier is derived from the verified scope, fail-closed. - Governance LLM controls. Live, no-redeploy admin control of LLM behavior:
governance.rotate_keyrotates the provider API key (the new key is a secret carried only on the request leg; events carry a fingerprint), and tenant-default overrides (governance.{set,get}_tenant_overrides) set a tenant's default model / sampling / additive instructions — composed UNDER the per-agent and per-session layers. - Console agent-config panel. A consolidated
/agent-configcontrol panel (admin-gated, four-state<PageState>, typed Protocol client) with: a derived per-revision change summary (client-side, no extra round-trip), diff-before-rollback (a rollback renders the structured diff for explicit confirmation, never a blind repoint), an atomic multi-section "Save all" (commit edits across every area as ONE revision), and a per-agent "Configure" entry point on the Agents list. - Durable distributed bus. A StateStore-backed
MessageBusdriver with cross-instance fan-out — the multi-replica substrate for the control plane. - Durable TaskService. The TaskService backend over an extracted shared task engine, recovering in-flight tasks across a runtime restart.
[1.4.1] — 2026-06-18
MCP Apps backend spec-conformance and a Console rollback. The Harbor Protocol stays at 0.1.0; the one new method and the new capability advertisement are additive, and the reverted Console surface returns the Playground to its working v1.4 behavior. No breaking changes.
Added
- MCP Apps backend spec-conformance. The runtime advertises the
text/html;profile=mcp-appUI-host capability to MCP servers during initialization, so a spec-conformantext-appsserver knows the host can render itsui://documents.runtime.infosurfaces the host's supported display modes (inline / fullscreen / pip), and a new Protocol methodmcp.apps.tool_contextexposes the tool-context the app needs after mount — identity-mandatory and Protocol-proxied like every other app→host call, so it never escapes the(tenant, user, session)boundary.
Reverted
- Console MCP-Apps data delivery rolled back to v1.4. The post-1.4 Console renderer/host that pushed tool input/result into a mounted app, live-re-themed a running app, and wired the operator pop-to-side-by-side display-mode panel broke the
ui/initializehandshake — a rendered app timed out before it could mount. The Console MCP-Apps surface is restored to its working v1.4 behavior; re-landing the data-delivery push is tracked in issue #347. The backend conformance above is unaffected and remains present.
[1.4.0] — 2026-06-16
Production adoption: interactive MCP Apps in the Console, multimodal input, a production authentication path, and a privilege-escalation fix on the steering control surface. No breaking changes — the Harbor Protocol stays at 0.1.0 and all new configuration fields are optional.
Added
- MCP Apps host — a server's tool can declare a
ui://resource (io.modelcontextprotocol/ui) that the Console renders as an interactive, sandboxed app inline in the Playground. Ships the runtime + Protocol surface (mcp.servers.read_resource,ui://projection, the app-tool-call proxy), a Console host built on the officialext-appsAppBridge in manual-handler mode (every app→host call is Protocol-proxied — an in-app call never escapes the(tenant, user, session)isolation boundary), three DisplayModes (inline / fullscreen tab / pip split), and inline discovery via themcp.app_availableevent. App documents render on every artifact driver, not only S3. - Multimodal input — agents accept image, audio, and video attachments. Provider-native upload happens inside the LLM driver; an attachment disposition policy (mechanism → policy, reference by default) keeps heavy bytes out of the context window. An
Embedderseam adds opt-in semantic memory + skill retrieval, consumed by the run loop. - Production authentication —
harbor serve, the headless production sibling ofharbor dev, verifies JWTs against a JWKS source (identity.jwks_url/jwks_file; asymmetric algorithms only) and boots with no dev-only surfaces (no bootstrap-token endpoint, no Console, no mock LLM — it fails loud at boot when a real provider or JWKS source is missing). Non-admin session-scoped and owner-scoped tokens are now authorized correctly across the steering control surface. - Protocol TS lockstep gate —
make protocol-ts-gen-checkverifies the Console's hand-maintained TypeScript wire client against the Go single source (CanonicalWireTypes) and fails CI on any drift (a new/renamed/ retyped wire field that the client did not mirror). It immediately caught and fixed real latent client drift. - A binding
frontendCI job (svelte-check+ stylelint + eslint + vitest + build) and a Console chat-module encapsulation guard that keeps the chat module renderable as a self-contained library.
Changed
- Chat module encapsulation hardening — the Console chat module is now self-contained (its own typography + theming, an injectable host identity and theme, a documented design-token contract) so it can render outside the Console shell, mechanically enforced.
- Godoc hygiene — internal phase / decision numbering stripped from operator-visible Go doc comments (the
pkg.go.devsurface now reads as product API docs). - CI runners moved to Node 24; dependency bumps (
pgxv5.10.0,aws-sdk-go-v2S3, others via Dependabot).
Fixed
- Image (and other) attachments now reach the agent — a stub-fetch tool-name mismatch dropped them silently.
- MCP App discovery reads the tool definition's
_meta.ui(spec-correct), so discovery fires against real ext-apps servers; heavy app documents that exceed the inline threshold now render via the offloaded artifact. - A notification-producer startup race (subscriber registered after assembly returned) is closed.
- The preflight gate no longer silently aborts when a parallel smoke fails.
Security
- Steering control surface privilege escalation closed. Caller authority for cancel / pause / resume / redirect / inject / approve / reject / prioritize is now derived from the verified request-context identity and JWT scope — never from the request body. A request can no longer assert its own privilege tier, the steering surface fails closed when no verified identity is present, and cross-tenant steering requires the admin scope (the cross-tenant check is now actually reachable). Latent before this release because only admin-scoped dev tokens existed; closed before any lesser-privileged token (now supported) could exploit it.
[1.3.1] — 2026-06-11
The Protocol adoption track: the docs site gains a complete, drift-proof Protocol surface for third-party client authors.
Added
- The Protocol adoption track — the published docs site gains a complete top-level Protocol section for third-party client authors: a four-page generated contract reference (methods / events / errors / types) emitted by
cmd/harbor-gen-protocol-docsfrom the canonical Protocol sources and drift-gated in CI bymake protocol-docs-gen-check; the executed quickstart ("Speak Protocol in 15 minutes"), whose curl steps the preflight smoke runs against a live dev server on every commit; and five choreography guides — auth & identity, streaming semantics, task control, the pause model (the full intervention choreography: approve/reject, the OAuth callback leg, plain resume, durable pauses across restarts, timeout reaps — wire examples captured from a production-driver assembly), and versioning & compatibility (what the Protocol version promises, what a client should pin, and the unknown-field / unknown-method tolerance rules). - Build a client + conformance certification — a worked event-viewer client at
examples/protocol-clients/event-viewer/: ~150 lines of stdlib-only Go (no Harbor import) that authenticates, handshakes viaruntime.info, and tails the SSE event stream, compile-gated in preflight so the published walkthrough cannot rot. The new conformance-certification page documents how to run the in-repo Protocol conformance suite against a runtime build under test and precisely what a pass claims.
Fixed
- The methods reference's Auth column now states the deployed scope gates exactly — several rows over-claimed
console:fleetcross-tenant fan-in on admin-only (or no-fan-in) read surfaces, and the posture rows omitted the note where it genuinely applies. The column is derived from a per-method machine-readable policy and pinned by a test that drives admin-only and fleet-only tokens against every noted row on a live wire, so the cell cannot silently drift again. The auth-and-identity scope table now statesconsole:fleet's full real grant set, and the pause-model page's wire-capture provenance header is scoped honestly around its transcribed OAuth leg. - The smoke-script fleet's dead-server probes uniformly report curl's honest
000and SKIP instead of failing with a confusing000000— the inline fallback shape behind it is swept tree-wide.
(Next up: the MCP Apps host — interactive, sandboxed ui:// resources in the Console — the remaining Console polish rounds, godoc hygiene, and the resilient-flows positioning work.)
[1.3.0] — 2026-06-10
The release where the SDK story becomes real: Harbor is now a go get-able runtime for external modules, not only a binary.
Added
- The public SDK facade (
sdk/) — a curated, alias-based re-export tree (RFC §3.6): 20+ packages spanning identity, events, config, tools, llm, memory/state/artifacts/skills, planner, tasks, steering, dispatch, runctx, and the one-callassemble.Assemblestack fan-out. External-module importability is enforced by a standing preflight gate that scaffolds and compiles a tool-declaring agent outside the module. assemble.Assemble— the exported, error-returning, dependency-ordered runtime assembly (D-197);bootDevStackandharbortest/devstackare thin callers of the one implementation.harbor skill import/harbor skill rm— CLI ingestion for Skills.md playbooks over the exportedimporter.ImportAndStore.- Governance enforcement — populated
governance.identity_tiersnow enforces cost ceilings, rate limits, and max-tokens caps; the latent-by-default posture is preserved. - Durable pauses — pause checkpoints carry the run trajectory and survive a Runtime restart; a max-park sweeper reaps expired and crash-orphaned pauses (
DecisionTimeout,StateStore.ListKind). - Tool-OAuth completion —
auth.CallbackHandlercloses the pause→callback→resume choreography. - Trajectory compression — long runs compress under
planner.token_budgetvia the LLM-backed summariser. - Production telemetry assembly — the redactor-mandatory Logger, the engine RunErrorHandler, and
BridgeBusToTracerare wired by the assembly. - The published docs site — VitePress on GitHub Pages, built from the canonical in-repo docs.
- The skills
<skills_context>prompt block is produced by the capability-filtered, redacted virtual Directory with functional operator pinning.
Changed
- The runtime's production semantics were re-homed out of
cmd/harborinto exported packages (internal/runtime/dispatch,runctx,assemble; five per-subsystemFromConfigprojections; theinternal/drivers/prodaggregator). The devstack mirror is collapsed to thin callers. - The approval gate's privilege check is an injected authorizer; the runtime no longer imports Protocol auth (the Protocol import-direction rule now has zero violations).
- Scaffold templates emit
sdk/import paths;harbortest's full vocabulary is externally satisfiable; the root README tells the embeddable-SDK story.
Fixed
- A planner-dispatched approval-gated tool no longer deadlocks the run loop — APPROVE/REJECT drain mid-step.
- Session GC can no longer reap RUNNING sessions (the
RunningProbeis wired). - The bifrost driver's
Closenow shuts down the provider worker pool (previously leaked ~1000 goroutines per stack close). - The pause park's subscribe-after-publish wake window is closed; sqlite
:memory:stores no longer collide across subsystems; the durable event bus honours publish-context cancellation; per-modelcost_overrides/correctionsYAML is no longer silently dropped.
[1.1.6] — 2026-05-26
A release-engineering hotfix that finishes what v1.1.5 started. v1.1.5 trimmed the LICENSE but the trimmed text still carried three substantive deviations from the canonical Apache-2.0 — pkg.go.dev's license detector (google/licensecheck, ~75% confidence threshold) saw the deviations and kept reporting "License: UNKNOWN" + ✗ Redistributable. This release ships the byte-identical canonical text so the badge can finally flip.
Fixed
- LICENSE is now byte-identical to apache.org's canonical Apache-2.0 text (
https://www.apache.org/licenses/LICENSE-2.0.txt). The three fixed deviations:- Missing leading blank line at line 1.
- §6 "Trademarks" was missing the phrase "reasonable and customary use in" — non-standard wording.
- §9 used the old "Accepting Warranty or Support" wording instead of the canonical "Accepting Warranty or Additional Liability" (both heading and body's closing phrase).
- Effect: pkg.go.dev will detect
License: Apache-2.0and flip the Redistributable badge from ✗ to ✓ on its next module fetch.
Changed — release pipeline now ships the full cross-compile matrix
- The release workflow (
.github/workflows/release.yml) cross-compiles six binaries per release (linux/darwin/windows×amd64/arm64) via a matrix strategy, attests SLSA build provenance per binary, and publishes them all in a single GitHub Release. scripts/release-build.shnow appends the.exesuffix automatically whenGOOS=windowsso the Windows artifact behaves like every other Windows CLI a user might download.- Each release carries an aggregate
checksums.txtalongside the per-binary.sha256sidecars. Downloaders verify with the standardsha256sum -c checksums.txttwo-column form. - Pre-release tags (
-rc/-beta/-alpha) keep the existing pre-release marking; no behavior change there.
Notes
- v1.1.5's binary attached to GitHub Releases is still valid and runs fine; this release does not deprecate it. The pkg.go.dev license display, however, is per-version cached — v1.1.5's UNKNOWN status will not retroactively heal. v1.1.6 is the first version on which pkg.go.dev's detector should succeed.
[1.1.5] — 2026-05-25
A pure docs-and-hygiene release — no Runtime, Console, or Protocol behavior changes. Adds Harbor's first cut of operator skills: ten Claude-Code-style playbooks covering the agent-builder loop end-to-end, plus a mechanical drift-prevention rule that keeps them honest.
Added — operator skills (docs/skills/)
- Ten focused
docs/skills/<slug>/SKILL.mdplaybooks for building Harbor agents, with Dockyard-style frontmatter (name/descriptioncarrying "Use when" framing /license: Apache-2.0/metadata.framework: harbor/metadata.surface/metadata.verbs):- Start a project:
scaffold-a-harbor-agent,define-the-agent-yaml. - Build the agent:
add-an-in-process-tool,wire-the-llm-provider,configure-memory-and-skills. - Drive it interactively:
run-the-dev-loop,drive-the-playground. - Observe + debug:
observe-with-the-console(the 14-page Console tour). - Ship:
validate-and-package. - Build a custom frontend:
use-the-harbor-protocol— Bearer-JWT + identity-triple headers + the typed wire surface +events.subscribeSSE +topology.snapshotcapability + artifact upload, with a 30-LoC TypeScript chatbot reference. Ships a working chat UI against a real Runtime in a day.
- Start a project:
docs/skills/INDEX.mdgroups the skills by agent-author stage (start → build → drive → observe → ship → frontend) and pins the first-five-minutes adoption chain (scaffold-a-harbor-agent→run-the-dev-loop→drive-the-playground).README.mdDocumentation table now points atdocs/skills/INDEX.md.- Glossary entry distinguishes skill (operator) —
docs/skills/adoption playbooks — from skill (runtime) — theinternal/skills/token-savvy planner subsystem. Same word, different consumers; the glossary pins the boundary so future contributors don't conflate them.
Added — same-PR drift prevention rule
- New §18 in
CLAUDE.md(mirrored verbatim inAGENTS.md): a change that mutates a documented surface (aharborCLI verb, a Harbor Protocol method / wire-shape field / capability advertisement / event payload key, a Console route or page or<PageState>branch, aharbor.yamlconfig field, a canonical artifact a skill quotes verbatim) MUST update the matching skill in the same PR. Affected skill is findable by grepingdocs/skills/for matchingsurface:frontmatter lines. Closes the failure mode where docs drift erodes the first-five-minutes adoption guarantee. - Mechanical frontmatter audit at
scripts/skills/check-frontmatter.shinvoked bymake drift-audit: everydocs/skills/<slug>/SKILL.mdis validated forname(matching directory slug),description(containing "Use when"),license: Apache-2.0,metadata.framework: harbor,metadata.surfacein the canonical set (cli/agent-yaml/tools/mcp/llm/memory/playground/console/tasks/protocol), andmetadata.verbskey presence. Content drift remains human-reviewed — frontmatter shape only is mechanical. - New static-only smoke script asserts every required slug ships its SKILL.md, the INDEX references them all, the frontmatter helper passes, §18 is present in CLAUDE.md, and the glossary carries both skill clarifications.
Notes
attach-an-mcp-serveris deliberately deferred to V1.2 — its surface depends on MCP wire shapes still stabilising; shipping it here would lock prose against a moving target. Per §18 it will land in the same PR that finalises the MCP wire.- Distinct from Dockyard's MCP-server-focused skills repo — the two products share the convention but cover separate adoption surfaces (Dockyard: building MCP servers; Harbor: building agents).
[1.1.0] — 2026-05-25
The V1.1 cut, focused on Playground multimodal input and 1:1 Console↔Runtime feature parity. Harbor Protocol stays at 0.1.0 — topology_snapshot is an additive capability, StartRequest.InputArtifactIDs is opt-in via omitempty.
Added — Playground multimodal artifact input
StartRequest.InputArtifactIDs []string— opt-in wire field on the canonicalstartrequest. Text-only spawns elide the field entirely (omitemptyhonours the existing wire shape). Operator-uploaded artifacts attach to a foreground task's first planner turn; the runtime resolves each id, materializes the appropriateContent.Partsshape, and routes per MIME.tasks.SpawnRequest.InputArtifactIDsfolds into the idempotency-key content hash so "same key, different attachments" surfaces asErrIdempotencyConflict.Tool.HandlesMIME []string— new tool-descriptor field declaring which MIME types a tool consumes. The planner's multimodal materializer populatesArtifactStub.Fetch.Toolfrom the first matching descriptor — explicit "use this tool for this ref" hint to the LLM, no catalog-discovery guesswork.Tool.MatchesMIME(mime string)helper supports exact +type/*wildcard matching (no full-*/*to keep operator-declared MIMEs predictable).- Planner per-MIME dispatcher (
internal/planner/multimodal.go) — pure-functionMaterializeInputContent(goal, []InputArtifactView, ToolCatalogView) → llm.Content. Routes:image/*inlines bytes asImagePart{DataURL}so vision-capable providers see the image directly;application/pdf→FilePart{Artifact}(Anthropic native PDF; others see the canonical ArtifactStub-JSON text);audio/*→AudioPart{Artifact}; everything else →ArtifactStubtext the LLM routes via the catalog with theFetch.Toolhint. - Run-loop pre-fetch —
cmd/harbor/cmd_dev_runloop.go::resolveInputArtifactsreadstask.InputArtifactIDs, callsArtifactStore.GetReffor metadata +Getfor bytes when MIME starts withimage/, and pre-resolves theInputArtifactViewslice the planner consumes synchronously. Cleared fromRunSpec.Base.InputArtifactsafter the first step so subsequent turns never re-inline bytes. Mirrored inharbortest/devstackso test fixtures and production share the same path. - Console Playground multimodal end-to-end —
ControlNamespace.start(query, {inputArtifactIDs})typed method;buildChatClient.sendMessageplumbs the chat-attach uploads through. Fixed the chat-attach upload adapter which previously shipped the wrong body ({filename, mime, size_bytes}vs{scope, bytes, opts: {mime_type, filename}}) and readresp.idinstead ofresp.ref.id— every upload had been silently producing empty artifact ids. NewfileToBase64helper, correct request body, fail-loud on missingresp.ref.id.
Added — Playground queue-vs-steer when a run is active
- Chat composer (
web/console/src/lib/chat/ChatComposer.svelte) gains arunningprop and a two-radio mode picker that appears only while a foreground task is in flight: Queue after current run (default) stashes the message and dispatches it viastartas soon as the active task reaches a terminal state; Steer current run dispatches the SHIPPEDuser_messagecontrol verb to inject the message into the running task's next planner turn. - Playground page hooks an
EventSourcelifecycle subscription totask.completed/task.failed/task.cancelledand drains the FIFO queue whenactiveTaskIDclears. The SSE envelope'spayload.TaskID(capital T) is the load-bearing read; an initial draft looked fortask_idand the queue never drained — caught on a live wire-tap before shipping.
Added — Runtime capability gate + session aggregates
- Per-instance capability advertisement —
runtime.info.capabilitiesnow reflects what THIS runtime instance has actually wired.topology_snapshotis registered in the canonical capability set (the handshake universe) and advertised by a runtime IFF the engine-graph projection accessor is wired.harbor devagainst a planner/RunLoop agent yaml correctly omits it; a future engine-graph runtime advertises it by settingPostureDeps.TopologyAvailable=true. Mirrored inharbortest/devstack. - Console capability gate —
HarborClient.capabilities()lazy-fetches the runtime's advertised set at attach time and memoises a frozenReadonlySet<string>. Live Runtime + Playground + Playground's trace toggle gate theirtopology.snapshot()calls behindcaps.has('topology_snapshot'); on planner/RunLoop runtimes the browser fetch never fires, the friendly info banner renders directly, and the operator's DevTools console stays clean. - Console-side session counter enrichment — Session detail's
tasks_count+events_countare now truthful. The page fetchestasks.list(filtered locally by session id) +events.aggregate(30-day session-scoped window, single bucket summed across all event types) aftersessions.inspectand merges into the snapshot.sessions.inspectitself stays a pure registry projection — the Console computes the aggregates.total_tokens+total_cost_centsremain at zero pending the V1.3cost.aggregatefollow-up; a TODO comment on the page calls out the gap.
Fixed
- Sessions catalog empty across reboots.
Registry.Opendid not hydrateidIndexwhen the StateStore returned an existing-record sentinel. On aharbor devboot against a SQLite state store with a pre-existing dev session, the Sessions page rendered "No sessions match these filters" andruntime.counters.sessions_activewas zero even with a live session in the store. Fix: hydrateidIndex(andopenSessions, for an open record) before returningErrSessionAlreadyOpen/ErrReopenAfterClose. Tests cover both branches. tasks.getcrashed the Cost-breakdown rail.cost.per_stepreturnednullagainst a TS contract that declaresTaskCostStep[]. Go projector normalizes emptyPerStepto[]TaskCostStep{}so the wire honours its shape; the Console rail uses?.length ?? 0for defence in depth. Test pin asserts"per_step":[]appears in JSON output and"per_step":nulldoes not.- Playground composer hidden on empty stream.
PageState'semptysnippet was swallowing theChatPanelchildren.statusnow always goes to'ready'on a successful load; ChatPanel owns its own empty-state copy + composer. Two previously-skipped Playwright specs un-skipped. - sendMessage shipped the wrong wire body.
dispatch('user_message', sessionID, ...)treated sessionID as a run id AND used the steering-verb body shape;starthas a flat shape. New typedcontrol.start(query, opts)method ships the correct wire body; both sendMessage + restartRun route through it. - Memory page rendered Go zero-time
0001-01-01for nullableexpires_at.shortTimehelper now returns"—"when the ISO starts with0001-01-01, matching the Tools page's existing guard. - Multimodal conformance probe used a malformed 1×1 PNG below OpenAI's image-API minimum-pixel threshold, surfacing as a generic "Provider returned error" 400 that looked like a wire-shape bug for months. Swap to a 132-byte 64×64 solid-red PNG (
internal/llm/drivers/bifrost/conformance_test.go::runLiveMultimodal). All six providers in the live matrix + multimodal subtests now pass underHARBOR_LIVE_LLM=1. Playground multimodal end-to-end is verified: operator uploads a PNG via the chat composer → bifrost → OpenRouter → vision model returns "Red".
Protocol additions
- Capability
topology_snapshot— registered ininternal/protocol/types/version.go::canonicalCapabilities. Per-instance advertisement inruntime.info.capabilitiesis conditional via the newPostureDeps.TopologyAvailableflag; the canonical handshake set is unconditional. RFC §5.3 minor-class additive change — no Protocol version bump. StartRequest.InputArtifactIDs []string— additive field on the existingstartProtocol method (internal/protocol/types/control.go).omitemptykeeps the wire shape backward-compatible for text-only callers. Round-trip test pins the wire shape.
Decisions logged
- D-166 — Playground multimodal artifact input. Three settled calls: (a) runtime inlines image bytes via DataURL rather than pushing materialization down into each LLM driver; (b) per-MIME dispatcher lives in the planner package, not the run loop or LLM driver; (c)
HandlesMIMEis an opt-in descriptor field with boundedtype/*wildcards, not a global registry.
Roadmap pointers
- V1.2 — MCP wave. Plans already on the master plan.
- V1.3 — bifrost extended multimodal: provider-native file uploads for over-threshold images, native PDF / audio / video / document parts, streaming-with-multimodal, per-MIME conformance probe matrix,
cost.aggregatefollow-up that completes the session counters'total_tokens/total_cost_centsslots. Plan file staged atdocs/plans/.
1.0.0 — 2026-05-22
The first stable release. The entry below is the complete V1 surface, grouped by subsystem.
Added — Identity, configuration, and foundations
- Identity & isolation triple —
internal/identity: the(tenant, user, session)triple, theQuadruple(triple +run), context carriers, and a conformance suite. Multi-isolation is a Day-1 guarantee (RFC §4). - Configuration loader —
internal/config: a typed YAML loader (goccy/go-yaml), environment overrides, validation, secret redaction, andexamples/harbor.yaml. - Audit redactor —
internal/audit: a single deep-redaction pass, a driver registry, canonical secret rules, and multimodal-aware redaction. Every payload is redacted before it is persisted. - slog logger + standard attribute set —
internal/telemetry: an identity-aware structured logger that redacts every record through the audit redactor, plus theBusEmitterseam forruntime.errorevents.
Added — Events, state, and sessions
- Event taxonomy + in-memory
EventBus—internal/events: a typed event bus with server-enforced identity-scoped filtering, drop-oldest backpressure with abus.droppedsignal, an idle reaper, and audit-before-emit. - Bus replay + ring buffer + cursor — bounded replay history with cursor-based catch-up.
StateStoreinterface + in-memory driver + conformance suite —internal/state: a generic(Quadruple, Kind, Bytes)surface, ULID-keyed idempotency, and aconformancetest.Runsuite every downstream driver inherits.SessionRegistry+ lifecycle + GC —internal/sessions: session creation, lifecycle states, and garbage collection.
Added — Runtime engine
- Envelopes, headers, identity quadruple —
internal/runtime/messages: the message envelope, headers, andtrace_idpropagation. - Engine + workers + cycle detection —
internal/runtime/engine: the node-graph executor with a bounded worker pool and graph cycle detection. - Reliability shell — retry / timeout policy wrapping for node execution.
- Streaming + per-run capacity backpressure —
internal/runtime/streaming: chunked outputs with per-run capacity limits and parent-trace correlation. - Cancellation + per-run fetch dispatcher — per-run cancellation with no cross-run cancellation cross-talk.
- Routers + concurrency utilities + subflows —
internal/runtime/routers,concurrency,playbooks: routing policies,map_concurrent/join_k, and composable subflows.
Added — Persistence drivers
- SQLite
StateStoredriver —internal/state/drivers/sqlite: a CGo-free (modernc.org/sqlite) driver with forward-only migrations and WAL journal mode. - Postgres
StateStoredriver —internal/state/drivers/postgres: apgx-backed driver with advisory-lock-serialised migrations, exercised againstpostgres:16in CI. ArtifactStoreinterface + in-memory + filesystem drivers —internal/artifacts: a content-addressed blob store with mandatory routing above the heavy-output threshold (noNoOpfallback).ArtifactStoreSQLite-blob + Postgres-blob drivers — durable artifact storage on the persistence triad.ArtifactStoreS3-style driver —internal/artifacts/drivers/s3: an S3-compatible driver, exercised against MinIO in CI.
Added — Tasks, distributed contracts, and memory
TaskRegistryinterface + in-process driver + lifecycle —internal/tasks: a unified foreground/background task service keyed byTaskID.TaskGroup+ retain-turn + patches — task grouping, retain-turn semantics, and incremental patches.MessageBus+RemoteTransportcontracts —internal/distributed: the V1 in-process loopback driver and the contracts a post-V1 durable bus / A2A wire will satisfy.MemoryStoreinterface + in-memory driver + conformance suite —internal/memory: session-scoped memory with a conformance suite.- Memory strategies —
truncationandrolling_summary. - SQLite + Postgres
MemoryStoredrivers — durable memory on the persistence triad.
Added — Tools and integrations
- Tool catalog core + in-process registration +
ToolPolicy—internal/tools: a transport-agnostic tool catalog with identity- filtered visibility. - Flow-as-Tool registration + per-flow budget — registering a flow as a callable tool with its own budget.
- HTTP tool driver —
internal/tools/http: tools backed by HTTP endpoints. - MCP southbound driver —
internal/tools/mcp: tools sourced from MCP servers. - A2A southbound driver (full spec) —
internal/tools/a2a: tools sourced over the A2A protocol. - Tool-side OAuth + HITL via pause/resume — OAuth flows for tools routed through the unified pause/resume primitive.
- Tool-side approval gates — human-in-the-loop approval gates on tool execution.
Added — LLM client and governance
- LLM client core +
StreamSinkcontract + context-window safety net —internal/llm: the LLM client surface, streaming sink, and the always-on heavy-content leak guard (ErrContextLeak). - bifrost integration —
internal/llm/drivers/bifrost: the production LLM driver. - Custom OpenAI-compatible providers + per-provider timeouts — arbitrary OpenAI-API-compatible endpoints with per-provider timeout configuration.
- Provider correction layer +
SchemaSanitizer— a single, baked-in correction mode for provider quirks. - Structured output strategies + downgrade chain — structured-output enforcement with a graceful downgrade chain.
- Retry with feedback — retry of malformed LLM responses with corrective feedback, failing loudly with
ErrRetryExhausted. - Cost accumulator + per-identity ceilings —
internal/governance: per-identity cost ceilings. - Per-identity rate limits + per-call
MaxTokens— per-identity rate limiting and per-call token caps.
Added — Skills subsystem
- Skill store + LocalDB driver + FTS5 ladder —
internal/skills: a DB-backed, token-savvy skill catalog with a full-text-search ranking ladder. - Skill planner tools —
skill_search/skill_get/skill_listexposed to the planner. - Virtual directory subsystem — a virtual filesystem view over the skill catalog.
- Skills.md importer — importing skills from a
Skills.mdmanifest, with path-traversal-safe normalisation. - In-runtime skill generator with persistence — generating and persisting new skills at runtime.
Added — Planner subsystem
- Planner interface + Decision sum + RunContext —
internal/planner: the onePlannerinterface, the Decision sum type, and the per-runRunContext. The planner is swappable; the Runtime owns mechanism. - Trajectory + fail-loudly
Serializecontract — theTrajectorytype, whoseSerializeraisesErrUnserializablerather than silently dropping context. - Schema repair pipeline —
internal/planner/repair: salvage → schema repair → graceful failure → multi-action salvage. - Reference ReAct planner —
internal/planner/react: the reference planner, shipped in the box. - Trajectory compression / summariser — trajectory compaction for long runs.
- Parallel-call executor + ReAct
CallParallel/SpawnTask/AwaitTaskemission — parallel tool calls and background-task spawn/await as a twinned pair. - Deterministic planner — a second concrete planner that proves the
Plannerinterface holds. - Planner conformance pack — a conformance suite every planner concrete must pass.
Added — Steering, pause/resume, and the Agent Registry
- Pause/Resume Coordinator + handle registry —
internal/runtime/pauseresume: Harbor's one pause/resume primitive, serving HITL approval, tool-side OAuth, A2AAUTH_REQUIRED, and operator/ConsolePAUSE. - Pause-state serialise contract — fail-loud pause-state serialisation (
ErrUnserializable, never a half-persisted checkpoint). - Steering inbox + control taxonomy — the steering inbox and the nine-type control taxonomy.
- Steering wiring (9 control events) —
INJECT_CONTEXT,REDIRECT,CANCEL,PRIORITIZE,PAUSE,RESUME,APPROVE,REJECT,USER_MESSAGEwired end-to-end. - Agent Registry —
internal/runtime/registry: registration identity, the three-ID model (agent_id,version_hash,incarnation), andagent.*events.
Added — Observability and the durable event log
- Protocol task control surface — the start/cancel/pause/resume/ redirect/inject control surface.
- OTel traces + propagation —
internal/telemetry: OpenTelemetry tracing baked in from the start, with trace-context propagation. - Metrics + OTLP + Prometheus drivers — OTLP-push and Prometheus- pull metric exporters.
- Durable event log driver —
internal/events/drivers/durable: a StateStore-backed durable event log (load-bearing for post-V1 replay-based evaluation).
Added — Harbor Protocol
- Protocol types/methods/errors single source —
internal/protocol: the canonical wire-type / method / error-code home, lint-enforced as the single source. - Protocol versioning + deprecation policy — the parsed
Version(semver, same-majorCompatible), the structuredDeprecationnote format, and theCapability+VersionHandshakenegotiation shape. - Protocol wire transport (SSE + REST) —
internal/protocol/transports: SSE for the event stream, REST/JSON for the control surface. - Protocol auth + identity-scope enforcement — JWT (asymmetric algorithms only) identity-scope enforcement at the Protocol edge.
- Protocol conformance suite — a conformance suite for the Protocol surface.
Added — Harbor CLI
harborbinary —cmd/harbor: a cobra-rooted CLI with global--quiet/--jsonflags.harbor dev— boots the local Runtime + Protocol surface, with hot-reload on agent-source change and draft-save scaffolding.harbor console— serves the Harbor Console (baked into the binary) against a co-resident Runtime.harbor scaffold— scaffolds a new agent project.harbor validate— validates a Harbor config; wired into CI as a pre-flight check for the example configs.harbor version— reports the product release version and the Harbor Protocol version as distinct fields.harbor inspect-events/inspect-runs/inspect-topology— inspect the event stream, run history, and runtime topology.harbortesttest kit package —harbortest/: an operator- importable public test kit (RunOnce,AssertSequence,AssertNoLeaks,SimulateFailure,RecordedEvents).
Added — Harbor Console
- Console subscription protocol surface — the
events.subscribeProtocol surface the Console consumes, with filter extensions and anevents.aggregatetime-bucket method. - Runtime / governance / LLM posture surfaces — the read-only
runtime.*,metrics.*,governance.posture,llm.posture, andpause.listProtocol methods. - Console DB local schema + SvelteKit scaffold —
web/console: the Console-local schema and the SvelteKit (Svelte 5 runes) application. - Console pages — Overview, Live Runtime, Sessions, Tasks, Agents, Tools, MCP Connections, Background Jobs, Events, Flows, Memory, Artifacts, Settings, and Playground — fourteen pages, each a Protocol client that never reads a Runtime object directly.
- Console state inspection + topology projection — the state-snapshot Protocol surface and the topology projection events behind the Console topology view.
- Console e2e Playwright harness — the Playwright e2e suite, gated by the
frontend-e2eCI job.
Added — Conformance harnesses, benchmarks, and release engineering
- Cross-tenant isolation conformance harness —
test/integration: a-raceharness running concurrent sessions and asserting no cross-tenant or cross-session leak. The integrity gate. - Goroutine leak conformance harness — a
-raceharness that constructs, exercises, and tears down every long-lived component and asserts the goroutine count returns to baseline. - Chaos / fault injection harness — a
-raceharness that injects five failure modes (kill mid-run, dropped messages, provider quirks, StateStore disconnect, pause-deserialize failure) and asserts each produces its documented loud error and recovery path. - Performance benchmarks —
test/benchmarks: ago test -benchsuite over the hottest runtime seams, with a CI perf-regression gate. - Documentation hygiene — an enforced
golangci-lintgate (godoc / package-comment + the full linter set), worked examples underexamples/, and recipe how-to guides underdocs/recipes/. - Release engineering — build-time product-version stamping via
-ldflags -X(harbor versionreports it);scripts/release-build.shandscripts/release-dryrun.shwith themake release-build/make release-dryruntargets; and therelease.ymlworkflow that, on av*tag push, builds the CGo-free static binary, emits a SHA-256 checksum, attaches SLSA-style build provenance, and publishes a GitHub Release.