From 0dff6b4bc9324df0d82559a68175244fb730c03f Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Mon, 21 Sep 2026 16:54:27 -0700 Subject: [PATCH 1/6] docs: plan workspace enrollment authority repair Co-Authored-By: Mecatl --- docs/acceptance/README.md | 1 + .../workspace-enrollment-authority.md | 91 +++++++++++++++++++ ...e-workspace-enrollment-broker-authority.md | 47 ++++++++++ docs/adr/README.md | 2 + 4 files changed, 141 insertions(+) create mode 100644 docs/acceptance/workspace-enrollment-authority.md create mode 100644 docs/adr/0350-durable-workspace-enrollment-broker-authority.md diff --git a/docs/acceptance/README.md b/docs/acceptance/README.md index f2ed43ee42..ae047bc768 100644 --- a/docs/acceptance/README.md +++ b/docs/acceptance/README.md @@ -131,6 +131,7 @@ PR after verification. There is no cleanup or status-only PR. - [Resumable session status metrics](resumable-session-status-metrics.md) — preserves latest known context occupancy for every persisted session kind and restores the authoritative resolved context window plus durable main-session token totals before mecatui submits a resumed chat’s next prompt. Status: proposed. - [Purpose-attributed auxiliary token usage](auxiliary-token-usage.md) — records every reachable session-associated auxiliary LLM call in the canonical ledger under a purpose-specific provider/model-attributed kind, without changing main-run accounting or budgets. Status: proposed. - [Acceptance-proof resolution efficiency](acceptance-proof-resolution-efficiency.md) — keeps plan-proof resolution fail closed while avoiding duplicate task execution within the existing Doc validation runner; Vitest batching is deferred. Status: proposed. +- [Workspace enrollment preserves session authority](workspace-enrollment-authority.md) — preserves all still-authorized composed tools while atomically replacing the authenticated broker bundle; already-corrupted snapshots remain narrowed by explicit compatibility decision. Status: proposed. - [Development-spine work classification](adr-scope-classification.md) — routes Spike, Routine, Bounded, and Architectural work; keeps acceptance planning for substantive work while reserving ADRs for genuinely durable architecture decisions. Status: landed in this diff --git a/docs/acceptance/workspace-enrollment-authority.md b/docs/acceptance/workspace-enrollment-authority.md new file mode 100644 index 0000000000..8d1bb6c553 --- /dev/null +++ b/docs/acceptance/workspace-enrollment-authority.md @@ -0,0 +1,91 @@ +# Workspace enrollment preserves session authority — acceptance plan + +**Contract:** human-reviewed/v2 +**Work classification:** Architectural — adds narrow durable provenance for dynamically installed broker capabilities so authority replacement remains exact across persistence and successors. +**Decision record:** [ADR 0350](../adr/0350-durable-workspace-enrollment-broker-authority.md) +**Phase:** workspace MCP broker enrollment +**Status:** proposed, 2026-09-21. Prepared from [issue #1740](https://github.com/stacklok/mecatl/issues/1740); the operator selected no repair for already-corrupted sessions and terminal whole-bundle failure for broker/non-broker registration-key conflicts. +**Delivery:** Split. The change alters persisted session authority provenance, snapshot/successor behavior, and terminal enrollment failure semantics. +**Expected tasks:** 3. +**Issue:** [stacklok/mecatl#1740](https://github.com/stacklok/mecatl/issues/1740). +**Plan PR:** absent until opened. +**Approved baseline:** absent until approved. + +Workspace enrollment reconfigures an idle existing session: it builds a session engine with the authenticated broker bundle and persists the same aggregate. Completion must preserve all non-broker capabilities, replace only the known dynamic broker bundle, and never widen a deliberately attenuated session. A rare duplicate between an enrolled broker registration and a non-broker registration fails the complete enrollment rather than silently redirecting or partially publishing tools. + +## Human decisions + +- [x] Treatment of already-corrupted sessions — Decision: leave them narrowed; no heuristic, migration, or automatic repair infers their former authority. A fresh session receives the corrected semantics. +- [x] Broker/non-broker registration-key collision policy — Decision: fail the complete enrollment terminally before publication; expose the safe conflicting registration key only, preserve the prior authority/runtime state, and require operator configuration correction before another explicit enrollment. + +## Interface contract + +- **gRPC / protobuf:** None — existing workspace-enrollment RPCs, routes, requests, and presentation-safe status vocabulary retain their current shape. A collision is represented through the existing terminal failure/error path. +- **Exported Go APIs / interfaces:** None — provenance and composition projections remain private to the existing session, snapshot, catalog-assembly, and server packages; no public engine API changes. +- **Tool schemas:** None — tools remain advertised by the existing catalog and authority-disclosure paths. A failed enrollment installs no partial broker wrapper. +- **CLI / config:** No new flag or setting. `/tools-connect` and `/mcp` retain their commands; a collision reports a terminal setup failure and an explicit retry remains available after configuration correction. +- **Events / persistence:** Add a private, bounded snapshot ledger for exact dynamically installed broker registration keys and an explicit presence bit; a present empty set is valid and differs from absent legacy provenance. Completion replaces the ledger atomically with the capability set; restore and successor paths preserve and validate it. A legacy snapshot with absent provenance cannot begin enrollment and remains unrepaired. +- **Security / authority:** Completion removes only the prior persisted dynamic broker keys, retains every other capability—including opaque MCP-resource and latent member capabilities—and adds only keys proven to have registered in the completed broker composition. Any overlap between either the incoming or prior broker-key set and the current non-broker registration set fails closed before candidate publication. Terminal cleanup may persist only the cleared pending control; it cannot publish candidate wrappers, binding, authority, or provenance. Permission mode and posture remain unable to widen persisted authority. +- **Compatibility / migration:** Fresh sessions and sessions with committed provenance receive exact replacement semantics. Already-corrupted legacy snapshots remain narrowed, receive no heuristic repair, and are refused before any enrollment-side mutation; a fresh session is the supported recovery path. No schema migration or compatibility reader is added. + +## In scope — 3 scenarios, in implementation order + +### Scenario 1 — successful enrollment replaces only the dynamic broker bundle + +Registration keys, not refreshed `Tool.Spec()` calls, are catalog metadata ([catalog](../../engine/tool/catalog.go)). The session aggregate owns durable authority ([workspace enrollment aggregate](../../engine/session/workspace_enrollment.go)); authority disclosure remains the runtime filter ([authority disclosure](../../engine/agent/authority_disclosure.go)). The dynamic broker-key ledger described by [ADR 0350](../adr/0350-durable-workspace-enrollment-broker-authority.md) permits exact bundle replacement without treating every carried capability as a catalog registration. + +**Acceptance:** +- AC1.1: A successful initial enrollment retains all carried non-broker capabilities, including core, delegation, memory, skill, global/client-MCP, opaque MCP-resource, and latent member capabilities, and adds every enrolled broker key that actually registered in the final catalog. + - verify: `TestWorkspaceEnrollmentAuthority_Scenario1_PreservesUnrelatedCapabilities` +- AC1.2: A successful broker refresh removes the prior committed broker keys, adds the replacement broker keys, and leaves all other authority capabilities unchanged. + - verify: `TestWorkspaceEnrollmentAuthority_Scenario1_ReplacesDynamicBrokerBundle` +- AC1.3: Enrollment does not restore a capability deliberately excluded from the prior authority, even if the rebuilt catalog exposes that capability. + - verify: `TestWorkspaceEnrollmentAuthority_Scenario1_DoesNotWidenAttenuatedAuthority` + +### Scenario 2 — provenance is durable and publication is failure-atomic + +A session's durable authority is copied through normal successor paths, while broker attachments are process-local ([broker attachment](../../internal/adapter/server/mcp_broker.go)). [ADR 0350](../adr/0350-durable-workspace-enrollment-broker-authority.md) requires the private dynamic-broker key set to follow the authority snapshot and successor without disclosing broker runtime or credential material. + +**Acceptance:** +- AC2.1: Resume and fork preserve a successfully committed authority and dynamic broker-key set, and rebuilt engines disclose exactly the authorized, currently registered tools. + - verify: `TestWorkspaceEnrollmentAuthority_Scenario2_PersistsAcrossResumeAndFork` +- AC2.2: Engine construction, attachment commit, aggregate completion, or snapshot persistence failure leaves the prior authority and dynamic broker-key set unchanged and publishes no partial replacement. + - verify: `TestWorkspaceEnrollmentAuthority_Scenario2_FailureIsAtomic` +- AC2.3: A legacy snapshot without dynamic-broker provenance remains narrowed and is not widened, guessed, or automatically repaired; its enrollment control fails before reset, discovery, browser consent, attachment mutation, or runtime mutation and directs recovery to a fresh session. + - verify: `TestWorkspaceEnrollmentAuthority_Scenario2_LegacySnapshotIsNotRepaired` +- AC2.4: A present empty dynamic-broker ledger remains distinguishable from missing legacy provenance through snapshot, restore, and fork; malformed or oversized present provenance fails closed. + - verify: `TestWorkspaceEnrollmentAuthority_Scenario2_ProvenancePresenceAndBounds` + +### Scenario 3 — a registration collision fails the whole enrollment safely + +Workspace enrollment is a complete authenticated bundle: a failure exposes no partial protected catalog (the broker deployment guide at `user-docs/building/deployment/mecak8s.md`). Exact registration keys select the executable tool, so a broker candidate must not replace or be silently omitted behind a non-broker key. + +**Acceptance:** +- AC3.1: When either an enrolled broker candidate or a prior recorded broker key conflicts with a current non-broker registration key, the server rejects the full enrollment before candidate wrapper, binding, authority, or provenance publication; no broker tool from that bundle becomes executable. It may persist only terminal cleanup that clears the pending control, while a destructive refresh leaves broker runtime unavailable. + - verify: `TestWorkspaceEnrollmentAuthority_Scenario3_ConflictFailsWholeEnrollment` +- AC3.2: The terminal failure gives the owning client the safe conflicting registration key and an actionable retry-after-configuration-correction message, while omitting OAuth URLs, endpoints, callbacks, credentials, tokens, and connector configuration. + - verify: `TestWorkspaceEnrollmentAuthority_Scenario3_ConflictPresentationIsSafe` +- AC3.3: Mecatui’s existing enrollment controls show the terminal setup failure, do not send a prompt retained pending successful enrollment, and offer the existing explicit retry path after the operation settles. + - verify: `TestWorkspaceEnrollmentAuthority_Scenario3_TerminalConflictUX` + +## Out of scope + +| Item | Defer-to | Decision | +|---|---|---| +| Automatic repair or migration of already-corrupted authority snapshots | Future explicitly approved compatibility work | The persisted set cannot distinguish accidental loss from deliberate attenuation; legacy sessions refuse enrollment before side effects and require a fresh session. | +| Origin metadata for every catalog entry | Future need for general source-aware authorization | Only the dynamic broker bundle needs durable provenance. | +| Partial broker enrollment or per-tool collision omission | None | Workspace enrollment remains complete and atomic at the catalog boundary. | +| New RPCs, events, flags, config keys, or model-facing notices | None | Existing controls and tool definitions are sufficient. | + +## Definition of done + +1. The Plan / Interface PR is merged and its commit is recorded as the approved baseline before implementation begins. +2. Offline aggregate, snapshot, server, broker, gRPC/HTTP, and mecatui tests prove every named acceptance criterion without a live model, OAuth provider, browser, or Kubernetes cluster. +3. `task lint`, `task test`, `task api:check`, `task docs`, and `task site:build` pass; `go run ./cmd/mecademo` remains green. +4. `task ac-trace-strict` resolves every named proof when the plan becomes `landed`. +5. The implementation PR links the Plan / Interface PR and approved commit and reports interface conformance and no unwaived `/panel-review` blocker. + +## Deferred decisions and known risks + +- Catalog registration outcomes must be captured at composition time without extra `Tool.Spec()` calls, in accordance with the [catalog metadata rule](../../AGENTS.md). +- The private snapshot representation and package-private helper placement are implementation details, provided ADR 0350's exact replacement, collision, and failure-atomicity rules hold. diff --git a/docs/adr/0350-durable-workspace-enrollment-broker-authority.md b/docs/adr/0350-durable-workspace-enrollment-broker-authority.md new file mode 100644 index 0000000000..9af2486e27 --- /dev/null +++ b/docs/adr/0350-durable-workspace-enrollment-broker-authority.md @@ -0,0 +1,47 @@ +# ADR 0350 — Durable workspace-enrollment broker authority provenance + +- Status: Proposed +- Date: 2026-09-21 +- Scope: session authority replacement, persistence, collision handling, and enrollment failure presentation for ToolHive workspace enrollment +- Supersedes: ADR 0335 Decision 6 only +- Superseded by: None + +## Context + +Workspace enrollment installs an authenticated, session-bound broker catalogue alongside the ordinary composed catalog. The existing completion path replaces the aggregate's entire durable tool capability set with the enrolled catalogue's names. That removes unrelated core, delegation, memory, skill, global-MCP, client-MCP, MCP-resource, and latent member capabilities from the session even when they remain available in the rebuilt runtime. + +A replacement cannot instead intersect authority with final catalog registration names. The durable capability set contains opaque MCP-resource capabilities and latent member capabilities that are intentionally not catalog registrations. It also cannot infer the prior broker bundle from the process-local attachment after restart: snapshots contain an undifferentiated authority set, while the broker binding carries no completed-catalogue membership. + +MCP remote tools conventionally use `mcp____` names, making a broker/non-broker collision unusual. It is nevertheless possible when two sources expose the same server and remote-tool names, and the catalog's exact registration key selects the executable implementation. Keeping one source's authority after silently switching that key to another source would make a durable authorization refer to a different implementation. + +## Decision + +1. The session aggregate persists one private, bounded, exact set of registration keys dynamically installed by the most recent successful workspace enrollment, together with an explicit presence bit. Presence with an empty set is a valid completed enrollment; absence means an unrepaired legacy snapshot and is not equivalent to an empty set. The ledger is distinct from the general derived capability set and carries no endpoints, connector metadata, OAuth state, credentials, or tool schemas. New sessions create a present empty ledger. Snapshot restore, clone, and validation preserve that distinction and reject malformed or oversized present values. +2. A successful enrollment uses the registration outcomes from the one successfully composed catalog. It does not re-read broker tools or call `Tool.Spec()` while calculating authority. It atomically replaces the prior dynamic-broker key set and computes the durable capability set as: all prior capabilities except the prior dynamic-broker keys, plus the newly registered dynamic-broker keys. All non-broker capabilities therefore survive unchanged, including opaque and latent capabilities; deliberately attenuated capabilities remain absent. +3. The private broker-key set and its presence bit are snapshotted, restored, and copied by ordinary session successors. Engine-build, aggregate-completion, attachment-commit, and persistence failures leave the prior authority and broker-key ledger unchanged. Resume and fork retain the successfully committed values. +4. Before publication, enrollment rejects a conflict between any key in the incoming broker bundle **or the prior recorded broker bundle** and a registration key owned by the current non-broker composition. It publishes no partial protected catalogue or candidate wrapper and changes neither candidate authority nor candidate provenance. It does persist the ordinary terminal cleanup that clears the pending enrollment; on a destructive refresh, the already-withdrawn broker runtime remains unavailable while the prior durable authority and ledger stay unchanged. The presentation-safe terminal failure identifies the conflicting registration key but never exposes OAuth URLs, endpoints, callback state, credentials, tokens, or raw broker configuration. A retry after configuration correction starts a new whole-bundle enrollment. +5. Existing snapshots without this private provenance are not repaired or inferred. Their owner cannot begin workspace enrollment: the server rejects the control before destructive reset, discovery, browser consent, attachment mutation, or runtime mutation, directs recovery to a fresh session, and leaves the legacy session subject to its persisted authority. A fresh session receives the new behavior. +6. This supersedes only ADR 0335's “no refresh-specific persisted state” clause. Its whole-bundle, explicit-control, ToolHive-custody, ordered publication, and no-partial-catalogue decisions remain unchanged. + +## Consequences + +New enrollment and refresh operations no longer clobber unrelated session authority, while broker refresh removes stale known broker keys exactly. The presence-bearing persistence field is a narrow provenance ledger, not a general catalog-origin model. It adds snapshot and successor coverage but no RPC, configuration, command, event, or model-facing surface. + +A rare conflicting deployment fails visibly rather than selecting a precedence rule or silently omitting one protected tool. The owning user sees a terminal setup failure in `/mcp` and may retry `/tools-connect` after the operator removes or renames the conflicting MCP configuration. Pending prompts are not resumed because enrollment did not succeed. + +Old affected snapshots remain narrowed by design and cannot begin workspace enrollment. This avoids an unsafe compatibility heuristic that could widen intentional attenuation, at the cost of requiring a fresh session for corrected authority semantics. + +## Rejected alternatives + +- **Replace authority with the final catalog names.** This deletes valid opaque MCP-resource and latent member capabilities and widens deliberately attenuated sessions. +- **Union the old authority with the enrolled catalogue.** This retains stale broker keys and can authorize a later unrelated registration with a stale broker capability. +- **Persist origin metadata for every catalog entry.** The durable need is limited to the dynamically installed broker bundle; broad catalog provenance adds an unnecessary engine-wide concept. +- **Omit only conflicting broker tools.** Workspace enrollment is a complete authenticated bundle; partial success would make an apparently successful connection incomplete and violate the no-partial-catalogue contract. +- **Choose broker or non-broker precedence on collisions.** Exact names are durable capabilities. Redirecting one name to a different implementation is an authority change, so collision fails closed. +- **Repair legacy snapshots heuristically.** Their current tool set cannot distinguish accidental loss from deliberate attenuation. + +## See also + +- [ADR 0234 — Derived delegation authority behind an evaluator port](./0234-authority-evaluator-port.md) +- [ADR 0335 — Idle-session MCP broker workspace refresh](./0335-idle-session-broker-workspace-refresh.md) +- [Workspace enrollment authority acceptance plan](../acceptance/workspace-enrollment-authority.md) diff --git a/docs/adr/README.md b/docs/adr/README.md index 15661579cd..d8a9513583 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -223,6 +223,8 @@ Documentation/citation conventions are in [`docs/design/README.md`](../design/RE - [0325 — Durable Dynamic Client Registration for direct MCP profiles](./0325-direct-mcp-dcr.md) - [0345 — Host-local direct MCP onboarding and credential custody](./0345-direct-mcp-onboarding.md) *(proposed)* - [0326 — Lazy ToolHive grants refresh declared metadata](./0326-lazy-toolhive-metadata-refresh.md) +- [0335 — Idle-session MCP broker workspace refresh](./0335-idle-session-broker-workspace-refresh.md) *(Decision 6 superseded by 0350)* +- [0350 — Durable workspace-enrollment broker authority provenance](./0350-durable-workspace-enrollment-broker-authority.md) *(proposed; supersedes ADR 0335 Decision 6 only)* ### Performance & diagnostics - [0018 — Perf observability](./0018-perf-observability.md) From 74f130599149fc7b8f88f01600ce7d402a608fbe Mon Sep 17 00:00:00 2001 From: Joe Beda Date: Mon, 21 Sep 2026 16:55:36 -0700 Subject: [PATCH 2/6] docs: link enrollment authority plan PR Co-Authored-By: Mecatl --- docs/acceptance/workspace-enrollment-authority.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/acceptance/workspace-enrollment-authority.md b/docs/acceptance/workspace-enrollment-authority.md index 8d1bb6c553..b7067662b2 100644 --- a/docs/acceptance/workspace-enrollment-authority.md +++ b/docs/acceptance/workspace-enrollment-authority.md @@ -8,7 +8,7 @@ **Delivery:** Split. The change alters persisted session authority provenance, snapshot/successor behavior, and terminal enrollment failure semantics. **Expected tasks:** 3. **Issue:** [stacklok/mecatl#1740](https://github.com/stacklok/mecatl/issues/1740). -**Plan PR:** absent until opened. +**Plan PR:** [#1741](https://github.com/stacklok/mecatl/pull/1741). **Approved baseline:** absent until approved. Workspace enrollment reconfigures an idle existing session: it builds a session engine with the authenticated broker bundle and persists the same aggregate. Completion must preserve all non-broker capabilities, replace only the known dynamic broker bundle, and never widen a deliberately attenuated session. A rare duplicate between an enrolled broker registration and a non-broker registration fails the complete enrollment rather than silently redirecting or partially publishing tools. From 4fee45da4d5b68d23629378afb5fbdf511ea8ea2 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Tue, 22 Sep 2026 20:54:28 +0200 Subject: [PATCH 3/6] docs(plan): tighten workspace enrollment authority contract Revise the proposed plan and ADR 0350 after tracing the implementation: - Name the engine API: add Session.WorkspaceEnrollmentBrokerKeys and Session.RestoreWorkspaceEnrollmentBrokerKeys, and classify the changed CompleteWorkspaceEnrollment semantics as a breaking change. - Specify how the ledger and its presence bit are encoded in sessnap and eventsource, the key bounds, and validation of raw JSON before decoding. - Add an authority-boundary section: no supported journey lets a child session enroll, so enrollment cannot widen narrowed child authority. - Keep a previously recorded broker key excluded when carried authority no longer holds it; admit keys the ledger has never seen. - Separate restart from successors: a restart keeps the ledger but no broker runtime; clear and fork copy the ledger with authority and start a fresh attachment. - Refuse legacy provenance before any reopen, persist, reset, discovery, or consent side effect. Relates to #1740. Co-Authored-By: mecatl --- .../workspace-enrollment-authority.md | 93 ++++++++++++------- ...e-workspace-enrollment-broker-authority.md | 48 ++++++---- 2 files changed, 89 insertions(+), 52 deletions(-) diff --git a/docs/acceptance/workspace-enrollment-authority.md b/docs/acceptance/workspace-enrollment-authority.md index b7067662b2..f9637037a8 100644 --- a/docs/acceptance/workspace-enrollment-authority.md +++ b/docs/acceptance/workspace-enrollment-authority.md @@ -4,68 +4,95 @@ **Work classification:** Architectural — adds narrow durable provenance for dynamically installed broker capabilities so authority replacement remains exact across persistence and successors. **Decision record:** [ADR 0350](../adr/0350-durable-workspace-enrollment-broker-authority.md) **Phase:** workspace MCP broker enrollment -**Status:** proposed, 2026-09-21. Prepared from [issue #1740](https://github.com/stacklok/mecatl/issues/1740); the operator selected no repair for already-corrupted sessions and terminal whole-bundle failure for broker/non-broker registration-key conflicts. +**Status:** proposed, 2026-09-22. Revision of [issue #1740](https://github.com/stacklok/mecatl/issues/1740)'s proposed plan; it remains proposed until the Plan / Interface PR receives human merge approval. **Delivery:** Split. The change alters persisted session authority provenance, snapshot/successor behavior, and terminal enrollment failure semantics. **Expected tasks:** 3. **Issue:** [stacklok/mecatl#1740](https://github.com/stacklok/mecatl/issues/1740). **Plan PR:** [#1741](https://github.com/stacklok/mecatl/pull/1741). **Approved baseline:** absent until approved. -Workspace enrollment reconfigures an idle existing session: it builds a session engine with the authenticated broker bundle and persists the same aggregate. Completion must preserve all non-broker capabilities, replace only the known dynamic broker bundle, and never widen a deliberately attenuated session. A rare duplicate between an enrolled broker registration and a non-broker registration fails the complete enrollment rather than silently redirecting or partially publishing tools. +Workspace enrollment reconfigures an owner-authorized idle session. A completed refresh replaces only its prior broker registration-key bundle: it retains all other carried capabilities and preserves absence of non-broker capabilities. `/tools-connect` is not authorization to override a persisted restriction. A rare registration-key collision between the broker bundle and the non-broker catalog fails the whole enrollment before publication. ## Human decisions - [x] Treatment of already-corrupted sessions — Decision: leave them narrowed; no heuristic, migration, or automatic repair infers their former authority. A fresh session receives the corrected semantics. -- [x] Broker/non-broker registration-key collision policy — Decision: fail the complete enrollment terminally before publication; expose the safe conflicting registration key only, preserve the prior authority/runtime state, and require operator configuration correction before another explicit enrollment. +- [x] Broker/non-broker registration-key collision policy — Decision: fail the complete enrollment terminally before publication; expose the safe conflicting registration key only, preserve the prior durable authority/provenance, and require operator configuration correction before another explicit enrollment. +- [x] Broker-specific attenuation — Decision: preserve exclusion of a previously recorded broker key that is absent from carried authority. The full replacement bundle still registers, but completion does not restore that key to authority. A key absent from the prior broker ledger is genuinely new and remains eligible to be added. + +## Authority boundary + +Enrollment must not override carried restrictions or widen narrowed child authority. The replacement formula applies to the existing enrollable root/successor flow, not to arbitrary capability editing or a new child-enrollment grant. + +The previously asserted child journey is blocked in the traced implementation. A child inherits the parent's owner (`engine/agent/subagent.go`, `stampDelegatedLabels` call), so ownership alone would pass, but its constructors in `engine/agent/child_environment.go` do not copy `ExternalBinding`. `internal/adapter/server/workspace_enrollment.go` (`workspaceEnrollmentTarget`) calls `openBrokerAttachment` with `bindingRequired=true`; `internal/adapter/server/mcp_broker.go` rejects an empty binding before lookup or `AttachSession`. That error is not `ErrBrokerBindingMismatch`, so it cannot enter the rebind branch. Clear/fork cannot turn the child into an enrollable successor: `internal/adapter/server/service.go` (`managementSession`) rejects non-main kinds and delegation prefixes before successor creation. A missing root-kind filter in enrollment is therefore not proof of child reachability. No supported child-widening journey is established; custom hosts or manually edited snapshots are outside this plan, not implicit authorization. If implementation establishes a different supported journey, stop for a narrow plan decision rather than regrant authority. ## Interface contract -- **gRPC / protobuf:** None — existing workspace-enrollment RPCs, routes, requests, and presentation-safe status vocabulary retain their current shape. A collision is represented through the existing terminal failure/error path. -- **Exported Go APIs / interfaces:** None — provenance and composition projections remain private to the existing session, snapshot, catalog-assembly, and server packages; no public engine API changes. +- **gRPC / protobuf:** None — existing workspace-enrollment RPCs, routes, requests, and presentation-safe status vocabulary retain their current shape. A collision uses the existing terminal failure/error path. +- **Exported Go APIs / interfaces:** Add `session.Session.WorkspaceEnrollmentBrokerKeys() ([]string, bool)` and `session.Session.RestoreWorkspaceEnrollmentBrokerKeys([]string, bool) error`. The accessor returns an independent exact registration-key set and its presence bit; the restore method validates copied provenance during idle snapshot restoration or successor construction, before history/control restoration. `CompleteWorkspaceEnrollment(pending, exactTools)` keeps its signature but its documented `exactTools` value becomes the completed broker registration-key bundle, replacing only the prior bundle. The new accessors are Added/minor. The existing completion method's changed replacement semantics and legacy refusal require separate behavioral compatibility review under `engine/COMPATIBILITY.md`; unchanged signatures do not make them additive (Changed is breaking under that policy). During implementation, run `task api:update`, include `engine/api/session.txt`, and record the additions and behavioral classification in `engine/CHANGELOG.md`. The exported `sessnap.Snapshot` and `eventsource.SessionMeta` fields specified below are reference-adapter API additions: `engine/adapter/*` is excluded from the guarded stability surface, so they require no new core API baseline. Document their same-revision host obligations and test restoration parity; the core accessor and completion compatibility obligations still apply. - **Tool schemas:** None — tools remain advertised by the existing catalog and authority-disclosure paths. A failed enrollment installs no partial broker wrapper. -- **CLI / config:** No new flag or setting. `/tools-connect` and `/mcp` retain their commands; a collision reports a terminal setup failure and an explicit retry remains available after configuration correction. -- **Events / persistence:** Add a private, bounded snapshot ledger for exact dynamically installed broker registration keys and an explicit presence bit; a present empty set is valid and differs from absent legacy provenance. Completion replaces the ledger atomically with the capability set; restore and successor paths preserve and validate it. A legacy snapshot with absent provenance cannot begin enrollment and remains unrepaired. -- **Security / authority:** Completion removes only the prior persisted dynamic broker keys, retains every other capability—including opaque MCP-resource and latent member capabilities—and adds only keys proven to have registered in the completed broker composition. Any overlap between either the incoming or prior broker-key set and the current non-broker registration set fails closed before candidate publication. Terminal cleanup may persist only the cleared pending control; it cannot publish candidate wrappers, binding, authority, or provenance. Permission mode and posture remain unable to widen persisted authority. -- **Compatibility / migration:** Fresh sessions and sessions with committed provenance receive exact replacement semantics. Already-corrupted legacy snapshots remain narrowed, receive no heuristic repair, and are refused before any enrollment-side mutation; a fresh session is the supported recovery path. No schema migration or compatibility reader is added. +- **CLI / config:** No new flag or setting. `/tools-connect` and `/mcp` retain their commands; they do not grant authority to restore, add, or remove unrelated persisted capabilities. A collision reports a terminal setup failure and an explicit retry remains available after configuration correction. +- **Events / persistence:** Add the exported snapshot fields below. `sessnap.Of`/`Restore` use the aggregate accessors. Clear/fork copy the exact ledger and presence bit alongside carried authority, including absent legacy provenance; a fresh attachment does not mean an empty ledger. No completed enrollment or runtime is copied. Persist registration keys only, never endpoints, connector metadata, OAuth state, credentials, schemas, or runtime handles. + +```go +// sessnap.Snapshot +WorkspaceEnrollmentBrokerKeys []string `json:"workspace_enrollment_broker_keys,omitempty"` +WorkspaceEnrollmentBrokerKeysPresent bool `json:"workspace_enrollment_broker_keys_present,omitempty"` + +// eventsource.SessionMeta (host-supplied metadata, not a JSON contract) +WorkspaceEnrollmentBrokerKeys []string +WorkspaceEnrollmentBrokerKeysPresent bool +``` + + Presence uses normal bool/`omitempty` semantics. Missing or explicit `false` with zero keys means legacy-absent; `true` with omitted keys or `[]` means present-empty; `true` with a valid nonempty array means present-nonempty. JSON `null` for the slice also decodes as zero keys, without changing the presence bit. Reject nonempty keys with missing/false presence, null/non-boolean presence, non-array/non-string key data, and invalid key sets. Reuse `ValidWorkspaceEnrollmentToolNames`: at most 256 unique, nonempty, valid-UTF-8 keys, at most 256 bytes each and 32 KiB combined, with no Unicode control characters. Validate raw JSON before decoding can silently normalize malformed input; test the bounds at and beyond each limit. Canonical writes omit zero-length keys and false presence, but preserve true presence even for empty keys. + + `eventsource.Fold` currently restores `Authority` from `SessionMeta`; its stream has no workspace-enrollment provenance or pending-enrollment reconstruction. Add the two exported metadata fields above and restore their copied values through the aggregate method after authority binding, before history/control restoration (including the awaiting early return). Authority and ledger must come from the **same committed authority revision**, not stale creation metadata after enrollment. Hosts must persist/read them consistently; Fold cannot detect a stale but structurally valid pair from events. If exact provenance is unavailable, restore absent provenance and refuse enrollment at the server guard, never default it to present-empty. Invalid metadata fails reconstruction. Pending workspace enrollment still requires snapshot restoration; this change adds no event semantics or pending-enrollment support to Fold. +- **Security / authority:** Let `excluded prior broker keys = prior broker keys − prior authority`. Completion computes `(prior authority − prior broker keys) + (newly registered broker keys − excluded prior broker keys)` and records the complete new broker bundle as provenance. It therefore preserves exclusion of a previously recorded broker key, admits a genuinely new broker key, cannot restore or add an unrelated capability absent from prior authority, and cannot subtract a non-broker capability. The existing owner, idle, no-live-run, no-conflicting-control, lease, and broker-configuration checks remain the enrollment boundary; no arbitrary capability-removal interface is introduced. Registration collisions fail before candidate engine/attachment/authority/provenance publication. Unauthorized or concealed callers receive the existing not-found-style failure and no key or state disclosure. +- **Compatibility / migration:** Existing snapshots without provenance are legacy and cannot begin enrollment. In `workspaceEnrollmentTarget`, immediately after `Store.Load` and owner authorization succeed, the service checks that the restored ledger is present and valid. It refuses absent or invalid provenance before the current completed-session `Reopen`/`saveSession`, `ResetWorkspaceEnrollment`, broker attachment open/rebind, discovery, browser consent, or any candidate runtime/authority mutation; a fresh session is the recovery path. Snapshot-format addition is backward readable but intentionally not a compatibility repair. The API baseline and classified changelog are implementation deliverables, not prerequisites for plan approval. ## In scope — 3 scenarios, in implementation order ### Scenario 1 — successful enrollment replaces only the dynamic broker bundle -Registration keys, not refreshed `Tool.Spec()` calls, are catalog metadata ([catalog](../../engine/tool/catalog.go)). The session aggregate owns durable authority ([workspace enrollment aggregate](../../engine/session/workspace_enrollment.go)); authority disclosure remains the runtime filter ([authority disclosure](../../engine/agent/authority_disclosure.go)). The dynamic broker-key ledger described by [ADR 0350](../adr/0350-durable-workspace-enrollment-broker-authority.md) permits exact bundle replacement without treating every carried capability as a catalog registration. +Registration keys—not refreshed `Tool.Spec()` calls—are catalog metadata ([catalog](../../engine/tool/catalog.go)). The aggregate currently binds a durable authority and completes enrollment through `CompleteWorkspaceEnrollment` ([authority](../../engine/session/principal.go), [enrollment](../../engine/session/workspace_enrollment.go)). The server permits only the owner-authorized, idle, broker-configured session path and reopens a completed session before that check ([control](../../internal/adapter/server/workspace_enrollment.go)). [ADR 0350](../adr/0350-durable-workspace-enrollment-broker-authority.md) limits the ledger to broker keys rather than catalog-origin metadata for every capability. + +**Restriction trace:** `internal/adapter/permconfig/permconfig.go` (`rulesFromConfig`) converts user/project/operator `permissions.deny` into permission rules, not capability deletions; `engine/adapter/permpolicy/permpolicy.go` (`Evaluate`) folds configured and learned rules with the session mode. These denies/asks remain effective for broker and non-broker calls. `engine/tool/catalog.go` applies plan-mode advertisement/dispatch filtering, and `internal/app/catalog.go` (`assembleCatalog`) retains no-FS profile exclusions. `internal/app/root_authority.go` (`mintRootAuthority`) mints catalog/resource/latent capabilities; `internal/adapter/server/service.go` (`setPerSessionLabels`) adds broker/client keys only for fresh authority, explicitly not for carried authority. `engine/agent/authority_delegation.go` (`deriveDelegatedAuthority`) narrows child capabilities, but the binding and successor gates traced above prevent that child from entering the supported enrollment flow. Root creation and main-session successors do not expose arbitrary per-key capability editing. As a defensive aggregate rule, completion preserves exclusion of a prior broker-ledger key absent from carried authority while admitting a key absent from the prior ledger as genuinely new. Permission/mode/profile restrictions continue to govern all calls, and the change authorizes no child widening. **Acceptance:** -- AC1.1: A successful initial enrollment retains all carried non-broker capabilities, including core, delegation, memory, skill, global/client-MCP, opaque MCP-resource, and latent member capabilities, and adds every enrolled broker key that actually registered in the final catalog. +- AC1.1: Initial enrollment retains every carried non-broker capability and adds every broker registration key that actually registered in the final catalog. - verify: `TestWorkspaceEnrollmentAuthority_Scenario1_PreservesUnrelatedCapabilities` -- AC1.2: A successful broker refresh removes the prior committed broker keys, adds the replacement broker keys, and leaves all other authority capabilities unchanged. +- AC1.2: Refresh removes only prior recorded broker keys, adds only replacement broker keys, and leaves every other carried capability unchanged, preserving absence of non-broker capabilities. - verify: `TestWorkspaceEnrollmentAuthority_Scenario1_ReplacesDynamicBrokerBundle` -- AC1.3: Enrollment does not restore a capability deliberately excluded from the prior authority, even if the rebuilt catalog exposes that capability. - - verify: `TestWorkspaceEnrollmentAuthority_Scenario1_DoesNotWidenAttenuatedAuthority` +- AC1.3: Enrollment never restores an excluded non-broker capability even if the rebuilt catalog exposes it. If a prior broker-ledger key is absent from carried authority, completion registers the full new bundle but excludes that key from replacement authority; a genuinely new broker key absent from the prior ledger remains eligible to be added. Configured permission denies/asks, mode filtering, and profile exclusions remain enforced for subsequent calls, including broker calls; authority's non-tool axes remain unchanged. Narrowed children gain no enrollment or authority-widening permission from this change; retain the empty-binding refusal and main-only successor gate. + - verify: `TestWorkspaceEnrollmentAuthority_Scenario1_DoesNotWidenAttenuatedAuthority`, `TestWorkspaceEnrollmentAuthority_Scenario1_PreservesSupportedRestrictions` -### Scenario 2 — provenance is durable and publication is failure-atomic +### Scenario 2 — provenance survives restart and successors without copying runtime -A session's durable authority is copied through normal successor paths, while broker attachments are process-local ([broker attachment](../../internal/adapter/server/mcp_broker.go)). [ADR 0350](../adr/0350-durable-workspace-enrollment-broker-authority.md) requires the private dynamic-broker key set to follow the authority snapshot and successor without disclosing broker runtime or credential material. +`rehydrateSession` deliberately builds without broker tools and does not resurrect a broker attachment from persisted binding ([rehydration](../../internal/adapter/server/service.go)). Clear and fork carry authority but allocate a new session and fresh broker attachment ([successors](../../internal/adapter/server/placement_successor.go)). [ADR 0350](../adr/0350-durable-workspace-enrollment-broker-authority.md) requires the ledger to follow carried authority across both boundaries so a subsequent explicit refresh can subtract inherited old keys. **Acceptance:** -- AC2.1: Resume and fork preserve a successfully committed authority and dynamic broker-key set, and rebuilt engines disclose exactly the authorized, currently registered tools. - - verify: `TestWorkspaceEnrollmentAuthority_Scenario2_PersistsAcrossResumeAndFork` -- AC2.2: Engine construction, attachment commit, aggregate completion, or snapshot persistence failure leaves the prior authority and dynamic broker-key set unchanged and publishes no partial replacement. +- AC2.1: A real snapshot/store restart preserves a committed authority and present broker-key ledger, but rehydrates no broker runtime, attachment, or wrappers. Ordinary prompts remain usable only through the non-broker runtime; an owner must explicitly refresh to install broker tools. An event-log system of record obtains identical ledger provenance only by supplying the exact presence bit and keys through `eventsource.SessionMeta`; no `session.Event` reconstructs enrollment provenance. + - verify: `TestWorkspaceEnrollmentAuthority_Scenario2_RestartDoesNotResurrectBrokerRuntime` +- AC2.2: The subsequent explicit refresh subtracts the pre-restart recorded broker keys and commits only the newly registered bundle, preserving the remaining authority exactly. + - verify: `TestWorkspaceEnrollmentAuthority_Scenario2_RefreshSubtractsRestoredBrokerKeys` +- AC2.3: Resume and authority-carrying successors, including clear and fork, preserve the exact committed ledger and presence bit alongside authority. Clear has empty history and fork carries valid history; both create fresh attachments/bindings without copying completed enrollment or source runtime. Their later explicit refresh subtracts inherited old broker keys. Neither a ledger nor carried authority proves a live connection. + - verify: `TestWorkspaceEnrollmentAuthority_Scenario2_PersistsAcrossResumeAndFork`, `TestWorkspaceEnrollmentAuthority_Scenario2_SuccessorsStartFreshBrokerEnrollment` +- AC2.4: Raw-JSON tests exercise the exact interface encoding: missing and explicit false presence with omitted/empty/null keys remain legacy-absent; true with omitted/empty/null keys stays present-empty; true with valid nonempty keys stays present-nonempty through marshal/unmarshal. Reject nonempty keys without true presence, null/non-boolean presence, non-array/non-string key data, duplicates, empty/control-bearing/invalid-UTF-8 keys, and all three size-bound violations. Prove the same cases through direct aggregate/snapshot restore and successor copy. Enrollment rejects absent provenance at the load-and-owner-auth guard before reopen/persist, reset, attachment rebind, discovery, consent, or runtime mutation and directs recovery to a fresh session. Eventsource tests prove same-revision authority/ledger parity for all three presence states, including the awaiting reconstruction branch; malformed metadata fails reconstruction, and unavailable provenance remains enrollment-refused, never present-empty. + - verify: `TestWorkspaceEnrollmentAuthority_Scenario2_ProvenancePresenceAndBounds`, `TestWorkspaceEnrollmentAuthority_Scenario2_LegacySnapshotIsNotRepaired` +- AC2.5: Engine construction, attachment commit, aggregate completion, or snapshot persistence failure leaves prior authority and broker-key provenance unchanged and publishes no partial replacement. Terminal cleanup may clear only the pending control; destructive refresh need not restore withdrawn broker runtime. Fault injection proves this ordered fail-closed behavior, not a cross-resource transaction. - verify: `TestWorkspaceEnrollmentAuthority_Scenario2_FailureIsAtomic` -- AC2.3: A legacy snapshot without dynamic-broker provenance remains narrowed and is not widened, guessed, or automatically repaired; its enrollment control fails before reset, discovery, browser consent, attachment mutation, or runtime mutation and directs recovery to a fresh session. - - verify: `TestWorkspaceEnrollmentAuthority_Scenario2_LegacySnapshotIsNotRepaired` -- AC2.4: A present empty dynamic-broker ledger remains distinguishable from missing legacy provenance through snapshot, restore, and fork; malformed or oversized present provenance fails closed. - - verify: `TestWorkspaceEnrollmentAuthority_Scenario2_ProvenancePresenceAndBounds` -### Scenario 3 — a registration collision fails the whole enrollment safely +### Scenario 3 — a rare cross-source collision fails the whole enrollment safely -Workspace enrollment is a complete authenticated bundle: a failure exposes no partial protected catalog (the broker deployment guide at `user-docs/building/deployment/mecak8s.md`). Exact registration keys select the executable tool, so a broker candidate must not replace or be silently omitted behind a non-broker key. +This is a cross-source safeguard at Mecatl's catalog-registration boundary, not a reimplementation of ToolHive's internal prefix or conflict resolution. [ADR 0350](../adr/0350-durable-workspace-enrollment-broker-authority.md) requires composition to report broker registration outcomes from the one final catalog build. It must register candidate tools through the error-returning path, not `MustRegister`, and hand a bounded safe duplicate-key error to the server without an additional `Tool.Spec()` call. **Acceptance:** -- AC3.1: When either an enrolled broker candidate or a prior recorded broker key conflicts with a current non-broker registration key, the server rejects the full enrollment before candidate wrapper, binding, authority, or provenance publication; no broker tool from that bundle becomes executable. It may persist only terminal cleanup that clears the pending control, while a destructive refresh leaves broker runtime unavailable. +- AC3.1: A current non-broker registration matching either an incoming broker key or a prior recorded broker key fails the whole enrollment before candidate wrapper, binding, authority, or provenance publication. No broker tool from that bundle becomes executable; destructive refresh may leave broker runtime unavailable while prior durable authority/provenance remains unchanged. - verify: `TestWorkspaceEnrollmentAuthority_Scenario3_ConflictFailsWholeEnrollment` -- AC3.2: The terminal failure gives the owning client the safe conflicting registration key and an actionable retry-after-configuration-correction message, while omitting OAuth URLs, endpoints, callbacks, credentials, tokens, and connector configuration. - - verify: `TestWorkspaceEnrollmentAuthority_Scenario3_ConflictPresentationIsSafe` -- AC3.3: Mecatui’s existing enrollment controls show the terminal setup failure, do not send a prompt retained pending successful enrollment, and offer the existing explicit retry path after the operation settles. +- AC3.2: The internal catalog/factory handoff returns the exact successfully registered broker keys on success, or a typed/safe duplicate-key failure on collision. It performs no extra `Tool.Spec()` calls and never panics through `MustRegister` for dynamic broker registration. + - verify: `TestWorkspaceEnrollmentAuthority_Scenario3_RegistrationMetadataAndDuplicateHandoff` +- AC3.3: An owner receives only the safe conflicting key and retry-after-configuration-correction guidance. An unauthorized collision caller receives the existing concealed failure, with no key disclosure or mutation; neither path exposes OAuth URLs, endpoints, callbacks, credentials, tokens, or connector configuration. + - verify: `TestWorkspaceEnrollmentAuthority_Scenario3_ConflictPresentationAndOwnership`, `TestWorkspaceEnrollmentAuthority_Scenario3_ConflictPresentationIsSafe` +- AC3.4: Mecatui's existing enrollment controls show the terminal setup failure, do not send a prompt retained pending successful enrollment, and offer the existing explicit retry path after the operation settles. - verify: `TestWorkspaceEnrollmentAuthority_Scenario3_TerminalConflictUX` ## Out of scope @@ -73,8 +100,10 @@ Workspace enrollment is a complete authenticated bundle: a failure exposes no pa | Item | Defer-to | Decision | |---|---|---| | Automatic repair or migration of already-corrupted authority snapshots | Future explicitly approved compatibility work | The persisted set cannot distinguish accidental loss from deliberate attenuation; legacy sessions refuse enrollment before side effects and require a fresh session. | -| Origin metadata for every catalog entry | Future need for general source-aware authorization | Only the dynamic broker bundle needs durable provenance. | +| Origin metadata for every catalog entry | Future need for general source-aware authorization | Persist only the completed workspace-enrollment broker registration keys. | +| Arbitrary authority editing/removal | Future separately authorized authority management | This change only replaces the known broker bundle during the existing enrollment flow. | | Partial broker enrollment or per-tool collision omission | None | Workspace enrollment remains complete and atomic at the catalog boundary. | +| ToolHive prefix/conflict policy | ToolHive | Mecatl only protects its cross-source final registration boundary. | | New RPCs, events, flags, config keys, or model-facing notices | None | Existing controls and tool definitions are sufficient. | ## Definition of done @@ -87,5 +116,5 @@ Workspace enrollment is a complete authenticated bundle: a failure exposes no pa ## Deferred decisions and known risks -- Catalog registration outcomes must be captured at composition time without extra `Tool.Spec()` calls, in accordance with the [catalog metadata rule](../../AGENTS.md). -- The private snapshot representation and package-private helper placement are implementation details, provided ADR 0350's exact replacement, collision, and failure-atomicity rules hold. +- The composition handoff may be a narrow addition to the existing root-internal `SessionEngineResult`/factory contract or an equivalent internal result; it must expose only exact registration keys and a safe duplicate-key classification, not general catalog provenance. +- If the existing factory cannot distinguish a prior key now owned by a non-broker registration without expanding that narrow handoff, implementation must stop for a plan amendment rather than infer origin or weaken the collision rule. diff --git a/docs/adr/0350-durable-workspace-enrollment-broker-authority.md b/docs/adr/0350-durable-workspace-enrollment-broker-authority.md index 9af2486e27..6f4cada2d6 100644 --- a/docs/adr/0350-durable-workspace-enrollment-broker-authority.md +++ b/docs/adr/0350-durable-workspace-enrollment-broker-authority.md @@ -1,44 +1,52 @@ # ADR 0350 — Durable workspace-enrollment broker authority provenance -- Status: Proposed +- Status: Proposed — remains proposed until the Plan / Interface PR receives human merge approval. - Date: 2026-09-21 -- Scope: session authority replacement, persistence, collision handling, and enrollment failure presentation for ToolHive workspace enrollment +- Scope: session authority replacement, persistence, successor boundary, collision handling, and enrollment failure presentation for ToolHive workspace enrollment - Supersedes: ADR 0335 Decision 6 only - Superseded by: None ## Context -Workspace enrollment installs an authenticated, session-bound broker catalogue alongside the ordinary composed catalog. The existing completion path replaces the aggregate's entire durable tool capability set with the enrolled catalogue's names. That removes unrelated core, delegation, memory, skill, global-MCP, client-MCP, MCP-resource, and latent member capabilities from the session even when they remain available in the rebuilt runtime. +Workspace enrollment installs an authenticated, session-bound broker catalog beside the ordinary composed catalog. The existing completion path replaces the aggregate's complete durable tool capability set with enrolled catalog names. That drops unrelated core, delegation, memory, skill, global/client-MCP, MCP-resource, and latent-member capabilities, even though they remain part of the session's authority. -A replacement cannot instead intersect authority with final catalog registration names. The durable capability set contains opaque MCP-resource capabilities and latent member capabilities that are intentionally not catalog registrations. It also cannot infer the prior broker bundle from the process-local attachment after restart: snapshots contain an undifferentiated authority set, while the broker binding carries no completed-catalogue membership. +A final-catalog intersection is not sufficient: durable authority includes opaque MCP-resource and latent-member capabilities that are not registrations. Nor can the broker bundle be inferred after restart from `ExternalBinding`: bindings and attachments are process-local, while snapshots contain an undifferentiated authority set. The necessary durable distinction is therefore only the exact registration keys installed by a successful workspace-enrollment broker bundle—not origin metadata for every catalog entry. -MCP remote tools conventionally use `mcp____` names, making a broker/non-broker collision unusual. It is nevertheless possible when two sources expose the same server and remote-tool names, and the catalog's exact registration key selects the executable implementation. Keeping one source's authority after silently switching that key to another source would make a durable authorization refer to a different implementation. +The control checks session ownership, a configured broker, idle state (reopening completed sessions), and no live run; the aggregate also rejects conflicting controls. Permission denies/asks are separate from capability membership: `internal/adapter/permconfig/permconfig.go` (`rulesFromConfig`) and `engine/adapter/permpolicy/permpolicy.go` (`Evaluate`) keep them effective, while mode and profile filtering remain unchanged. `internal/adapter/server/service.go` (`setPerSessionLabels`) explicitly avoids adding broker/client capabilities to carried authority at creation. The supported surfaces do not expose arbitrary per-key capability editing. Delegation narrows child authority and inherits the parent's owner, but does not copy `ExternalBinding` (`engine/agent/subagent.go`, `engine/agent/child_environment.go`). `internal/adapter/server/workspace_enrollment.go` (`workspaceEnrollmentTarget`) requires a binding: `internal/adapter/server/mcp_broker.go` (`openBrokerAttachment`) rejects an empty binding before broker lookup/attachment. This is not the mismatch error that permits rebind. `internal/adapter/server/service.go` (`managementSession`) also rejects child kinds/prefixes from clear/fork, so a child cannot acquire a successor binding there. The previously claimed supported child-enrollment journey is blocked, despite the absence of a root-kind filter in enrollment. This proposal authorizes no widening of narrowed child authority; arbitrary capability editing and custom-host placement of such sessions are outside scope. If another supported journey is established, implementation must stop for a narrow plan decision, not silently regrant authority. + +MCP remote tools commonly use `mcp____` keys, so a broker/non-broker collision is rare. It remains unsafe: an exact durable key must not silently select a different source's implementation. This guard protects Mecatl's final cross-source registration boundary only; ToolHive continues to own its internal prefixes and conflict resolution. ## Decision -1. The session aggregate persists one private, bounded, exact set of registration keys dynamically installed by the most recent successful workspace enrollment, together with an explicit presence bit. Presence with an empty set is a valid completed enrollment; absence means an unrepaired legacy snapshot and is not equivalent to an empty set. The ledger is distinct from the general derived capability set and carries no endpoints, connector metadata, OAuth state, credentials, or tool schemas. New sessions create a present empty ledger. Snapshot restore, clone, and validation preserve that distinction and reject malformed or oversized present values. -2. A successful enrollment uses the registration outcomes from the one successfully composed catalog. It does not re-read broker tools or call `Tool.Spec()` while calculating authority. It atomically replaces the prior dynamic-broker key set and computes the durable capability set as: all prior capabilities except the prior dynamic-broker keys, plus the newly registered dynamic-broker keys. All non-broker capabilities therefore survive unchanged, including opaque and latent capabilities; deliberately attenuated capabilities remain absent. -3. The private broker-key set and its presence bit are snapshotted, restored, and copied by ordinary session successors. Engine-build, aggregate-completion, attachment-commit, and persistence failures leave the prior authority and broker-key ledger unchanged. Resume and fork retain the successfully committed values. -4. Before publication, enrollment rejects a conflict between any key in the incoming broker bundle **or the prior recorded broker bundle** and a registration key owned by the current non-broker composition. It publishes no partial protected catalogue or candidate wrapper and changes neither candidate authority nor candidate provenance. It does persist the ordinary terminal cleanup that clears the pending enrollment; on a destructive refresh, the already-withdrawn broker runtime remains unavailable while the prior durable authority and ledger stay unchanged. The presentation-safe terminal failure identifies the conflicting registration key but never exposes OAuth URLs, endpoints, callback state, credentials, tokens, or raw broker configuration. A retry after configuration correction starts a new whole-bundle enrollment. -5. Existing snapshots without this private provenance are not repaired or inferred. Their owner cannot begin workspace enrollment: the server rejects the control before destructive reset, discovery, browser consent, attachment mutation, or runtime mutation, directs recovery to a fresh session, and leaves the legacy session subject to its persisted authority. A fresh session receives the new behavior. -6. This supersedes only ADR 0335's “no refresh-specific persisted state” clause. Its whole-bundle, explicit-control, ToolHive-custody, ordered publication, and no-partial-catalogue decisions remain unchanged. +1. `Session` owns a private, bounded, exact workspace-enrollment broker-key ledger and a presence bit. New sessions without carried authority are present-and-empty; authority-carrying successors copy the source ledger and presence bit. Present-empty records known empty provenance, not proof of completed enrollment; absent means unrepaired legacy provenance. The ledger contains only registration keys, not endpoints, connector metadata, OAuth state, credentials, tool schemas, bindings, or runtime handles. +2. The minimal engine API addition is `Session.WorkspaceEnrollmentBrokerKeys() ([]string, bool)` and `Session.RestoreWorkspaceEnrollmentBrokerKeys([]string, bool) error`. The accessor returns a copy and presence; the restore method validates copied provenance during idle snapshot restoration or successor construction, before history/control restoration. `CompleteWorkspaceEnrollment(pending, exactTools)` retains its signature, but `exactTools` is documented as the completed broker registration-key bundle. Let `excluded prior broker keys = prior broker keys − prior authority`; completion atomically computes `(prior authority − prior broker keys) + (completed broker keys − excluded prior broker keys)` and replaces the ledger with the complete completed bundle. Within the existing enrollable root/successor flow, this preserves all non-broker membership and absence, preserves exclusion of previously recorded broker keys, and admits genuinely new broker keys. It neither makes narrowed children enrollable nor authorizes overriding their carried restrictions. Configured denies/asks, mode/profile enforcement, and non-tool authority axes remain unchanged. +3. `sessnap.Snapshot` persists `workspace_enrollment_broker_keys []string \`json:"workspace_enrollment_broker_keys,omitempty"\`` and `workspace_enrollment_broker_keys_present bool \`json:"workspace_enrollment_broker_keys_present,omitempty"\``. Both fields absent means legacy absent provenance. `workspace_enrollment_broker_keys_present:true` with the keys field absent, `null`, or an empty array means present-empty; the same true field with a valid nonempty array means present-nonempty. Normal Go JSON slice decoding therefore accepts `null` as zero keys, while the explicit presence bit retains the security distinction. Raw JSON validation rejects nonempty keys without true presence, null/non-boolean presence, non-array or non-string key data, and malformed, duplicate, or over-bound keys before aggregate restore. `sessnap.Of` reads the ledger through the accessor; `Snapshot.Restore` applies it through the restore method. `eventsource.SessionMeta` carries the same exact keys and presence bit as external creation metadata and restores them by the same method before event folding; no enrollment event is created or consumed. Its false/absent value is the same legacy-absent ledger, which the server enrollment guard refuses; an event-log host must supply true plus exact keys to reconstruct an enrollment-capable session. In the server, `workspaceEnrollmentTarget` checks present, valid provenance immediately after `Store.Load` and owner authorization, before the existing completed-session `Reopen`/save, reset, attachment open/rebind, discovery, consent, or runtime mutation. Absent legacy data remains absent and is rejected at that guard. It is not inferred or repaired; a fresh session is the recovery path. +4. Snapshot restore preserves an enrollment ledger but never resurrects broker runtime. Ordinary rehydration continues to build without broker attachment/wrappers from persisted broker keys or binding. Only an explicit owner control can establish a fresh attachment and completed bundle. Clear and fork carry the source ledger and presence bit alongside carried authority, so a later refresh can subtract inherited broker keys. They create fresh broker attachments/bindings and do not copy the source's completed enrollment, pending operation, or runtime. Provenance is subtraction evidence, not connectivity. Engine construction, attachment commit, aggregate completion, or snapshot persistence failure leaves prior authority and ledger unchanged and publishes no partial replacement; destructive refresh may leave broker runtime unavailable. +5. Composition records the exact broker keys from the one final catalog registration pass. It must use an error-returning dynamic registration path—not `MustRegister`—and return a safe duplicate-key classification plus the registered-key metadata through the existing root-internal factory/result handoff. Registration metadata is captured in that pass, with no second `Tool.Spec()` call. Before candidate publication, it rejects a current non-broker registration matching either an incoming key or a prior ledger key. Collision fails the complete bundle: no candidate wrapper, binding, authority, or provenance is published. The only allowed durable cleanup is clearing the pending enrollment; after destructive refresh, broker runtime may remain unavailable while prior durable authority and ledger remain unchanged. +6. The owner receives a terminal failure naming only the safe conflicting registration key and retry-after-configuration-correction guidance. Unauthorized callers stay on the existing concealed not-found-style path and receive neither that key nor state information. Neither path exposes OAuth URLs, endpoints, callback state, credentials, tokens, or connector configuration. Mecatui shows the terminal failure, does not send a prompt retained pending success, and retains the existing explicit retry path after settlement. +7. This supersedes only ADR 0335's “no refresh-specific persisted state” clause. Its whole-bundle, explicit-control, ToolHive-custody, destructive replacement, ordered-publication, and no-partial-catalog decisions remain unchanged. ## Consequences -New enrollment and refresh operations no longer clobber unrelated session authority, while broker refresh removes stale known broker keys exactly. The presence-bearing persistence field is a narrow provenance ledger, not a general catalog-origin model. It adds snapshot and successor coverage but no RPC, configuration, command, event, or model-facing surface. +Successful enrollment and refresh no longer clobber unrelated session authority, while a later refresh removes known old broker keys exactly. A previously recorded broker key removed from carried authority remains excluded even when the refreshed bundle registers it again; genuinely new broker keys can still be admitted. The ledger is narrow provenance, not a general catalog-source model or arbitrary authority editor. Existing supported restrictions remain intact: owner/idle/control eligibility, configured permission denies/asks, session mode/profile enforcement, non-tool authority axes, and exact non-broker membership and absence. + +A restart retains the authority ledger but not a live broker capability. An explicit post-restart refresh can subtract the old recorded broker keys and install only a newly verified bundle. A clear or fork carries the subtraction ledger with authority while establishing its own fresh attachment, not inheriting a source's connection. -A rare conflicting deployment fails visibly rather than selecting a precedence rule or silently omitting one protected tool. The owning user sees a terminal setup failure in `/mcp` and may retry `/tools-connect` after the operator removes or renames the conflicting MCP configuration. Pending prompts are not resumed because enrollment did not succeed. +A rare deployment collision fails visibly for the owner rather than silently choosing precedence or omitting a tool. The mechanism neither replaces ToolHive's own collision policy nor adds a cross-source framework. -Old affected snapshots remain narrowed by design and cannot begin workspace enrollment. This avoids an unsafe compatibility heuristic that could widen intentional attenuation, at the cost of requiring a fresh session for corrected authority semantics. +The new accessors are Added/minor. Changing the existing exported `CompleteWorkspaceEnrollment` replacement semantics and refusing legacy enrollment requires separate behavioral compatibility review under `engine/COMPATIBILITY.md`; an unchanged signature does not imply additive compatibility (Changed is breaking under that policy). `task api:update`, the `engine/api/session.txt` baseline, and the separately classified `engine/CHANGELOG.md` entries are implementation deliverables, not plan-approval prerequisites. No protobuf, RPC, config, command, event, or model-facing surface changes. ## Rejected alternatives -- **Replace authority with the final catalog names.** This deletes valid opaque MCP-resource and latent member capabilities and widens deliberately attenuated sessions. -- **Union the old authority with the enrolled catalogue.** This retains stale broker keys and can authorize a later unrelated registration with a stale broker capability. -- **Persist origin metadata for every catalog entry.** The durable need is limited to the dynamically installed broker bundle; broad catalog provenance adds an unnecessary engine-wide concept. -- **Omit only conflicting broker tools.** Workspace enrollment is a complete authenticated bundle; partial success would make an apparently successful connection incomplete and violate the no-partial-catalogue contract. -- **Choose broker or non-broker precedence on collisions.** Exact names are durable capabilities. Redirecting one name to a different implementation is an authority change, so collision fails closed. -- **Repair legacy snapshots heuristically.** Their current tool set cannot distinguish accidental loss from deliberate attenuation. +- **Replace authority with final catalog names.** This deletes opaque/non-registered capabilities and can widen intentionally attenuated authority. +- **Union prior authority with enrolled names.** This retains stale broker keys that could later name an unrelated registration. +- **Persist origin metadata for every catalog entry.** Only workspace-enrollment broker keys need durable provenance. +- **Treat `/tools-connect` as authority administration.** The control is an existing owner-authorized broker enrollment path, not permission to override session restrictions. +- **Fail the whole refresh when a previously recorded broker key was excluded from carried authority.** The complete bundle can still register while authority disclosure preserves the exclusion; failure would unnecessarily block unrelated or genuinely new broker keys in the same refresh. +- **Discard successor provenance because its attachment is fresh.** Carried authority still contains old broker keys; dropping their ledger prevents exact subtraction at the successor's next refresh. Copy provenance, never completed enrollment or runtime. +- **Omit conflicting broker tools or choose a source precedence.** The broker bundle is atomic, and redirecting a durable key to another implementation changes authority. +- **Reimplement ToolHive conflict resolution.** Mecatl needs only the final cross-source registration safeguard. +- **Repair legacy snapshots heuristically.** Their authority cannot distinguish accidental loss from deliberate attenuation. ## See also From 45cdb1f239871c8103a3881bddd487eab9df9201 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Wed, 23 Sep 2026 16:51:22 +0200 Subject: [PATCH 4/6] docs(plan): amend enrollment authority after spike review A review of the spike implementation found gaps in the contract itself. Record the human decisions of 2026-09-23: - Authority and the broker-key ledger are always set together. Root creation records exactly the broker keys it grants; clear, fork, and CreateSession with a source session copy the source ledger. session.New starts with absent provenance, so a path that forgets the ledger refuses enrollment instead of keeping stale broker keys. The restore method is write-once. - Excluded broker keys stay in the ledger (new ledger = completed bundle plus prior excluded keys), so an exclusion survives a refresh that omits the key. Exceeding the ledger bounds fails enrollment without mutation. - A recorded broker key still held in authority that a non-broker tool now owns fails every engine build (rehydrate, successor, enrollment) with a failed-precondition error naming only the key. Keys that are only excluded never trigger this. - An enrollment-time collision or ledger overflow clears and persists the pending enrollment, so the session accepts prompts again. The acceptance criteria follow: AC1.1 covers creation-time keys, new AC1.4 covers exclusion memory, AC2.1 gains the collision exception, AC2.3 covers carryover, and AC3.1 requires an end-to-end proof that pending is cleared. The compatibility section notes that an older binary re-saving a snapshot drops the ledger. Relates to #1740. Co-Authored-By: mecatl --- .../workspace-enrollment-authority.md | 32 +++++++++++-------- ...e-workspace-enrollment-broker-authority.md | 12 ++++--- 2 files changed, 26 insertions(+), 18 deletions(-) diff --git a/docs/acceptance/workspace-enrollment-authority.md b/docs/acceptance/workspace-enrollment-authority.md index f9637037a8..ee4214e72a 100644 --- a/docs/acceptance/workspace-enrollment-authority.md +++ b/docs/acceptance/workspace-enrollment-authority.md @@ -6,7 +6,7 @@ **Phase:** workspace MCP broker enrollment **Status:** proposed, 2026-09-22. Revision of [issue #1740](https://github.com/stacklok/mecatl/issues/1740)'s proposed plan; it remains proposed until the Plan / Interface PR receives human merge approval. **Delivery:** Split. The change alters persisted session authority provenance, snapshot/successor behavior, and terminal enrollment failure semantics. -**Expected tasks:** 3. +**Expected tasks:** 3 (plus the 2026-09-23 spike-review repair, recorded under Human decisions). **Issue:** [stacklok/mecatl#1740](https://github.com/stacklok/mecatl/issues/1740). **Plan PR:** [#1741](https://github.com/stacklok/mecatl/pull/1741). **Approved baseline:** absent until approved. @@ -18,6 +18,10 @@ Workspace enrollment reconfigures an owner-authorized idle session. A completed - [x] Treatment of already-corrupted sessions — Decision: leave them narrowed; no heuristic, migration, or automatic repair infers their former authority. A fresh session receives the corrected semantics. - [x] Broker/non-broker registration-key collision policy — Decision: fail the complete enrollment terminally before publication; expose the safe conflicting registration key only, preserve the prior durable authority/provenance, and require operator configuration correction before another explicit enrollment. - [x] Broker-specific attenuation — Decision: preserve exclusion of a previously recorded broker key that is absent from carried authority. The full replacement bundle still registers, but completion does not restore that key to authority. A key absent from the prior broker ledger is genuinely new and remains eligible to be added. +- [x] Authority/ledger coupling (amendment, 2026-09-23 spike review) — Decision: authority and the broker-key ledger are always set together. Every server root-creation path records a PRESENT ledger holding exactly the broker registration keys it granted (present-empty when it granted none); every authority-carrying path (clear, fork, and `CreateSession` with a source session) copies the source's ledger and presence bit with its authority. `session.New` defaults the ledger to ABSENT, so a path that forgets to set it refuses enrollment instead of silently keeping stale broker keys. `RestoreWorkspaceEnrollmentBrokerKeys` is write-once. +- [x] Exclusion memory (amendment, 2026-09-23 spike review) — Decision: excluded prior broker keys stay in the ledger. Completion records `completed broker keys ∪ excluded prior broker keys` as the new ledger, so a key removed from authority stays excluded even when an intermediate refresh omits it. If the union exceeds the ledger bounds, the enrollment fails with no mutation. +- [x] Collision outside enrollment (amendment, 2026-09-23 spike review) — Decision: a recorded broker key that is still held in authority and is now owned by a non-broker registration fails every engine build for that session (rehydrate, successor, enrollment) with a failed-precondition error naming only the safe key and operator-correction guidance. A durable key must never resolve to a different implementation; availability yields to that rule. Keys that are only excluded (in the ledger but not in authority) never trigger this refusal. +- [x] Collision cleanup (amendment, 2026-09-23 spike review) — Decision: an enrollment-time collision, or a ledger-bound overflow at completion, clears and persists the pending enrollment before returning the terminal failure, so the session accepts prompts again and a retry is an explicit new enrollment. ## Authority boundary @@ -28,10 +32,10 @@ The previously asserted child journey is blocked in the traced implementation. A ## Interface contract - **gRPC / protobuf:** None — existing workspace-enrollment RPCs, routes, requests, and presentation-safe status vocabulary retain their current shape. A collision uses the existing terminal failure/error path. -- **Exported Go APIs / interfaces:** Add `session.Session.WorkspaceEnrollmentBrokerKeys() ([]string, bool)` and `session.Session.RestoreWorkspaceEnrollmentBrokerKeys([]string, bool) error`. The accessor returns an independent exact registration-key set and its presence bit; the restore method validates copied provenance during idle snapshot restoration or successor construction, before history/control restoration. `CompleteWorkspaceEnrollment(pending, exactTools)` keeps its signature but its documented `exactTools` value becomes the completed broker registration-key bundle, replacing only the prior bundle. The new accessors are Added/minor. The existing completion method's changed replacement semantics and legacy refusal require separate behavioral compatibility review under `engine/COMPATIBILITY.md`; unchanged signatures do not make them additive (Changed is breaking under that policy). During implementation, run `task api:update`, include `engine/api/session.txt`, and record the additions and behavioral classification in `engine/CHANGELOG.md`. The exported `sessnap.Snapshot` and `eventsource.SessionMeta` fields specified below are reference-adapter API additions: `engine/adapter/*` is excluded from the guarded stability surface, so they require no new core API baseline. Document their same-revision host obligations and test restoration parity; the core accessor and completion compatibility obligations still apply. +- **Exported Go APIs / interfaces:** Add `session.Session.WorkspaceEnrollmentBrokerKeys() ([]string, bool)` and `session.Session.RestoreWorkspaceEnrollmentBrokerKeys([]string, bool) error`. The accessor returns an independent exact registration-key set and its presence bit; the restore method validates copied provenance during idle snapshot restoration, root creation, or successor construction, before history/control restoration, and may be applied at most once per aggregate. `session.New` starts with absent provenance. `CompleteWorkspaceEnrollment(pending, exactTools)` keeps its signature but its documented `exactTools` value becomes the completed broker registration-key bundle, replacing only the prior bundle. The new accessors are Added/minor. The existing completion method's changed replacement semantics and legacy refusal require separate behavioral compatibility review under `engine/COMPATIBILITY.md`; unchanged signatures do not make them additive (Changed is breaking under that policy). During implementation, run `task api:update`, include `engine/api/session.txt`, and record the additions and behavioral classification in `engine/CHANGELOG.md`. The exported `sessnap.Snapshot` and `eventsource.SessionMeta` fields specified below are reference-adapter API additions: `engine/adapter/*` is excluded from the guarded stability surface, so they require no new core API baseline. Document their same-revision host obligations and test restoration parity; the core accessor and completion compatibility obligations still apply. - **Tool schemas:** None — tools remain advertised by the existing catalog and authority-disclosure paths. A failed enrollment installs no partial broker wrapper. - **CLI / config:** No new flag or setting. `/tools-connect` and `/mcp` retain their commands; they do not grant authority to restore, add, or remove unrelated persisted capabilities. A collision reports a terminal setup failure and an explicit retry remains available after configuration correction. -- **Events / persistence:** Add the exported snapshot fields below. `sessnap.Of`/`Restore` use the aggregate accessors. Clear/fork copy the exact ledger and presence bit alongside carried authority, including absent legacy provenance; a fresh attachment does not mean an empty ledger. No completed enrollment or runtime is copied. Persist registration keys only, never endpoints, connector metadata, OAuth state, credentials, schemas, or runtime handles. +- **Events / persistence:** Add the exported snapshot fields below. `sessnap.Of`/`Restore` use the aggregate accessors. Server root creation records present provenance holding exactly the broker registration keys it adds to fresh authority (taken from the catalog's registration result, not refreshed `Tool.Spec()` calls). Clear, fork, and `CreateSession` with a source session copy the exact ledger and presence bit alongside carried authority, including absent legacy provenance; a fresh attachment does not mean an empty ledger. No completed enrollment or runtime is copied. Persist registration keys only, never endpoints, connector metadata, OAuth state, credentials, schemas, or runtime handles. ```go // sessnap.Snapshot @@ -46,8 +50,8 @@ WorkspaceEnrollmentBrokerKeysPresent bool Presence uses normal bool/`omitempty` semantics. Missing or explicit `false` with zero keys means legacy-absent; `true` with omitted keys or `[]` means present-empty; `true` with a valid nonempty array means present-nonempty. JSON `null` for the slice also decodes as zero keys, without changing the presence bit. Reject nonempty keys with missing/false presence, null/non-boolean presence, non-array/non-string key data, and invalid key sets. Reuse `ValidWorkspaceEnrollmentToolNames`: at most 256 unique, nonempty, valid-UTF-8 keys, at most 256 bytes each and 32 KiB combined, with no Unicode control characters. Validate raw JSON before decoding can silently normalize malformed input; test the bounds at and beyond each limit. Canonical writes omit zero-length keys and false presence, but preserve true presence even for empty keys. `eventsource.Fold` currently restores `Authority` from `SessionMeta`; its stream has no workspace-enrollment provenance or pending-enrollment reconstruction. Add the two exported metadata fields above and restore their copied values through the aggregate method after authority binding, before history/control restoration (including the awaiting early return). Authority and ledger must come from the **same committed authority revision**, not stale creation metadata after enrollment. Hosts must persist/read them consistently; Fold cannot detect a stale but structurally valid pair from events. If exact provenance is unavailable, restore absent provenance and refuse enrollment at the server guard, never default it to present-empty. Invalid metadata fails reconstruction. Pending workspace enrollment still requires snapshot restoration; this change adds no event semantics or pending-enrollment support to Fold. -- **Security / authority:** Let `excluded prior broker keys = prior broker keys − prior authority`. Completion computes `(prior authority − prior broker keys) + (newly registered broker keys − excluded prior broker keys)` and records the complete new broker bundle as provenance. It therefore preserves exclusion of a previously recorded broker key, admits a genuinely new broker key, cannot restore or add an unrelated capability absent from prior authority, and cannot subtract a non-broker capability. The existing owner, idle, no-live-run, no-conflicting-control, lease, and broker-configuration checks remain the enrollment boundary; no arbitrary capability-removal interface is introduced. Registration collisions fail before candidate engine/attachment/authority/provenance publication. Unauthorized or concealed callers receive the existing not-found-style failure and no key or state disclosure. -- **Compatibility / migration:** Existing snapshots without provenance are legacy and cannot begin enrollment. In `workspaceEnrollmentTarget`, immediately after `Store.Load` and owner authorization succeed, the service checks that the restored ledger is present and valid. It refuses absent or invalid provenance before the current completed-session `Reopen`/`saveSession`, `ResetWorkspaceEnrollment`, broker attachment open/rebind, discovery, browser consent, or any candidate runtime/authority mutation; a fresh session is the recovery path. Snapshot-format addition is backward readable but intentionally not a compatibility repair. The API baseline and classified changelog are implementation deliverables, not prerequisites for plan approval. +- **Security / authority:** Let `excluded prior broker keys = prior broker keys − prior authority`. Completion computes `(prior authority − prior broker keys) + (newly registered broker keys − excluded prior broker keys)` and records `newly registered broker keys ∪ excluded prior broker keys` as provenance (amended 2026-09-23; the ledger must stay within the existing bounds or completion fails without mutation). It therefore preserves exclusion of a previously recorded broker key, admits a genuinely new broker key, cannot restore or add an unrelated capability absent from prior authority, and cannot subtract a non-broker capability. The existing owner, idle, no-live-run, no-conflicting-control, lease, and broker-configuration checks remain the enrollment boundary; no arbitrary capability-removal interface is introduced. Registration collisions fail before candidate engine/attachment/authority/provenance publication. The prior-key collision check considers only prior ledger keys that are also in authority. Unauthorized or concealed callers receive the existing not-found-style failure and no key or state disclosure. +- **Compatibility / migration:** Existing snapshots without provenance are legacy and cannot begin enrollment. In `workspaceEnrollmentTarget`, immediately after `Store.Load` and owner authorization succeed, the service checks that the restored ledger is present and valid. It refuses absent or invalid provenance before the current completed-session `Reopen`/`saveSession`, `ResetWorkspaceEnrollment`, broker attachment open/rebind, discovery, browser consent, or any candidate runtime/authority mutation; a fresh session is the recovery path. Snapshot-format addition is backward readable but intentionally not a compatibility repair. An older binary that re-saves a snapshot drops both fields (for example during a rolling update over a shared store); the session then reads as legacy and refuses enrollment permanently. Release notes must call this out. The API baseline and classified changelog are implementation deliverables, not prerequisites for plan approval. ## In scope — 3 scenarios, in implementation order @@ -58,24 +62,26 @@ Registration keys—not refreshed `Tool.Spec()` calls—are catalog metadata ([c **Restriction trace:** `internal/adapter/permconfig/permconfig.go` (`rulesFromConfig`) converts user/project/operator `permissions.deny` into permission rules, not capability deletions; `engine/adapter/permpolicy/permpolicy.go` (`Evaluate`) folds configured and learned rules with the session mode. These denies/asks remain effective for broker and non-broker calls. `engine/tool/catalog.go` applies plan-mode advertisement/dispatch filtering, and `internal/app/catalog.go` (`assembleCatalog`) retains no-FS profile exclusions. `internal/app/root_authority.go` (`mintRootAuthority`) mints catalog/resource/latent capabilities; `internal/adapter/server/service.go` (`setPerSessionLabels`) adds broker/client keys only for fresh authority, explicitly not for carried authority. `engine/agent/authority_delegation.go` (`deriveDelegatedAuthority`) narrows child capabilities, but the binding and successor gates traced above prevent that child from entering the supported enrollment flow. Root creation and main-session successors do not expose arbitrary per-key capability editing. As a defensive aggregate rule, completion preserves exclusion of a prior broker-ledger key absent from carried authority while admitting a key absent from the prior ledger as genuinely new. Permission/mode/profile restrictions continue to govern all calls, and the change authorizes no child widening. **Acceptance:** -- AC1.1: Initial enrollment retains every carried non-broker capability and adds every broker registration key that actually registered in the final catalog. - - verify: `TestWorkspaceEnrollmentAuthority_Scenario1_PreservesUnrelatedCapabilities` +- AC1.1: Initial enrollment retains every carried non-broker capability and adds every broker registration key that actually registered in the final catalog. A session created with broker tools records exactly those registration keys as present provenance, so its first refresh subtracts them. + - verify: `TestWorkspaceEnrollmentAuthority_Scenario1_PreservesUnrelatedCapabilities`, `TestWorkspaceEnrollmentAuthority_Scenario1_CreationRecordsBrokerKeys` - AC1.2: Refresh removes only prior recorded broker keys, adds only replacement broker keys, and leaves every other carried capability unchanged, preserving absence of non-broker capabilities. - verify: `TestWorkspaceEnrollmentAuthority_Scenario1_ReplacesDynamicBrokerBundle` - AC1.3: Enrollment never restores an excluded non-broker capability even if the rebuilt catalog exposes it. If a prior broker-ledger key is absent from carried authority, completion registers the full new bundle but excludes that key from replacement authority; a genuinely new broker key absent from the prior ledger remains eligible to be added. Configured permission denies/asks, mode filtering, and profile exclusions remain enforced for subsequent calls, including broker calls; authority's non-tool axes remain unchanged. Narrowed children gain no enrollment or authority-widening permission from this change; retain the empty-binding refusal and main-only successor gate. - verify: `TestWorkspaceEnrollmentAuthority_Scenario1_DoesNotWidenAttenuatedAuthority`, `TestWorkspaceEnrollmentAuthority_Scenario1_PreservesSupportedRestrictions` +- AC1.4: A prior broker key excluded from authority stays excluded across any sequence of refreshes, including a refresh whose bundle omits it followed by one that offers it again. The excluded key remains in the ledger and never re-enters authority. A union exceeding the ledger bounds fails the enrollment with prior authority and ledger unchanged and the pending enrollment cleared. + - verify: `TestWorkspaceEnrollmentAuthority_Scenario1_ExclusionSurvivesOmittingRefresh` ### Scenario 2 — provenance survives restart and successors without copying runtime `rehydrateSession` deliberately builds without broker tools and does not resurrect a broker attachment from persisted binding ([rehydration](../../internal/adapter/server/service.go)). Clear and fork carry authority but allocate a new session and fresh broker attachment ([successors](../../internal/adapter/server/placement_successor.go)). [ADR 0350](../adr/0350-durable-workspace-enrollment-broker-authority.md) requires the ledger to follow carried authority across both boundaries so a subsequent explicit refresh can subtract inherited old keys. **Acceptance:** -- AC2.1: A real snapshot/store restart preserves a committed authority and present broker-key ledger, but rehydrates no broker runtime, attachment, or wrappers. Ordinary prompts remain usable only through the non-broker runtime; an owner must explicitly refresh to install broker tools. An event-log system of record obtains identical ledger provenance only by supplying the exact presence bit and keys through `eventsource.SessionMeta`; no `session.Event` reconstructs enrollment provenance. - - verify: `TestWorkspaceEnrollmentAuthority_Scenario2_RestartDoesNotResurrectBrokerRuntime` +- AC2.1: A real snapshot/store restart preserves a committed authority and present broker-key ledger, but rehydrates no broker runtime, attachment, or wrappers. Ordinary prompts remain usable only through the non-broker runtime; an owner must explicitly refresh to install broker tools. Exception (amended 2026-09-23): if a recorded broker key still held in authority is now owned by a non-broker registration, rehydration fails with a failed-precondition error naming only the safe key until the operator corrects the configuration. An event-log system of record obtains identical ledger provenance only by supplying the exact presence bit and keys through `eventsource.SessionMeta`; no `session.Event` reconstructs enrollment provenance. + - verify: `TestWorkspaceEnrollmentAuthority_Scenario2_RestartDoesNotResurrectBrokerRuntime`, `TestWorkspaceEnrollmentAuthority_Scenario2_RestartCollisionFailsPrecondition` - AC2.2: The subsequent explicit refresh subtracts the pre-restart recorded broker keys and commits only the newly registered bundle, preserving the remaining authority exactly. - verify: `TestWorkspaceEnrollmentAuthority_Scenario2_RefreshSubtractsRestoredBrokerKeys` -- AC2.3: Resume and authority-carrying successors, including clear and fork, preserve the exact committed ledger and presence bit alongside authority. Clear has empty history and fork carries valid history; both create fresh attachments/bindings without copying completed enrollment or source runtime. Their later explicit refresh subtracts inherited old broker keys. Neither a ledger nor carried authority proves a live connection. - - verify: `TestWorkspaceEnrollmentAuthority_Scenario2_PersistsAcrossResumeAndFork`, `TestWorkspaceEnrollmentAuthority_Scenario2_SuccessorsStartFreshBrokerEnrollment` +- AC2.3: Resume and authority-carrying successors, including clear, fork, and `CreateSession` with a source session, preserve the exact committed ledger and presence bit alongside authority. Clear has empty history and fork carries valid history; both create fresh attachments/bindings without copying completed enrollment or source runtime. Their later explicit refresh subtracts inherited old broker keys. Neither a ledger nor carried authority proves a live connection. + - verify: `TestWorkspaceEnrollmentAuthority_Scenario2_PersistsAcrossResumeAndFork`, `TestWorkspaceEnrollmentAuthority_Scenario2_SuccessorsStartFreshBrokerEnrollment`, `TestWorkspaceEnrollmentAuthority_Scenario2_CarryoverCopiesProvenance` - AC2.4: Raw-JSON tests exercise the exact interface encoding: missing and explicit false presence with omitted/empty/null keys remain legacy-absent; true with omitted/empty/null keys stays present-empty; true with valid nonempty keys stays present-nonempty through marshal/unmarshal. Reject nonempty keys without true presence, null/non-boolean presence, non-array/non-string key data, duplicates, empty/control-bearing/invalid-UTF-8 keys, and all three size-bound violations. Prove the same cases through direct aggregate/snapshot restore and successor copy. Enrollment rejects absent provenance at the load-and-owner-auth guard before reopen/persist, reset, attachment rebind, discovery, consent, or runtime mutation and directs recovery to a fresh session. Eventsource tests prove same-revision authority/ledger parity for all three presence states, including the awaiting reconstruction branch; malformed metadata fails reconstruction, and unavailable provenance remains enrollment-refused, never present-empty. - verify: `TestWorkspaceEnrollmentAuthority_Scenario2_ProvenancePresenceAndBounds`, `TestWorkspaceEnrollmentAuthority_Scenario2_LegacySnapshotIsNotRepaired` - AC2.5: Engine construction, attachment commit, aggregate completion, or snapshot persistence failure leaves prior authority and broker-key provenance unchanged and publishes no partial replacement. Terminal cleanup may clear only the pending control; destructive refresh need not restore withdrawn broker runtime. Fault injection proves this ordered fail-closed behavior, not a cross-resource transaction. @@ -86,12 +92,12 @@ Registration keys—not refreshed `Tool.Spec()` calls—are catalog metadata ([c This is a cross-source safeguard at Mecatl's catalog-registration boundary, not a reimplementation of ToolHive's internal prefix or conflict resolution. [ADR 0350](../adr/0350-durable-workspace-enrollment-broker-authority.md) requires composition to report broker registration outcomes from the one final catalog build. It must register candidate tools through the error-returning path, not `MustRegister`, and hand a bounded safe duplicate-key error to the server without an additional `Tool.Spec()` call. **Acceptance:** -- AC3.1: A current non-broker registration matching either an incoming broker key or a prior recorded broker key fails the whole enrollment before candidate wrapper, binding, authority, or provenance publication. No broker tool from that bundle becomes executable; destructive refresh may leave broker runtime unavailable while prior durable authority/provenance remains unchanged. +- AC3.1: A current non-broker registration matching either an incoming broker key or a prior recorded broker key still held in authority fails the whole enrollment before candidate wrapper, binding, authority, or provenance publication. No broker tool from that bundle becomes executable; destructive refresh may leave broker runtime unavailable while prior durable authority/provenance remains unchanged. The pending enrollment is cleared and persisted, so the session accepts prompts again. The proof drives `ConnectWorkspaceServices` end to end and checks the stored session. - verify: `TestWorkspaceEnrollmentAuthority_Scenario3_ConflictFailsWholeEnrollment` - AC3.2: The internal catalog/factory handoff returns the exact successfully registered broker keys on success, or a typed/safe duplicate-key failure on collision. It performs no extra `Tool.Spec()` calls and never panics through `MustRegister` for dynamic broker registration. - verify: `TestWorkspaceEnrollmentAuthority_Scenario3_RegistrationMetadataAndDuplicateHandoff` - AC3.3: An owner receives only the safe conflicting key and retry-after-configuration-correction guidance. An unauthorized collision caller receives the existing concealed failure, with no key disclosure or mutation; neither path exposes OAuth URLs, endpoints, callbacks, credentials, tokens, or connector configuration. - - verify: `TestWorkspaceEnrollmentAuthority_Scenario3_ConflictPresentationAndOwnership`, `TestWorkspaceEnrollmentAuthority_Scenario3_ConflictPresentationIsSafe` + - verify: `TestWorkspaceEnrollmentAuthority_Scenario3_ConflictPresentationAndOwnership` - AC3.4: Mecatui's existing enrollment controls show the terminal setup failure, do not send a prompt retained pending successful enrollment, and offer the existing explicit retry path after the operation settles. - verify: `TestWorkspaceEnrollmentAuthority_Scenario3_TerminalConflictUX` diff --git a/docs/adr/0350-durable-workspace-enrollment-broker-authority.md b/docs/adr/0350-durable-workspace-enrollment-broker-authority.md index 6f4cada2d6..6669cf5776 100644 --- a/docs/adr/0350-durable-workspace-enrollment-broker-authority.md +++ b/docs/adr/0350-durable-workspace-enrollment-broker-authority.md @@ -12,17 +12,17 @@ Workspace enrollment installs an authenticated, session-bound broker catalog bes A final-catalog intersection is not sufficient: durable authority includes opaque MCP-resource and latent-member capabilities that are not registrations. Nor can the broker bundle be inferred after restart from `ExternalBinding`: bindings and attachments are process-local, while snapshots contain an undifferentiated authority set. The necessary durable distinction is therefore only the exact registration keys installed by a successful workspace-enrollment broker bundle—not origin metadata for every catalog entry. -The control checks session ownership, a configured broker, idle state (reopening completed sessions), and no live run; the aggregate also rejects conflicting controls. Permission denies/asks are separate from capability membership: `internal/adapter/permconfig/permconfig.go` (`rulesFromConfig`) and `engine/adapter/permpolicy/permpolicy.go` (`Evaluate`) keep them effective, while mode and profile filtering remain unchanged. `internal/adapter/server/service.go` (`setPerSessionLabels`) explicitly avoids adding broker/client capabilities to carried authority at creation. The supported surfaces do not expose arbitrary per-key capability editing. Delegation narrows child authority and inherits the parent's owner, but does not copy `ExternalBinding` (`engine/agent/subagent.go`, `engine/agent/child_environment.go`). `internal/adapter/server/workspace_enrollment.go` (`workspaceEnrollmentTarget`) requires a binding: `internal/adapter/server/mcp_broker.go` (`openBrokerAttachment`) rejects an empty binding before broker lookup/attachment. This is not the mismatch error that permits rebind. `internal/adapter/server/service.go` (`managementSession`) also rejects child kinds/prefixes from clear/fork, so a child cannot acquire a successor binding there. The previously claimed supported child-enrollment journey is blocked, despite the absence of a root-kind filter in enrollment. This proposal authorizes no widening of narrowed child authority; arbitrary capability editing and custom-host placement of such sessions are outside scope. If another supported journey is established, implementation must stop for a narrow plan decision, not silently regrant authority. +The control checks session ownership, a configured broker, idle state (reopening completed sessions), and no live run; the aggregate also rejects conflicting controls. Permission denies/asks are separate from capability membership: `internal/adapter/permconfig/permconfig.go` (`rulesFromConfig`) and `engine/adapter/permpolicy/permpolicy.go` (`Evaluate`) keep them effective, while mode and profile filtering remain unchanged. `internal/adapter/server/service.go` (`setPerSessionLabels`) explicitly avoids adding broker/client capabilities to carried authority at creation, but does add the attachment's broker keys to fresh authority, so those keys need provenance from the moment of creation. The supported surfaces do not expose arbitrary per-key capability editing. Delegation narrows child authority and inherits the parent's owner, but does not copy `ExternalBinding` (`engine/agent/subagent.go`, `engine/agent/child_environment.go`). `internal/adapter/server/workspace_enrollment.go` (`workspaceEnrollmentTarget`) requires a binding: `internal/adapter/server/mcp_broker.go` (`openBrokerAttachment`) rejects an empty binding before broker lookup/attachment. This is not the mismatch error that permits rebind. `internal/adapter/server/service.go` (`managementSession`) also rejects child kinds/prefixes from clear/fork, so a child cannot acquire a successor binding there. The previously claimed supported child-enrollment journey is blocked, despite the absence of a root-kind filter in enrollment. This proposal authorizes no widening of narrowed child authority; arbitrary capability editing and custom-host placement of such sessions are outside scope. If another supported journey is established, implementation must stop for a narrow plan decision, not silently regrant authority. MCP remote tools commonly use `mcp____` keys, so a broker/non-broker collision is rare. It remains unsafe: an exact durable key must not silently select a different source's implementation. This guard protects Mecatl's final cross-source registration boundary only; ToolHive continues to own its internal prefixes and conflict resolution. ## Decision -1. `Session` owns a private, bounded, exact workspace-enrollment broker-key ledger and a presence bit. New sessions without carried authority are present-and-empty; authority-carrying successors copy the source ledger and presence bit. Present-empty records known empty provenance, not proof of completed enrollment; absent means unrepaired legacy provenance. The ledger contains only registration keys, not endpoints, connector metadata, OAuth state, credentials, tool schemas, bindings, or runtime handles. -2. The minimal engine API addition is `Session.WorkspaceEnrollmentBrokerKeys() ([]string, bool)` and `Session.RestoreWorkspaceEnrollmentBrokerKeys([]string, bool) error`. The accessor returns a copy and presence; the restore method validates copied provenance during idle snapshot restoration or successor construction, before history/control restoration. `CompleteWorkspaceEnrollment(pending, exactTools)` retains its signature, but `exactTools` is documented as the completed broker registration-key bundle. Let `excluded prior broker keys = prior broker keys − prior authority`; completion atomically computes `(prior authority − prior broker keys) + (completed broker keys − excluded prior broker keys)` and replaces the ledger with the complete completed bundle. Within the existing enrollable root/successor flow, this preserves all non-broker membership and absence, preserves exclusion of previously recorded broker keys, and admits genuinely new broker keys. It neither makes narrowed children enrollable nor authorizes overriding their carried restrictions. Configured denies/asks, mode/profile enforcement, and non-tool authority axes remain unchanged. +1. `Session` owns a private, bounded, exact workspace-enrollment broker-key ledger and a presence bit. `session.New` starts with absent provenance. Every server root-creation path sets authority and ledger together: a fresh session records present provenance holding exactly the broker registration keys it granted (present-empty when none), and every authority-carrying path (clear, fork, and `CreateSession` with a source session) copies the source ledger and presence bit. A path that forgets the ledger therefore fails closed (enrollment refused) instead of treating stale broker keys as non-broker authority. Present-empty records known empty provenance, not proof of completed enrollment; absent means unrepaired legacy provenance. The ledger contains only registration keys, not endpoints, connector metadata, OAuth state, credentials, tool schemas, bindings, or runtime handles. +2. The minimal engine API addition is `Session.WorkspaceEnrollmentBrokerKeys() ([]string, bool)` and `Session.RestoreWorkspaceEnrollmentBrokerKeys([]string, bool) error`. The accessor returns a copy and presence; the restore method validates copied provenance during idle snapshot restoration, root creation, or successor construction, before history/control restoration, and is write-once. `CompleteWorkspaceEnrollment(pending, exactTools)` retains its signature, but `exactTools` is documented as the completed broker registration-key bundle. Let `excluded prior broker keys = prior broker keys − prior authority`; completion atomically computes `(prior authority − prior broker keys) + (completed broker keys − excluded prior broker keys)` and replaces the ledger with `completed broker keys ∪ excluded prior broker keys`, so an exclusion survives a refresh that omits the key. A union exceeding the ledger bounds fails completion without mutation. Within the existing enrollable root/successor flow, this preserves all non-broker membership and absence, preserves exclusion of previously recorded broker keys, and admits genuinely new broker keys. It neither makes narrowed children enrollable nor authorizes overriding their carried restrictions. Configured denies/asks, mode/profile enforcement, and non-tool authority axes remain unchanged. 3. `sessnap.Snapshot` persists `workspace_enrollment_broker_keys []string \`json:"workspace_enrollment_broker_keys,omitempty"\`` and `workspace_enrollment_broker_keys_present bool \`json:"workspace_enrollment_broker_keys_present,omitempty"\``. Both fields absent means legacy absent provenance. `workspace_enrollment_broker_keys_present:true` with the keys field absent, `null`, or an empty array means present-empty; the same true field with a valid nonempty array means present-nonempty. Normal Go JSON slice decoding therefore accepts `null` as zero keys, while the explicit presence bit retains the security distinction. Raw JSON validation rejects nonempty keys without true presence, null/non-boolean presence, non-array or non-string key data, and malformed, duplicate, or over-bound keys before aggregate restore. `sessnap.Of` reads the ledger through the accessor; `Snapshot.Restore` applies it through the restore method. `eventsource.SessionMeta` carries the same exact keys and presence bit as external creation metadata and restores them by the same method before event folding; no enrollment event is created or consumed. Its false/absent value is the same legacy-absent ledger, which the server enrollment guard refuses; an event-log host must supply true plus exact keys to reconstruct an enrollment-capable session. In the server, `workspaceEnrollmentTarget` checks present, valid provenance immediately after `Store.Load` and owner authorization, before the existing completed-session `Reopen`/save, reset, attachment open/rebind, discovery, consent, or runtime mutation. Absent legacy data remains absent and is rejected at that guard. It is not inferred or repaired; a fresh session is the recovery path. 4. Snapshot restore preserves an enrollment ledger but never resurrects broker runtime. Ordinary rehydration continues to build without broker attachment/wrappers from persisted broker keys or binding. Only an explicit owner control can establish a fresh attachment and completed bundle. Clear and fork carry the source ledger and presence bit alongside carried authority, so a later refresh can subtract inherited broker keys. They create fresh broker attachments/bindings and do not copy the source's completed enrollment, pending operation, or runtime. Provenance is subtraction evidence, not connectivity. Engine construction, attachment commit, aggregate completion, or snapshot persistence failure leaves prior authority and ledger unchanged and publishes no partial replacement; destructive refresh may leave broker runtime unavailable. -5. Composition records the exact broker keys from the one final catalog registration pass. It must use an error-returning dynamic registration path—not `MustRegister`—and return a safe duplicate-key classification plus the registered-key metadata through the existing root-internal factory/result handoff. Registration metadata is captured in that pass, with no second `Tool.Spec()` call. Before candidate publication, it rejects a current non-broker registration matching either an incoming key or a prior ledger key. Collision fails the complete bundle: no candidate wrapper, binding, authority, or provenance is published. The only allowed durable cleanup is clearing the pending enrollment; after destructive refresh, broker runtime may remain unavailable while prior durable authority and ledger remain unchanged. +5. Composition records the exact broker keys from the one final catalog registration pass. It must use an error-returning dynamic registration path—not `MustRegister`—and return a safe duplicate-key classification plus the registered-key metadata through the existing root-internal factory/result handoff. Registration metadata is captured in that pass, with no second `Tool.Spec()` call. Before candidate publication, it rejects a current non-broker registration matching either an incoming key or a prior ledger key that is still held in authority. The same prior-key refusal applies to every engine build for the session (rehydrate and successors, not only enrollment) and surfaces as a failed-precondition error naming only the safe key; excluded keys absent from authority never trigger it. Collision fails the complete bundle: no candidate wrapper, binding, authority, or provenance is published. The only allowed durable cleanup is clearing the pending enrollment, which an enrollment-time collision or ledger-bound overflow must do (and persist) so the session accepts prompts again; after destructive refresh, broker runtime may remain unavailable while prior durable authority and ledger remain unchanged. 6. The owner receives a terminal failure naming only the safe conflicting registration key and retry-after-configuration-correction guidance. Unauthorized callers stay on the existing concealed not-found-style path and receive neither that key nor state information. Neither path exposes OAuth URLs, endpoints, callback state, credentials, tokens, or connector configuration. Mecatui shows the terminal failure, does not send a prompt retained pending success, and retains the existing explicit retry path after settlement. 7. This supersedes only ADR 0335's “no refresh-specific persisted state” clause. Its whole-bundle, explicit-control, ToolHive-custody, destructive replacement, ordered-publication, and no-partial-catalog decisions remain unchanged. @@ -32,7 +32,9 @@ Successful enrollment and refresh no longer clobber unrelated session authority, A restart retains the authority ledger but not a live broker capability. An explicit post-restart refresh can subtract the old recorded broker keys and install only a newly verified bundle. A clear or fork carries the subtraction ledger with authority while establishing its own fresh attachment, not inheriting a source's connection. -A rare deployment collision fails visibly for the owner rather than silently choosing precedence or omitting a tool. The mechanism neither replaces ToolHive's own collision policy nor adds a cross-source framework. +A rare deployment collision fails visibly for the owner rather than silently choosing precedence or omitting a tool. If the colliding non-broker tool appears after enrollment, the session cannot run at all until the operator corrects the configuration; this trades availability for the guarantee that a durable key never resolves to a different implementation. + +An older binary re-saving a snapshot drops the ledger fields (for example during a rolling update over a shared store). The session then reads as legacy and refuses enrollment permanently; release notes must say so. The mechanism neither replaces ToolHive's own collision policy nor adds a cross-source framework. The new accessors are Added/minor. Changing the existing exported `CompleteWorkspaceEnrollment` replacement semantics and refusing legacy enrollment requires separate behavioral compatibility review under `engine/COMPATIBILITY.md`; an unchanged signature does not imply additive compatibility (Changed is breaking under that policy). `task api:update`, the `engine/api/session.txt` baseline, and the separately classified `engine/CHANGELOG.md` entries are implementation deliverables, not plan-approval prerequisites. No protobuf, RPC, config, command, event, or model-facing surface changes. From 89567c1a7ffe88d8293cb98632521d6f36ca6335 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Wed, 23 Sep 2026 22:11:31 +0200 Subject: [PATCH 5/6] docs(plan): renumber ADR to 0356 and relate it to ADR 0355 Main now has its own ADRs 0350-0355, so the workspace enrollment authority decision record becomes ADR 0356. The plan, the ADR index, and ADR 0335's supersession note follow. #1673 added direct MCP refresh (ADR 0355), a separate add-only authority path through Session.GrantToolAuthority. Name it in the plan's authority boundary: it refuses broker-bound sessions and broker mode clears the global MCP inputs, so the two paths never act on the same session. Add AC1.5 pinning that refusal. In the ADR, link ADR 0355 and record why its union-and-availability model is rejected for broker keys. Relates to #1740. Co-Authored-By: mecatl --- docs/acceptance/workspace-enrollment-authority.md | 12 +++++++----- ...durable-workspace-enrollment-broker-authority.md} | 4 +++- docs/adr/README.md | 4 ++-- 3 files changed, 12 insertions(+), 8 deletions(-) rename docs/adr/{0350-durable-workspace-enrollment-broker-authority.md => 0356-durable-workspace-enrollment-broker-authority.md} (96%) diff --git a/docs/acceptance/workspace-enrollment-authority.md b/docs/acceptance/workspace-enrollment-authority.md index ee4214e72a..afcea9a1b4 100644 --- a/docs/acceptance/workspace-enrollment-authority.md +++ b/docs/acceptance/workspace-enrollment-authority.md @@ -2,7 +2,7 @@ **Contract:** human-reviewed/v2 **Work classification:** Architectural — adds narrow durable provenance for dynamically installed broker capabilities so authority replacement remains exact across persistence and successors. -**Decision record:** [ADR 0350](../adr/0350-durable-workspace-enrollment-broker-authority.md) +**Decision record:** [ADR 0356](../adr/0356-durable-workspace-enrollment-broker-authority.md) **Phase:** workspace MCP broker enrollment **Status:** proposed, 2026-09-22. Revision of [issue #1740](https://github.com/stacklok/mecatl/issues/1740)'s proposed plan; it remains proposed until the Plan / Interface PR receives human merge approval. **Delivery:** Split. The change alters persisted session authority provenance, snapshot/successor behavior, and terminal enrollment failure semantics. @@ -27,7 +27,7 @@ Workspace enrollment reconfigures an owner-authorized idle session. A completed Enrollment must not override carried restrictions or widen narrowed child authority. The replacement formula applies to the existing enrollable root/successor flow, not to arbitrary capability editing or a new child-enrollment grant. -The previously asserted child journey is blocked in the traced implementation. A child inherits the parent's owner (`engine/agent/subagent.go`, `stampDelegatedLabels` call), so ownership alone would pass, but its constructors in `engine/agent/child_environment.go` do not copy `ExternalBinding`. `internal/adapter/server/workspace_enrollment.go` (`workspaceEnrollmentTarget`) calls `openBrokerAttachment` with `bindingRequired=true`; `internal/adapter/server/mcp_broker.go` rejects an empty binding before lookup or `AttachSession`. That error is not `ErrBrokerBindingMismatch`, so it cannot enter the rebind branch. Clear/fork cannot turn the child into an enrollable successor: `internal/adapter/server/service.go` (`managementSession`) rejects non-main kinds and delegation prefixes before successor creation. A missing root-kind filter in enrollment is therefore not proof of child reachability. No supported child-widening journey is established; custom hosts or manually edited snapshots are outside this plan, not implicit authorization. If implementation establishes a different supported journey, stop for a narrow plan decision rather than regrant authority. +The previously asserted child journey is blocked in the traced implementation. A child inherits the parent's owner (`engine/agent/subagent.go`, `stampDelegatedLabels` call), so ownership alone would pass, but its constructors in `engine/agent/child_environment.go` do not copy `ExternalBinding`. `internal/adapter/server/workspace_enrollment.go` (`workspaceEnrollmentTarget`) calls `openBrokerAttachment` with `bindingRequired=true`; `internal/adapter/server/mcp_broker.go` rejects an empty binding before lookup or `AttachSession`. That error is not `ErrBrokerBindingMismatch`, so it cannot enter the rebind branch. Clear/fork cannot turn the child into an enrollable successor: `internal/adapter/server/service.go` (`managementSession`) rejects non-main kinds and delegation prefixes before successor creation. A missing root-kind filter in enrollment is therefore not proof of child reachability. Direct/global MCP refresh (ADR 0355: `RefreshMcpSources` over `Session.GrantToolAuthority`) is a separate, add-only authority path. It refuses broker-bound sessions, and broker mode clears the global MCP inputs, so the two paths never act on the same session and neither reads nor writes the other's provenance. No supported child-widening journey is established; custom hosts or manually edited snapshots are outside this plan, not implicit authorization. If implementation establishes a different supported journey, stop for a narrow plan decision rather than regrant authority. ## Interface contract @@ -57,7 +57,7 @@ WorkspaceEnrollmentBrokerKeysPresent bool ### Scenario 1 — successful enrollment replaces only the dynamic broker bundle -Registration keys—not refreshed `Tool.Spec()` calls—are catalog metadata ([catalog](../../engine/tool/catalog.go)). The aggregate currently binds a durable authority and completes enrollment through `CompleteWorkspaceEnrollment` ([authority](../../engine/session/principal.go), [enrollment](../../engine/session/workspace_enrollment.go)). The server permits only the owner-authorized, idle, broker-configured session path and reopens a completed session before that check ([control](../../internal/adapter/server/workspace_enrollment.go)). [ADR 0350](../adr/0350-durable-workspace-enrollment-broker-authority.md) limits the ledger to broker keys rather than catalog-origin metadata for every capability. +Registration keys—not refreshed `Tool.Spec()` calls—are catalog metadata ([catalog](../../engine/tool/catalog.go)). The aggregate currently binds a durable authority and completes enrollment through `CompleteWorkspaceEnrollment` ([authority](../../engine/session/principal.go), [enrollment](../../engine/session/workspace_enrollment.go)). The server permits only the owner-authorized, idle, broker-configured session path and reopens a completed session before that check ([control](../../internal/adapter/server/workspace_enrollment.go)). [ADR 0356](../adr/0356-durable-workspace-enrollment-broker-authority.md) limits the ledger to broker keys rather than catalog-origin metadata for every capability. **Restriction trace:** `internal/adapter/permconfig/permconfig.go` (`rulesFromConfig`) converts user/project/operator `permissions.deny` into permission rules, not capability deletions; `engine/adapter/permpolicy/permpolicy.go` (`Evaluate`) folds configured and learned rules with the session mode. These denies/asks remain effective for broker and non-broker calls. `engine/tool/catalog.go` applies plan-mode advertisement/dispatch filtering, and `internal/app/catalog.go` (`assembleCatalog`) retains no-FS profile exclusions. `internal/app/root_authority.go` (`mintRootAuthority`) mints catalog/resource/latent capabilities; `internal/adapter/server/service.go` (`setPerSessionLabels`) adds broker/client keys only for fresh authority, explicitly not for carried authority. `engine/agent/authority_delegation.go` (`deriveDelegatedAuthority`) narrows child capabilities, but the binding and successor gates traced above prevent that child from entering the supported enrollment flow. Root creation and main-session successors do not expose arbitrary per-key capability editing. As a defensive aggregate rule, completion preserves exclusion of a prior broker-ledger key absent from carried authority while admitting a key absent from the prior ledger as genuinely new. Permission/mode/profile restrictions continue to govern all calls, and the change authorizes no child widening. @@ -70,10 +70,12 @@ Registration keys—not refreshed `Tool.Spec()` calls—are catalog metadata ([c - verify: `TestWorkspaceEnrollmentAuthority_Scenario1_DoesNotWidenAttenuatedAuthority`, `TestWorkspaceEnrollmentAuthority_Scenario1_PreservesSupportedRestrictions` - AC1.4: A prior broker key excluded from authority stays excluded across any sequence of refreshes, including a refresh whose bundle omits it followed by one that offers it again. The excluded key remains in the ledger and never re-enters authority. A union exceeding the ledger bounds fails the enrollment with prior authority and ledger unchanged and the pending enrollment cleared. - verify: `TestWorkspaceEnrollmentAuthority_Scenario1_ExclusionSurvivesOmittingRefresh` +- AC1.5: Direct MCP refresh refuses a broker-bound session (enrolled or not) and leaves its authority and broker-key ledger unchanged. + - verify: `TestWorkspaceEnrollmentAuthority_Scenario1_DirectRefreshRefusesBrokerSession` ### Scenario 2 — provenance survives restart and successors without copying runtime -`rehydrateSession` deliberately builds without broker tools and does not resurrect a broker attachment from persisted binding ([rehydration](../../internal/adapter/server/service.go)). Clear and fork carry authority but allocate a new session and fresh broker attachment ([successors](../../internal/adapter/server/placement_successor.go)). [ADR 0350](../adr/0350-durable-workspace-enrollment-broker-authority.md) requires the ledger to follow carried authority across both boundaries so a subsequent explicit refresh can subtract inherited old keys. +`rehydrateSession` deliberately builds without broker tools and does not resurrect a broker attachment from persisted binding ([rehydration](../../internal/adapter/server/service.go)). Clear and fork carry authority but allocate a new session and fresh broker attachment ([successors](../../internal/adapter/server/placement_successor.go)). [ADR 0356](../adr/0356-durable-workspace-enrollment-broker-authority.md) requires the ledger to follow carried authority across both boundaries so a subsequent explicit refresh can subtract inherited old keys. **Acceptance:** - AC2.1: A real snapshot/store restart preserves a committed authority and present broker-key ledger, but rehydrates no broker runtime, attachment, or wrappers. Ordinary prompts remain usable only through the non-broker runtime; an owner must explicitly refresh to install broker tools. Exception (amended 2026-09-23): if a recorded broker key still held in authority is now owned by a non-broker registration, rehydration fails with a failed-precondition error naming only the safe key until the operator corrects the configuration. An event-log system of record obtains identical ledger provenance only by supplying the exact presence bit and keys through `eventsource.SessionMeta`; no `session.Event` reconstructs enrollment provenance. @@ -89,7 +91,7 @@ Registration keys—not refreshed `Tool.Spec()` calls—are catalog metadata ([c ### Scenario 3 — a rare cross-source collision fails the whole enrollment safely -This is a cross-source safeguard at Mecatl's catalog-registration boundary, not a reimplementation of ToolHive's internal prefix or conflict resolution. [ADR 0350](../adr/0350-durable-workspace-enrollment-broker-authority.md) requires composition to report broker registration outcomes from the one final catalog build. It must register candidate tools through the error-returning path, not `MustRegister`, and hand a bounded safe duplicate-key error to the server without an additional `Tool.Spec()` call. +This is a cross-source safeguard at Mecatl's catalog-registration boundary, not a reimplementation of ToolHive's internal prefix or conflict resolution. [ADR 0356](../adr/0356-durable-workspace-enrollment-broker-authority.md) requires composition to report broker registration outcomes from the one final catalog build. It must register candidate tools through the error-returning path, not `MustRegister`, and hand a bounded safe duplicate-key error to the server without an additional `Tool.Spec()` call. **Acceptance:** - AC3.1: A current non-broker registration matching either an incoming broker key or a prior recorded broker key still held in authority fails the whole enrollment before candidate wrapper, binding, authority, or provenance publication. No broker tool from that bundle becomes executable; destructive refresh may leave broker runtime unavailable while prior durable authority/provenance remains unchanged. The pending enrollment is cleared and persisted, so the session accepts prompts again. The proof drives `ConnectWorkspaceServices` end to end and checks the stored session. diff --git a/docs/adr/0350-durable-workspace-enrollment-broker-authority.md b/docs/adr/0356-durable-workspace-enrollment-broker-authority.md similarity index 96% rename from docs/adr/0350-durable-workspace-enrollment-broker-authority.md rename to docs/adr/0356-durable-workspace-enrollment-broker-authority.md index 6669cf5776..d4235e1c33 100644 --- a/docs/adr/0350-durable-workspace-enrollment-broker-authority.md +++ b/docs/adr/0356-durable-workspace-enrollment-broker-authority.md @@ -1,4 +1,4 @@ -# ADR 0350 — Durable workspace-enrollment broker authority provenance +# ADR 0356 — Durable workspace-enrollment broker authority provenance - Status: Proposed — remains proposed until the Plan / Interface PR receives human merge approval. - Date: 2026-09-21 @@ -47,6 +47,7 @@ The new accessors are Added/minor. Changing the existing exported `CompleteWorks - **Fail the whole refresh when a previously recorded broker key was excluded from carried authority.** The complete bundle can still register while authority disclosure preserves the exclusion; failure would unnecessarily block unrelated or genuinely new broker keys in the same refresh. - **Discard successor provenance because its attachment is fresh.** Carried authority still contains old broker keys; dropping their ledger prevents exact subtraction at the successor's next refresh. Copy provenance, never completed enrollment or runtime. - **Omit conflicting broker tools or choose a source precedence.** The broker bundle is atomic, and redirecting a durable key to another implementation changes authority. +- **Union new broker names into authority and let availability hide stale ones (the ADR 0355 model for direct MCP).** Direct names have no provenance and no cross-source redirection risk inside one runtime; a stale broker key can later resolve to a different source's implementation, so broker keys need exact replacement. - **Reimplement ToolHive conflict resolution.** Mecatl needs only the final cross-source registration safeguard. - **Repair legacy snapshots heuristically.** Their authority cannot distinguish accidental loss from deliberate attenuation. @@ -54,4 +55,5 @@ The new accessors are Added/minor. Changing the existing exported `CompleteWorks - [ADR 0234 — Derived delegation authority behind an evaluator port](./0234-authority-evaluator-port.md) - [ADR 0335 — Idle-session MCP broker workspace refresh](./0335-idle-session-broker-workspace-refresh.md) +- [ADR 0355 — Reconcile stale direct MCP source snapshots](./0355-mcp-source-reconciliation.md) — the separate direct-MCP refresh path; it refuses broker-bound sessions - [Workspace enrollment authority acceptance plan](../acceptance/workspace-enrollment-authority.md) diff --git a/docs/adr/README.md b/docs/adr/README.md index d8a9513583..0b98c5c205 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -223,8 +223,8 @@ Documentation/citation conventions are in [`docs/design/README.md`](../design/RE - [0325 — Durable Dynamic Client Registration for direct MCP profiles](./0325-direct-mcp-dcr.md) - [0345 — Host-local direct MCP onboarding and credential custody](./0345-direct-mcp-onboarding.md) *(proposed)* - [0326 — Lazy ToolHive grants refresh declared metadata](./0326-lazy-toolhive-metadata-refresh.md) -- [0335 — Idle-session MCP broker workspace refresh](./0335-idle-session-broker-workspace-refresh.md) *(Decision 6 superseded by 0350)* -- [0350 — Durable workspace-enrollment broker authority provenance](./0350-durable-workspace-enrollment-broker-authority.md) *(proposed; supersedes ADR 0335 Decision 6 only)* +- [0335 — Idle-session MCP broker workspace refresh](./0335-idle-session-broker-workspace-refresh.md) *(Decision 6 superseded by 0356)* +- [0356 — Durable workspace-enrollment broker authority provenance](./0356-durable-workspace-enrollment-broker-authority.md) *(proposed; supersedes ADR 0335 Decision 6 only)* ### Performance & diagnostics - [0018 — Perf observability](./0018-perf-observability.md) From 68be96dcbd9e42d655e8e16957e6651ce8ef61c7 Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Thu, 24 Sep 2026 11:29:13 +0200 Subject: [PATCH 6/6] docs(plan): address plan review and renumber ADR to 0358 Main now has its own ADRs 0356 and 0357, so the workspace enrollment authority decision record becomes ADR 0358. Review feedback on #1741: - Clearing the pending enrollment is not the same as the session being usable again. AC3.1, the collision-cleanup decision, and ADR decision 5 now say that an incoming-key collision leaves the session usable, while a collision on a recorded broker key still held in authority keeps engine builds refused until the operator corrects the configuration. - No upgrade compatibility work is required. Drop the older-binary re-save caveat; sessions without provenance are refused and a fresh session is the recovery path. Relates to #1740. Co-Authored-By: mecatl --- docs/acceptance/workspace-enrollment-authority.md | 14 +++++++------- ...rable-workspace-enrollment-broker-authority.md} | 6 +++--- docs/adr/README.md | 4 ++-- 3 files changed, 12 insertions(+), 12 deletions(-) rename docs/adr/{0356-durable-workspace-enrollment-broker-authority.md => 0358-durable-workspace-enrollment-broker-authority.md} (96%) diff --git a/docs/acceptance/workspace-enrollment-authority.md b/docs/acceptance/workspace-enrollment-authority.md index afcea9a1b4..180d6f9264 100644 --- a/docs/acceptance/workspace-enrollment-authority.md +++ b/docs/acceptance/workspace-enrollment-authority.md @@ -2,7 +2,7 @@ **Contract:** human-reviewed/v2 **Work classification:** Architectural — adds narrow durable provenance for dynamically installed broker capabilities so authority replacement remains exact across persistence and successors. -**Decision record:** [ADR 0356](../adr/0356-durable-workspace-enrollment-broker-authority.md) +**Decision record:** [ADR 0358](../adr/0358-durable-workspace-enrollment-broker-authority.md) **Phase:** workspace MCP broker enrollment **Status:** proposed, 2026-09-22. Revision of [issue #1740](https://github.com/stacklok/mecatl/issues/1740)'s proposed plan; it remains proposed until the Plan / Interface PR receives human merge approval. **Delivery:** Split. The change alters persisted session authority provenance, snapshot/successor behavior, and terminal enrollment failure semantics. @@ -21,7 +21,7 @@ Workspace enrollment reconfigures an owner-authorized idle session. A completed - [x] Authority/ledger coupling (amendment, 2026-09-23 spike review) — Decision: authority and the broker-key ledger are always set together. Every server root-creation path records a PRESENT ledger holding exactly the broker registration keys it granted (present-empty when it granted none); every authority-carrying path (clear, fork, and `CreateSession` with a source session) copies the source's ledger and presence bit with its authority. `session.New` defaults the ledger to ABSENT, so a path that forgets to set it refuses enrollment instead of silently keeping stale broker keys. `RestoreWorkspaceEnrollmentBrokerKeys` is write-once. - [x] Exclusion memory (amendment, 2026-09-23 spike review) — Decision: excluded prior broker keys stay in the ledger. Completion records `completed broker keys ∪ excluded prior broker keys` as the new ledger, so a key removed from authority stays excluded even when an intermediate refresh omits it. If the union exceeds the ledger bounds, the enrollment fails with no mutation. - [x] Collision outside enrollment (amendment, 2026-09-23 spike review) — Decision: a recorded broker key that is still held in authority and is now owned by a non-broker registration fails every engine build for that session (rehydrate, successor, enrollment) with a failed-precondition error naming only the safe key and operator-correction guidance. A durable key must never resolve to a different implementation; availability yields to that rule. Keys that are only excluded (in the ledger but not in authority) never trigger this refusal. -- [x] Collision cleanup (amendment, 2026-09-23 spike review) — Decision: an enrollment-time collision, or a ledger-bound overflow at completion, clears and persists the pending enrollment before returning the terminal failure, so the session accepts prompts again and a retry is an explicit new enrollment. +- [x] Collision cleanup (amendment, 2026-09-23 spike review) — Decision: an enrollment-time collision, or a ledger-bound overflow at completion, clears and persists the pending enrollment before returning the terminal failure, so no pending enrollment blocks the session and a retry is an explicit new enrollment. An incoming-key collision leaves the session usable with its prior authority; a collision on a recorded broker key still held in authority keeps every engine build refused (see AC2.1) until the operator corrects the configuration. ## Authority boundary @@ -51,13 +51,13 @@ WorkspaceEnrollmentBrokerKeysPresent bool `eventsource.Fold` currently restores `Authority` from `SessionMeta`; its stream has no workspace-enrollment provenance or pending-enrollment reconstruction. Add the two exported metadata fields above and restore their copied values through the aggregate method after authority binding, before history/control restoration (including the awaiting early return). Authority and ledger must come from the **same committed authority revision**, not stale creation metadata after enrollment. Hosts must persist/read them consistently; Fold cannot detect a stale but structurally valid pair from events. If exact provenance is unavailable, restore absent provenance and refuse enrollment at the server guard, never default it to present-empty. Invalid metadata fails reconstruction. Pending workspace enrollment still requires snapshot restoration; this change adds no event semantics or pending-enrollment support to Fold. - **Security / authority:** Let `excluded prior broker keys = prior broker keys − prior authority`. Completion computes `(prior authority − prior broker keys) + (newly registered broker keys − excluded prior broker keys)` and records `newly registered broker keys ∪ excluded prior broker keys` as provenance (amended 2026-09-23; the ledger must stay within the existing bounds or completion fails without mutation). It therefore preserves exclusion of a previously recorded broker key, admits a genuinely new broker key, cannot restore or add an unrelated capability absent from prior authority, and cannot subtract a non-broker capability. The existing owner, idle, no-live-run, no-conflicting-control, lease, and broker-configuration checks remain the enrollment boundary; no arbitrary capability-removal interface is introduced. Registration collisions fail before candidate engine/attachment/authority/provenance publication. The prior-key collision check considers only prior ledger keys that are also in authority. Unauthorized or concealed callers receive the existing not-found-style failure and no key or state disclosure. -- **Compatibility / migration:** Existing snapshots without provenance are legacy and cannot begin enrollment. In `workspaceEnrollmentTarget`, immediately after `Store.Load` and owner authorization succeed, the service checks that the restored ledger is present and valid. It refuses absent or invalid provenance before the current completed-session `Reopen`/`saveSession`, `ResetWorkspaceEnrollment`, broker attachment open/rebind, discovery, browser consent, or any candidate runtime/authority mutation; a fresh session is the recovery path. Snapshot-format addition is backward readable but intentionally not a compatibility repair. An older binary that re-saves a snapshot drops both fields (for example during a rolling update over a shared store); the session then reads as legacy and refuses enrollment permanently. Release notes must call this out. The API baseline and classified changelog are implementation deliverables, not prerequisites for plan approval. +- **Compatibility / migration:** Existing snapshots without provenance are legacy and cannot begin enrollment. In `workspaceEnrollmentTarget`, immediately after `Store.Load` and owner authorization succeed, the service checks that the restored ledger is present and valid. It refuses absent or invalid provenance before the current completed-session `Reopen`/`saveSession`, `ResetWorkspaceEnrollment`, broker attachment open/rebind, discovery, browser consent, or any candidate runtime/authority mutation; a fresh session is the recovery path. Snapshot-format addition is backward readable but intentionally not a compatibility repair. The API baseline and classified changelog are implementation deliverables, not prerequisites for plan approval. ## In scope — 3 scenarios, in implementation order ### Scenario 1 — successful enrollment replaces only the dynamic broker bundle -Registration keys—not refreshed `Tool.Spec()` calls—are catalog metadata ([catalog](../../engine/tool/catalog.go)). The aggregate currently binds a durable authority and completes enrollment through `CompleteWorkspaceEnrollment` ([authority](../../engine/session/principal.go), [enrollment](../../engine/session/workspace_enrollment.go)). The server permits only the owner-authorized, idle, broker-configured session path and reopens a completed session before that check ([control](../../internal/adapter/server/workspace_enrollment.go)). [ADR 0356](../adr/0356-durable-workspace-enrollment-broker-authority.md) limits the ledger to broker keys rather than catalog-origin metadata for every capability. +Registration keys—not refreshed `Tool.Spec()` calls—are catalog metadata ([catalog](../../engine/tool/catalog.go)). The aggregate currently binds a durable authority and completes enrollment through `CompleteWorkspaceEnrollment` ([authority](../../engine/session/principal.go), [enrollment](../../engine/session/workspace_enrollment.go)). The server permits only the owner-authorized, idle, broker-configured session path and reopens a completed session before that check ([control](../../internal/adapter/server/workspace_enrollment.go)). [ADR 0358](../adr/0358-durable-workspace-enrollment-broker-authority.md) limits the ledger to broker keys rather than catalog-origin metadata for every capability. **Restriction trace:** `internal/adapter/permconfig/permconfig.go` (`rulesFromConfig`) converts user/project/operator `permissions.deny` into permission rules, not capability deletions; `engine/adapter/permpolicy/permpolicy.go` (`Evaluate`) folds configured and learned rules with the session mode. These denies/asks remain effective for broker and non-broker calls. `engine/tool/catalog.go` applies plan-mode advertisement/dispatch filtering, and `internal/app/catalog.go` (`assembleCatalog`) retains no-FS profile exclusions. `internal/app/root_authority.go` (`mintRootAuthority`) mints catalog/resource/latent capabilities; `internal/adapter/server/service.go` (`setPerSessionLabels`) adds broker/client keys only for fresh authority, explicitly not for carried authority. `engine/agent/authority_delegation.go` (`deriveDelegatedAuthority`) narrows child capabilities, but the binding and successor gates traced above prevent that child from entering the supported enrollment flow. Root creation and main-session successors do not expose arbitrary per-key capability editing. As a defensive aggregate rule, completion preserves exclusion of a prior broker-ledger key absent from carried authority while admitting a key absent from the prior ledger as genuinely new. Permission/mode/profile restrictions continue to govern all calls, and the change authorizes no child widening. @@ -75,7 +75,7 @@ Registration keys—not refreshed `Tool.Spec()` calls—are catalog metadata ([c ### Scenario 2 — provenance survives restart and successors without copying runtime -`rehydrateSession` deliberately builds without broker tools and does not resurrect a broker attachment from persisted binding ([rehydration](../../internal/adapter/server/service.go)). Clear and fork carry authority but allocate a new session and fresh broker attachment ([successors](../../internal/adapter/server/placement_successor.go)). [ADR 0356](../adr/0356-durable-workspace-enrollment-broker-authority.md) requires the ledger to follow carried authority across both boundaries so a subsequent explicit refresh can subtract inherited old keys. +`rehydrateSession` deliberately builds without broker tools and does not resurrect a broker attachment from persisted binding ([rehydration](../../internal/adapter/server/service.go)). Clear and fork carry authority but allocate a new session and fresh broker attachment ([successors](../../internal/adapter/server/placement_successor.go)). [ADR 0358](../adr/0358-durable-workspace-enrollment-broker-authority.md) requires the ledger to follow carried authority across both boundaries so a subsequent explicit refresh can subtract inherited old keys. **Acceptance:** - AC2.1: A real snapshot/store restart preserves a committed authority and present broker-key ledger, but rehydrates no broker runtime, attachment, or wrappers. Ordinary prompts remain usable only through the non-broker runtime; an owner must explicitly refresh to install broker tools. Exception (amended 2026-09-23): if a recorded broker key still held in authority is now owned by a non-broker registration, rehydration fails with a failed-precondition error naming only the safe key until the operator corrects the configuration. An event-log system of record obtains identical ledger provenance only by supplying the exact presence bit and keys through `eventsource.SessionMeta`; no `session.Event` reconstructs enrollment provenance. @@ -91,10 +91,10 @@ Registration keys—not refreshed `Tool.Spec()` calls—are catalog metadata ([c ### Scenario 3 — a rare cross-source collision fails the whole enrollment safely -This is a cross-source safeguard at Mecatl's catalog-registration boundary, not a reimplementation of ToolHive's internal prefix or conflict resolution. [ADR 0356](../adr/0356-durable-workspace-enrollment-broker-authority.md) requires composition to report broker registration outcomes from the one final catalog build. It must register candidate tools through the error-returning path, not `MustRegister`, and hand a bounded safe duplicate-key error to the server without an additional `Tool.Spec()` call. +This is a cross-source safeguard at Mecatl's catalog-registration boundary, not a reimplementation of ToolHive's internal prefix or conflict resolution. [ADR 0358](../adr/0358-durable-workspace-enrollment-broker-authority.md) requires composition to report broker registration outcomes from the one final catalog build. It must register candidate tools through the error-returning path, not `MustRegister`, and hand a bounded safe duplicate-key error to the server without an additional `Tool.Spec()` call. **Acceptance:** -- AC3.1: A current non-broker registration matching either an incoming broker key or a prior recorded broker key still held in authority fails the whole enrollment before candidate wrapper, binding, authority, or provenance publication. No broker tool from that bundle becomes executable; destructive refresh may leave broker runtime unavailable while prior durable authority/provenance remains unchanged. The pending enrollment is cleared and persisted, so the session accepts prompts again. The proof drives `ConnectWorkspaceServices` end to end and checks the stored session. +- AC3.1: A current non-broker registration matching either an incoming broker key or a prior recorded broker key still held in authority fails the whole enrollment before candidate wrapper, binding, authority, or provenance publication. No broker tool from that bundle becomes executable; destructive refresh may leave broker runtime unavailable while prior durable authority/provenance remains unchanged. The pending enrollment is cleared and persisted. An incoming-key collision leaves the session usable with its prior authority; a collision on a recorded broker key still held in authority keeps every engine build refused (see AC2.1) until the operator corrects the configuration. The proof drives `ConnectWorkspaceServices` end to end and checks the stored session. - verify: `TestWorkspaceEnrollmentAuthority_Scenario3_ConflictFailsWholeEnrollment` - AC3.2: The internal catalog/factory handoff returns the exact successfully registered broker keys on success, or a typed/safe duplicate-key failure on collision. It performs no extra `Tool.Spec()` calls and never panics through `MustRegister` for dynamic broker registration. - verify: `TestWorkspaceEnrollmentAuthority_Scenario3_RegistrationMetadataAndDuplicateHandoff` diff --git a/docs/adr/0356-durable-workspace-enrollment-broker-authority.md b/docs/adr/0358-durable-workspace-enrollment-broker-authority.md similarity index 96% rename from docs/adr/0356-durable-workspace-enrollment-broker-authority.md rename to docs/adr/0358-durable-workspace-enrollment-broker-authority.md index d4235e1c33..80c50a1798 100644 --- a/docs/adr/0356-durable-workspace-enrollment-broker-authority.md +++ b/docs/adr/0358-durable-workspace-enrollment-broker-authority.md @@ -1,4 +1,4 @@ -# ADR 0356 — Durable workspace-enrollment broker authority provenance +# ADR 0358 — Durable workspace-enrollment broker authority provenance - Status: Proposed — remains proposed until the Plan / Interface PR receives human merge approval. - Date: 2026-09-21 @@ -22,7 +22,7 @@ MCP remote tools commonly use `mcp____` keys, so a broker/non-brok 2. The minimal engine API addition is `Session.WorkspaceEnrollmentBrokerKeys() ([]string, bool)` and `Session.RestoreWorkspaceEnrollmentBrokerKeys([]string, bool) error`. The accessor returns a copy and presence; the restore method validates copied provenance during idle snapshot restoration, root creation, or successor construction, before history/control restoration, and is write-once. `CompleteWorkspaceEnrollment(pending, exactTools)` retains its signature, but `exactTools` is documented as the completed broker registration-key bundle. Let `excluded prior broker keys = prior broker keys − prior authority`; completion atomically computes `(prior authority − prior broker keys) + (completed broker keys − excluded prior broker keys)` and replaces the ledger with `completed broker keys ∪ excluded prior broker keys`, so an exclusion survives a refresh that omits the key. A union exceeding the ledger bounds fails completion without mutation. Within the existing enrollable root/successor flow, this preserves all non-broker membership and absence, preserves exclusion of previously recorded broker keys, and admits genuinely new broker keys. It neither makes narrowed children enrollable nor authorizes overriding their carried restrictions. Configured denies/asks, mode/profile enforcement, and non-tool authority axes remain unchanged. 3. `sessnap.Snapshot` persists `workspace_enrollment_broker_keys []string \`json:"workspace_enrollment_broker_keys,omitempty"\`` and `workspace_enrollment_broker_keys_present bool \`json:"workspace_enrollment_broker_keys_present,omitempty"\``. Both fields absent means legacy absent provenance. `workspace_enrollment_broker_keys_present:true` with the keys field absent, `null`, or an empty array means present-empty; the same true field with a valid nonempty array means present-nonempty. Normal Go JSON slice decoding therefore accepts `null` as zero keys, while the explicit presence bit retains the security distinction. Raw JSON validation rejects nonempty keys without true presence, null/non-boolean presence, non-array or non-string key data, and malformed, duplicate, or over-bound keys before aggregate restore. `sessnap.Of` reads the ledger through the accessor; `Snapshot.Restore` applies it through the restore method. `eventsource.SessionMeta` carries the same exact keys and presence bit as external creation metadata and restores them by the same method before event folding; no enrollment event is created or consumed. Its false/absent value is the same legacy-absent ledger, which the server enrollment guard refuses; an event-log host must supply true plus exact keys to reconstruct an enrollment-capable session. In the server, `workspaceEnrollmentTarget` checks present, valid provenance immediately after `Store.Load` and owner authorization, before the existing completed-session `Reopen`/save, reset, attachment open/rebind, discovery, consent, or runtime mutation. Absent legacy data remains absent and is rejected at that guard. It is not inferred or repaired; a fresh session is the recovery path. 4. Snapshot restore preserves an enrollment ledger but never resurrects broker runtime. Ordinary rehydration continues to build without broker attachment/wrappers from persisted broker keys or binding. Only an explicit owner control can establish a fresh attachment and completed bundle. Clear and fork carry the source ledger and presence bit alongside carried authority, so a later refresh can subtract inherited broker keys. They create fresh broker attachments/bindings and do not copy the source's completed enrollment, pending operation, or runtime. Provenance is subtraction evidence, not connectivity. Engine construction, attachment commit, aggregate completion, or snapshot persistence failure leaves prior authority and ledger unchanged and publishes no partial replacement; destructive refresh may leave broker runtime unavailable. -5. Composition records the exact broker keys from the one final catalog registration pass. It must use an error-returning dynamic registration path—not `MustRegister`—and return a safe duplicate-key classification plus the registered-key metadata through the existing root-internal factory/result handoff. Registration metadata is captured in that pass, with no second `Tool.Spec()` call. Before candidate publication, it rejects a current non-broker registration matching either an incoming key or a prior ledger key that is still held in authority. The same prior-key refusal applies to every engine build for the session (rehydrate and successors, not only enrollment) and surfaces as a failed-precondition error naming only the safe key; excluded keys absent from authority never trigger it. Collision fails the complete bundle: no candidate wrapper, binding, authority, or provenance is published. The only allowed durable cleanup is clearing the pending enrollment, which an enrollment-time collision or ledger-bound overflow must do (and persist) so the session accepts prompts again; after destructive refresh, broker runtime may remain unavailable while prior durable authority and ledger remain unchanged. +5. Composition records the exact broker keys from the one final catalog registration pass. It must use an error-returning dynamic registration path—not `MustRegister`—and return a safe duplicate-key classification plus the registered-key metadata through the existing root-internal factory/result handoff. Registration metadata is captured in that pass, with no second `Tool.Spec()` call. Before candidate publication, it rejects a current non-broker registration matching either an incoming key or a prior ledger key that is still held in authority. The same prior-key refusal applies to every engine build for the session (rehydrate and successors, not only enrollment) and surfaces as a failed-precondition error naming only the safe key; excluded keys absent from authority never trigger it. Collision fails the complete bundle: no candidate wrapper, binding, authority, or provenance is published. The only allowed durable cleanup is clearing the pending enrollment, which an enrollment-time collision or ledger-bound overflow must do (and persist) so no pending enrollment blocks the session; an incoming-key collision leaves the session usable, while a recorded-key collision keeps engine builds refused until the configuration is corrected; after destructive refresh, broker runtime may remain unavailable while prior durable authority and ledger remain unchanged. 6. The owner receives a terminal failure naming only the safe conflicting registration key and retry-after-configuration-correction guidance. Unauthorized callers stay on the existing concealed not-found-style path and receive neither that key nor state information. Neither path exposes OAuth URLs, endpoints, callback state, credentials, tokens, or connector configuration. Mecatui shows the terminal failure, does not send a prompt retained pending success, and retains the existing explicit retry path after settlement. 7. This supersedes only ADR 0335's “no refresh-specific persisted state” clause. Its whole-bundle, explicit-control, ToolHive-custody, destructive replacement, ordered-publication, and no-partial-catalog decisions remain unchanged. @@ -34,7 +34,7 @@ A restart retains the authority ledger but not a live broker capability. An expl A rare deployment collision fails visibly for the owner rather than silently choosing precedence or omitting a tool. If the colliding non-broker tool appears after enrollment, the session cannot run at all until the operator corrects the configuration; this trades availability for the guarantee that a durable key never resolves to a different implementation. -An older binary re-saving a snapshot drops the ledger fields (for example during a rolling update over a shared store). The session then reads as legacy and refuses enrollment permanently; release notes must say so. The mechanism neither replaces ToolHive's own collision policy nor adds a cross-source framework. +The mechanism neither replaces ToolHive's own collision policy nor adds a cross-source framework. The new accessors are Added/minor. Changing the existing exported `CompleteWorkspaceEnrollment` replacement semantics and refusing legacy enrollment requires separate behavioral compatibility review under `engine/COMPATIBILITY.md`; an unchanged signature does not imply additive compatibility (Changed is breaking under that policy). `task api:update`, the `engine/api/session.txt` baseline, and the separately classified `engine/CHANGELOG.md` entries are implementation deliverables, not plan-approval prerequisites. No protobuf, RPC, config, command, event, or model-facing surface changes. diff --git a/docs/adr/README.md b/docs/adr/README.md index 0b98c5c205..bf4d8571a1 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -223,8 +223,8 @@ Documentation/citation conventions are in [`docs/design/README.md`](../design/RE - [0325 — Durable Dynamic Client Registration for direct MCP profiles](./0325-direct-mcp-dcr.md) - [0345 — Host-local direct MCP onboarding and credential custody](./0345-direct-mcp-onboarding.md) *(proposed)* - [0326 — Lazy ToolHive grants refresh declared metadata](./0326-lazy-toolhive-metadata-refresh.md) -- [0335 — Idle-session MCP broker workspace refresh](./0335-idle-session-broker-workspace-refresh.md) *(Decision 6 superseded by 0356)* -- [0356 — Durable workspace-enrollment broker authority provenance](./0356-durable-workspace-enrollment-broker-authority.md) *(proposed; supersedes ADR 0335 Decision 6 only)* +- [0335 — Idle-session MCP broker workspace refresh](./0335-idle-session-broker-workspace-refresh.md) *(Decision 6 superseded by 0358)* +- [0358 — Durable workspace-enrollment broker authority provenance](./0358-durable-workspace-enrollment-broker-authority.md) *(proposed; supersedes ADR 0335 Decision 6 only)* ### Performance & diagnostics - [0018 — Perf observability](./0018-perf-observability.md)