Skip to content

Stage 2: cross-workspace tool dispatch + identity-bound sessions#272

Open
mgoldsborough wants to merge 29 commits into
mainfrom
stage-2/main
Open

Stage 2: cross-workspace tool dispatch + identity-bound sessions#272
mgoldsborough wants to merge 29 commits into
mainfrom
stage-2/main

Conversation

@mgoldsborough
Copy link
Copy Markdown
Contributor

@mgoldsborough mgoldsborough commented May 23, 2026

Stage 2 — identity-bound sessions + cross-workspace tool routing

Reframes the runtime around identity-bound sessions: a user's chat / /mcp session aggregates tools across every workspace they belong to, with per-call routing through src/orchestrator/. Builds toward the delegation-model North Star (User / Workspace / Conversation / Automation as siblings).

See nimblebrain-ops/research/delegation-model/ for the spec line (NORTH_STAR.md, STAGE_2_DESIGN_DECISIONS.md, IDENTITY_APP_SURFACE.md).

What's in here (29 commits)

Stage 2 core (T001–T013) — the namespaced cross-workspace routing stack:

  • T001–T005: cross-workspace E2E + two-workspace fixture, the ws_<id>-<tool> namespace primitive + AST lint, user-cred migration script, the src/orchestrator/ skeleton (per-call workspace routing), and the watcher-cached cross-workspace tool-list aggregator.
  • T006/T007: identity-bound runtime chat + /mcp sessions with namespaced tool routing.
  • T008–T013: removed oauthScope:"user", web shell teardown (deleted the workspace switcher), connector install workspace picker, sidebar workspace+apps navigator / tool-call provenance, credential-path lint + codegen.

Post-T012 — live-bug fixes (both confirmed in use):

  • 5368ef2 Tools reach the model again — surfaceTools Tier-2 was returning an empty direct list because namespaced nb__* tools failed a bare-prefix check (the /- sweep missed src/tools/surfacing.ts).
  • 5ccec27 nb__search scope:tools searches the identity's full cross-workspace union (Runtime.listDiscoverableTools()) — was per-workspace, so a tool installed in another workspace (e.g. a CRM) was invisible. Regression test in 841d72e.

Post-T012 — web shell IA + bridge:

  • c542213 WORKSPACES sidebar section + global/overview pages + chat on every route. 431e013 sidebar highlight follows the route. 2fc13ef localStorage sync on bootstrap fallback. 4b0212a bridge auto-prefixes the active workspace on tools/call. 68e80e2 code-style lint skips vendored node_modules.

Post-T012 — identity-app-surface foundation:

  • a65696ecf08bbe Tool-name scope model: generalized to <scope>-<tool>, then settled on bare-global grammar (ws_<id>-<tool> = workspace; a bare name parses to { kind: "global" }; no me- sentinel). IdentityContext primitive added. NOTE: bare/global dispatch is parsed but not yet wired — routeToolCall fail-closes a bare name with GlobalScopeNotRoutable (deferred). Everything that ships today is workspace-namespaced.

Post-T012 — review fixes:

  • a536451 Removed the dead pre-Stage-2 workspace-bound dispatch path (workspaceToolRouter/_engine); getUnionForIdentity uses Promise.allSettled so one failing workspace doesn't nuke the union. 416231d test for that.
  • 6525859 doc-drift sweep (separator + scope).
  • d006781 QA-review fixes: corrected AGENTS.md to describe shipped per-workspace namespacing (bare-global is grammar-only / W3); finished the /- doc-comment sweep across ~10 files; reap orphaned fs.watch handles in invalidateIdentity (fd-leak fix + test); log.debug("mcp", …) when a source's tools() throws.

Tests

  • Unit: 3091 pass / 0 fail
  • Web: 311 pass / 0 fail
  • Integration: 611 pass / 0 fail / 12 skip
  • bun run verify (full CI parity) green.

Remaining pre-merge gates

  • Rebase onto origin/main — branch is currently CONFLICTING; git merge-tree shows conflicts in src/bundles/lifecycle.ts and src/tools/connector-tools.ts (both heavily refactored here and by #223 on main). Do this as the last step (one rebase) and re-review the conflict resolution.
  • nb__manage_app removal (W1) — conversational install/configure removal, in flight on a parallel branch; lands on top of this before the rebase.

Known follow-ups (tracked, not blocking)

  • Identity-app surface W3+ (global dispatch via IdentityContext, Conversations as a bare identity app, root routes, Files/Automations migrations) — own PRs.
  • 3 instructions-rework integration cases (Layer-2 overlay injection in compose.ts) — pre-existing, tracked separately.
  • System-tool dedup (nb__search surfaces once per workspace) — addressed when global dispatch (W3) lands.

…-gated)

Pins the Stage 2 load-bearing contract: a single chat invokes a tool
from one workspace and a tool from another workspace, with each call
landing in the originating workspace's bundle subprocess + audit.

Test cases (all five from Tests Required in 001-cross-workspace-e2e.md):
- Happy path — both calls complete, per-source counters each 1.
- Topology guard — ws_a tool MUST NOT increment ws_b's counter.
- Audit attribution — tool.done.workspaceId matches namespace prefix.
- Strict invariant — un-namespaced tool name throws from orchestrator.
- Unknown prefix — ws_doesnotexist surfaces structured error, no fallback.

Gated behind a single grep-able SKIP_UNTIL_T006 constant via Bun's
it.if(...) per case. T006 flips the constant to false to turn the
contract live. Fixture is shape-clean for T011's smoke-tier reuse.
src/tools/namespace.ts is the single construction/parsing site for
ws_<id>/<name> cross-workspace tool names:
- namespacedToolName(wsId, name) validates wsId against WORKSPACE_ID_RE
  (imported from workspace-store, single source of truth) and throws on
  invalid input — no silent passthrough.
- parseNamespacedToolName(s) throws structured UnknownNamespacedToolName
  on malformed input; no null/undefined fallback. First-/ split so tool
  names may contain slashes if a future source emits them (documented +
  asserted).
- InvalidNamespacedToolNameInput separates build-side bugs from
  parse-side input errors so the orchestrator (T004) can distinguish.

scripts/check-tool-namespace.ts is the AST lint, modeled on
check-conversation-paths.ts. Flags string-literal/template-literal/
binary-plus construction of the ws_X/Y shape and .split("/") on
namespaced bindings. Allowlist: src/tools/namespace.ts itself. Honors
// lint-ok:tool-namespace marker.

Wired into verify:static between check:personal-workspace-id and
check:code-style. CI fails on drift.

Constraint enforced: SPEC_REFERENCE.md § Constraints item 6 — single
construction site, lint-enforced.
Moves user-scoped credentials from users/<userId>/credentials/... to
workspaces/ws_user_<userId>/credentials/... so credentials live with
the personal workspace they belong to. Required carryover for T008's
oauthScope: "user" deprecation — code path assumes credentials are
already at the personal-workspace path.

Operator surface mirrors Stage 1's migrate-tenant-files.ts:
- --dry-run (default) / --apply / --work-dir <path>.
- Lock-guarded via acquireMigrationLock (both dry and apply).
- Same-FS pre-flight (NB_MIGRATE_FORCE_EXDEV=1 test hook simulates
  EXDEV; no portable cross-mount setup in CI).
- Partial-rename heal: source-missing + destination-present is a no-op.
- Idempotent: re-run on a fully-migrated workdir = zero moves, exit 0.
- Conflict refusal: both sides exist with different contents = hard
  error, no auto-merge.
- Personal workspace id sourced only via personalWorkspaceIdFor
  (check:personal-workspace-id passes).

Tests (12, integration tier via spawnSync): happy / idempotent /
partial-heal / EXDEV / conflict-refusal / lock-contention /
stale-lock-takeover / dry-run-read-only / dry-run-on-migrated /
helper-only-id-construction / --help / binary-content safety.

package.json: migrate:user-creds alias added (was already in place
from concurrent T002 work that included this line; commit consolidates).
…outing

Introduces the per-call workspace-routing primitive every Stage 2 tool
dispatch flows through. Given a namespaced tool name and an identity,
the orchestrator parses the namespace via T002's primitive, confirms
the workspace exists, authorizes the identity against workspace
membership, constructs a fresh WorkspaceContext from the parsed wsId,
and resolves the dispatch handle.

Per SPEC_REFERENCE.md § Constraints item 3, the context is constructed
from the parsed namespace — never from session-level state. Per
Constraints item 5 and Stage 1 lesson 3, every failure mode throws a
structured error: no `?? "ws_default"` fallbacks, no fall-back to
"current workspace."

Files added (each disclosed per Group A audit precedent on T002):

- src/orchestrator/route.ts — routeToolCall + the structured error
  taxonomy (UnknownWorkspace, WorkspaceAccessDenied, UnknownToolSource)
  + the narrow OrchestratorRuntime structural type that lets unit tests
  stub without booting Runtime. Re-exports UnknownNamespacedToolName
  from T002 so callers get the full error surface in one place.
- src/orchestrator/index.ts — public surface of the orchestrator
  module. Re-exports routeToolCall + error classes + types only.
  Internal helpers stay unexported. Coordinates with T005: that task
  is explicitly instructed NOT to edit this file; Group B integration
  folds T005's exports in.
- test/unit/orchestrator/route.test.ts — 8 cases mapping 1:1 to the
  task spec's Tests Required (happy / strict invariant / non-member /
  personal workspace auth / context isolation / no ambient state /
  unknown workspace) plus one bonus case pinning UnknownToolSource.
  Each case names the failure mode it pins.

Error taxonomy disclosure: the task spec lists three distinct error
classes (WorkspaceAccessDenied, UnknownWorkspace,
UnknownNamespacedToolName). A fourth, UnknownToolSource, surfaces when
the inner toolName's source prefix isn't registered in the target
workspace's registry — the alternative is throwing a bare Error from
a load-bearing routing path, which the HTTP/audit layer can't
distinguish from "tool execution failed." Added rather than dropped
or conflated; documented in route.ts module doc-comment.

Verification: bun run check / lint / check:cycles / check:tool-namespace
/ check:personal-workspace-id all green. test/unit/orchestrator/route.test.ts
all 8 cases pass.
Files in this change:
- src/orchestrator/tool-list-cache.ts (new): watcher-driven per-workspace
  tool cache + per-identity union cache. Modeled on
  src/bundles/conversations/src/index-cache.ts — lazy populate, fs.watch
  invalidation, debounce. Default 100ms debounce; configurable.
- src/orchestrator/tool-list-aggregator.ts (new): public surface
  `createToolListAggregator(...)` returning aggregateToolList /
  invalidateIdentity / activeWatcherCount / dispose. Every entry built
  through namespacedToolName() from src/tools/namespace.ts.
- test/unit/orchestrator/tool-list-aggregator.test.ts (new): 8 tests —
  happy path, collision, cache hit, perf-regression guard (N=50 calls,
  lister fires once per workspace), concurrent enumeration (~100ms,
  not 500ms), per-identity isolation (disjoint sets), membership-change
  invalidation, namespace primitive enforcement.
- test/integration/tool-list-aggregator-watch.test.ts (new): 3 tests —
  fs.watch invalidation through workspace.json writes, workspace removal,
  watcher-leak-free dispose (closes every watcher; post-dispose calls
  throw).

Stage 1 lesson 5 pin: re-scan-per-call (the getUserConversationStore
perf footgun) is rejected by the perf-regression guard test. A naive
impl would call the per-workspace lister 100× for 50 aggregator calls
across 2 workspaces; the cached impl calls it exactly 2×.

No touches to src/orchestrator/index.ts (T004's territory) — this PR
adds only the two new files in src/orchestrator/ and their tests.

Verification: bun run check, lint, check:cycles, check:tool-namespace,
check:personal-workspace-id all green. Test suites pass clean (8 unit
+ 3 integration).
T004 owned index.ts during parallel Group B execution and reserved
T005's surface for post-integration. This commit folds T005's
public exports (createToolListAggregator + types, ToolListCache +
ToolListCacheOptions) into the barrel so external callers reach
the aggregator without deep imports.

Files modified:
- src/orchestrator/index.ts — added T005 re-exports; removed the
  "Group B coordination" note (now obsolete).
…uting

Stage 2 (delegation-model refactor) Task 007. Hard-cut `workspaceId`
from `SessionMeta` and both registry implementations per Q4 locked.
`/mcp` sessions are now identity-bound: `tools/list` returns the
cross-workspace union via T005's aggregator, `tools/call` parses the
namespaced name via T002's primitive and routes via T004's
orchestrator. The `X-Workspace-Id` header on `/mcp` is no longer
consulted — logged once per session at debug (`NB_DEBUG=mcp`) for
operator visibility. Per Q3 the bridge survives a workspace switch in
the web shell: the switcher cannot influence routing because every
tool call derives its target workspace from the parsed namespace.

Files modified:

  src/api/session-store/types.ts        — drop `workspaceId` from
                                          `SessionMeta` (Q4 hard cut).
                                          Header doc updated.
  src/api/session-store/memory.ts       — already minimal; the type
                                          cut alone removes the field.
  src/api/session-store/redis.ts        — stop writing `workspaceId`
                                          in `HSET`; `parseHash`
                                          ignores any legacy field
                                          rather than failing on it.
  src/api/mcp-server.ts                 — major refactor:
                                          - `McpServerHostOptions`
                                            gains `runtime?: Runtime`.
                                          - `handle()` signature drops
                                            `toolRegistry` + workspace
                                            context; takes
                                            `McpSessionContext { identity }`.
                                          - `TransportEntry` carries
                                            `identityId` instead of
                                            `workspaceId`.
                                          - `tools/list` consumes
                                            `runtime.getToolListAggregator()`.
                                          - `tools/call` parses via
                                            `parseNamespacedToolName`
                                            and routes via
                                            `routeToolCall`. All 4
                                            orchestrator errors map
                                            to distinct JSON-RPC
                                            responses with structured
                                            `error.data.reason`:
                                              -32602 invalid_tool_name
                                              -32602 unknown_workspace
                                              -32602 workspace_access_denied
                                              -32601 unknown_tool_source
                                          - `resources/list` +
                                            `resources/read` aggregate
                                            across every workspace the
                                            identity can access.
                                          - `X-Workspace-Id` is read
                                            once per session for a
                                            debug log line; never used
                                            for routing.
                                          - `fmtSessionContext` drops
                                            the `workspace=` field.
  src/api/mcp-task-store.ts             — task store re-keyed by
                                          (identityId, taskId) so a
                                          single identity-bound session
                                          can hold tasks across multiple
                                          workspaces. `ownerContext`
                                          retains per-task workspace for
                                          McpSource's downstream
                                          authorization.
  src/api/routes/mcp.ts                 — drop `resolveWorkspace` call;
                                          pass an identity-only
                                          `McpSessionContext` to the host.
  src/api/server.ts                     — wire `runtime` into
                                          `McpServerHost` constructor.
  src/runtime/runtime.ts                — add `getToolListAggregator()`
                                          accessor (lazy, single
                                          instance; disposed on
                                          `shutdown()`). Production
                                          satisfies `OrchestratorRuntime`
                                          shape via existing accessors.

Tests modified:

  test/unit/api/session-store/conformance.ts — drop `workspaceId` from
                                          `sample()`; assert
                                          round-trip carries no
                                          legacy field. Idempotent
                                          re-create test now keys
                                          on `identityId`.
  test/unit/api/session-store/redis.test.ts — drop `workspaceId` from
                                          fixture create call.
  test/unit/api/mcp-server-host.test.ts — update `handle()` signature
                                          to the new identity-only
                                          context; drop `ToolRegistry`
                                          import (no longer required
                                          by the session-miss surface).
  test/unit/api/mcp-server-reclamation.test.ts — same signature update;
                                          uses a shared `SESSION_CTX`
                                          const for brevity.
  test/integration/mcp-server-endpoint.test.ts — namespace tool names
                                          (`ws_test/fake__echo`).
                                          Session-miss log assertions
                                          updated for the dropped
                                          `workspace=` field. Drop
                                          `X-Workspace-Id` from the
                                          tests that no longer need
                                          it for routing.
  test/integration/mcp-resources.test.ts — `resources/list` now
                                          aggregates across both
                                          workspaces the identity is
                                          a member of; the
                                          cross-workspace test is
                                          rewritten to assert the
                                          new aggregation contract.
  test/integration/mcp-tasks.test.ts    — namespace every tool name
                                          for tools/call. Reword the
                                          cross-workspace task
                                          isolation test as
                                          cross-session.
  test/integration/api/mcp-workspace-scope.test.ts — namespace tool
                                          names; the "denied bundle"
                                          test now asserts the
                                          orchestrator's
                                          `unknown_tool_source` →
                                          -32601 JSON-RPC envelope
                                          instead of `isError: true`.
  test/integration/shell-integration.test.ts — namespace
                                          (`ws_test/nb__*`) the
                                          three MCP-client e2e tests.

Tests added:

  test/integration/mcp-server-identity-bound.test.ts — Stage 2 T007
                                          contract. Eight tests
                                          pinning every failure mode
                                          on the task spec's
                                          "Tests Required":
                                          - identity-only session
                                          - cross-workspace call
                                            (per-source counters 1,1)
                                          - workspace-switcher header
                                            does not invalidate session
                                          - synapse iframe auto-prefix
                                            routes to host workspace
                                          - strict invariant: bare
                                            name → -32602
                                          - UnknownWorkspace mapping
                                          - WorkspaceAccessDenied
                                            mapping
                                          - UnknownToolSource mapping
  test/integration/session-store-legacy-compat.test.ts — round-trip
                                          on both
                                          `InMemorySessionRegistry`
                                          and `RedisSessionRegistry`
                                          surfaces no `workspaceId`;
                                          a legacy Redis hash with
                                          a stray `workspaceId`
                                          loads cleanly and the
                                          parser ignores the field.
Stage 2 (delegation-model refactor) Task 006. Hard-cut `workspaceId`
from `ChatRequest` and `ChatResult` per Q1 / Q4 locked. `runtime.chat()`
is now identity-bound: the session workspace is the identity's personal
workspace (`personalWorkspaceIdFor(identity.id)`), tools come from the
cross-workspace aggregator (`Runtime.getToolListAggregator()` — the same
instance T007 consumes), and each tool call routes through the
orchestrator (`routeToolCall`) which derives a fresh `WorkspaceContext`
from the parsed namespace. T001's cross-workspace E2E is unblocked: the
5 contract cases plus the self-check pass.

Files modified:

  src/runtime/types.ts             — drop `workspaceId` from
                                     `ChatRequest` and `ChatResult`. Doc
                                     comments explain the new
                                     identity-bound contract +
                                     per-tool-call attribution on each
                                     `tool.done` event.
  src/runtime/runtime.ts           — major refactor:
                                     - `_chatInner` reworked to hard-error
                                       on missing identity when an
                                       identity provider is configured,
                                       fall back to `DEV_IDENTITY` in
                                       dev mode (no `?? "anonymous"`
                                       sentinel), and derive the session
                                       workspace from
                                       `personalWorkspaceIdFor(...)`.
                                     - `ensureUserWorkspace` is called
                                       before `ensureWorkspaceRegistry`
                                       so embedded/CLI/test paths
                                       auto-provision the personal
                                       workspace, matching the production
                                       login posture.
                                     - `_buildIdentityToolRouter` builds
                                       a `ToolRouter` whose
                                       `availableTools()` returns the
                                       aggregated cross-workspace list
                                       (via the runtime's single
                                       aggregator instance) and whose
                                       `execute(call, ...)` routes via
                                       `routeToolCall` → the bare tool
                                       name after stripping the
                                       `<source>__` prefix (mirroring
                                       T007's `/mcp` dispatch contract).
                                     - `_wrapSinkWithWorkspaceAttribution`
                                       stamps `workspaceId` on
                                       `tool.progress` / `tool.done`
                                       events from a per-call dispatch
                                       map, satisfying the audit
                                       attribution criterion.
                                     - Orchestrator errors map to
                                       distinct `isError: true` tool
                                       results with structured
                                       `reason` values matching T007:
                                       `invalid_tool_name`,
                                       `unknown_workspace`,
                                       `workspace_access_denied`,
                                       `unknown_tool_source`.
                                     - `loadBundleSkills` iterates over
                                       every workspace the identity can
                                       see so bundle-published
                                       `skill://<name>/usage` resources
                                       reach Layer 3 cross-workspace.
                                     - `focusedApp` resolution now
                                       searches every accessible
                                       workspace's registry, not just
                                       the request's.
                                     - The aggregator is constructed
                                       eagerly in `Runtime.start()`
                                       (single instance shared with
                                       T007's `/mcp` path) and disposed
                                       in `shutdown()` — Group B audit
                                       closeout follow-up.
  src/runtime/tools.ts             — `matchToolPattern` accepts both the
                                     full namespaced (`ws_<id>/...`)
                                     and the bare-inner form so legacy
                                     `allowedTools` patterns from skill
                                     manifests / chat composer keep
                                     working unchanged.
  src/skills/select.ts             — same namespace-aware match in
                                     `toolMatches` for the
                                     `applies_to_tools` glob path —
                                     skills with `appliesToTools:
                                     ["source__*"]` still light up
                                     correctly under namespaced tool
                                     names.
  src/api/handlers.ts              — `/v1/chat` body parser strips
                                     `workspaceId` from the resulting
                                     `ChatRequest` (accept-but-ignore on
                                     the chat path), and chat-multipart
                                     ingest writes to the identity's
                                     personal workspace file store so
                                     the chat reads from the same
                                     location. Cross-workspace ingest
                                     remains a Stage 6 concern.
  src/api/routes/chat.ts           — `/v1/chat` switches from
                                     `requireWorkspace` to
                                     `optionalWorkspace` middleware:
                                     the header still validates against
                                     membership when sent (400/403 on
                                     malformed / cross-tenant) but the
                                     route handler discards the value.
  src/api/schemas/rest.ts          — `ChatRequestBody.workspaceId`
                                     description updated to flag it as
                                     deprecated + accept-but-ignore.
  test/helpers/two-workspace-fixture.ts
                                   — fixture migrated to use
                                     `namespacedToolName(wsId, name)`
                                     from `src/tools/namespace.ts`
                                     (Group A audit's reconciliation
                                     criterion, option (a)).
                                     `buildChatRequest` drops
                                     `workspaceId`.

Tests modified:

  test/integration/cross-workspace-chat.test.ts
                                   — flip `SKIP_UNTIL_T006` to `false`.
                                     5 contract cases + 1 self-check
                                     pass.
  test/integration/runtime/chat-identity-guard.test.ts
                                   — rewrite to assert the new
                                     identity-only guard (identity
                                     required when auth provider
                                     configured; DEV_IDENTITY fallback
                                     in dev mode). The old
                                     `workspaceId is required` cases
                                     are deleted.
  test/integration/runtime/workspace-conversations.test.ts
                                   — rewrite to assert the
                                     identity-bound conversation layout
                                     (top-level path, ownerId on the
                                     metadata, session workspace =
                                     personal workspace breadcrumb).
  test/integration/runtime/concurrent-chat.test.ts
                                   — release-on-throw case now provokes
                                     the throw via a foreign-owner
                                     resume (`ConversationAccessDenied`)
                                     instead of missing `workspaceId`.
  test/integration/identity-wiring.test.ts
                                   — `Chat with workspace context` and
                                     `Chat without workspace
                                     (workspaceId is now required)`
                                     replaced with two cases asserting
                                     the identity-bound contract.
  test/integration/stage-1-cross-workspace-conversation.test.ts
                                   — metadata `workspaceId` is now the
                                     session (personal) workspace, not
                                     the `X-Workspace-Id` of the
                                     request — assertions updated.
  test/integration/skills-read-tools.test.ts
                                   — skill is pre-staged in the
                                     identity's personal workspace dir
                                     so the chat's session-workspace
                                     read finds it. Read tools are now
                                     called against the personal
                                     workspace registry.
  test/integration/chat-file-visibility.test.ts
                                   — `X-Workspace-Id` on file-tool
                                     calls switched to the personal
                                     workspace (where chat-multipart
                                     ingest now lands).
  test/integration/files-mcp-resources.test.ts
                                   — same: read resources from the
                                     personal workspace.

Tests added:

  test/integration/runtime/t006-acceptance.test.ts
                                   — 6 tests pinning the two
                                     audit-added acceptance criteria:
                                     - aggregator's
                                       `activeWatcherCount() === 0`
                                       after `runtime.shutdown()`
                                     - idempotent shutdown
                                     - each of the four orchestrator
                                       errors (`UnknownNamespacedToolName`,
                                       `UnknownWorkspace`,
                                       `WorkspaceAccessDenied`,
                                       `UnknownToolSource`) surfaces a
                                       distinct `isError: true` tool
                                       result via `runtime.chat()`.
Schema cut + hard-error on legacy disk records for the deleted
`BundleRef.oauthScope: "user"` literal. The Stage 2 deploy runbook
(`nimblebrain-ops/research/delegation-model/STAGE_2_DEPLOY.md`) is the
operator contract: stop the platform → run `bun run migrate:user-creds`
→ deploy. The runtime is strict — no boot-time normalization, no in-
process re-implementations of T003, no backwards-compat shims. A
skipped migration is operator error and surfaces as a loud boot
failure naming the offending record.

Removed surface area (delete-don't-deprecate per Group A audit):

- src/users/user-connector-store.ts — DELETED. The class was kept
  "to detect and migrate legacy user.json files on subsequent boots."
  That is the T003 migration re-implemented in the binary. Gone.
- src/runtime/runtime.ts — boot-time migration block deleted (the
  walk over `users/<userId>/user.json` that wrote into personal
  workspaces). The `_userConnectorStore` field + `getUserConnectorStore`
  method gone too. Stale comment referencing `oauthScope: "user"`
  bundles in `getAllowInsecureRemotes` doc refreshed.
- src/bundles/lifecycle.ts — `normalizeBundleRefAtLoad` and
  `LegacyUserScopeWithoutOwnerError` REPLACED with
  `assertBundleRefIsPostStage2` + `LegacyOAuthScopeError`. No
  translation. The new helper throws on encounter, naming the
  bundle's serverName + url and pointing operators at
  `bun run migrate:user-creds` and the deploy runbook. Removed the
  unused `personalWorkspaceIdFor` import. `seedInstance`'s doc
  refreshed to point at the disk-read boundary, not the deleted
  normalization helper.
- src/runtime/workspace-runtime.ts — `buildProcessInventory` is now
  the disk-read boundary: every ref goes through
  `assertBundleRefIsPostStage2` before reaching the inventory.
- src/engine/types.ts — `ToolSource.execute`'s `principalId?` arg
  REMOVED from the interface (no in-tree consumer existed; identity
  flows via AsyncLocalStorage / `runWithRequestContext`).
  `EngineConfig.principalId` field deleted too.
- src/tools/types.ts, src/tools/registry.ts, src/tools/mcp-source.ts,
  src/tools/upjack-source.ts — `principalId` arg removed from every
  implementation and the SharedSourceRef wrapper.
- src/engine/engine.ts — call site no longer passes `config.principalId`.
- src/runtime/runtime.ts — `_buildIdentityToolRouter`'s execute signature
  drops the unused `principalId` arg; the chat-config builder no
  longer threads `principalId` into the engine config.
- src/api/handlers.ts — REST `/v1/tools/call` path no longer extracts
  and forwards a `principalId`; identity is already in the
  AsyncLocalStorage `reqCtx`.
- src/api/types.ts, src/api/server.ts — `RouteContext.userConnectorStore`
  field deleted from the `AppContext` and the construction site.
- src/bundles/types.ts — deletion-comment tombstones referencing
  the deleted `"user"` literal removed from BundleRef.oauthScope and
  BundleInstance.oauthScope doc blocks; the doc states what
  `"workspace"` means today.

Catalog field rename — option (a) `defaultScope` → `defaultBinding`:

- src/connectors/catalog.yaml — every entry renamed; values
  `workspace` (active workspace) / `personal` (caller's personal
  workspace). 19 entries touched.
- src/connectors/server-detail.ts — `NimbleBrainConnectorMeta.defaultScope`
  renamed to `defaultBinding: "workspace" | "personal"`; doc states
  it's a UX hint that selects the wsId, NOT a per-ref `oauthScope`
  literal.
- src/registries/types.ts — `DirectoryEntry.defaultBinding`.
- src/registries/projection.ts — `ConnectorCatalogEntry.defaultBinding`;
  both projection functions thread the new field.
- src/tools/connector-tools.ts — every dispatch site reads
  `defaultBinding`; composio gate compares against `"workspace"`.
- src/tools/system-tools.ts — doc refreshed.
- web/src/api/client.ts — both projected interfaces renamed.
- web/src/components/connectors/{OperatorOAuthSection,ConnectorStatusHero}.tsx
  — DirectoryEntry construction sites use `defaultBinding`.
- web/src/pages/settings/ConnectorBrowsePage.tsx — page-scope `"user"`
  / `"workspace"` (UI mode) is mapped to `"personal"` / `"workspace"`
  (binding) at the filter boundary inside the memo.

Why (a) not (b): catalog entries inherently know their install
target (Granola = always personal, Slack = workspace shared). Removing
the field would force the UI to maintain that knowledge separately —
worse architecture. The rename gives the field a name that means what
it does (selects the binding workspace) without referencing the
deleted oauthScope literal.

Tests:

- test/unit/bundles/legacy-oauth-scope-rejected.test.ts — NEW. 6
  cases pinning the throw-on-load contract: URL bundle with legacy
  `oauthScope: "user"` throws `LegacyOAuthScopeError`; error carries
  serverName + url + operator-actionable message naming the migration
  command and deploy runbook; clean URL bundles + non-URL refs
  pass through; missing serverName falls back to `"(unknown)"`.
- test/integration/legacy-oauth-scope-boot-error.test.ts — NEW. 1
  case pinning the disk-read boundary: a `workspace.json` with a
  legacy bundle ref causes `Runtime.start()` to throw
  `LegacyOAuthScopeError` at `buildProcessInventory` — the operator
  sees a hard boot failure with the offending record named.
- test/unit/bundles/normalize-legacy-oauth-scope.test.ts — DELETED.
  Was guarding the deleted normalization helper.
- test/integration/oauth-scope-user-loader-migration.test.ts — DELETED.
  Was exercising the deleted boot-time loader migration.
- test/unit/connector-tools.test.ts — `defaultScope` → `defaultBinding`
  in every helper + test case (3 catalog fixtures, 1 test name
  rewrite). Mock harness drops `getUserConnectorStore`.
- test/unit/connector-tools-composio-install.test.ts — mock harness
  drops `getUserConnectorStore`.
- test/unit/composio-auth.test.ts, test/unit/connector-directory.test.ts,
  test/unit/directory-entry-projection.test.ts,
  test/unit/connectors-catalog.test.ts — `defaultScope`/`defaultBinding`
  rename through fixtures and assertions.
- web/src/__tests__/connector-sections.test.tsx — same rename.

Verification: `bun run verify` is clean — 3059 unit + 258 web-unit +
605 integration + 17 smoke, 0 fail, 12 skip. Cross-workspace E2E
(`cross-workspace-chat.test.ts`, `ambient-context-cross-workspace.test.ts`)
8/8. T007's `mcp-server-identity-bound.test.ts` + `mcp-server-endpoint.test.ts`
18/18. Greps (per the cleanup task spec): zero matches for
`normalizeBundleRefAtLoad`, `userConnectorStore`/`UserConnectorStore`,
`principalId\??:` in `src/engine/`+`src/tools/`, `"user"` in
`src/bundles/types.ts`, `defaultScope: user` in `src/bundles/` +
`src/connectors/`.

Operator action (unchanged from prior commit): run
`bun run migrate:user-creds` before deploying Stage 2. The runbook at
`nimblebrain-ops/research/delegation-model/STAGE_2_DEPLOY.md` is the
operator contract; the runtime no longer migrates or normalizes at
boot. T012's CHANGELOG entry carries the migration ordering.
…t, narrow scope

Stage 2 Group E, Task 009. Implements three things the locked Q1 + Q3
design decisions (2026-05-22) and the Group D audit call for:

1. Header workspace switcher (`WorkspaceSelector` — was historically the
   "header switcher"; the actual file in this codebase) is DELETED
   entirely. T013 fills the slot in the sidebar with the workspace+apps
   navigator; T009 leaves the slot empty so the shell never briefly
   hosts two workspace surfaces.

2. Q3 bridge-lifecycle unwiring. `setActiveWorkspaceId` no longer fires
   the `resetMcpBridgeClient` lifecycle handler — the `/mcp` session is
   identity-bound, so workspace switches reuse the same session and
   carry context via the per-request `X-Workspace-Id` header. The setter
   itself is preserved (T013's sidebar will call it). `setAuthToken`
   continues to fire `resetMcpBridgeClient` on logout (identity boundary).
   Both setters keep their equality guards.

3. Dead-union cleanup per the Group D audit ("no bandaids" directive).
   `InstalledConnector.scope: "workspace" | "user"` narrowed to
   `"workspace"` on both sides of the boundary — the `"user"` arm was
   dead surface after T008 (every population site emits `"workspace"`
   only). Three downstream return shapes (disconnect / uninstall /
   install / list_tools_with_permissions / set_permissions) narrowed
   the same way; their `scope` arg parameters tightened to `"workspace"`.

4. UI prop rename per the Group D audit: the prop `scope: "user" |
   "workspace"` on 4 components is a page/route mode indicator (which
   settings page is rendering), not an oauthScope. Renamed to
   `mode: "personal" | "workspace"` across ConnectorBrowsePage,
   ConnectorDetailPage, ConnectorList, ToolPermissionsTable, and every
   call site. The REST helpers underneath always read/write
   workspace-scope permissions; the `mode` is purely UI.

Files
-----
src/tools/connector-tools.ts — narrow `InstalledEntry.scope` to
  `"workspace"` literal (the `"user"` arm is dead surface; every
  population site already emits `"workspace"`).

web/src/api/client.ts — split lifecycle hook contract:
  `setAuthToken` fires `onAuthLifecycleChange` on real change;
  `setActiveWorkspaceId` no longer does (Q3). Both keep equality
  guards. `InstalledConnector.scope` + every helper return / arg type
  narrowed to `"workspace"`.

web/src/mcp-bridge-client.ts — doc comment updated to match the new
  lifecycle contract. No behavior change to the function itself; the
  `setAuthLifecycleHandler(resetMcpBridgeClient)` registration is
  unchanged and now only fires on auth-token transitions.

web/src/components/WorkspaceSelector.tsx — DELETED.

web/src/components/ShellLayout.tsx — drop the `WorkspaceSelector`
  import and the two JSX call sites (desktop + mobile). Empty
  comment-anchored slot remains for T013 to fill.

web/src/components/UserMenu.tsx — comments updated to remove stale
  references to the deleted component.

web/src/pages/settings/ConnectorBrowsePage.tsx
web/src/pages/settings/ConnectorDetailPage.tsx
web/src/components/connectors/ConnectorList.tsx
web/src/components/connectors/ToolPermissionsTable.tsx — prop renamed
  from `scope: "user" | "workspace"` to `mode: "personal" |
  "workspace"`. Internal logic now maps `mode` directly to catalog
  entry's `defaultBinding` (the legacy "user → personal" mapping
  collapses to identity). REST helper calls underneath always pass
  `"workspace"` (Stage 2: personal connectors live in the user's
  personal workspace).

web/src/App.tsx — `<ConnectorBrowsePage scope="workspace" />` →
  `mode="workspace"`. Same for `ConnectorDetailPage`.

web/src/pages/settings/WorkspaceConnectorsTab.tsx — `scope="workspace"`
  → `mode="workspace"` on `<ConnectorList>`.

AGENTS.md — `## MCP App Bridge Rules` bullet on lifecycle handler
  updated: `setAuthToken` fires the handler; `setActiveWorkspaceId`
  does not. Same content as the doc comment in api/client.ts.

Tests
-----
web/src/__tests__/a-t009-acceptance.test.ts — NEW. Pins the
  `ChatRequest` wire shape (mutual-extends type guard catches future
  widening or narrowing), confirms `setActiveWorkspaceId` is still a
  callable export (T013 dependency), and walks `web/src/` to assert no
  source file imports or names `WorkspaceSwitcher` / `WorkspaceSelector`.
  Filename is `a-` prefixed to load early — `connector-sections.test.tsx`
  later in the suite installs a `mock.module("../api/client", ...)`
  stub that would otherwise blow up any file importing additional
  exports from api/client. Same pattern `api-client-lifecycle.test.ts`
  uses.

web/src/__tests__/api-client-lifecycle.test.ts — flipped the
  `setActiveWorkspaceId fires the handler` test to "does NOT fire
  (Q3)". The equality-guard tests for both setters remain; the
  contract narrative at the top of the file is updated to reflect the
  split lifecycle.

web/src/__tests__/mcp-bridge-client.test.ts — added three tests under
  a new `bridge session lifecycle vs auth/workspace setters` describe:
    (1) workspace switch reuses the same bridge client (Q3
        regression test — required by the task spec).
    (2) logout (`setAuthToken(null)`) drops the bridge client
        (Q3 boundary — auth-token branch must remain wired).
    (3) next bridge fetch after switch carries the new
        `X-Workspace-Id` header (topology guard for T013's plumbing).

Verification
------------
bun run check                          OK
bun run check:web                      OK
bun run lint                           OK
bun run check:cycles                   OK
bun run check:tool-namespace           OK
bun run check:personal-workspace-id    OK
bun run check:codegen                  OK
bun run verify:static                  OK
bun run test:unit                      3059 pass / 0 fail
bun run test:web                       264 pass / 0 fail
bun run test:integration               605 pass / 0 fail / 12 skip
…d wsId

Replaces the connector install "scope" dropdown with an explicit
WorkspaceTargetPicker dialog and tightens `manage_connectors.install`
to require an explicit `wsId` argument with no default-to-personal
fallback inside the tool. Honors the no-bandaids directive (Mat's
T008 closeout note): the tool refuses if `wsId` is missing; the UI
picks the target. The legacy `oauthScope: "user"` literal — already
gone after T008 — stays gone.

The dialog presents every workspace the caller has admin role in
(filtering member-only rows, including the personal workspace which
is always sole-owner-by-design per Stage 1 invariants). Personal-
typical connectors (`defaultBinding: "personal"`) preselect the
personal workspace. Installing into any non-personal workspace
requires the user to type the workspace's display name (case-
insensitive after trimming — friction, not key-entry precision) to
enable Install. Closing and reopening the dialog clears the typed-
confirmation and selection so a stale "Helix" can't satisfy the gate
for a different workspace.

Server side: `manage_connectors.install` now takes a top-level `wsId`
arg in the input schema. `handleInstall` hard-errors on missing /
empty `wsId`, validates the workspace exists, gates on admin role,
checks `connectorsAllowList`, then dispatches. The previous two-
branch flow (workspace vs personal, keyed on `entry.defaultBinding`)
collapsed into one — `defaultBinding` is now only a UX preselection
hint the picker may consult, never read by the tool. The composio
"shared-only" restriction now reads `ws.isPersonal` (source of
truth) instead of `entry.defaultBinding` (UX hint). Personal-
workspace ids stay sourced from `personalWorkspaceIdFor` —
`check:personal-workspace-id` lint passes.

Files:
- src/tools/connector-tools.ts — install action schema gains `wsId`;
  `handleInstall` requires it (hard-error, Stage 1 precedent); two-
  branch dispatcher collapsed into one parameterized by the picked
  wsId; composio personal-target gate now inspects `ws.isPersonal`.
- src/tools/workspace-mgmt-tools.ts — `manage_workspaces.list`
  surfaces `isPersonal` per workspace so the bridge-list fallback
  can preselect the personal workspace.
- web/src/api/client.ts — `installConnector(entry, wsId)` — wsId
  required; new return includes `wsId` for caller-side audit.
- web/src/context/WorkspaceContext.tsx — `WorkspaceInfo` gains
  `isPersonal?: boolean`.
- web/src/lib/bootstrap.ts — both mappers propagate `isPersonal`
  from bootstrap and `manage_workspaces.list` contracts.
- web/src/components/connectors/WorkspaceTargetPicker.tsx (CREATE) —
  radiogroup of installable workspaces, controlled component;
  `workspacesEligibleForInstall` filters to admin-role rows.
- web/src/components/connectors/InstallConnectorDialog.tsx (CREATE)
  — dialog with preselection + typed-confirmation gate + state
  reset on close. Exports `preselectWorkspaceId` +
  `typedConfirmationMatches` for unit-test coverage.
- web/src/pages/settings/ConnectorBrowsePage.tsx — onInstall opens
  the dialog instead of immediately calling `installConnector`;
  the dialog hands back `{serverName, wsId}` and the page routes
  to OAuth initiate / Configure as before.
- test/unit/connector-tools.test.ts — install tests tightened to
  pass `wsId` explicitly; new adversarial coverage for missing /
  empty / unknown wsId; personal-workspace test uses
  `personalWorkspaceIdFor` helper + pins `oauthScope: "workspace"`
  on the persisted ref; audit-attribution test pins "picked wsId
  reaches the dispatcher, not the session header".
- test/unit/connector-tools-composio-install.test.ts — every
  install call site updated to pass `wsId: h.wsId`.
- test/integration/connector-tools-install.test.ts (CREATE) — pins
  persisted on-disk shape (`oauthScope: "workspace"`, `wsId:
  <picked>`, no legacy `"user"` literal anywhere), personal install
  records `wsId === personalWorkspaceIdFor(userId)`, hard-error +
  zero on-disk writes when `wsId` is missing, and audit attribution
  (install lands in picked wsId, not session header).
- web/src/__tests__/WorkspaceTargetPicker.test.tsx (CREATE) — picker
  is fully controlled; admin-role filter hides member-only rows;
  Personal/Shared badge contract.
- web/src/__tests__/InstallConnectorDialog.test.tsx (CREATE) —
  preselection heuristic, typed-confirmation gate (case-insensitive
  decision documented), install call uses picked wsId (no ambient
  leak), and the adversarial state-reset-on-close walk (open for
  Helix + type "Helix" → close → reopen for Acme → typed "Helix"
  still disables Install; only "Acme" unlocks).

Verification:
- bun run check / check:web / lint / check:personal-workspace-id /
  check:tool-namespace — all clean.
- bun run test:unit — 3062/3062 pass.
- bun run test:web — 276/276 pass (incl. 7 picker + 11 dialog).
- bun run test:integration — 605/605 pass.
- cross-workspace-chat: 6/6 (load-bearing E2E unchanged).
- mcp-server-identity-bound: 8/8 (load-bearing E2E unchanged).

No bandaids, no backwards compatibility: a buggy client that omits
wsId hard-errors with a message naming the missing arg; the tool
never silently picks a target. Pre-T010 single-step install via
`installConnector(entry)` no longer compiles — every caller must
pass a workspace id picked by the UI.
…crumbs + tool-call provenance

The user-visible payoff of Stage 2 — the sidebar mockup Mat locked Q1
against (2026-05-22). Replaces the deleted header switcher (T009) with
a sidebar WORKSPACES section, adds a composer footer with viewing +
TOOLS FROM breadcrumbs, and renders namespaced tool calls as friendly
`tool · Workspace` rows in the transcript.

Sidebar navigator (new)

- web/src/components/shell/Sidebar.tsx (new) — spec-named entry point;
  re-exports SidebarWorkspaceNav so ShellLayout has one mount point.
- web/src/components/shell/SidebarWorkspaceNav.tsx (new) — WORKSPACES
  heading + `+` affordance (routes to the existing
  /settings/org/workspaces create flow — no new UX invented). Reads
  workspaces from useWorkspaceContext; orders personal-first then
  shared alphabetical via lib/workspace-order. Active workspace
  auto-expands; last-viewed app restoration on mount.
- web/src/components/shell/WorkspaceRow.tsx (new) — workspace row with
  expand chevron + role badge. Per-row expansion state owned by the
  parent so toggling one row doesn't collapse another.
- web/src/components/shell/WorkspaceAppList.tsx (new) — indented app
  list. Click handler:
    1. Equality-guards setActiveWorkspace at the React layer
       (double-guards api/client's setActiveWorkspaceId — T009
       invariant). Cross-workspace click fires the setter once;
       re-click on the same app is a no-op.
    2. Persists `<wsId>:<appRoute>` to localStorage key
       `nb:last-viewed-app` so a reload restores the selection.
    3. Navigates to `/w/<slug>/app/<bundleName>` (existing convention).

Composer footer + tool provenance (new)

- web/src/components/chat/ComposerFooter.tsx (new) — two-line footer.
  - Viewing line: "You're viewing <app> in <workspace>" derived from
    URL + WorkspaceContext + ShellContext placement label. Falls back
    to "Not viewing an app" when no app matches.
  - TOOLS FROM line: badges driven by useToolWorkspaces (NOT by the
    active workspace). Pinning to active would pass single-workspace
    tests and silently break the multi-workspace case the spec
    explicitly asserts.
- web/src/components/chat/Composer.tsx (new) — wraps MessageInput +
  ComposerFooter. `hideFooter` prop for compact/popover compositions.
- web/src/components/chat/ToolCallProvenance.tsx (new) — renders
  `<friendly-tool> · <workspace-badge> [status-pill]`. Parses through
  parseNamespacedToolName (no .split). Q2 fallback: missing workspace
  in user's list renders raw `ws_<id>/<tool>` — never falls back to
  personal-workspace display name. Personal workspace gets a fixed
  badge variant so it's visually distinct across sidebar + provenance
  + composer footer.
- web/src/context/ToolWorkspacesContext.tsx (new) — separate context
  for the TOOLS FROM set so tests can stub multi-workspace + watcher-
  invalidation topology independently of identity. Default derives
  from useWorkspaceContext.

Primitives + helpers (new)

- web/src/lib/namespaced-tool.ts (new) — web-side mirror of
  src/tools/namespace.ts::parseNamespacedToolName. Web cannot import
  from `src/` (tsconfig + vite alias scope), so we keep a separate
  primitive with identical contract. Differs only in error handling:
  returns null instead of throwing (transcript renderer degrades to
  raw rather than crash on a removed workspace).
- web/src/lib/workspace-order.ts (new) — orderWorkspacesForSidebar.
  Personal-first then shared alphabetical (case-insensitive),
  id-stable tie-break.

Integration

- web/src/components/ShellLayout.tsx (modified) — mounts
  WorkspaceNavSidebar in both the desktop sidebar and the mobile
  drawer. Removes the "left empty by T009" placeholder comments.
- web/src/components/ChatPanel.tsx (modified) — swaps MessageInput
  for Composer. Compact (popover/embedded) ChatPanel hides the
  footer (`hideFooter={compact}`) — the breadcrumb strip is sized
  for the full chat surface, not the embedded composer.

Tests (new)

- web/test/namespaced-tool.test.ts — parser contract (9 tests):
  well-formed parse, first `/` as separator, raw fallback (not
  fail-loud) for missing separator / empty components / invalid
  wsId. Pins the Q2 invariant against silent-fallback regressions.
- web/test/workspace-order.test.ts — ordering rule (5 tests):
  personal-first, case-insensitive alphabetical, id-stable
  tie-break, legacy `isPersonal=undefined` treated as shared, no
  input mutation.
- web/test/ToolCallProvenance.test.tsx — provenance contract (8
  tests): friendly name + workspace badge, prefix stripping (`gmail__`
  → `send_message`), Q2 fallback to raw on missing workspace
  (adversarial — Personal name MUST NOT bleed in), non-namespaced
  input verbatim, status pills, personal-workspace variant pin, and
  an audit grep that fails if any new component contains
  `.split("/")`.
- web/test/ComposerFooter.test.tsx — footer contract (7 tests):
  multi-workspace TOOLS FROM (the load-bearing adversarial topology
  pin), single-workspace + empty cases, watcher-add re-render with
  three badges, viewing-line "no app" fallback, app+workspace
  rendering, switch-workspace remount updates both lines.
- web/src/__tests__/b-t013-sidebar-nav.test.tsx — sidebar contract (8
  tests). Prefixed `b-` to load after `a-t009-acceptance` and before
  `connector-sections` (which installs a partial mock.module on
  api/client). Mocks api/client minimally with an equality-guarded
  spy mirroring the production setter. Pins: ordering + role badges,
  `+` affordance present, per-row expansion (toggle does not affect
  other rows), the equality-guard topology test (cross-workspace
  click fires setter once, re-click is no-op), navigation contract
  (`/w/<slug>/app/<route>`), persistence (write + restore on
  remount).

Audit

- No `as unknown as T` casts in new components.
- No `?? "ws_<id>"` fallbacks anywhere.
- No raw `ws_<X>/<Y>` substitution-headed template literals in the
  new component dirs.
- No `.split("/")` on tool-name bindings in new components (audit
  test enforces).
- No `ChatContext` consumption from shell components.
- No new REST routes — `+` affordance reuses existing
  /settings/org/workspaces flow; workspace+bundle data sourced from
  the existing WorkspaceContext bootstrap (which already routes
  through manage_workspaces.list per CLAUDE.md § API Surfaces:
  prefer tool actions over new routes).
- No conversation schema change — Q1 follow-up "anchorAppId /
  anchorWorkspaceId" deferred to Stage 4+ per task spec.

Verification

- bun run check / check:web / lint / format:check: clean.
- bun run check:cycles / check:tool-namespace /
  check:personal-workspace-id / check:codegen / check:code-style:
  all green.
- bun run test:unit: 3062 pass / 0 fail.
- bun run test:web: 319 pass / 0 fail.
- bun run test:integration: 609 pass / 12 skip / 0 fail.
- Stage 2 invariant suites still green: cross-workspace-chat 6/6,
  mcp-server-identity-bound 8/8, t006-acceptance 6/6,
  ambient-context-cross-workspace 2/2, connector-tools-install 4/4.
Proves SPEC_REFERENCE.md § Verification line 3 ("External MCP test:
Claude Desktop sees aggregated tool list, can invoke cross-workspace")
through the real `/mcp` Streamable HTTP transport — the same protocol
surface Claude Desktop / Claude Code / Cursor use.

test/smoke/external-mcp-cross-workspace.test.ts — new smoke-tier file.
Constructs the MCP SDK `Client` with `StreamableHTTPClientTransport`
against a `startServer()`-backed `/mcp` endpoint and pins 7 failure
modes (one per case): no-X-Workspace-Id init, aggregated tools/list,
cross-workspace dispatch (per-source counters at 1/1), session
survives without a switcher, un-namespaced strict invariant rejects
with -32602 invalid_tool_name and does NOT silently route anywhere,
audit attribution via RequestContext.workspaceId (read directly from
handler-execution, NOT from a server envelope — Stage 1 lesson 2),
and the T008 connector-from-personal carryover proof.

test/helpers/two-workspace-fixture.ts — extended. Adds an
`auditTrail()` / `resetAuditTrail()` probe per workspace handle that
records the `RequestContext.workspaceId` value the counter source's
handler saw at execution time. Independent observation channel from
`callCount()`: a misattribution that still routes to the right source
(e.g. session-default workspace id stamped onto a correctly-routed
dispatch) is invisible to the counter but a smoking gun in the audit
trail. The probe is fixture-level so both the existing cross-workspace
chat E2E and the new external-MCP smoke can use it without
duplication.

Smoke tier per CLAUDE.md § Testing classification: real subprocess +
network. Runs under `bun run smoke`, excluded from `bun run test`.
No flake retries, no setTimeout workarounds — green on first run.
…NTS/CHANGELOG

Closes Stage 2 of the delegation-model refactor with the same kind of
structural enforcement Stage 1 ended with, plus the bandaid cleanup
Mat's no-backwards-compatibility directive surfaced.

Part 1 — credential-path AST lint
- scripts/check-credential-paths.ts: AST-scans src/**/*.ts for
  `join(..., "users", X, "credentials", ...)`, equivalent template
  literals, and string-literal substrings. Modeled on
  check-conversation-paths.ts / check-personal-workspace-id.ts —
  same allow-marker convention (// lint-ok:credential-path), same
  allowed-files mechanism (empty by design; T008 already deleted the
  pre-Stage-2 consumers).
- test/unit/scripts/check-credential-paths.test.ts: 13 unit tests
  covering positive + negative fixtures and clean-tree subprocess
  invocation. Same shape as the Stage 1 predicate tests.
- package.json: check:credential-paths script + verify:static wiring.
  Stage 2 closeout brings the count of delegation-model structural
  lints to four (conversation-paths, personal-workspace-id,
  tool-namespace, credential-paths).

Part 2 — web-side WORKSPACE_ID_RE divergence fix
- Pre-Stage-2 the web parser used /^ws_[a-zA-Z0-9_-]+$/ while the
  server used /^ws_[a-z0-9_]{1,64}$/i. Web was strictly more permissive
  — server-produced names always parsed, but a future server-side
  tightening wouldn't have propagated.
- src/workspace/workspace-id-pattern.ts: new shared module exporting
  WORKSPACE_ID_PATTERN (literal string), WORKSPACE_ID_FLAGS (literal
  flags), and the compiled WORKSPACE_ID_RE. src/workspace/workspace-store.ts
  re-exports the regex so existing call sites keep working.
- scripts/codegen-web-platform-schemas.ts: emits
  web/src/_generated/workspace-id-pattern.ts from the server source.
  Web cannot import from src/ (tsconfig + vite alias both scoped to
  ./src), so codegen is the only mechanism that keeps the literal in
  lockstep. CI's check:codegen catches drift.
- web/src/lib/namespaced-tool.ts: imports the generated literal and
  builds its own RegExp locally.
- web/test/namespaced-tool.test.ts: regex-equality contract test —
  pins WORKSPACE_ID_PATTERN / WORKSPACE_ID_FLAGS against the literal
  embedded in the source, asserts the constructed RegExp's .source /
  .flags match, and pins rejection of hyphens / no-prefix / path
  traversal / length-overflow shapes.

Part 3 — dead-union audit grep
Ran `grep -rnE '"workspace"\s*\|\s*"user"|"user"\s*\|\s*"workspace"' src/`.
Hits: only src/tools/platform/skills.ts (layered-skill scope
"org"|"workspace"|"user"|"bundle") and src/permissions/permission-store.ts
(PermissionOwner discriminator). Both confirmed-not-dead by Group E
audit — separate concepts (layered skills, permissions storage layer).
Zero new narrowings. Documented as the audit signal per spec.

Part 4 — AGENTS.md updates
- Workspace Isolation section: credentials now live in
  workspaces/<wsId>/credentials/..., not users/<userId>/credentials/...
- New "Cross-workspace tool namespacing (Stage 2)" subsection:
  ws_<id>/<tool> canonical, namespacedToolName / parseNamespacedToolName
  primitives, check:tool-namespace lint enforces, web tier mirrored via
  codegen, BundleRef.oauthScope: "user" deletion + LegacyOAuthScopeError,
  dev-mode parity noted.
- New "Stage 2 follow-ups — tenant migration order" subsection: cross-
  links to nimblebrain-ops/research/delegation-model/STAGE_2_DEPLOY.md.
- CLAUDE.md symlink intact (verified `readlink CLAUDE.md` → AGENTS.md).
- MCP App Bridge Rules section already updated by T009 for Q3 — left
  alone.

Part 5 — CHANGELOG.md Stage 2 entry
Added Highlight + Added + Changed + Breaking + Removed bullets covering
every audit-mandated entry (cross-workspace dispatch, identity-bound
sessions, sidebar navigator, oauthScope deletion, no-bandaids stance,
installConnector / ToolSource.execute / ChatRequest / ChatResult /
SessionMeta signature changes, WorkspaceSelector / UserConnectorStore /
UserPoolSource removal). Required operator action line:
"run `bun run migrate:user-creds` BEFORE deploying" with cross-link to
STAGE_2_DEPLOY.md, plus the smoke-test gating reference.

Stage 2 word count is over the 250-350 target (~460 words) because the
audit criteria mandate 23+ specific bullets; further trimming would
drop required items.

Verification
- bun run verify: green end-to-end (verify:static + test:unit +
  test:web + test:integration + smoke).
- 3075 unit tests pass (+13 new from check-credential-paths.test.ts).
- 322 web tests pass (+3 new regex-equality contract tests).
- 6/6 cross-workspace-chat integration tests; 8/8 mcp-server-identity-
  bound integration tests; 24/24 smoke tests.
- All four delegation-model lints clean against src/.

Audit criteria
- Lint script structure matches Stage 1 templates exactly (allowlist,
  // lint-ok: marker, AST predicates exported for unit-test exercise,
  import.meta.main gating).
- CHANGELOG names migrate-user-creds-to-personal-workspace.ts and
  references STAGE_2_DEPLOY.md.
- AGENTS.md is the source, CLAUDE.md is the symlink.
- Three Stage 2 lints (tool-namespace, credential-paths, conversation-
  paths, personal-workspace-id) all in verify:static.
- No `as unknown as T` casts in check-credential-paths.ts.
- Dev-mode parity noted in AGENTS.md.
…weep

The cross-workspace separator moved from `/` to `-` (LLM provider tool-name
validators reject `/` against ^[a-zA-Z0-9_-]{1,128}$). Three read-side
sites that parse/detect namespaced names by hand were missed by that
sweep, and `check:tool-namespace` didn't catch them (they used
`indexOf("/")`, not `.split("/")`):

  - surfaceTools: detected system tools via `name.startsWith("nb__")`,
    but Stage 2 namespaces them to `ws_<id>-nb__<tool>`. In Tier 2
    (tool union > maxDirect) the direct list IS the system-tool list,
    so it came back EMPTY — the model got zero tools and hallucinated
    tool calls as plain text instead of invoking anything.
  - skills/select toolMatches: `/`-split left bare allowedTools globs
    (`crm__*`) unable to match namespaced tools, breaking tool-affined
    Layer 3 skill selection.
  - engine removeTool guard: `startsWith("nb__")` no longer recognized
    namespaced system tools, so they could be released.

Fix: add `bareToolName()` to tools/namespace.ts — one non-throwing
read-side helper built on the canonical parser, so the separator and the
workspace-id boundary stay defined in exactly one place. Apply it at all
three sites.

surfaceTools/filterTools/matchToolPattern now need namespace parsing, and
the runtime↛tools layering rule (correctly) flagged the import — the
signal that these tool-name-aware shaping functions belong in the tools/
layer. Moved src/runtime/tools.ts → src/tools/surfacing.ts, which also
removed two pre-existing tools/→runtime/ imports (delegate, compose).

Adds a surfaceTools regression block exercising namespaced names through
the real builder (the gap that let this ship green: every prior case used
bare names). Fixes the bundle-skills integration test.
Iframe MCP-App tool calls arrive with bare qualified names
(`<source>__<tool>`). The bridge now namespaces the wire name via
`namespacedToolName(getActiveWorkspaceId(), name)` before dispatch, so a
widget's tool call lands in the host workspace's bundle subprocess and
audit — the Q3 contract. Returns a structured JSON-RPC error if no active
workspace is in scope rather than guessing.

Today the iframe is always mounted under its host workspace, so
active == host; if cross-workspace iframes land later (Stage 6), capture
the host at bridge construction instead.
…es, chat on every route

Workspaces are siblings of Conversations/Automations/Files (all
identity-bound), not parents — so they live in a labelled WORKSPACES
section in the sidebar, single click navigates + activates. Replaces the
T013 inline-app-list nav (the "two actions at once" row plus a data path
that lied with "No apps installed"). Deterministic avatars from the
workspace id; role in aria-label, not a pill.

New surfaces:
  - GlobalHomePage at `/` — greeting + workspaces grid.
  - WorkspaceOverviewPage at `/w/:slug/` — app discovery sourced from the
    placements registry (single source of truth), not the empty bundles[].

Chat lifted to ShellLayout via ChatChrome, so it's one click from every
route (was only mounted by AppWithChat → invisible on /, overview,
settings). <main> pushes over with transition timing matched to the panel
slide.

Removes the obsolete T013 components (SidebarWorkspaceNav, WorkspaceRow,
WorkspaceAppList, the rail variant, HomeAppRoute, WorkspaceRedirect) and
their tests; adds b-workspace-section coverage.
…llback

When bootstrap falls back from a stale active-workspace id (a workspace
the user was removed from or that was deleted), the in-memory api/client
state reflects the server-resolved fallback but localStorage still held
the stale id — the drift surfaced as a workspace_error on reload. Keep
localStorage in lockstep with the resolved workspace; guard for
unavailable storage (private mode, quota).
…isted active workspace

Two sidebar items showed active at once — e.g. on Home (`/`), the
persisted active workspace's row stayed highlighted. `activeWorkspace`
is always set (it scopes cross-workspace tool dispatch), so keying the
row highlight off it lit a workspace even on global, identity-bound
routes (`/`, `/conversations`).

Derive the highlight from the URL via `matchPath("/w/:slug", { end:
false })` instead, mirroring how the core NavLinks compute active state.
A workspace row is now active only on `/w/<slug>/...`; global routes
show zero active workspace rows. Selection still sets the active
workspace for tool scoping — the two concerns are now decoupled.

Updates the active-state test to be route-driven and adds a regression
pin for the global-route (zero-active) case.
The no-inline-type-imports check globbed src/**/*.ts and walked any
node_modules nested under src/ — notably src/bundles/<name>/ui/node_modules,
which a local bundle-UI install creates (gitignored, absent in CI). That
made the check pass in CI but fail locally with 1700+ violations in
third-party .d.ts files. Skip any path with a node_modules segment;
matches the script's documented "src source only" scope.
…<tool>

Foundation for identity-scoped ("personal") apps. The Stage 2 namespace
had one axis — the workspace. Generalize the leading segment to a SCOPE:
a workspace id (`ws_<id>`) or the identity sentinel `me`.

  ws_acme-crm__search        → { kind: "workspace", wsId: "ws_acme" }
  me-conversations__search   → { kind: "identity" }

`parseNamespacedToolName` now returns `{ scope, toolName }` where scope is
a discriminated union; `me` is collision-safe (a ws id must match
`^ws_[a-z0-9_]{1,64}$`, which `me` can't). Adds `identityToolName(name)`
builder + `IDENTITY_SCOPE` const, mirrored in the web parser. `me-` tools
authorize by identity, carry no workspace.

The orchestrator routes the workspace scope exactly as before; the
identity branch fails closed via `IdentityScopeNotRoutable` until the
`IdentityContext` dispatch lands (next commit, A2). No surface emits a
`me-` name yet — the aggregator still namespaces by workspace — so the
branch is unreachable in normal flow; a crafted /mcp call fails closed.

ToolCallProvenance renders identity-scoped tools without a workspace
badge. Tests updated to the scope shape + identity coverage. Behavior
for existing workspace-scoped dispatch is unchanged.
…l union

Cross-workspace bug: the aggregator namespaces every tool per workspace,
including the system tools — so nb__search is duplicated across every
workspace the identity can see (ws_mat-nb__search, ws_user_*-nb__search,
…). Each copy searched only its OWN workspace's registry. The model
picked the personal workspace's copy, which can't see a CRM installed in
ws_mat, and reported "no CRM tools installed" — even though synapse-crm
was running there. (Confirmed in a conversation log: the call was
ws_user_<id>-nb__search.)

nb__search is an identity-level discovery surface. Add
Runtime.listDiscoverableTools() — returns the identity's aggregated
cross-workspace union when an identity is in scope, falling back to the
current workspace's registry otherwise. Point scope:tools at it. Results
are namespaced (ws_<id>-<tool>), so the model promotes via manage_tools
and dispatches through the orchestrator unchanged.

This is the per-workspace mis-scoping of system tools the identity-app
surface work fixes structurally (system tools → me-scoped); this is the
targeted fix for the search surface.
…arch)

Regression for the nb__search-finds-nothing bug. From the personal
workspace's request context, listDiscoverableTools() must still surface a
tool installed in another workspace (the "CRM in ws_mat" case), and the
union must be identity-stable regardless of which workspace is in context.
Would have failed before the fix: the old per-workspace search returned
only the calling workspace's registry.
Identity counterpart to WorkspaceContext: a typed access path to per-user
data at {workDir}/users/{userId}/... (files, skills — the North Star
user-owned layout). The orchestrator will construct one when routing a
`me-<tool>` name so identity-scoped tools dispatch with identity authority
and no ambient workspace.

Deliberately credential-free (personal credentials live in the personal
workspace per Stage 2) and conversation-free (conversations are top-level,
ownership-authorized). Validates userId against path traversal + rejects
`/` so an id can't escape the users/ tree.
…inel)

Revises A1 per the design review. Two-axis model: a tool is either
workspace-replicated or a singleton. Namespace encodes only the former.

  ws_<id>-<tool>   → { kind: "workspace", wsId }
  bare <tool>      → { kind: "global" }   (whole name; no prefix to strip)

The workspace prefix means "this specific workspace"; its absence means
global. Drops the `me`/IDENTITY_SCOPE sentinel + identityToolName builder
(global tools have no builder — the bare name IS the wire name). Platform
tools (`nb__*`) and identity-owned app tools (`conversations__*`) are
global singletons.

parseNamespacedToolName now returns `{ scope, toolName }` where a bare
name parses to global (the whole name); only empty input or a malformed
`ws_<id>-` prefix throws (a workspace attempt, surfaced not globalized).
Stage 2 safety preserved: a bare WORKSPACE-app name is still refused (no
silent current-workspace routing).

Orchestrator routes the global branch fail-closed via GlobalScopeNotRoutable
until W3 wires global dispatch (IdentityContext + kernel global-source
validation) — no surface emits bare global names yet. Both error mappers
(chat + /mcp) map it to reason `global_not_routable`. Web mirror + tests
updated; ToolCallProvenance renders global tools with no workspace badge.
…st degradation (review)

Two review fixes, applied from a worktree to avoid colliding with the
parallel W1 work in the main checkout.

MF1 — delete the dead pre-Stage-2 dispatch chain in runtime.ts: the
`workspaceToolRouter` (workspace-bound `execute()` with no membership
check), the `engine` built from it, and the unused `_engine` constructor
param + its `new Runtime(...)` arg. After T006, chat() builds its own
identity-bound engine per request (`_buildIdentityToolRouter`); the
constructor engine was never assigned or read. Removing it deletes the
exact auth-bypass pattern from the hot file and makes the dispatch
architecture read as one router, not two.

S3 — `getUnionForIdentity` uses `Promise.allSettled` instead of
`Promise.all`: a single workspace whose listing rejects (e.g. its registry
can't be constructed) no longer nukes the identity's entire aggregated
tool list. Degrade gracefully, skip the failed workspace, and log it under
the `mcp` namespace — matching the per-source containment posture the
surrounding code already documents.
Exercises the allSettled branch added in a536451: N workspaces where one
lister rejects → the union returns exactly the healthy workspaces' tools,
the failed workspace absent. Without it a refactor back to all-or-nothing
(Promise.all) stays green.
- Separator `/` → `-` in docstrings that predated the late sweep:
  namespace.ts, route.ts, tool-list-cache.ts, tool-list-aggregator.ts,
  and CLAUDE.md's cross-workspace-namespacing section.
- CLAUDE.md + identity/context.ts updated to the bare-global model: drop
  the stale `me-<tool>` sentinel; workspace tools are `ws_<id>-…`, platform
  + identity-owned tools are bare global singletons.
- tool-list-aggregator.ts dispose comment → past tense (dispose is wired
  at runtime.ts shutdown, not "eventually").
…watchers, log skipped sources

Addresses the QA pass on PR #272.

C2 (AGENTS.md accuracy) — the namespace section overstated bare-global as
shipped. Corrected to describe what ACTUALLY ships: every tool is
namespaced per workspace (`ws_<id>-<source>__<tool>`, incl. the platform
`nb` source); the bare/global scope is parsed but dispatch fail-closes
(`GlobalScopeNotRoutable`), deferred to a later stage. (The prior doc edit
went through the `CLAUDE.md` symlink and never staged the real `AGENTS.md`
— fixed here by staging `AGENTS.md` directly.)

C3 (finish the / → - separator sweep) — stale `ws_<id>/<tool>` doc comments
the earlier sweep missed: mcp-server.ts, session-store/types.ts, runtime.ts
(×3), runtime/types.ts, skills/select.ts, workspace-id-pattern.ts,
bridge.ts, ToolCallProvenance.tsx, and AGENTS.md. (Left the legitimate
`skill://…/usage` and path/URI splits untouched.)

S1 (watcher fd leak) — `ToolListCache.invalidateIdentity` now reaps a
workspace's `fs.watch` handle when its last subscriber leaves (membership
change / workspace delete), instead of leaking it for the process
lifetime. Lazily re-created on next listing; shared workspaces keep their
watcher. Placed in invalidateIdentity (not invalidateWorkspace, which
fires on every workspace.json change → would churn). Test asserts the
reap via activeWatcherCount.

S2 (silent source swallow) — the cross-workspace lister now emits a
`log.debug("mcp", …)` when a source's `tools()` throws, so "my tool
disappeared from the list" is diagnosable (gated behind NB_DEBUG=mcp).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant