Skip to content

Implement session-scoped Agent Definitions - #2003

Open
kantord wants to merge 11 commits into
mainfrom
impl/session-scoped-agent-identity
Open

kantord wants to merge 11 commits into
mainfrom
impl/session-scoped-agent-identity

Conversation

@kantord

@kantord kantord commented Sep 29, 2026 •

Copy link
Copy Markdown
Member

Stage: Implementation (ready for review)

Implements ADR 0353 and its acceptance plan, both reviewed and merged before the ADR/acceptance-plan scaffolding was removed in #2105. They are no longer on main; read them from history:

The task letters and AC numbers below and in commit messages refer to that plan; treat them as a partial reference point, not a live contract.

Relates to: #1053 (motivating Slack-bot use case).

What this does

CreateSessionRequest.agent_definition_name binds a session's root to a named AgentDef. The session gets a strict, non-widenable tool ceiling taken from that definition and otherwise behaves like an ordinary main session (same guardrails, permission ask flow, persistence, fork/clear). An unknown name is InvalidArgument.

  • Catalog and authority: the root engine is built from the definition's own tools; its Authority carries a write-once Ceiling that GrantToolAuthority and CompleteWorkspaceEnrollment cannot widen past.
  • Provider/model, limits, mode: ADR 0353's 5-case provider/model resolution; limits and permission mode are tighten-only against the definition.
  • Persistence and rebuilds: the binding is stored (snapshot and event-source paths), survives restart, and every engine-rebuild path fails closed rather than falling back to the default engine. Fork/Clear preserve the binding; project memory is scoped to the session's own placement.
  • mecatui: the /agent command for trying this end to end is in the stacked follow-up PR (see below).

Stack: this PR is the backend. The mecatui /agent <name> command and picker are a small stacked follow-up, #2055, based on this branch.

Tasks (all landed)

  • A: agent_definition_name wire/domain/persistence plumbing (AC2.1, 2.2)
  • B: core session-creation catalog ceiling, minted Authority (AC1.1-1.6, 1.9-1.11, 1.15-1.17)
  • C: provider/model pair resolution, Limits/PermissionMode tighten (AC1.12-1.14)
  • D: session.Authority.Ceiling domain mechanism (AC3.1, 3.2)
  • E: guardrail contextual-review pinning tests (AC1.7, 1.8)
  • F: fail-closed guards across engine-rebuild paths (AC3.3-3.6)
  • G: Fork/Clear persistence, placement-aware project memory (AC2.3-2.6)

Integration fixes found after the tasks landed

The tasks were built in isolation and several defects only appeared when they were composed. Each has a mutation-verified regression test.

  1. Ceiling never wired (4fed89362): Authority.Ceiling was never populated on the real mint path, so enforcement was dead code in production.
  2. Deps.Role misread as "delegated child" (ec23650e9): the agent-bound root set a diagnostic Role, which engine/agent treats as the child signal. Contextual guardrail verdicts degraded to unresolved, which a headless engine denies. Fixed by not setting Role at all.
  3. Restore without a Ceiling (b14f10f99): sessnap.Restore and eventsource.Fold could restore an agent-bound label with no Ceiling, which the grant paths treat as unrestricted. Both restores now set the label before binding authority, and BindAuthority rejects an agent-bound bind without a Ceiling.
  4. Create idempotency (e114ac1c2): an explicit-ID retry did not compare the agent binding, and compared raw request limits to the persisted clamped limits. It now compares the binding and skips the raw-limits comparison for agent-bound requests.
  5. Earlier: both persistence paths now carry the label, task api:update and the changelog entry are done, grpc-schema.md regenerated.

Items 3 and 4 came from an external review (hashes above are post-rebase); each claim was checked against the code before fixing.

Verification

  • task test:race: passed before the latest rebase onto main. After the rebase I re-ran build, vet, and focused tests for the touched packages (engine/session, engine/adapter/*, internal/adapter/server, internal/app), and confirmed task generate / task api:update produce no drift. The full race suite is left to CI.

  • task test (all modules) passed before the mecatui /agent commit. After that commit I re-ran only task lint and go test ./cmd/mecatui/..., both clean.

  • /panel-review ran earlier with 0 ship-blockers, before items 3 and 4 were found. It has not been re-run since.

  • go run ./cmd/mecademo: shows the tool call, the permission ask and approval, and the result.

Known gaps and open questions

  • An external review also listed about a dozen test-coverage and documentation findings that I have not yet verified or addressed, including: debug_mcp_servers coverage for AC1.2, an end-to-end create-to-bound-Ceiling assertion, a more representative fake engine in the fork/clear test, full-composition catalog checks, context-propagation coverage for placement-scoped memory, grpc-api.md documentation of agent-bound semantics, a stale model_id-without-provider_id statement in grpc-schema.md, and whether the engine/CHANGELOG.md entry should be classified as Changed rather than Added for the two guard behaviours.
  • A server older than ADR 0353 that advertises agent definitions would silently ignore agent_definition_name.

Fully or partially written by an AI agent.

🤖 Generated with Claude Code

@kantord
kantord marked this pull request as ready for review October 1, 2026 11:27
@kantord kantord changed the title Implementation (draft, in progress): session-scoped agent identity Implement session-scoped Agent Definitions Oct 1, 2026
kantord and others added 11 commits October 8, 2026 13:02
Adds the CreateSessionRequest.agent_definition_name / CreateSessionResponse
.resolved_agent_definition_name wire fields, a write-once AgentDefinitionName
label on session.Session and sessnap.Snapshot, and verbatim echo on the
response. Plumbing only: no catalog restriction, provider/model/limits/
permission-mode resolution, or validation is wired from this field yet — that
is later work in docs/acceptance/session-scoped-agent-identity.md.

Pins AC2.1 (TestSessionScopedAgentIdentity_Scenario2_PersistsAcrossSnapshotRoundTrip)
and AC2.2 (TestSessionScopedAgentIdentity_Scenario2_ResponseEchoesBoundName).

Plan / Interface PR: #1815 (merged, a9dfd84)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…l-widening paths

Task D of the session-scoped-agent-identity plan (AC3.1, AC3.2). Authority
gains an optional, write-once Ceiling capability set, bound once alongside
BindAuthority/RestoreLabels and never re-derived. GrantToolAuthority and
CompleteWorkspaceEnrollment — the two aggregate methods that can widen a
bound session's tools post-bind — now reject any grant that would exceed
the Ceiling, making it a Session-aggregate invariant rather than an
enumerated adapter-layer guard list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…atalog (ADR 0353, Task B)

CreateSession with agent_definition_name set now builds the session's catalog
exclusively from the resolved AgentDef's tools/disallowedTools/mcpServers,
mints session.Authority from that same resolved catalog (never the
deployment default), and skips Config.MCPBroker attachment entirely — while
keeping ordinary main-session guardrails/governance/ask-flow (never the
child-shaped headless auto-deny).

- internal/app/agentdefs.go: extract resolvedAgentDefCatalog from
  buildAgentDefEngine (byte-identical for its 3 existing child call sites);
  add baseSubagentToolsNoFS for the no-fs profile intersection.
- internal/app/agentdef_root.go: new buildAgentDefRootEngine (bottoms out in
  engineDepsForProvider + attachGuardrailReviewer instead of the child-shaped
  newChildEngineForProvider; mints Authority via mintRootAuthority over the
  def's own catalog) and agentDefSessionEngineFactory.
- internal/app/build.go: wire AgentDefSessionEngine onto server.Config from
  the same collaborators as SessionEngineWithTools.
- internal/adapter/server/service.go: new AgentDefSessionEngineFactory type
  and Config field, SessionEngineResult.Authority field, agent_definition_name
  validation (rejects a debug target or client/debug MCP servers), and a
  dedicated create-path branch that never reaches MCPBroker attachment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Task B (mints session.Authority via mintRootAuthority) and Task D (added
Authority.Ceiling + its enforcement in GrantToolAuthority/
CompleteWorkspaceEnrollment) were dispatched concurrently and never wired
together: buildAgentDefRootEngine minted an Authority with a nil Ceiling for
every real agent-bound session, making the Ceiling mechanism dead code in
production even though its unit tests (which set Ceiling by hand) passed.

- buildAgentDefRootEngine now sets Ceiling to a full-field copy of the
  CapabilitySet it just minted, for every agent-bound session (AC3.1).
- BindAuthority now rejects a payload whose CapabilitySet.Tools already
  exceeds its own Ceiling, tools-only (matching GrantToolAuthority /
  CompleteWorkspaceEnrollment's existing AllowsTool convention, not the
  fuller CapabilitySet.Contains dimensional check) — hardening flagged by
  external review, since neither post-bind guard could otherwise repair an
  already-inconsistent bind.
- Extended TestSessionScopedAgentIdentity_Scenario1_AuthorityMintedFromDefCatalog
  (AC1.9) to also assert Ceiling is set and consistent, and added a
  mutation-verified rejection subtest to
  TestSessionScopedAgentIdentity_Scenario3_AuthorityCeilingBoundOnce (AC3.1).

Both gaps found by independently re-verifying the codebase (not trusting a
report) after an external review flagged the BindAuthority half; the more
severe wiring gap was found in the course of checking that specific claim.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ped Role

Task E's AC1.7/AC1.8 pinning tests surfaced a real production gap:
buildAgentDefRootEngine set Deps.Role to "agent-root:<def>" purely for
diagnostic readability, but engine/agent uses Role != "" throughout
(loop.go, steer.go, actionreview.go, dispatch.go) as the signal that an
engine is a delegated child — gating principal-prompt-provenance trust,
review-principal establishment, and external-authorization presentation.

Consequence in production: establishReviewPrincipal short-circuited for
every agent-bound session, so PrincipalFactsComplete was permanently
false, so every "acceptable" contextual-guardrail verdict was rejected as
untrustworthy (acceptable_with_incomplete_context) and fell back to
"unresolved" — which a headless/non-interactive engine (the Slack-bot
deployment this whole feature targets, issue #1053) denies outright. Every
guardrail-matched tool call on a real agent-bound session running headless
would have been silently blocked, whenever GuardrailsRules matched it.

Considered adding an explicit Deps.ChildShaped field decoupled from the
display-only Role, set once at the shared child-engine builder
(childEngineDeps/childEngineDepsForProvider) and read at all 8 behavior-
gating call sites instead of Role. Reverted: several existing engine/agent
tests construct a Deps{Role: ...} literal directly (engine-level tests
cannot import internal/app's builders) to simulate a child, and lacking
the new field they were silently reclassified as root-shaped — one such
test then presented an interactive authorization ask nothing resolves,
deadlocking `go test ./agent/...` (goroutine dump: askRegistry.await via
surfaceAsk/authorizeDecision). That approach's blast radius was broader
than it looked.

Minimal fix instead: buildAgentDefRootEngine simply never sets Role.
Zero changes to engine/agent's core dispatch logic; the bound def's name
is already logged once per engine build by agentDefSessionEngineFactory's
own diagnostic call, so no observability is lost. This is also more
faithful to AC1.6 ("ordinary main-session behavior... in every respect
except its tool catalog") than a differentiated Role ever was. The now-
unused role parameter is removed from buildAgentDefRootEngine and its
three call sites.

Verified: fresh `go test -count=1 ./agent/...` (engine module, was hanging
to the 600s timeout with the reverted ChildShaped approach, now green in
~5s) and `go test -count=1 ./internal/app/... ./internal/adapter/server/...`
— all green; scoped lint clean.

Pins AC1.7 (TestSessionScopedAgentIdentity_Scenario1_ContextualGuardrailInspectsEffectivePayload)
and AC1.8 (TestSessionScopedAgentIdentity_Scenario1_HookMutationCannotBypassGuardrail).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… (ADR 0353, Task C)

Implements ADR 0353's 5-case provider/model pair resolution
(resolveAgentDefRootProviderModel) and tighten-only Limits/PermissionMode
clamps (tightenLimits, clampPermissionMode) for an agent-bound session,
wired into agentDefSessionEngineFactory in place of the placeholder
resolveChildProvider call (whose def-wins-over-parent, silent-fallback
contract is wrong for case 5 — an unavailable def-pinned provider must
fail session creation, never silently fall back).

Case 1 delegates to the existing resolveProviderModel for its base-pair
computation, per the plan's own implementation guidance; cases 2-5 are
fresh logic against the request's own ProviderSelector.

Pins AC1.12 (TestSessionScopedAgentIdentity_Scenario1_LimitsTightenOnly),
AC1.13 (TestSessionScopedAgentIdentity_Scenario1_PermissionModeClampedNotRaised),
AC1.14 (TestSessionScopedAgentIdentity_Scenario1_ProviderModelPairResolution).

Plan / Interface PR: #1815 (merged, a9dfd84)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ADR 0353, Task F)

SetMode, LoadSessionWithMCP, the needsRehydration()/engineAndEnvironmentFor
choke point (StartRunContent, RetryFailedRun, CompactSession,
resumeFromAwaiting), rebuildGrantedAuthorizationEngine, and
ConnectWorkspaceServices all now refuse an agent-bound session rather than
silently rebuilding on the deployment's default catalog, so its tool-scope
ceiling survives every rebuild trigger that doesn't know about
agent_definition_name.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…coped project memory (ADR 0353, Task G)

ForkSession/ClearSession now unconditionally copy the source session's
agent_definition_name onto the successor (Profile-style, no override field)
and route the successor's engine rebuild through AgentDefSessionEngine rather
than the deployment's default catalog factory, skipping Config.MCPBroker
attachment for agent-bound successors the same way create-time does (AC2.3,
AC2.4).

resolveAgentMemoryHead's "project" tier now binds to the caller's own resolved
placement root instead of the single process-wide cfg.Workspace, using the
root-aware projectIngestionAdmittedForRoot gate — two agent-bound sessions on
the same def but different placements (e.g. a fork onto an alternate
worktree) no longer share or leak the same project-memory file, and a
placement the operator never specifically vetted is never admitted merely
because the deployment's global trust flag is true. Under profile: "no-fs"
this is a silent no-op, never an error (AC2.5, AC2.6). The session's own
placement root reaches AgentDefSessionEngineFactory's closure via a small
ContextWithAgentDefSessionRoot/AgentDefSessionRootFromContext context-value
pair rather than growing the factory's frozen signature.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…y.Ceiling

Aggregate-gates pass after all 7 tasks landed. Two gaps found:

1. task api:update was never run for the two additive engine/session surface
   changes from earlier tasks (Session.AgentDefinitionName, Authority.Ceiling)
   — engine/api/session.txt now reflects both; classified Added (minor) in
   engine/CHANGELOG.md per COMPATIBILITY.md.

2. A second, separate persistence path — eventsource.SessionMeta/Fold, used
   for durable event-log session reconstruction (distinct from sessnap, which
   Task A already handled) — never carried AgentDefinitionName at all.
   TestFoldContractDocMatchesSessionFields (a reflective drift guard) caught
   this: it fails until a new session.Session field is classified in
   COMPATIBILITY.md's reconstruction contract. Fixed: SessionMeta gained the
   field, Fold restores it by direct assignment (mirroring Profile exactly),
   the one production caller that already threads full session metadata
   through this path (internal/app/source_evidence.go) now includes it, and
   COMPATIBILITY.md's contract table + the drift-guard test's field map are
   both updated.

Also regenerated user-docs/reference/grpc-schema.md via `task docs` (buf was
missing from this environment; installed via `go install
github.com/bufbuild/buf/cmd/buf@latest` for this session) — it had never
picked up Task A's agent_definition_name/resolved_agent_definition_name proto
fields until now.

Verified: full `go test ./...` in both engine/ and root modules green
(including TestPublicAPIUnchanged and TestFoldContractDocMatchesSessionFields);
`task docs` passes (0 broken links/anchors); scoped lint clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… (ADR 0353)

External review found sessnap.Restore and eventsource.Fold could restore a
session carrying AgentDefinitionName with no Ceiling. Everything else routes
it as agent-bound, yet GrantToolAuthority and CompleteWorkspaceEnrollment
treat a nil Ceiling as unrestricted, defeating the non-widenable ceiling.

Both restore paths set AgentDefinitionName after binding authority, so a
check inside BindAuthority could not see the label. Reorder both to set it
first (as the create path already does) and make BindAuthority reject an
agent-bound bind without a Ceiling.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…(ADR 0353)

createRequest.matches ignored AgentDefinitionName, so a same-owner retry
could swap between an agent-bound and an ordinary (or different) session.
It also compared raw request limits with persisted limits, which for an
agent-bound session are the definition's clamped values, so legitimate
retries could be rejected.

Compare the binding explicitly and skip the raw-limits comparison for an
agent-bound request.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@kantord
kantord force-pushed the impl/session-scoped-agent-identity branch from 595be9f to e114ac1 Compare October 8, 2026 11:41

This branch was successfully deployed

1 active (outdated) deployment
Preview — 3a893c5a Deployed Sep 30, 2026 by vercel[bot]
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