Skip to content

M2-12: Agent sessions per thread

Status: ready | Depends on: M2-09 | Milestone: M2

Goal

After this ticket every thread has at most one current agent session and a history of earlier ones, kept by the module agents in the table agent_sessions (03). People read the history (listAgentSessions), start a new session (resetAgentSession, the built-in /new) and bind an existing external session with a compare-and-swap over the current session (bindAgentSession, the built-in /resume, with take_over for a session that is current in another thread); the adapter reports the external reference, the model, started and a failed binding (reportAgentSession) under If-Match. The run path carries the session: every invocation of the thread's operator names its session mode (new, resume or bind) and its session row, run.start with a reference creates the row, otherwise the first report creates it, and an imported earlier session is bound exactly once at the first trigger and fails open with exactly one system line when the runtime no longer knows it (AG-18). A stale expected_current_session_id and a take-over without take_over give 409 (AG-17); two concurrent reports never both succeed; an external session is current in at most one thread. Why: a thread is the unit of an agent's conversation (01 section 1 point 2; REUSE L01 and L14). Without sessions an adapter could not continue a conversation after a restart, people could neither start over nor resume, and the import could not carry earlier sessions over (22 section 9).

Read first

  • docs/PLAN.md: section 3 "Way of working per ticket"; section 8 "M2 Agent platform, extensions and terminal" (Goal, Scope, Out of scope, Exit criteria items 1 and 10, Test strategy, Red team first paragraph with "another person triggering a personal subscription through every path (..., session switch, ...)", Risks), block "agents and extensions", rows M2-05, M2-08, M2-09, M2-11, M2-12, M2-13, M2-14, M2-15, M2-16, M2-22, M2-28, M2-31, M2-33, M2-34, M2-35; section 14 rows M8-03, M8-04 and M8-06 (import API, import tool, binding of imported session references); V2-01 (dev_environments); section 19 P-04, P-09, P-10, P-11.
  • docs/spec/09-agents.md: 2.1 (row "Sessions"); 3.6 "Guest agents" (a guest run has no session); 3.7; 4.1; 4.4 (row agent.invocation, field session); 4.7 (rows "Start run" and "Report session"); 4.9; section 9 completely (9.1 "Lifecycle" with the mode table, 9.2 "New session (/new)", 9.3 "Previous sessions", 9.4 "Rebinding (/resume)", 9.5 "Import binding and fail open"); 10.1 (/new, /resume, /status); 16.1 and 16.2 (AG-17, AG-18; "Real runtimes with model calls run only behind an explicit switch"); 17.4 "Session key"; 18.1 and 18.9 (pre-assigned session ID of the runner, "Session missing in Claude Code", "Session already exists at the first turn"); 19 (R-AG-02, R-AG-04); 20 (E-AG-19); 21.
  • docs/spec/03-data-model.md (core contract, read only): agent_sessions (every column, the check is_current = (ended_at IS NULL), both partial unique indexes, the index for the history, the table "Meaning of end_reason"); runs.agent_session_id and its FK ON DELETE SET NULL, the partial index of R-DM-14; cost_entries.agent_session_id; messages (origin, kind, system_code and its CHECK, the paragraph on system lines after "Retention", R-DM-34, R-DM-42); threads.operator_agent_id (R-DM-12, R-DM-21); channels (effective operator); 6.3 "Lock order".
  • docs/spec/05-actions.md: rule 1.6 (a "session change" also checks agent.invoke), rule 1.8 (read-only channels), the table of section 2.1 with agent-self, 4.6 rows agent_session.read, agent_session.reset, agent_session.bind, agent_session.report; section 5 "Agent invocation agent.invoke" points 1 to 8; section 6 row agent_session.changed; R-AZ-01, R-AZ-04.
  • docs/spec/04-sync-protocol.md: 2.2 "Envelope"; 9 "Delivery guarantee" point 3 (agent_session.changed has no resource.version); 15.3 "Invocation and run start" steps 1 and 4 (external_session_ref optional in run.start; the first agent_session.report creates the row).
  • docs/spec/07-permissions.md: 4.1, 4.2 "Flow" (actions with two targets, audit of denials), 4.3 "Visibility", SR4, SR9 (agent_session.report is an exception), 10 "Examples" and 11.1 "Test matrix" (the world, AZ-M20), 11.2 (AZ-P1, AZ-P13).
  • docs/spec/06-api.md: 2 (row "Unknown values"), 5 "Errors", 6 "Pagination" point 6, 7 "Idempotency", 8 "Concurrency", 16 groups "Threads" and "Agent runtime", 17 row agent_session.changed.
  • docs/spec/22-import-migration.md: section 9 "Binding to earlier agent sessions" (steps 1 to 7) and section 16 row "Binding once".
  • docs/spec/02-architecture.md: 4.2 (module agents owns agent_sessions), 4.3 (port Sessions of agents; In-Tx events), 4.4 (edges runs -> agents, runs -> messaging, agents -> chat; empty sources), 4.5, 5 "Layers within a module", 6 "Code layout", 8.1 to 8.4.
  • docs/spec/10-backend-policy.md: 12.2 (session sum in the thread header). docs/spec/01-product.md: 3.2 row "One agent session per thread, earlier sessions, start over, resume". docs/spec/19-security.md: SEC-IN-4, SEC-OUT-3, SEC-AUD-5. docs/spec/20-privacy.md: row agent_sessions.
  • docs/api/openapi.yaml: operations listAgentSessions, resetAgentSession, bindAgentSession, reportAgentSession, startRun, resumeRun; schemas AgentSession, AgentSessionList, AgentSessionBind, AgentSessionReport, AgentSessionChangedPayload, AgentSessionChangedEvent, AgentInvocationPayload, RunStart, Money, Message (system_code); parameters ThreadIdPath, IdempotencyKey, IfMatchOptional; header ETag; responses Conflict, PreconditionFailed, NotFound, UnprocessableEntity.
  • docs/api/asyncapi.yaml: channel gateway, message event.agent_session.changed. docs/api/webhooks.md: row agent_session.changed.
  • ADRs 0008, 0009, 0013, 0015, 0017, 0021, 0022, 0024, 0025, 0043, 0049, 0050, 0052, 0053.
  • docs/DECISIONS.md: D-K-I5, D-K-I6, D-W-20, D-W-100, D-W-104, section 10 "Rulings of the build milestones" (row format D-M2-12-<n>).
  • docs/security/threat-model.md: T-AI-01 ("a runtime retains knowledge"), T-AI-08. docs/RISKS.md: RK-12. docs/REUSE.md: L01, L14.
  • docs/spec/00-glossary.md: row "Done"; docs/spec/01-product.md 6.1. docs/operations/upgrade.md: section 4, rules M5, M6 and M8 (Design note 1).
  • docs/operations/configuration.md: database.tx_retry_max, audit.deny_aggregate_window, sync.event_payload_max_bytes, http.max_json_body_bytes; no key limits external_session_ref yet (Design note 13).
  • M1 briefs and merged code this ticket builds on: M1-12 (pgtest, migrations, make migrations-lock), M1-13 (txn.Publish, txn.Subscribe, registry golden, direction rule, nested Publish), M1-16 (eventlog.Appender, eventlog.Event), M1-17 (api/operations.yaml, strict-server generation), M1-18 (HTTP chain, problem rendering, httpservertest), M1-19 (rate-limit classes), M1-20 (Idempotency-Key), M1-24 (auditing.Recorder with Record, RecordDenial and the value kinds Code, Bool, List), M1-25 (registration duty, AZ-P1), M1-26 (kernel/textcheck.DisplayText), M1-28 (authorize.Resource, InvokeFacts, the requirement table, the "Deferred entries" list, the agent.invoke guard, TestDeferredActionsGolden), M1-44 (agent API tokens), M1-51 (layout of internal/agents), M1-54 (effective operator, world builder in test/integration), M1-55 (chat.Threads.Info, ThreadInfo, SetTitle), M1-56 (messaging.Messages, MessagePosted, D6 system line, AppendToThread with ReopenIfClosed), M1-65 (generic delivery by receive action).
  • M2 code on the base branch: M2-05 (runs table and state machine), M2-07 (agent consumer, pollAgentEvents), M2-08 (the writer of agent.invocation), M2-09 (startRun, resumeRun, its integration-test helpers that accept, deliver and start a run as the agent, and its mapping of agent.invoke denials to 403 agent_invocation_denied for AG-29). Code paths: internal/agents, internal/runs, internal/messaging, internal/chat, internal/authz, internal/kernel/authorize, internal/kernel/auditing, internal/kernel/eventlog, internal/kernel/txn, internal/kernel/textcheck, internal/wire, api/operations.yaml, architecture/modules.yaml.
  • Implied dependencies: the header names only M2-09. M2-08, M2-07, M2-06, M2-05, M2-04, M2-02, M1-65, M1-56, M1-55, M1-54, M1-51, M1-44, M1-28, M1-24, M1-20, M1-13 and every ticket they build on are in its closure and therefore merged before this brief starts. M1-62 (moving threads), M1-68, M1-69 and M1-71 are in the closure through M2-02 as well; M1-70 is not: do not rely on it.

Fixed requirements that apply

Owner's fixed build rules (apply verbatim):

  1. No MVP. New from scratch and directly in the final state; stages are only the order.
  2. Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
  3. Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
  4. Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
  5. Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.

Rules for every brief:

  1. Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
  2. No en dash and no em dash in any file you write; the text-rules pre-commit hook rejects them. Use a hyphen or rephrase.
  3. White label: examples and test data use example.com and the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory.
  4. Limits, windows and thresholds only through named configuration keys listed in docs/operations/configuration.md; code comments and docs name the key, never the number.
  5. Specification first: a change to behaviour, API, event, permission or configuration updates docs/spec/ or docs/api/ in the same change. The core contract (docs/spec/00-glossary.md, 03-data-model.md, 04-sync-protocol.md, 05-actions.md) is not edited by this ticket.

Product requirements implemented here:

  1. At most one current session per thread, and an external session (agent_id, external_session_ref) is current in at most one thread; both are partial unique indexes and hold under concurrency (03 agent_sessions; PLAN row M2-12).
  2. listAgentSessions returns the current and the earlier sessions of a thread with reference, model, cost, period and end reason; permission read on the thread, rl.read, no audit (09 9.3, 05 4.6).
  3. resetAgentSession ends the current session with end_reason = 'reset', writes agent_session.changed, and the next run gets session.mode = new; it never disturbs a run that is active (09 9.2). Permission trigger and agent.invoke (05 4.6, rule 1.6), rl.trigger, Idempotency-Key, no allow audit.
  4. bindAgentSession binds an external session to the thread with expected_current_session_id as compare-and-swap and take_over for a session current elsewhere: 409 conflict with reason session_bound_elsewhere or session_changed, nothing changes on a 409; the old rows end with rebind, the new row has started = true, the next run gets bind; permission trigger and agent.invoke here and, when taking over, also there; audited (09 9.4, 05 4.6, 07 4.2, AG-17).
  5. reportAgentSession lets the adapter report reference, model, started and bind_failed for the session of its own active run in the thread, as compare-and-swap over agent_sessions.version with If-Match; the first report creates the row when run.start carried no reference; permission agent-self, rl.agent_runtime, SR9 exception (05 4.6, 09 4.7, 9.1, 9.5, 04 15.3 step 4, 07 SR9).
  6. Every operator invocation carries its session: mode new, resume or bind and the session row; a guest run carries neither (09 9.1, 3.6, R-AG-04). run.start with external_session_ref creates the row (is_current, started = false) and sets runs.agent_session_id; otherwise the first report sets it (09 9.1, 03 runs.agent_session_id, E-AG-19).
  7. An imported earlier session is bound at the first trigger in the run's transaction, exactly once (compare-and-swap over the partial indexes), with audit agent_session.bind and agent_session.changed, never after the thread had a current session; when the runtime no longer knows it, the adapter reports bind_failed: session_missing, the bound row ends with rebind, a new row starts and exactly one system line agent_session.resume_failed with the run's agent as author is written, which does not reopen a closed thread (03 messages, R-DM-42); the run continues (09 9.5, 22 section 9, AG-18).
  8. After an operator change in a main thread the session of the former operator ends with agent_changed at the next run (03 end_reason, R-AG-02).
  9. agent_session.changed: resource thread without resource.version, receive action thread.read, payload AgentSessionChangedPayload (05 section 6, 04 9 point 3, openapi).
  10. No read permission gives 404, 403 only with read permission (R-AZ-01); every denial is audited by authz (R-AZ-04, AZ-P13); audit details carry only IDs and codes, never the reference (SEC-AUD-5).
  11. Versioned answers carry ETag; no JSON null (06 8, ADR 0053); unknown request fields give 400 validation_failed (06 2 "Unknown values").

Scope

All paths under services/server/ unless noted. Names of merged code win over the names below (Boundaries).

Create:

  • migrations/<next free number>_agents_agent_sessions.sql (Design note 3).
  • Only when the merged CHECK of M1-56 on messages.system_code lacks agent_session.resume_failed: migrations/<next free number + 1>_messaging_system_code_resume_failed.sql and migrations/<next free number + 2>_messaging_system_code_validate.sql (Design note 1).
  • internal/agents/domain/session.go: the session entity with its end transitions, the report rules of Design notes 11 and 12, the import-binding rule of Design note 9, the cost baseline of Design note 14 and the validation of Design note 13, all pure.
  • internal/agents/app/sessions.go: use cases List, Reset, Bind, Report, ForInvocation, AttachOnStart (Design notes 6 to 12).
  • internal/agents/adapters/postgres/queries/agent_sessions.sql and the repository.
  • internal/agents/adapters/httpapi: strict-server methods of listAgentSessions, resetAgentSession, bindAgentSession, reportAgentSession.
  • internal/agents/adapters/agentstest (or the merged test-helper package of agents): InsertImportedSession(ctx, ImportedSession{ThreadID, AgentID ids.ID; ExternalSessionRef string; CostBaselineMicros int64; Currency string}), which writes a row exactly as 22 section 9 step 1 describes (the importer of M8 is not built yet); keep it out of the binary (test like M1-33's identitytest).
  • internal/runs/domain/session_mode.go: the pure function of Design note 9 (mode from row, started and "a run used this row").
  • internal/runs/adapters/postgres: the implementation of agents.RunSource (Design note 2).
  • test/integration/agentsessions/: the cross-module tests of this ticket.

Change:

  • internal/agents (port): interface Sessions with ForInvocation(ctx, InvocationSessionQuery) (InvocationSession, error) and AttachOnStart(ctx, AttachOnStartCmd) (ids.ID, error) for runs, plus the four operation methods; DTO Session (all columns that AgentSession shows, plus CostBaselineMicros); declared source RunSource with ActiveRun; In-Tx event SessionAttached (Design note 2).
  • internal/runs: the single constructor of AgentInvocationPayload takes the session decision as a required argument; the transaction that appends agent.invocation calls agents.Sessions.ForInvocation for operator runs; the queued -> running path of startRun calls agents.Sessions.AttachOnStart and sets runs.agent_session_id; subscriber OnSessionAttached (Design notes 2, 9, 10, 12).
  • internal/messaging (port and app): Messages.AppendSystemLine(ctx, SystemLine{ThreadID, AuthorID ids.ID; Code string}) (Message, error) built on M1-56 D6 (Design note 12).
  • internal/chat (port): a read-only lookup of a thread's operator only if no port function for it exists (Design note 2).
  • internal/kernel/authorize and internal/authz: descriptor field TargetThreadID; requirements of agent_session.reset and agent_session.bind instead of the deferred entries; TestDeferredActionsGolden without them (Design note 5).
  • internal/wire: handlers, the RunSource wiring, the subscription agents.SessionAttached -> runs; the golden file of subscriptions.
  • api/operations.yaml: move listAgentSessions, resetAgentSession, bindAgentSession, reportAgentSession from later to enabled; rerun the route generator.
  • architecture/modules.yaml (module register): the new event and source of agents, the subscription and source implementation of runs, the port addition of messaging; regenerate what make arch and make register-check read.
  • The configuration key of Design note 13 in the configuration schema (M1-07) and in docs/operations/configuration.md.
  • docs/api/openapi.yaml and docs/spec/ as listed in Design note 17 (specification first).
  • READMEs of agents, runs, messaging, authz (and chat if changed); services/server/CHANGELOG.md; docs/DECISIONS.md section 10 rows D-M2-12-<n>.

This ticket adds no job, no metric, no new problem code and no new top-level make target.

Out of scope

  • Booking costs into cumulative_cost_micros, cost_is_estimate after a reconciliation, hiding amounts for personal agents, the thread header sum: M2-22 and M2-28. Here every new row starts with cumulative_cost_micros = 0.
  • Writing earlier session rows from an import (--sessions, import.apply): M8-03, M8-04 and M8-06; tests insert such rows through the helper above. The server-side binding of such rows at the first trigger, with fail open and cost baseline (22 section 9), is built here although PLAN row M8-06 names it too, because AG-18 is in this ticket's row and in the M2 exit criteria (item 1); M8-06 reuses it and adds the path from the import tool. The report names this overlap.
  • The command registry with the reserved names /new and /resume (M2-16); the client views with the session column and the /resume selection (M4, M5, M6).
  • How adapters use the modes, the "note once in the box" of the live box and the reference handling in the runtimes: SDKs (M2-29, M2-30) and reference adapters (M2-33, M2-34, M2-35); the data-driven scenarios AG-17 and AG-18 against the fake agent (M2-31).
  • Starting, finishing, cancelling and renewing runs (M2-09), delivery and tokens (M2-06, M2-08), follow-ups (M2-11), run context (M2-13), the acting_for binding (M2-14).
  • Moving a thread with a session: the thread ID never changes (03 threads.id), so the rows stay; the move half of REUSE L01 needs M1-62 and is not part of this ticket.
  • dev_environment_id and its FK (V2-01, V2-02); it stays NULL.
  • Delivery of agent_session.changed over the gateway (generic, M1-65) and over webhooks (M2-26); retention of the class content (M7).
  • Changes to the core contract.

Boundaries

  • A new migration needs its line in migrations.sha256: run make migrations-lock after creating the file and before make check; never edit a merged migration (M1-01, ADR 0007).
  • Work in a dedicated git worktree on the branch feat/M2-12-agent-sessions; one writer per worktree.
  • Conventional Commits with the module as scope (for example feat(agents): ..., feat(runs): ..., docs(api): ...) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md).
  • Never push to main, never tag and never create remote repositories; the only remote steps are the branch push and the pull request of AGENTS.md.
  • Do not modify files outside the scope above, except the CHANGELOG, README and docs/DECISIONS.md entries named in this brief.
  • Follow the host rules supplied by the launcher (ports, machine rules).
  • Tests bind only 127.0.0.1 and port 0; PostgreSQL runs only as a throwaway container on a random port through pgtest (P-09, P-10). Without a Docker-compatible runtime the integration tests fail; they never skip.
  • Stop only processes you started, by PID. Temporary directories only via mktemp -d, removed at the end.
  • Never read or print secrets. No network calls except package downloads for pinned tools and Go modules. No real agent runtime and no model call: the tests play the adapter through the public API with an agent token (09 16.1); a real runtime runs only behind an explicit switch, and this ticket adds none.
  • No new third-party Go module.
  • Never lower a threshold, disable a gate or delete a test.
  • If the core contract lacks something this ticket needs, stop with Verdict: BLOCKED <reason> instead of building a deviation (P-11). The code of the fail-open system line is in the core contract (03 messages.system_code, R-DM-42, D-W-104; Design note 1).
  • This brief was written before milestone M1 closed (D-W-100). Where the merged code of an M1 ticket names a package, type, function, file or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with Verdict: BLOCKED <reason>.
  • The same applies to the merged code of M2-05, M2-07, M2-08 and M2-09: follow their names; if their behaviour leaves no place for the calls of Design notes 9 and 10 without changing a specified behaviour, stop with Verdict: BLOCKED <reason>.

Design notes

  1. The code of the fail-open system line (03 messages, R-DM-34, R-DM-42, D-W-104). 09 9.5 point 4 and 22 section 9 step 5 require exactly one system message when an imported or rebound session is missing. A system message is kind = 'system' with a code in messages.system_code (SEC-OUT-3). The core contract has the rule: the CHECK of messages.system_code in 03 lists agent_session.resume_failed, and 03 states that when the server accepts an agent_session.report with bind_failed, it writes in the same transaction exactly one such line with the run's agent as author, and that a system line does not reopen a closed thread (also 00 "Done" and 01 6.1). Use exactly that code; the enum Message.system_code in openapi.yaml lists it as well. Before writing any code, read the CHECK on messages.system_code in the merged migration of M1-56 (never edit that file). If it already lists both codes of 03 (M1-56 builds its CHECKs as 03), add no migration for it. If it lists only thread.reopened (M1-56 was built from a 03 before R-DM-42), the migration <next free number + 1>_messaging_system_code_resume_failed.sql drops that CHECK (its name from the merged migration, or the PostgreSQL default name when that migration names none) and adds it again with exactly the two codes of 03 as NOT VALID, and <next free number + 2>_messaging_system_code_validate.sql validates it (upgrade.md M5 and M8, M6 through the runner of M1-12). Any other content of that CHECK is a deviation of M1-56 from 03: stop with Verdict: BLOCKED <reason> as the Boundaries say for merged M1 code (D-W-100). Never write the note as text, as another code or as a normal message.

  2. Module boundaries (02 4.3, 4.4, 8.3).

    • agents owns agent_sessions, the four operations and every session rule; no other module reads or writes the table (03 principle 12).
    • runs calls the port agents.Sessions in its own unit of work: ForInvocation(ctx, InvocationSessionQuery{ThreadID, AgentID, TriggeredBy ids.ID}) (InvocationSession, error) with InvocationSession{Row *Session} (Design note 9) and AttachOnStart(ctx, AttachOnStartCmd{ThreadID, RunID, AgentID ids.ID; ExternalSessionRef, Model string}) (sessionID ids.ID, err error) (Design note 10).
    • agents declares the source RunSource with ActiveRun(ctx, threadID ids.ID) (ActiveRun, bool, error), ActiveRun{RunID, AgentID, SessionID ids.ID; State string}: the run of the thread in queued, running or waiting_input (R-DM-14), its row locked FOR UPDATE. runs implements it in its postgres adapter; internal/wire wires it (an empty source answers "no run" until wiring, 02 4.4).
    • agents publishes the In-Tx event SessionAttached{ThreadID, RunID, AgentID, SessionID, ReplacedSessionID ids.ID; BindFailed bool}; runs.OnSessionAttached sets runs.agent_session_id = SessionID for that run and, when BindFailed, writes the system line through messaging.Messages.AppendSystemLine (Design note 12). runs may use messaging and subscribe to agents (02 4.4, M1-13 direction rule).
    • The operator of a thread comes from chat: ThreadInfo.OperatorAgentID for kind = thread, the channel's effective operator for a main thread (03 channels, R-DM-21). Reuse the port function the trigger path of M2-04 and M2-05 uses; add a read-only port method to chat only if none exists.
    • Ruling for docs/DECISIONS.md: RunSource and SessionAttached. Reason: a report must find the reporting agent's active run and the row of runs.agent_session_id belongs to runs (03), while agents may not use runs (02 4.4); a declared source and an In-Tx event keep every edge downward, as the authz sources and agents.AgentDisabled do. Update 02 4.3 (rows agents, runs, messaging) and the module register.
  3. Migration. Table agent_sessions exactly as 03: every column with type, null and default; CHECK (end_reason IN ('reset','rebind','import','agent_changed')); CHECK (is_current = (ended_at IS NULL)); FKs thread_id -> threads(id) ON DELETE RESTRICT, agent_id -> agents(principal_id) ON DELETE RESTRICT; unique partial indexes (thread_id) WHERE is_current and (agent_id, external_session_ref) WHERE is_current; index (thread_id, created_at DESC). Deliberate deviation, commented in the file: the FK dev_environment_id -> dev_environments(id) comes with V2-01 (the table does not exist); the column exists now. In the same migration: the FK runs.agent_session_id -> agent_sessions(id) ON DELETE SET NULL that M2-05 could not create (add the column too if the merged runs lacks it). cost_entries does not exist yet; M2-22 (which depends on this ticket, D-W-103) adds its FK agent_session_id -> agent_sessions(id) ON DELETE RESTRICT.

  4. Representation. AgentSession from a row: id, thread_id, agent_id, external_session_ref, is_current, started, model (omitted when NULL), cost = Money{amount_micros: cumulative_cost_micros, currency, is_estimate: cost_is_estimate}, created_at, ended_at and end_reason (omitted when NULL), version; never cost_baseline_micros or dev_environment_id; no null (ADR 0053). Single-session answers carry ETag: "<version>" (06 8.1). listAgentSessions sorts by created_at DESC, id DESC and is not paginated (AgentSessionList has only items); Ruling for docs/DECISIONS.md: the sessions of one thread are a small list in the sense of 06 6 point 6; add them to that list.

  5. Permissions (05 4.6, 07 4.2, M1-28).

    • agent_session.read: Level(read) on the thread, as M1-28 maps it; descriptor {kind: thread, id: T}.
    • agent_session.reset and agent_session.bind are "Deferred entries" of M1-28. Replace each with Level(trigger) plus the agent.invoke guard of M1-28 ("trigger and agent.invoke", 05 4.6; rule 1.6 names the "session change"), remove both from the deferred list and from TestDeferredActionsGolden. Descriptor {kind: thread, id: T, Invoke: &InvokeFacts{AgentID: operator of T, TriggerOrigin: "human", MentionsAgent: true}} with ActiveRunAgentID zero. Ruling for docs/DECISIONS.md: a session switch addresses the thread's operator explicitly, so the mention rule of 05 section 5 point 3 counts as met, and it starts no run, so point 8 does not apply; points 1, 2 and 4 to 7 apply unchanged (I2, SR2, project readiness). A thread without operator has AgentID zero and the guard denies with agent_not_reachable.
    • Answers: no read on the thread gives 404 not_found; a missing level gives 403 forbidden (level_insufficient); a read-only or archived channel gives 403 channel_read_only; a guard reason gives 403 agent_invocation_denied with that reason, mapped exactly as M2-09 maps agent.invoke denials of run.token.renew (AG-29).
    • Second target of agent_session.bind: add TargetThreadID ids.ID to authorize.Resource ("agent_session.bind: thread in which the reference is current"). authz resolves its path; visibility and the same requirement (level and guard with the same Invoke facts) hold on both paths; an invisible target gives not_found with exactly the body and headers of an unknown thread ID (07 4.2). Ruling for docs/DECISIONS.md, and add agent_session.bind to the sentence on actions with two targets in 07 4.2: 09 9.4 point 1 checks the permission "also there", and 07 4.2 forbids a second check in the use case.
    • agent_session.report: AgentSelf (05 table: "the principal is the agent that owns the resource"); descriptor {kind: agent_session, id: session of the run or zero, ThreadID: T, OwnerID: the run's agent}. SR9 lists it as exception, so no acting_for intersection applies (M2-14 builds that).
    • Denials are audited by authz through RecordDenial (R-AZ-04); the use cases audit nothing else on denial.
  6. listAgentSessions. Load the thread through chat (unknown gives 404); authorize agent_session.read; return every row of the thread (Design note 4). An empty history is {items: []}.

  7. resetAgentSession (09 9.2), one unit of work: a. Load the thread (unknown gives 404) and its operator; authorize (Design note 5). b. Lock the thread's current row FOR UPDATE. None: 409 conflict with reason no_current_session. Ruling for docs/DECISIONS.md, documented in 09 9.2: there is nothing to end, and a 201 without a session would have no body that fits AgentSession. c. End it: ended_at = now(), is_current = false, end_reason = 'reset', version + 1; append agent_session.changed; answer 201 with the ended row and its ETag. d. Ruling for docs/DECISIONS.md, documented in 09 9.2: a reset during an active run ends the current row at once and leaves the run alone; the run keeps runs.agent_session_id, its reports still reach that row (Design note 11), and the next run gets new. Reason: 03 has no column for a pending reset, and "waits until it ends" means the active run is not switched; the visible effect (the next run starts a new session, the command answers "after the run") is the same.

  8. bindAgentSession (09 9.4), in this order: a. Load thread T (unknown gives 404) and its operator A. b. When A is set, find the current row of (A, external_session_ref) in another thread T2 (no lock yet). c. Authorize with TargetThreadID = T2 (zero when none) (Design note 5). d. Validate the body (Design note 13); expected_current_session_id and take_over as in AgentSessionBind. e. T2 set and take_over false: 409 conflict with reason session_bound_elsewhere; nothing changes. f. Unit of work: lock T's current row and, if taking over, T2's row FOR UPDATE in ascending id. Compare-and-swap: the ID of T's current row (or none) must equal expected_current_session_id (or its absence), else 409 conflict with reason session_changed and nothing changes (09 9.4 point 3; 412 belongs to If-Match). If T2's row is no longer current, continue without a take-over. g. End T's current row with rebind; when taking over, end T2's row with rebind; insert the new row (T, A, reference, started = true, is_current, cost baseline of Design note 14) with INSERT ... ON CONFLICT DO NOTHING. No row inserted: re-read and answer 409 conflict with reason session_changed (T got another current row) or session_bound_elsewhere (the reference became current in a third thread). h. Events (Design note 15), the allow audit entry, 201 with the new row and its ETag. The next run gets bind (Design note 9). A bind during an active run leaves that run on its row, like a reset.

  9. Session of a new run (09 9.1, 9.5; 22 section 9). runs calls ForInvocation in the transaction that appends agent.invocation, only for a run of the thread's operator; a guest run gets no session (09 3.6, R-AG-04). ForInvocation: a. Lock the thread's current row FOR UPDATE. b. Current row of another agent: end it with agent_changed and append agent_session.changed (03 end_reason, R-AG-02). c. Current row of this agent: return it. d. No current row: when the thread has no row other than rows with end_reason = 'import' and has an import row of this agent, take the newest (created_at, then id, descending) and insert a current row with the same reference, started = true, its model, cost_baseline_micros and currency (ON CONFLICT DO NOTHING). Inserted: audit agent_session.bind with the triggering human as actor and cause import, append agent_session.changed, return the row. Not inserted because the thread got a current row concurrently: re-read and return it (exactly one of two concurrent first triggers binds, 22 section 9 step 3). Not inserted because the reference is current in another thread: return none (fail open, 22 section 9 steps 3 and 5). Ruling for docs/DECISIONS.md: "has had no current session since the import" means the thread has no row other than import rows, so a later reset never binds again (09 9.5 point 5). e. Otherwise return none. The mode is decided in runs/domain (runs owns the table that tells whether a run used a row): no row gives new without agent_session; a row with started = false gives new with the row (the adapter reuses its reference, REUSE L14); a started row that some run already used (runs.agent_session_id) gives resume; a started row no run used gives bind. The payload carries agent_session and the new member session_mode; a guest run has neither. Ruling for docs/DECISIONS.md: session_mode is the openapi name of session.mode in 09 9.1 (Design note 17). The single constructor of AgentInvocationPayload takes the session decision as a required argument, so every writer of agent.invocation (acceptance, a released held invocation, follow-up runs, interactions without a waiting run, model commands, whichever are merged) carries it.

  10. run.start (09 9.1, 4.7, 04 15.3 step 4). The queued -> running path of startRun calls AttachOnStart after its token checks and its compare-and-swap succeeded; resumeRun (waiting_input -> running) does not, because the run keeps its row. AttachOnStart:

    • Guest run (the agent is not the thread's operator): return zero; the reference is ignored.
    • Lock the current row. A current row of this agent: attach it, unchanged even if the reference or model in run.start differ.
    • No current row of this agent and a reference given: the most recent row of this thread and agent with that reference (a reset or bind ended it after the run was queued): attach it, unchanged. Otherwise insert a current row with the reference, model and started = false (ON CONFLICT DO NOTHING), append agent_session.changed, attach it; when nothing was inserted, attach nothing and log the run and thread IDs (no reference).
    • No reference: attach nothing; the first report creates the row. runs writes runs.agent_session_id in the same transaction. Ruling for docs/DECISIONS.md: run.start never changes an existing row, so the version the adapter got with the payload stays valid for its first If-Match, and a new row starts at version 1 (03 default); a changed reference or model of an existing row comes only through agent_session.report. The reference is validated as in Design note 13; an invalid one fails startRun with 400 validation_failed and pointer /external_session_ref.
  11. reportAgentSession (05 4.6, 09 4.7, 9.1, 9.5): a. Load thread T. Read the active run through RunSource (locks its row). No active run, a run of another agent, a state other than running or waiting_input, or a run whose agent is not T's operator (a guest run has no session): 404 not_found with the body of an unknown thread. b. Authorize (Design note 5). c. Validate the body (Design note 13); unknown members give 400 validation_failed. d. Let R be the row of runs.agent_session_id, locked FOR UPDATE.

    • R exists: If-Match must equal R's version; absent or different gives 412 version_conflict with current = R. With bind_failed: Design note 12. Otherwise apply: a different reference replaces R's reference (if R is current and the new reference is current in another thread: 409 conflict with reason session_bound_elsewhere); a given, different model replaces it; started: true sets it; started: false never resets it. Nothing changed: 200 with R, no version change, no event. Changed: version + 1, agent_session.changed, 200.
    • No R: If-Match present gives 412 version_conflict without current; bind_failed gives 409 conflict with reason session_not_bound. Insert a current row (T, A, reference, model, started as given, absent is false) with ON CONFLICT DO NOTHING. Inserted: agent_session.changed, publish SessionAttached, 200. Not inserted: 409 conflict with reason session_changed (the thread got a current row, for example by a bind during the run) or session_bound_elsewhere. e. Answer with ETag. Because step a locks the run row, the second of two concurrent first reports sees the row the first created and gets 412 version_conflict with current; two reports with the same If-Match give one 200 and one 412 (PLAN acceptance "two concurrent reports yield exactly one success"). Ruling for docs/DECISIONS.md: If-Match is the compare-and-swap of the report (IfMatchOptional): absent means "this run has no session row yet", so a missing header where a row exists is a version conflict, not 428; started only moves forward; a reference change of a started row is the same session under a resolved reference (09 9.5 point 3, 18.9 "Session missing in Claude Code" reports the new reference without bind_failed). Reasons session_not_bound and no_current_session are new and documented in 09.
  12. bind_failed: session_missing (09 9.5 point 4, 03 end_reason rebind). Allowed when R exists and started = true, else 409 conflict with reason session_not_bound. In the unit of work: when R is current, end it with rebind and insert a new current row N (reference, model and started from the body, cost baseline 0); when R is no longer current (a reset or bind ended it during the run), insert N already ended with R's end_reason and ended_at = now(), so that human decision stays in force (03 ended_at: "or was never current"). Events R then N; publish SessionAttached{SessionID: N, ReplacedSessionID: R, BindFailed: true}. runs.OnSessionAttached sets runs.agent_session_id = N and calls messaging.Messages.AppendSystemLine once with the code of Design note 1, author the agent, after M1-56 D6 (kind = 'system', origin = 'system', empty content_md, server client_msg_id, silent, no mentions, no trigger_state), through AppendToThread with ReopenIfClosed = false (a note does not reopen a closed thread). A system line never triggers (SR4). The run continues; the answer is 200 with N. "Exactly once": a retry with the same Idempotency-Key returns the stored answer (06 7), a concurrent duplicate fails the If-Match compare-and-swap. Ruling for docs/DECISIONS.md: N ended when R was ended (author and reopen rule of the note are in 03, R-DM-42).

  13. Validation. external_session_ref (bind, report, run.start): not empty and not only white space (errors[].code required), textcheck.DisplayText (codes invalid_utf8, control_character, bidi_control; SEC-IN-4, because the reference is shown in the session column), at most agent.session_ref_max_bytes bytes (too_long); stored exactly as sent, because the server does not interpret it (03). model (report, run.start): the pattern of default_model in 09 2.2 agent_operator_config.v1. Pointers /external_session_ref, /model, /bind_failed. Ruling for docs/DECISIONS.md: new server key agent.session_ref_max_bytes, registered in the configuration schema (M1-07), in docs/operations/configuration.md and in 09 section 21, with a default that keeps an entry of the unique index on (agent_id, external_session_ref) within the PostgreSQL B-tree entry size (state the reason next to the default there); without a limit an oversized reference ends in a database error and a 500 (rule 9 needs a named key).

  14. Cost baseline (03 cost_baseline_micros, 22 section 9 step 7). An import binding copies cost_baseline_micros and currency of the import row. A bind (Design note 8) sets the new row's baseline to cost_baseline_micros + cumulative_cost_micros of the most recent row of the same agent and reference in any thread, with its currency; none gives 0 and the default currency. Rows created by run.start, by a report or by bind_failed start at 0. Ruling for docs/DECISIONS.md: a runtime that reports sums per session would otherwise book the earlier amount a second time after a rebind (03, "the server books only the part above it"; booking itself is M2-22).

  15. Events and audit.

    • agent_session.changed for every committed change of a row (insert, report change, end). Several in one transaction: ended rows first in ascending id, then the new row. Envelope: scope {space_id, channel_id, thread_id} of the row's thread, resource {kind: thread, id: thread_id} without version (04 9 point 3), receive action thread.read, actor the principal of the request (for changes made by ForInvocation, the triggering human), payload AgentSessionChangedPayload{thread_id, agent_session}. Ruling for docs/DECISIONS.md: the 05 note "new session, rebinding, binding after an import" names examples; model and started changes are announced too, so the session column stays current.
    • Audit (auditing.Recorder.Record in the transaction): one allow entry per agent_session.bind, explicit or by import binding, resource the thread, details (SEC-AUD-5: IDs and codes only, never the reference): session_id, ended_session_id when one ended, taken_over_session_id and taken_over_thread_id when taking over, take_over (bool), cause (bind or import). agent_session.reset, agent_session.read and agent_session.report write no allow entry (05 Audit no).
  16. Locks and concurrency (02 8.2, 03 6.3). Rows of runs before rows of agent_sessions; several rows of agent_sessions in ascending id. Report: the run row through RunSource, then R. Acceptance and start: the run row (in runs), then the session rows. Bind and reset: session rows only. Deadlocks and lock timeouts are retried by the unit of work (database.tx_retry_max). The partial unique indexes are the final guard: every insert uses ON CONFLICT DO NOTHING and maps the outcome, so no unique violation reaches a client as 500. All write operations accept Idempotency-Key (M1-20).

  17. Specification and API changes (specification first, same change):

    • docs/api/openapi.yaml: AgentInvocationPayload.session_mode (enum new, resume, bind; present exactly for operator runs) and descriptions of agent_session there; AgentSessionReport gets started (boolean) and bind_failed (enum session_missing); reportAgentSession gets the parameter IfMatchOptional with the meaning of Design note 11 and the responses 409 and 412; resetAgentSession, bindAgentSession and reportAgentSession declare the header ETag on their success response; descriptions name the reasons no_current_session, session_bound_elsewhere, session_changed, session_not_bound. Message.system_code already lists agent_session.resume_failed (03, R-DM-42, D-W-104; Design note 1). Run make api-lint and make api-checks, regenerate and keep make codegen-check clean.
    • docs/spec/09-agents.md: 4.4 row agent.invocation (agent_session, session_mode), 4.7 row "Report session", 9.1 (attach rules of Design note 10, version 1 of a new row), 9.2 (Design note 7), 9.4 (second target, order, race), 9.5 (the system code, an N created ended), 21 (agent.session_ref_max_bytes).
    • docs/spec/07-permissions.md 4.2, docs/spec/02-architecture.md 4.3, docs/spec/06-api.md 6 point 6, docs/operations/configuration.md; run make config-reference and make config-doc-check.
  18. Reported, not built. 09 9.4 checks the other thread only where the reference is current. A reference whose rows in this instance are all ended can be bound by a person who cannot read the thread that used it, and the runtime then continues with that thread's history (T-AI-01). Build 9.4 as written and list this as a spec gap in the report with the proposal to require read on the most recent thread of the reference.

  19. Rulings for docs/DECISIONS.md (one row each, D-M2-12-<n>, with reason): note 2 (RunSource, SessionAttached); note 4 (unpaginated history, order); note 5 (invoke facts of a session switch, second target); note 7 (reset without session, reset during a run); note 9 (import binding condition, session_mode, mode in runs); note 10 (attach rules, no change of existing rows at start); note 11 (If-Match semantics, monotone started, reference change in place, new reasons); note 12 (N ended after a reset); note 13 (agent.session_ref_max_bytes, validation); note 14 (cost baseline); note 15 (event for every change, audit details). The one-session rule, the end reasons, the bind conflicts and the fail-open rule are in 03, 09 and 22; the code, author and reopen rule of the fail-open line are in 03 (R-DM-42).

Tests (write them first)

Unit:

  • internal/agents/domain: TestEndSession (each end reason sets ended_at, clears is_current, raises version; an ended row cannot end again); TestApplyReport (table: reference change, model change, started true, started false on a started row is no change, an empty report is no change, bind_failed on an unstarted row is session_not_bound); TestImportBindingCandidate (no rows, only import rows of this agent, an import row of another agent, an earlier reset row, two import rows choose the newest); TestCostBaseline; TestValidateExternalSessionRef (empty, white space only, each textcheck rule, exactly agent.session_ref_max_bytes bytes passes, one more fails, UTF-8 letters and emoji pass); TestValidateModel.
  • internal/runs/domain: TestSessionMode (guest run gives no session fields; no row gives new; unstarted row gives new with the row; started and used gives resume; started and unused gives bind).
  • internal/authz: TestAgentSessionResetRequirement and TestAgentSessionBindRequirement with fake sources in the world of 07 10: Owner allow; Alex with trigger on Support allow; Sam with only read (G-4) forbidden level_insufficient; a principal without read not_found; a read-only channel forbidden channel_read_only; Alex with a trigger grant on Engineering/Werkzeuge (Runner, host) forbidden tool_class_owner_only (as AZ-M20); a personal agent and a principal who is not its holder billing_mode_personal_not_holder; a thread without operator agent_not_reachable; an agent or app principal forbidden. TestAgentSessionBindTarget: invisible target not_found, target with only read forbidden, guard failing on the target denies, both paths fine allow. TestAgentSessionReportRequirement: the run's agent allow, another agent forbidden not_self. TestDeferredActionsGolden without the two ids.

Integration (test/integration/agentsessions; PostgreSQL 18 via pgtest; real modules through internal/wire; HTTP through the real chain; the world of 07 10 and 11.1 from the world builder of M1-54; Alex, Sam and Robin; the agent Lotse operates Support; the tests act as the adapter with Lotse's API token and the run helpers of M2-09, reading agent.invocation through pollAgentEvents):

  • TestMigration_AgentSessions: a second current row of a thread fails; the same (agent_id, external_session_ref) current in two threads fails; the same reference ended in one thread and current in another passes; is_current contradicting ended_at fails; an unknown end_reason fails; deleting a session row sets runs.agent_session_id to NULL.
  • TestMigration_SystemCodeResumeFailed: on the migrated schema (with the migrations of Design note 1 where they were needed) a system line (kind = 'system', origin = 'system', silent, empty content_md) with system_code agent_session.resume_failed passes, one with thread.reopened still passes, an unknown code fails, and the CHECK on system_code is validated (pg_constraint.convalidated).
  • TestListAgentSessions: newest first, every field of AgentSession, no JSON null, cost in Money form; an empty thread gives items: []; Robin (no read on Support) gets 404 with the body of an unknown thread ID.
  • TestNewSession_ReferenceAtStart (runner path): the first trigger delivers session_mode = new without agent_session; startRun with a reference creates a current row with started = false and sets runs.agent_session_id; a report with started: true and If-Match: "1" gives version 2; the next trigger delivers resume with that row.
  • TestNewSession_FirstReportCreatesRow (Hermes path, 09 17.4): startRun without reference creates nothing; a report without If-Match creates the current row and sets runs.agent_session_id; exactly one agent_session.changed.
  • TestSessionCrashBeforeStarted (REUSE L14): the run fails before started; the next trigger delivers new with the same row; startRun with the same reference attaches it and creates no second row.
  • TestSessionSurvivesRename (REUSE L01, rename half): setThreadTitle on a thread with a started session; the same row stays current; the next trigger delivers resume with it.
  • TestResetSession: Alex resets; 201 with the ended row (end_reason = reset) and its ETag; one agent_session.changed; the next trigger delivers new without agent_session; a second reset gives 409 conflict with reason no_current_session; no allow audit entry.
  • TestResetDuringActiveRun: with a run running on row S, a reset ends S; a report on S with If-Match still succeeds; runs.agent_session_id stays S; after finishRun the next trigger delivers new.
  • TestSessionSwitchRights: Sam (read only) gets 403 forbidden; Robin gets 404; Alex with a trigger grant on Engineering/Werkzeuge (operator Runner, host, as in AZ-M20) gets 403 agent_invocation_denied with reason tool_class_owner_only there; a read-only channel gives 403 channel_read_only; each denial is in the audit log, aggregated per audit.deny_aggregate_window (AZ-P13).
  • AG-17, TestAG17_StaleExpectedSession: a bind with an outdated expected_current_session_id gives 409 conflict with reason session_changed; rows, events and audit entries are unchanged.
  • AG-17, TestAG17_TakeOverWithoutFlag: the reference is current in another thread of Support; a bind without take_over gives 409 conflict with reason session_bound_elsewhere; nothing changed.
  • TestBindTakeOver: with take_over: true both old rows end with rebind, the new row is current with started = true and the computed cost baseline; events in the order of Design note 15; one allow audit entry with the IDs of Design note 15 and without the reference; the next trigger in this thread delivers bind, in the other thread new.
  • TestBindTakeOverTargetRights: a target thread the actor cannot read gives 404 with the same status, body and headers as an unknown thread ID; a target with only read gives 403 forbidden.
  • TestBindConcurrent: two binds with the same expected_current_session_id give exactly one 201 and one 409 conflict (session_changed); one current row.
  • TestExternalSessionInOneThread (plan acceptance): after any sequence of binds, take-overs and reports no (agent_id, external_session_ref) is current twice; a report that changes the reference to one current in another thread gives 409 conflict with reason session_bound_elsewhere.
  • TestReportConcurrent (plan acceptance): two reports with the same If-Match give one 200 and one 412 version_conflict with current; two first reports without If-Match give one 200 and one 412 version_conflict whose current is the winner's row; exactly one row and one event per success.
  • TestReportRules: no active run, another agent's run, a finished run and a guest run each give 404; a wrong If-Match gives 412; If-Match on a run without row gives 412; started: false keeps started; an unchanged report gives 200 without version change and without event; an invalid reference gives 400 validation_failed with pointer /external_session_ref; an unknown member gives 400 validation_failed; a replay with the same Idempotency-Key returns the stored answer with Idempotent-Replay: true, a different body with it gives 422 idempotency_key_reuse.
  • AG-18, TestAG18_ImportBindingSessionMissing: a thread with an import row of Lotse (helper); Alex triggers; in the run's transaction a current row with the same reference and started = true appears, with one allow audit entry (cause import, actor Alex) and one agent_session.changed; the delivered payload has session_mode = bind and that row; startRun; a report with bind_failed: session_missing, If-Match and a new reference ends the bound row with rebind, makes the new row current, sets runs.agent_session_id to it and writes exactly one system line with the code of Design note 1 (author Lotse, origin = system, no run triggered); the run stays running and finishes; a replay with the same Idempotency-Key writes no second line; the next trigger delivers resume with the new row.
  • TestFailOpenLineKeepsThreadClosed (R-DM-42): as AG-18, but Alex closes the thread (closeThread) while the run is running; the report with bind_failed: session_missing writes exactly one system line agent_session.resume_failed (author Lotse), the thread stays closed, and neither a thread.reopened event nor a system line with system_code thread.reopened is written.
  • TestImportBindingOnce (22 section 16 "Binding once", also PLAN row M8-06): two concurrent first triggers in the same thread bind exactly once (one current row, one audit entry); two threads with import rows of the same reference and concurrent first triggers give exactly one binding, and the other run gets new (22 section 9 step 3); after a reset the import row never binds again; an import row of an agent that no longer operates the thread never binds; an import reference current in another thread does not bind and the run gets new.
  • TestAgentChanged: in a main thread with a session of Lotse, the Owner sets another operator on the channel (setChannelOperator, I1 respected); the next trigger ends Lotse's row with agent_changed, appends agent_session.changed and delivers new to the new operator.
  • TestGuestRunHasNoSession: a guest run's payload has neither session_mode nor agent_session; its startRun with a reference creates no row; its report gives 404.
  • TestSessionEventsAndPayloadSchemas: every agent_session.changed row validates against AgentSessionChangedEvent (resource thread, no resource.version, receive action thread.read, no null); every delivered agent.invocation payload validates against the changed AgentInvocationPayload.
  • AZ-P1: the route test counts the four new routes, each with its x-authz-action; the server starts with them registered.

Commands that must pass, from the repository root:

  • cd services/server && go test -race -count=1 ./internal/agents/... ./internal/runs/... ./internal/messaging/... ./internal/chat/... ./internal/authz/... ./internal/kernel/... ./internal/wire/... ./test/integration/agentsessions/...
  • make migrations-lock, make migrations-lint, make codegen-check, make arch, make register-check, make api-lint, make api-checks, make config-reference, make config-doc-check, make docs
  • make check

Acceptance criteria

  • The fail-open system line uses the code agent_session.resume_failed that 03 lists for it (R-DM-42), with the run's agent as author and without reopening a closed thread; the CHECK of messages.system_code allows the code and is validated (new migrations only where the merged CHECK of M1-56 lacked it, Design note 1).
  • listAgentSessions, resetAgentSession, bindAgentSession and reportAgentSession are enabled and registered; AZ-P1 passes; agent_session.reset and agent_session.bind are no longer deferred.
  • AG-17: a stale expected_current_session_id and a take-over without take_over each give 409 (session_changed, session_bound_elsewhere) and change nothing.
  • AG-18: an import binding with a missing old session gives a new session, exactly one system line, and the run keeps running.
  • Two concurrent reports give exactly one success; an external session is current in at most one thread, also under concurrent binds and import bindings.
  • Reset and bind check trigger and agent.invoke (I2, SR2) on the thread and, when taking over, on the other thread; an invisible other thread gives the same 404 as an unknown one; every denial is audited.
  • Every operator invocation carries session_mode and, where a row exists, agent_session; run.start and the first report create the row and set runs.agent_session_id; guest runs have no session.
  • agent_session.changed follows Design note 15 and validates against its schema; bind entries in the audit log carry IDs, never the reference.
  • openapi.yaml, 09, 07, 02, 06, configuration.md, the READMEs, docs/DECISIONS.md and the CHANGELOG are updated.
  • make check is green.

Definition of done

  • Code and tests as specified; doc comment on every public symbol (purpose, parameters, return value, errors, example where useful); comments explain why.
  • READMEs of agents, runs, messaging and authz (and chat if changed) updated (ports, the source RunSource, the event SessionAttached and its subscriber, the session rules, the new system line, the requirements of the two session actions).
  • Rulings of Design note 19 recorded in docs/DECISIONS.md section 10 (one row each with reason).
  • services/server/CHANGELOG.md entry under 'Unreleased'.
  • An ADR only if you take an architecture decision beyond this brief (MADR 4 as in docs/adr/0000-template-madr-4.md, headings and text in English, next free number, make adr-index).
  • make check green.
  • An independent code review over the diff, then the red team gate (docs/quality/red-team-gate.md, verdict JA or NEIN) with attention to the M2 red team item "another person triggering a personal subscription through every path (..., session switch, ...)": reset and bind by a person who is not the holder of a personal agent, take-over across threads and spaces, reports by a foreign or guest agent, races on the compare-and-swaps; on NEIN fix with a test per finding and have the delta checked again. Record both verdicts.
  • Land the change as AGENTS.md ("How work lands on main") describes: a pull request from this branch, squash-merged only when review, red team and make check, ci-ok and the review threads are green; then git worktree remove.

Report

Write to {{REPORT_PATH}}:

  1. First line exactly Verdict: DONE or Verdict: BLOCKED <reason>.
  2. Commits (SHA and subject), changed files with line counts, test results with counts (the red run before the change and the green run after, including the red run of AG-17 and AG-18 for M2-31), the summary of make check.
  3. Review and red team verdicts and the findings fixed.
  4. What is not proven (for example the move half of REUSE L01, the behaviour of real runtimes, the data-driven scenarios of M2-31).
  5. Follow-ups that could not be done in this scope, each with its reason, and spec gaps found; at least Design note 18, the overlap with PLAN row M8-06 (Out of scope), and every difference between the names of this brief and the merged code.

Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.