Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
43f7eca
feat(session): plumb agent_definition_name field (ADR 0353, Task A)
kantord Sep 29, 2026
5ec7c75
feat(session): add write-once Authority.Ceiling and enforce it in too…
kantord Sep 29, 2026
aaa9c2e
feat(session): build agent-bound session engines from the def's own c…
kantord Sep 29, 2026
1383fbc
fix(session): wire Authority.Ceiling into the real agent-bound mint path
kantord Sep 30, 2026
32eb565
fix(agent): stop an agent-bound root engine from carrying a child-sha…
kantord Sep 30, 2026
68f7159
feat(session): resolve provider/model pair + tighten-only limits/mode…
kantord Sep 30, 2026
b89ca87
feat(session): fail closed on every agent-bound engine-rebuild path (…
kantord Sep 30, 2026
1c72ef7
feat(session): Fork/Clear preserve agent-bound identity + placement-s…
kantord Sep 30, 2026
8bd375b
chore(session): close ADR 0037 gates for AgentDefinitionName/Authorit…
kantord Sep 30, 2026
71774e5
fix(session): reject an agent-bound restore with no Authority.Ceiling…
kantord Oct 1, 2026
4f7a47c
fix(server): include agent binding in explicit-ID create idempotency …
kantord Oct 1, 2026
6065a11
fix(session): attribute an agent-bound root engine to its resolved pr…
kantord Oct 13, 2026
7dcda05
fix(session): harden agent-bound root composition and lifecycle
kantord Oct 15, 2026
7de20ce
test(session): simplify agent identity regression fixtures
kantord Oct 15, 2026
c4b5b1f
Merge main into agent identity backend
kantord Oct 15, 2026
1ef3e92
fix(session): apply review feedback to agent-bound root construction
kantord Oct 16, 2026
1c2549a
Merge origin/main into agent identity backend
kantord Oct 16, 2026
7fff693
test(session): record broker provenance in the ceiling test fixture
kantord Oct 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6,603 changes: 3,320 additions & 3,283 deletions contracts/gen/go/mecatl/v1/harness.pb.go

Large diffs are not rendered by default.

37 changes: 27 additions & 10 deletions contracts/proto/mecatl/v1/harness.proto
Original file line number Diff line number Diff line change
Expand Up @@ -546,20 +546,22 @@ message CreateSessionRequest {
Limits limits = 3;

// provider_id selects which configured provider backs this session, by its
// stable registry id ("openai" / "openrouter"). Empty => the server default
// provider (today's behaviour). An unknown/unavailable id is a loud
// stable registry id ("openai" / "openrouter"). Empty => the agent definition's
// base provider when bound, otherwise the server default provider.
// An unknown/unavailable id is a loud
// InvalidArgument, never a silent fallback. The provider is FIXED for the
// session lifetime (reasoning-replay + byte-stable prefix are provider-private);
// "switch provider" = new session. Keys are NEVER on the wire — only this id.
// (multi-provider Phase 0, S3.)
string provider_id = 4;

// model_id is the opaque model selector within provider_id. Empty => the
// provider's default model. An id ListModels did not advertise is passed
// through to the provider VERBATIM (a power-user escape hatch for a model the
// embedded catalog doesn't yet know). NOT slash-joined with provider_id —
// two distinct fields by design. Setting model_id WITHOUT provider_id is a loud
// InvalidArgument (a bare model on an env-derived default provider is ambiguous).
// model_id is an opaque literal model selector, never expanded as an alias.
// With provider_id alone, the provider's default model applies. With neither
// field, an agent-bound session uses its definition's base provider/model pair;
// an ordinary session uses the deployment default. An id ListModels did not
// advertise is passed through VERBATIM. Provider and model are distinct fields,
// never slash-joined. model_id WITHOUT provider_id requires agent_definition_name
// to supply the base provider; otherwise it is InvalidArgument.
string model_id = 5;

// execution selects the deployment default when omitted, explicit no-filesystem
Expand All @@ -579,8 +581,8 @@ message CreateSessionRequest {
// low/medium/high only, so xhigh/max clamp DOWN to high; Anthropic maps all
// five) and CAPABILITY-GATES it (a model with no reasoning support drops it).
// An unknown value falls back to the operator default with a WARN (it never
// 400s the request). It OUT-RANKS the operator default; unlike model_id it is
// meaningful WITHOUT a provider_id (it rides the server-default provider). The
// 400s the request). It OUT-RANKS the operator default and is meaningful WITHOUT
// a provider_id (it rides the resolved provider). The
// EFFECTIVE resolved value is echoed back on resolved_model.reasoning_effort.
string reasoning_effort = 7;

Expand Down Expand Up @@ -612,6 +614,16 @@ message CreateSessionRequest {
// policy: a stdio entry and an sse entry are hard-rejected as such (AGENTS.md:
// "No stdio MCP, ever" — mecatl never spawns an MCP server process).
repeated McpServerSpec mcp_servers = 11;

// agent_definition_name optionally binds this session's root to a named
// AgentDef (ADR 0353). Empty preserves ordinary session creation. Clients MUST
// require session_agent_definitions in GetCompatibilityInfo.features before
// relying on binding, then verify resolved_agent_definition_name echoes the
// requested name. Older gRPC servers can silently ignore this field.
// Unknown definitions and combinations with debug_target_session_id,
// debug_mcp_servers, or mcp_servers are rejected. The definition supplies the
// exclusive catalog and a nonwidenable authority ceiling.
string agent_definition_name = 13;
}

// ExecutionSelection is an exclusive choice; absence means deployment default.
Expand Down Expand Up @@ -914,6 +926,11 @@ message CreateSessionResponse {
ResolvedModel resolved_model = 4;
// placement is bounded display-only metadata, never reusable authority.
PlacementMetadata placement = 5;

// resolved_agent_definition_name echoes the bound agent_definition_name
// VERBATIM (mirroring resolved_model), the same server-owns-resolution
// discipline: empty when the request never set agent_definition_name.
string resolved_agent_definition_name = 6;
}

// PlacementMetadata is safe display data. It never contains a filesystem path,
Expand Down
9 changes: 9 additions & 0 deletions docs/architecture/api-surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,15 @@ by the major's baseline do not need duplicate feature identifiers. Deployment
capabilities answer whether functionality is available here; disabling a
capability is not version skew, and advertisement does not replace authorization.

`session_agent_definitions` advertises optional root binding on both gRPC
`CreateSession.agent_definition_name` and HTTP `POST /v1/sessions`. Both echo
`resolved_agent_definition_name` and the effective `resolved_model`. It is a build
fact even with no configured definitions. Clients must require the feature and
verify the binding echo before relying on it: an older gRPC server can silently
ignore the request field. These optional fields keep API major 1 and ordinary
creation unchanged; required-authority restore checks enforce the bound-session
contract rather than adding requirements to ordinary legacy records.

For a breaking change, explain the old and proposed contracts, affected clients,
and client/server upgrade implications to the operator. Obtain explicit approval
before implementing the incompatible behavior or bumping the major. Coordinate
Expand Down
22 changes: 22 additions & 0 deletions docs/architecture/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ an error listing accepted credentials and the `--mock` escape hatch.
`CreateSession` takes an optional `provider_id`/`model_id` selector, which the server
passes on as a neutral `server.ProviderSelector` for composition to resolve.

The following table describes ordinary sessions without `agent_definition_name`.
Wire `model_id` values are opaque literals, never alias-expanded.

| Selector | Outcome |
| --- | --- |
| both empty | the shared engine on the deployment default |
Expand All @@ -108,6 +111,25 @@ passes on as a neutral `server.ProviderSelector` for composition to resolve.
| provider and model | per-session engine bound to that pair; an uncatalogued model is passed through verbatim |
| unknown or unavailable provider | invalid argument, never a silent fallback |

With `agent_definition_name`, `resolveAgentDefRootProviderModel` resolves a root
base pair from the definition over the deployment default. Definition model
aliases retain their provider/model pairing; `SubagentModel` and automatic child
routing do not participate. The five cases are:

| Selector | Agent-bound root outcome |
| --- | --- |
| both empty | definition's base pair |
| model without provider | base provider plus the request's literal model |
| provider only | requested provider's own default model |
| provider and model | explicit pair verbatim |
| unavailable explicit definition provider, without a request provider override | invalid argument, including model-only requests |

Unknown request providers also fail. An unknown or conflicting definition model
alias warns and falls back atomically to the base fallback pair, preserving an
explicit provider pin; it never rebases the alias's model onto another provider.
The response reports the effective pair. This root resolver is separate from the
child resolver's forgiving provider fallback described below.

A per-session engine is built by `sessionEngineFactory` through
`engineDepsForProvider`. That function re-derives everything that depends on the
provider or model: the LLM, the compactor, the model-keyed token counter, the
Expand Down
23 changes: 23 additions & 0 deletions engine/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ The covered surface is the eight core packages (`session`, `governance`, `learni

### Added

- **Agent-bound lifecycle persistence** — adds `Session.AgentCreateIntent`, an
opaque original explicit-ID create fingerprint, and `Session.TightenConfiguration`
for atomic, tighten-only configuration of an unpublished successor. Snapshot and
event-source metadata round-trip the optional fingerprint; older snapshots remain
readable with valid authority but cannot prove explicit-ID retry equality. No
fingerprint is inferred from effective values or inherited by successors.
Added (minor).

- **Schedule-store claim and retry conformance** — adds
`scheduleconformance.RunClaimAndRetryConformance` with the complete shared
suite plus unresolved prior-claim rejection, late-result isolation, and
Expand Down Expand Up @@ -63,6 +71,12 @@ The covered surface is the eight core packages (`session`, `governance`, `learni
aliased, and automatic router provider/model pairs while rebuilding
provider-dependent child dependencies. Added (minor).

- **Session-scoped agent identity** — adds `session.Session.AgentDefinitionName`
(a write-once creation label, empty for an ordinary session) and
`session.Authority.Ceiling` (an optional, write-once capability-set upper
bound set once at bind time for an agent-bound session; nil/absent —
unrestricted — for an ordinary session). Added (minor).

- **Configurable standard commit co-author guidance** — adds
`prompt.Config.CommitCoauthor`. `nil` and `true` include the canonical Mecatl
commit-trailer guidance in the standard stable prompt prefix; `false` omits it.
Expand Down Expand Up @@ -419,6 +433,15 @@ The covered surface is the eight core packages (`session`, `governance`, `learni

### Changed

- **Agent-bound authority enforcement** — `Session.BindAuthority` requires a
ceiling when `AgentDefinitionName` is set and rejects tools outside that ceiling.
`GrantToolAuthority` and `CompleteWorkspaceEnrollment` reject tool grants beyond
the bound ceiling. Snapshot and event-source restoration reject agent-bound
records with absent authority or ceiling; ordinary legacy unbound records remain
supported. Hosts constructing agent-bound sessions must bind consistent authority
before persisting them. Changed (breaking pre-v1 minor); existing ordinary-session
wire clients are unaffected.

- **Atomic one-shot retry eligibility (issue #2273)** —
`port.ScheduleOneShotReArmer.ReArmOneShot` now requires store-side atomic
validation of current disabled/settled-failure state, one-shot retry policy,
Expand Down
2 changes: 1 addition & 1 deletion engine/COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ Unknown custom-store failures intentionally remain `port.SessionLoadFailureUnkno
| pending ask (`PendingAsk`, when awaiting) | **MUST** | the trailing `EvPermissionAsk` with no following `EvApproval`/`EvResult` |
| cumulative `Usage` | **MUST** | the **SUM** of every per-run `EvResult.Usage` (each `EvResult.Usage` is PER-RUN; the budget brake reads the cumulative aggregate) |
| `Title`, `TitleProvenance`, `TitleGeneration`, and `TitleRevision` | **MUST** | authoritative values supplied out-of-band via `eventsource.SessionMeta`; for legacy metadata with an empty title, `Title` is seeded from the FIRST genuine `EvUserPrompt` via `session.SetTitle` (set-once + clamped), which also records `first-prompt` provenance. `TitleRevision` is a title-specific durable revision; a missing legacy value remains zero. The bounded title-source prompts, attempts, and auxiliary-usage ledger are likewise supplied via `SessionMeta`, never derived from history or normal `EvResult.Usage`. A synthesised-summary `EvUserPrompt` does not seed. For a compacted session the opener's `EvUserPrompt` was emitted before the compaction, so the fallback survives compaction. An operator-authored title cannot be reconstructed from events and therefore must be supplied. |
| creation metadata: id, mode, limits, exact `EnvironmentRef`, host-observed `ExecutionCapabilities`, safe placement metadata, profile, exact `ExecutionTemplateID` and `ExecutionTemplateRevision`, provider/model selector, reasoning-effort, debug selected-global-MCP names and exact direct-tool ceiling, **session kind and relationship**, createdAt | **MUST** (supplied out-of-band) | **NOT in any event** — provided by the caller via `eventsource.SessionMeta`; absent execution facts do not inherit current deployment defaults; the exact template selection must not be inferred from environment identity; an absent legacy kind restores as fail-closed `unknown` |
| creation metadata: id, mode, limits, exact `EnvironmentRef`, host-observed `ExecutionCapabilities`, safe placement metadata, profile, `AgentDefinitionName` (the session-scoped agent-identity binding), `AgentCreateIntent` (opaque original explicit-ID create fingerprint; missing legacy evidence stays empty, never inferred from effective values), exact `ExecutionTemplateID` and `ExecutionTemplateRevision`, provider/model selector, reasoning-effort, debug selected-global-MCP names and exact direct-tool ceiling, **session kind and relationship**, createdAt | **MUST** (supplied out-of-band) | **NOT in any event** — provided by the caller via `eventsource.SessionMeta`; absent execution facts do not inherit current deployment defaults; the exact template selection must not be inferred from environment identity; an absent legacy kind restores as fail-closed `unknown` |
| identity labels: `Owner` (the verified caller) and `Authority` (Track C, inert) | **MUST** (supplied out-of-band) | provided by `eventsource.SessionMeta`; neither is inferred from event content |
| identity label: `ExternalBinding` (opaque host runtime binding) | **MUST** (supplied out-of-band) when exact external-runtime reattachment is required; otherwise safe to omit on a pure fold | provided by `eventsource.SessionMeta.ExternalBinding`, restored by direct assignment exactly as `sessnap` does; the event annotation is log-only and the fold neither requires nor re-derives it, so an omitted field simply stays the zero value (**never** fabricated or backfilled) |
| `Counters` (turns / tool calls / consecutive failures) | run-scoped — reflects the **latest run segment** (they reset on `Reopen`), derived from the latest run's events | `EvTurnStart` (turns), `EvToolResult` (tool calls / consecutive failures) |
Expand Down
20 changes: 17 additions & 3 deletions engine/adapter/eventsource/eventsource.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,13 @@ type SessionMeta struct {
// value can be reconstructed from events or inferred from EnvironmentRef.
ExecutionTemplateID string
ExecutionTemplateRevision string
// AgentDefinitionName optionally binds the session's root to a named
// AgentDef (ADR 0353); "" is an ordinary, non-agent-bound session. Write-once
// creation label, opaque to the fold — mirrors Profile's discipline exactly.
AgentDefinitionName string
// AgentCreateIntent is opaque original-create retry evidence, not event-carried.
// Missing legacy evidence must remain empty.
AgentCreateIntent string
// ProviderID and ModelID are the opaque neutral provider+model selector pair
// ("" / "" = server default).
ProviderID string
Expand Down Expand Up @@ -133,8 +140,9 @@ type SessionMeta struct {
Incarnation session.IncarnationID
Owner *session.Principal
// Authority is the plain derived-capability payload supplied with creation
// metadata. Nil is a documented legacy record written without authority; a
// present payload is validated and bound before reconstruction proceeds.
// metadata. Nil is allowed only for ordinary legacy sessions. Agent-bound
// metadata requires authority and a ceiling; present payloads always validate
// and bind before reconstruction proceeds.
Authority *session.Authority
// WorkspaceEnrollmentBrokerKeys and its presence bit must come from the same
// committed authority revision. They are not reconstructed from events.
Expand Down Expand Up @@ -200,6 +208,12 @@ func Fold(meta SessionMeta, events iter.Seq2[session.Event, error]) (*session.Se
if err := s.RestoreSessionMetadata(meta.Kind, meta.Relationship); err != nil {
return nil, fmt.Errorf("%w: %w", ErrReconstruct, err)
}
// AgentDefinitionName restores BEFORE authority: BindAuthority (reached via
// restoreAuthority below) rejects a bind that claims this label without a
// Ceiling (ADR 0353) — it can only see the label if it is set first, exactly
// as the real create path already sequences it.
s.AgentDefinitionName = meta.AgentDefinitionName
s.AgentCreateIntent = meta.AgentCreateIntent
if err := restoreAuthority(s, meta.Authority); err != nil {
return nil, fmt.Errorf("%w: %w", ErrReconstruct, err)
}
Expand Down Expand Up @@ -302,7 +316,7 @@ func (f *folder) finalizeOpenTurn() {
}

func restoreAuthority(s *session.Session, authority *session.Authority) error {
if authority == nil {
if authority == nil && s.AgentDefinitionName == "" {
return nil
}
if err := sessnap.ValidatePersistedAuthority(authority); err != nil {
Expand Down
65 changes: 65 additions & 0 deletions engine/adapter/eventsource/eventsource_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (

"github.com/stacklok/mecatl/engine/adapter/eventsource"
"github.com/stacklok/mecatl/engine/adapter/sessnap"
"github.com/stacklok/mecatl/engine/governance"
"github.com/stacklok/mecatl/engine/session"
)

Expand Down Expand Up @@ -77,6 +78,70 @@ func TestEventFoldRejectsEmptyAuthorityClaim(t *testing.T) {
}
}

// TestSessionScopedAgentIdentity_FoldRejectsAgentBoundMetaWithoutCeiling pins
// the eventsource counterpart to sessnap's equivalent hardening: SessionMeta
// claiming AgentDefinitionName without a bound Ceiling would otherwise fold
// into a session routed everywhere as agent-bound yet fully widenable in
// practice (GrantToolAuthority/CompleteWorkspaceEnrollment treat a nil Ceiling
// as unrestricted). Fold must reject this combination, not silently restore an
// unsafe session. Mutation-verified: this test failed (folded successfully)
// before AgentDefinitionName was restored ahead of authority in Fold.
func TestSessionScopedAgentIdentity_FoldRejectsAgentBoundMetaWithoutCeiling(t *testing.T) {
t.Parallel()

t.Run("nil authority is rejected", func(t *testing.T) {
m := meta()
m.AgentDefinitionName = "escalator"
if restored, err := eventsource.Fold(m, seq(nil)); !errors.Is(err, eventsource.ErrReconstruct) || restored != nil {
t.Fatalf("missing authority folded: %v, %v", restored, err)
}
})

t.Run("agent-bound label with a Ceiling-less authority is rejected", func(t *testing.T) {
m := meta()
m.AgentDefinitionName = "escalator"
m.Authority = &session.Authority{
CapabilitySet: governance.CapabilitySet{Tools: []string{"Read"}},
Provenance: "agent-def:test",
}
restored, err := eventsource.Fold(m, seq(nil))
if err == nil {
t.Fatal("agent-bound meta without a Ceiling folded successfully")
}
if restored != nil {
t.Fatal("agent-bound meta without a Ceiling returned a session")
}
if !errors.Is(err, eventsource.ErrReconstruct) {
t.Fatalf("error = %v, want ErrReconstruct", err)
}
})

t.Run("agent-bound label WITH a matching Ceiling folds fine (positive control)", func(t *testing.T) {
m := meta()
m.AgentDefinitionName = "escalator"
m.AgentCreateIntent = "v1:opaque-create-intent"
m.Authority = &session.Authority{
CapabilitySet: governance.CapabilitySet{Tools: []string{"Read"}},
Provenance: "agent-def:test",
Ceiling: &governance.CapabilitySet{Tools: []string{"Read"}},
}
restored, err := eventsource.Fold(m, seq(nil))
if err != nil {
t.Fatalf("agent-bound meta with a consistent Ceiling: fold failed: %v", err)
}
if restored.AgentCreateIntent != m.AgentCreateIntent {
t.Fatalf("create intent = %q, want %q", restored.AgentCreateIntent, m.AgentCreateIntent)
}
if restored.AgentDefinitionName != "escalator" {
t.Fatalf("AgentDefinitionName = %q, want escalator", restored.AgentDefinitionName)
}
got, bound := restored.BoundAuthority()
if !bound || got.Ceiling == nil {
t.Fatalf("bound=%v Ceiling=%v, want a bound authority with a non-nil Ceiling", bound, got.Ceiling)
}
})
}

// ev is a terse Event constructor.
func toolCall(id, name, args string) session.ToolCall {
return session.NewToolCall(session.ToolCallID(id), name, json.RawMessage(args))
Expand Down
Loading
Loading