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 (rowagent.invocation, fieldsession); 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 checkis_current = (ended_at IS NULL), both partial unique indexes, the index for the history, the table "Meaning ofend_reason");runs.agent_session_idand its FKON DELETE SET NULL, the partial index of R-DM-14;cost_entries.agent_session_id;messages(origin,kind,system_codeand 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 checksagent.invoke), rule 1.8 (read-only channels), the table of section 2.1 withagent-self, 4.6 rowsagent_session.read,agent_session.reset,agent_session.bind,agent_session.report; section 5 "Agent invocationagent.invoke" points 1 to 8; section 6 rowagent_session.changed; R-AZ-01, R-AZ-04.docs/spec/04-sync-protocol.md: 2.2 "Envelope"; 9 "Delivery guarantee" point 3 (agent_session.changedhas noresource.version); 15.3 "Invocation and run start" steps 1 and 4 (external_session_refoptional inrun.start; the firstagent_session.reportcreates 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.reportis 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 rowagent_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 (moduleagentsownsagent_sessions), 4.3 (portSessionsofagents; In-Tx events), 4.4 (edgesruns -> 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: rowagent_sessions.docs/api/openapi.yaml: operationslistAgentSessions,resetAgentSession,bindAgentSession,reportAgentSession,startRun,resumeRun; schemasAgentSession,AgentSessionList,AgentSessionBind,AgentSessionReport,AgentSessionChangedPayload,AgentSessionChangedEvent,AgentInvocationPayload,RunStart,Money,Message(system_code); parametersThreadIdPath,IdempotencyKey,IfMatchOptional; headerETag; responsesConflict,PreconditionFailed,NotFound,UnprocessableEntity.docs/api/asyncapi.yaml: channelgateway, messageevent.agent_session.changed.docs/api/webhooks.md: rowagent_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 formatD-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.md6.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 limitsexternal_session_refyet (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, nestedPublish), 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.RecorderwithRecord,RecordDenialand the value kindsCode,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, theagent.invokeguard,TestDeferredActionsGolden), M1-44 (agent API tokens), M1-51 (layout ofinternal/agents), M1-54 (effective operator, world builder intest/integration), M1-55 (chat.Threads.Info,ThreadInfo,SetTitle), M1-56 (messaging.Messages,MessagePosted, D6 system line,AppendToThreadwithReopenIfClosed), M1-65 (generic delivery by receive action). - M2 code on the base branch: M2-05 (
runstable and state machine), M2-07 (agent consumer,pollAgentEvents), M2-08 (the writer ofagent.invocation), M2-09 (startRun,resumeRun, its integration-test helpers that accept, deliver and start a run as the agent, and its mapping ofagent.invokedenials to403 agent_invocation_deniedfor 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):
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- 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).
- 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.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
Rules for every brief:
- Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
- No en dash and no em dash in any file you write; the
text-rulespre-commit hook rejects them. Use a hyphen or rephrase. - White label: examples and test data use
example.comand the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory. - 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. - Specification first: a change to behaviour, API, event, permission or configuration updates
docs/spec/ordocs/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:
- 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 (03agent_sessions; PLAN row M2-12). listAgentSessionsreturns the current and the earlier sessions of a thread with reference, model, cost, period and end reason; permissionreadon the thread,rl.read, no audit (09 9.3, 05 4.6).resetAgentSessionends the current session withend_reason = 'reset', writesagent_session.changed, and the next run getssession.mode = new; it never disturbs a run that is active (09 9.2). Permissiontriggerandagent.invoke(05 4.6, rule 1.6),rl.trigger,Idempotency-Key, no allow audit.bindAgentSessionbinds an external session to the thread withexpected_current_session_idas compare-and-swap andtake_overfor a session current elsewhere:409 conflictwithreasonsession_bound_elsewhereorsession_changed, nothing changes on a409; the old rows end withrebind, the new row hasstarted = true, the next run getsbind; permissiontriggerandagent.invokehere and, when taking over, also there; audited (09 9.4, 05 4.6, 07 4.2, AG-17).reportAgentSessionlets the adapter report reference, model,startedandbind_failedfor the session of its own active run in the thread, as compare-and-swap overagent_sessions.versionwithIf-Match; the first report creates the row whenrun.startcarried no reference; permissionagent-self,rl.agent_runtime, SR9 exception (05 4.6, 09 4.7, 9.1, 9.5, 04 15.3 step 4, 07 SR9).- Every operator invocation carries its session: mode
new,resumeorbindand the session row; a guest run carries neither (09 9.1, 3.6, R-AG-04).run.startwithexternal_session_refcreates the row (is_current,started = false) and setsruns.agent_session_id; otherwise the first report sets it (09 9.1, 03runs.agent_session_id, E-AG-19). - 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.bindandagent_session.changed, never after the thread had a current session; when the runtime no longer knows it, the adapter reportsbind_failed: session_missing, the bound row ends withrebind, a new row starts and exactly one system lineagent_session.resume_failedwith the run's agent as author is written, which does not reopen a closed thread (03messages, R-DM-42); the run continues (09 9.5, 22 section 9, AG-18). - After an operator change in a main thread the session of the former operator ends with
agent_changedat the next run (03end_reason, R-AG-02). agent_session.changed: resourcethreadwithoutresource.version, receive actionthread.read, payloadAgentSessionChangedPayload(05 section 6, 04 9 point 3, openapi).- No read permission gives
404,403only with read permission (R-AZ-01); every denial is audited byauthz(R-AZ-04, AZ-P13); audit details carry only IDs and codes, never the reference (SEC-AUD-5). - Versioned answers carry
ETag; no JSONnull(06 8, ADR 0053); unknown request fields give400 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_codelacksagent_session.resume_failed:migrations/<next free number + 1>_messaging_system_code_resume_failed.sqlandmigrations/<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 casesList,Reset,Bind,Report,ForInvocation,AttachOnStart(Design notes 6 to 12).internal/agents/adapters/postgres/queries/agent_sessions.sqland the repository.internal/agents/adapters/httpapi: strict-server methods oflistAgentSessions,resetAgentSession,bindAgentSession,reportAgentSession.internal/agents/adapters/agentstest(or the merged test-helper package ofagents):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'sidentitytest).internal/runs/domain/session_mode.go: the pure function of Design note 9 (mode from row,startedand "a run used this row").internal/runs/adapters/postgres: the implementation ofagents.RunSource(Design note 2).test/integration/agentsessions/: the cross-module tests of this ticket.
Change:
internal/agents(port): interfaceSessionswithForInvocation(ctx, InvocationSessionQuery) (InvocationSession, error)andAttachOnStart(ctx, AttachOnStartCmd) (ids.ID, error)forruns, plus the four operation methods; DTOSession(all columns thatAgentSessionshows, plusCostBaselineMicros); declared sourceRunSourcewithActiveRun; In-Tx eventSessionAttached(Design note 2).internal/runs: the single constructor ofAgentInvocationPayloadtakes the session decision as a required argument; the transaction that appendsagent.invocationcallsagents.Sessions.ForInvocationfor operator runs; thequeued -> runningpath ofstartRuncallsagents.Sessions.AttachOnStartand setsruns.agent_session_id; subscriberOnSessionAttached(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/authorizeandinternal/authz: descriptor fieldTargetThreadID; requirements ofagent_session.resetandagent_session.bindinstead of the deferred entries;TestDeferredActionsGoldenwithout them (Design note 5).internal/wire: handlers, theRunSourcewiring, the subscriptionagents.SessionAttached -> runs; the golden file of subscriptions.api/operations.yaml: movelistAgentSessions,resetAgentSession,bindAgentSession,reportAgentSessionfromlatertoenabled; rerun the route generator.architecture/modules.yaml(module register): the new event and source ofagents, the subscription and source implementation ofruns, the port addition ofmessaging; regenerate whatmake archandmake register-checkread.- The configuration key of Design note 13 in the configuration schema (M1-07) and in
docs/operations/configuration.md. docs/api/openapi.yamlanddocs/spec/as listed in Design note 17 (specification first).- READMEs of
agents,runs,messaging,authz(andchatif changed);services/server/CHANGELOG.md;docs/DECISIONS.mdsection 10 rowsD-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_estimateafter a reconciliation, hiding amounts forpersonalagents, the thread header sum: M2-22 and M2-28. Here every new row starts withcumulative_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
/newand/resume(M2-16); the client views with the session column and the/resumeselection (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_forbinding (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_idand its FK (V2-01, V2-02); it stays NULL.- Delivery of
agent_session.changedover the gateway (generic, M1-65) and over webhooks (M2-26); retention of the classcontent(M7). - Changes to the core contract.
Boundaries¶
- A new migration needs its line in
migrations.sha256: runmake migrations-lockafter creating the file and beforemake 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 ofAGENTS.md. - Do not modify files outside the scope above, except the CHANGELOG, README and
docs/DECISIONS.mdentries named in this brief. - Follow the host rules supplied by the launcher (ports, machine rules).
- Tests bind only
127.0.0.1and port 0; PostgreSQL runs only as a throwaway container on a random port throughpgtest(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 (03messages.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¶
-
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 iskind = 'system'with a code inmessages.system_code(SEC-OUT-3). The core contract has the rule: the CHECK ofmessages.system_codein 03 listsagent_session.resume_failed, and 03 states that when the server accepts anagent_session.reportwithbind_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 enumMessage.system_codeinopenapi.yamllists it as well. Before writing any code, read the CHECK onmessages.system_codein 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 onlythread.reopened(M1-56 was built from a 03 before R-DM-42), the migration<next free number + 1>_messaging_system_code_resume_failed.sqldrops 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 asNOT VALID, and<next free number + 2>_messaging_system_code_validate.sqlvalidates 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 withVerdict: 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. -
Module boundaries (02 4.3, 4.4, 8.3).
agentsownsagent_sessions, the four operations and every session rule; no other module reads or writes the table (03 principle 12).runscalls the portagents.Sessionsin its own unit of work:ForInvocation(ctx, InvocationSessionQuery{ThreadID, AgentID, TriggeredBy ids.ID}) (InvocationSession, error)withInvocationSession{Row *Session}(Design note 9) andAttachOnStart(ctx, AttachOnStartCmd{ThreadID, RunID, AgentID ids.ID; ExternalSessionRef, Model string}) (sessionID ids.ID, err error)(Design note 10).agentsdeclares the sourceRunSourcewithActiveRun(ctx, threadID ids.ID) (ActiveRun, bool, error),ActiveRun{RunID, AgentID, SessionID ids.ID; State string}: the run of the thread inqueued,runningorwaiting_input(R-DM-14), its row lockedFOR UPDATE.runsimplements it in its postgres adapter;internal/wirewires it (an empty source answers "no run" until wiring, 02 4.4).agentspublishes the In-Tx eventSessionAttached{ThreadID, RunID, AgentID, SessionID, ReplacedSessionID ids.ID; BindFailed bool};runs.OnSessionAttachedsetsruns.agent_session_id = SessionIDfor that run and, whenBindFailed, writes the system line throughmessaging.Messages.AppendSystemLine(Design note 12).runsmay usemessagingand subscribe toagents(02 4.4, M1-13 direction rule).- The operator of a thread comes from
chat:ThreadInfo.OperatorAgentIDforkind = thread, the channel's effective operator for a main thread (03channels, R-DM-21). Reuse the port function the trigger path of M2-04 and M2-05 uses; add a read-only port method tochatonly if none exists. - Ruling for docs/DECISIONS.md:
RunSourceandSessionAttached. Reason: a report must find the reporting agent's active run and the row ofruns.agent_session_idbelongs toruns(03), whileagentsmay not useruns(02 4.4); a declared source and an In-Tx event keep every edge downward, as theauthzsources andagents.AgentDisableddo. Update 02 4.3 (rowsagents,runs,messaging) and the module register.
-
Migration. Table
agent_sessionsexactly 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)); FKsthread_id -> threads(id) ON DELETE RESTRICT,agent_id -> agents(principal_id) ON DELETE RESTRICT; unique partial indexes(thread_id) WHERE is_currentand(agent_id, external_session_ref) WHERE is_current; index(thread_id, created_at DESC). Deliberate deviation, commented in the file: the FKdev_environment_id -> dev_environments(id)comes with V2-01 (the table does not exist); the column exists now. In the same migration: the FKruns.agent_session_id -> agent_sessions(id) ON DELETE SET NULLthat M2-05 could not create (add the column too if the mergedrunslacks it).cost_entriesdoes not exist yet; M2-22 (which depends on this ticket, D-W-103) adds its FKagent_session_id -> agent_sessions(id) ON DELETE RESTRICT. -
Representation.
AgentSessionfrom 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_atandend_reason(omitted when NULL),version; nevercost_baseline_microsordev_environment_id; nonull(ADR 0053). Single-session answers carryETag: "<version>"(06 8.1).listAgentSessionssorts bycreated_at DESC, id DESCand is not paginated (AgentSessionListhas onlyitems); 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. -
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.resetandagent_session.bindare "Deferred entries" of M1-28. Replace each withLevel(trigger)plus theagent.invokeguard of M1-28 ("trigger andagent.invoke", 05 4.6; rule 1.6 names the "session change"), remove both from the deferred list and fromTestDeferredActionsGolden. Descriptor{kind: thread, id: T, Invoke: &InvokeFacts{AgentID: operator of T, TriggerOrigin: "human", MentionsAgent: true}}withActiveRunAgentIDzero. 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 hasAgentIDzero and the guard denies withagent_not_reachable.- Answers: no read on the thread gives
404 not_found; a missing level gives403 forbidden(level_insufficient); a read-only or archived channel gives403 channel_read_only; a guard reason gives403 agent_invocation_deniedwith thatreason, mapped exactly as M2-09 mapsagent.invokedenials ofrun.token.renew(AG-29). - Second target of
agent_session.bind: addTargetThreadID ids.IDtoauthorize.Resource("agent_session.bind: thread in which the reference is current").authzresolves its path; visibility and the same requirement (level and guard with the sameInvokefacts) hold on both paths; an invisible target givesnot_foundwith exactly the body and headers of an unknown thread ID (07 4.2). Ruling for docs/DECISIONS.md, and addagent_session.bindto 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 noacting_forintersection applies (M2-14 builds that).- Denials are audited by
authzthroughRecordDenial(R-AZ-04); the use cases audit nothing else on denial.
-
listAgentSessions. Load the thread throughchat(unknown gives404); authorizeagent_session.read; return every row of the thread (Design note 4). An empty history is{items: []}. -
resetAgentSession(09 9.2), one unit of work: a. Load the thread (unknown gives404) and its operator; authorize (Design note 5). b. Lock the thread's current rowFOR UPDATE. None:409 conflictwithreasonno_current_session. Ruling for docs/DECISIONS.md, documented in 09 9.2: there is nothing to end, and a201without a session would have no body that fitsAgentSession. c. End it:ended_at = now(),is_current = false,end_reason = 'reset',version + 1; appendagent_session.changed; answer201with the ended row and itsETag. 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 keepsruns.agent_session_id, its reports still reach that row (Design note 11), and the next run getsnew. 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. -
bindAgentSession(09 9.4), in this order: a. Load thread T (unknown gives404) 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 withTargetThreadID = T2(zero when none) (Design note 5). d. Validate the body (Design note 13);expected_current_session_idandtake_overas inAgentSessionBind. e. T2 set andtake_overfalse:409 conflictwithreasonsession_bound_elsewhere; nothing changes. f. Unit of work: lock T's current row and, if taking over, T2's rowFOR UPDATEin ascendingid. Compare-and-swap: the ID of T's current row (or none) must equalexpected_current_session_id(or its absence), else409 conflictwithreasonsession_changedand nothing changes (09 9.4 point 3;412belongs toIf-Match). If T2's row is no longer current, continue without a take-over. g. End T's current row withrebind; when taking over, end T2's row withrebind; insert the new row (T, A, reference,started = true,is_current, cost baseline of Design note 14) withINSERT ... ON CONFLICT DO NOTHING. No row inserted: re-read and answer409 conflictwithreasonsession_changed(T got another current row) orsession_bound_elsewhere(the reference became current in a third thread). h. Events (Design note 15), the allow audit entry,201with the new row and itsETag. The next run getsbind(Design note 9). A bind during an active run leaves that run on its row, like a reset. -
Session of a new run (09 9.1, 9.5; 22 section 9).
runscallsForInvocationin the transaction that appendsagent.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 rowFOR UPDATE. b. Current row of another agent: end it withagent_changedand appendagent_session.changed(03end_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 withend_reason = 'import'and has an import row of this agent, take the newest (created_at, thenid, descending) and insert a current row with the same reference,started = true, itsmodel,cost_baseline_microsandcurrency(ON CONFLICT DO NOTHING). Inserted: auditagent_session.bindwith the triggering human as actor andcauseimport, appendagent_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 inruns/domain(runsowns the table that tells whether a run used a row): no row givesnewwithoutagent_session; a row withstarted = falsegivesnewwith the row (the adapter reuses its reference, REUSE L14); a started row that some run already used (runs.agent_session_id) givesresume; a started row no run used givesbind. The payload carriesagent_sessionand the new membersession_mode; a guest run has neither. Ruling for docs/DECISIONS.md:session_modeis the openapi name ofsession.modein 09 9.1 (Design note 17). The single constructor ofAgentInvocationPayloadtakes the session decision as a required argument, so every writer ofagent.invocation(acceptance, a released held invocation, follow-up runs, interactions without a waiting run, model commands, whichever are merged) carries it. -
run.start(09 9.1, 4.7, 04 15.3 step 4). Thequeued -> runningpath ofstartRuncallsAttachOnStartafter 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.startdiffer. - 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,
modelandstarted = false(ON CONFLICT DO NOTHING), appendagent_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.
runswritesruns.agent_session_idin the same transaction. Ruling for docs/DECISIONS.md:run.startnever changes an existing row, so the version the adapter got with the payload stays valid for its firstIf-Match, and a new row starts at version 1 (03 default); a changed reference or model of an existing row comes only throughagent_session.report. The reference is validated as in Design note 13; an invalid one failsstartRunwith400 validation_failedand pointer/external_session_ref.
-
reportAgentSession(05 4.6, 09 4.7, 9.1, 9.5): a. Load thread T. Read the active run throughRunSource(locks its row). No active run, a run of another agent, a state other thanrunningorwaiting_input, or a run whose agent is not T's operator (a guest run has no session):404 not_foundwith the body of an unknown thread. b. Authorize (Design note 5). c. Validate the body (Design note 13); unknown members give400 validation_failed. d. Let R be the row ofruns.agent_session_id, lockedFOR UPDATE.- R exists:
If-Matchmust equal R's version; absent or different gives412 version_conflictwithcurrent= R. Withbind_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 conflictwithreasonsession_bound_elsewhere); a given, differentmodelreplaces it;started: truesets it;started: falsenever resets it. Nothing changed:200with R, no version change, no event. Changed:version + 1,agent_session.changed,200. - No R:
If-Matchpresent gives412 version_conflictwithoutcurrent;bind_failedgives409 conflictwithreasonsession_not_bound. Insert a current row (T, A, reference,model,startedas given, absent is false) withON CONFLICT DO NOTHING. Inserted:agent_session.changed, publishSessionAttached,200. Not inserted:409 conflictwithreasonsession_changed(the thread got a current row, for example by a bind during the run) orsession_bound_elsewhere. e. Answer withETag. Because step a locks the run row, the second of two concurrent first reports sees the row the first created and gets412 version_conflictwithcurrent; two reports with the sameIf-Matchgive one200and one412(PLAN acceptance "two concurrent reports yield exactly one success"). Ruling for docs/DECISIONS.md:If-Matchis 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, not428;startedonly 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 withoutbind_failed). Reasonssession_not_boundandno_current_sessionare new and documented in 09.
- R exists:
-
bind_failed: session_missing(09 9.5 point 4, 03end_reasonrebind). Allowed when R exists andstarted = true, else409 conflictwithreasonsession_not_bound. In the unit of work: when R is current, end it withrebindand insert a new current row N (reference,modelandstartedfrom 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'send_reasonandended_at = now(), so that human decision stays in force (03ended_at: "or was never current"). Events R then N; publishSessionAttached{SessionID: N, ReplacedSessionID: R, BindFailed: true}.runs.OnSessionAttachedsetsruns.agent_session_id = Nand callsmessaging.Messages.AppendSystemLineonce with the code of Design note 1, author the agent, after M1-56 D6 (kind = 'system',origin = 'system', emptycontent_md, serverclient_msg_id, silent, no mentions, notrigger_state), throughAppendToThreadwithReopenIfClosed = false(a note does not reopen a closed thread). A system line never triggers (SR4). The run continues; the answer is200with N. "Exactly once": a retry with the sameIdempotency-Keyreturns the stored answer (06 7), a concurrent duplicate fails theIf-Matchcompare-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). -
Validation.
external_session_ref(bind, report,run.start): not empty and not only white space (errors[].coderequired),textcheck.DisplayText(codesinvalid_utf8,control_character,bidi_control; SEC-IN-4, because the reference is shown in the session column), at mostagent.session_ref_max_bytesbytes (too_long); stored exactly as sent, because the server does not interpret it (03).model(report,run.start): the pattern ofdefault_modelin 09 2.2agent_operator_config.v1. Pointers/external_session_ref,/model,/bind_failed. Ruling for docs/DECISIONS.md: new server keyagent.session_ref_max_bytes, registered in the configuration schema (M1-07), indocs/operations/configuration.mdand 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 a500(rule 9 needs a named key). -
Cost baseline (03
cost_baseline_micros, 22 section 9 step 7). An import binding copiescost_baseline_microsandcurrencyof the import row. A bind (Design note 8) sets the new row's baseline tocost_baseline_micros + cumulative_cost_microsof 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 byrun.start, by a report or bybind_failedstart 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). -
Events and audit.
agent_session.changedfor every committed change of a row (insert, report change, end). Several in one transaction: ended rows first in ascendingid, then the new row. Envelope: scope{space_id, channel_id, thread_id}of the row's thread, resource{kind: thread, id: thread_id}withoutversion(04 9 point 3), receive actionthread.read, actor the principal of the request (for changes made byForInvocation, the triggering human), payloadAgentSessionChangedPayload{thread_id, agent_session}. Ruling for docs/DECISIONS.md: the 05 note "new session, rebinding, binding after an import" names examples; model andstartedchanges are announced too, so the session column stays current.- Audit (
auditing.Recorder.Recordin the transaction): one allow entry peragent_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_idwhen one ended,taken_over_session_idandtaken_over_thread_idwhen taking over,take_over(bool),cause(bindorimport).agent_session.reset,agent_session.readandagent_session.reportwrite no allow entry (05 Auditno).
-
Locks and concurrency (02 8.2, 03 6.3). Rows of
runsbefore rows ofagent_sessions; several rows ofagent_sessionsin ascendingid. Report: the run row throughRunSource, then R. Acceptance and start: the run row (inruns), 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 usesON CONFLICT DO NOTHINGand maps the outcome, so no unique violation reaches a client as500. All write operations acceptIdempotency-Key(M1-20). -
Specification and API changes (specification first, same change):
docs/api/openapi.yaml:AgentInvocationPayload.session_mode(enumnew,resume,bind; present exactly for operator runs) and descriptions ofagent_sessionthere;AgentSessionReportgetsstarted(boolean) andbind_failed(enumsession_missing);reportAgentSessiongets the parameterIfMatchOptionalwith the meaning of Design note 11 and the responses409and412;resetAgentSession,bindAgentSessionandreportAgentSessiondeclare the headerETagon their success response; descriptions name the reasonsno_current_session,session_bound_elsewhere,session_changed,session_not_bound.Message.system_codealready listsagent_session.resume_failed(03, R-DM-42, D-W-104; Design note 1). Runmake api-lintandmake api-checks, regenerate and keepmake codegen-checkclean.docs/spec/09-agents.md: 4.4 rowagent.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.md4.2,docs/spec/02-architecture.md4.3,docs/spec/06-api.md6 point 6,docs/operations/configuration.md; runmake config-referenceandmake config-doc-check.
-
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.
-
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 inruns); note 10 (attach rules, no change of existing rows at start); note 11 (If-Matchsemantics, monotonestarted, 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 setsended_at, clearsis_current, raisesversion; an ended row cannot end again);TestApplyReport(table: reference change, model change,startedtrue,startedfalse on a started row is no change, an empty report is no change,bind_failedon an unstarted row issession_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, eachtextcheckrule, exactlyagent.session_ref_max_bytesbytes passes, one more fails, UTF-8 letters and emoji pass);TestValidateModel.internal/runs/domain:TestSessionMode(guest run gives no session fields; no row givesnew; unstarted row givesnewwith the row; started and used givesresume; started and unused givesbind).internal/authz:TestAgentSessionResetRequirementandTestAgentSessionBindRequirementwith fake sources in the world of 07 10: Ownerallow; Alex withtriggeron Supportallow; Sam with onlyread(G-4)forbiddenlevel_insufficient; a principal without readnot_found; a read-only channelforbiddenchannel_read_only; Alex with atriggergrant on Engineering/Werkzeuge (Runner,host)forbiddentool_class_owner_only(as AZ-M20); apersonalagent and a principal who is not its holderbilling_mode_personal_not_holder; a thread without operatoragent_not_reachable; an agent or app principalforbidden.TestAgentSessionBindTarget: invisible targetnot_found, target with onlyreadforbidden, guard failing on the target denies, both paths fineallow.TestAgentSessionReportRequirement: the run's agentallow, another agentforbiddennot_self.TestDeferredActionsGoldenwithout 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_currentcontradictingended_atfails; an unknownend_reasonfails; deleting a session row setsruns.agent_session_idto 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, emptycontent_md) withsystem_codeagent_session.resume_failedpasses, one withthread.reopenedstill passes, an unknown code fails, and the CHECK onsystem_codeis validated (pg_constraint.convalidated).TestListAgentSessions: newest first, every field ofAgentSession, no JSONnull,costinMoneyform; an empty thread givesitems: []; Robin (no read on Support) gets404with the body of an unknown thread ID.TestNewSession_ReferenceAtStart(runner path): the first trigger deliverssession_mode = newwithoutagent_session;startRunwith a reference creates a current row withstarted = falseand setsruns.agent_session_id; a report withstarted: trueandIf-Match: "1"gives version 2; the next trigger deliversresumewith that row.TestNewSession_FirstReportCreatesRow(Hermes path, 09 17.4):startRunwithout reference creates nothing; a report withoutIf-Matchcreates the current row and setsruns.agent_session_id; exactly oneagent_session.changed.TestSessionCrashBeforeStarted(REUSE L14): the run fails beforestarted; the next trigger deliversnewwith the same row;startRunwith the same reference attaches it and creates no second row.TestSessionSurvivesRename(REUSE L01, rename half):setThreadTitleon a thread with a started session; the same row stays current; the next trigger deliversresumewith it.TestResetSession: Alex resets;201with the ended row (end_reason = reset) and itsETag; oneagent_session.changed; the next trigger deliversnewwithoutagent_session; a second reset gives409 conflictwithreasonno_current_session; no allow audit entry.TestResetDuringActiveRun: with a runrunningon row S, a reset ends S; a report on S withIf-Matchstill succeeds;runs.agent_session_idstays S; afterfinishRunthe next trigger deliversnew.TestSessionSwitchRights: Sam (read only) gets403 forbidden; Robin gets404; Alex with atriggergrant on Engineering/Werkzeuge (operator Runner,host, as in AZ-M20) gets403 agent_invocation_deniedwithreasontool_class_owner_onlythere; a read-only channel gives403 channel_read_only; each denial is in the audit log, aggregated peraudit.deny_aggregate_window(AZ-P13).- AG-17,
TestAG17_StaleExpectedSession: a bind with an outdatedexpected_current_session_idgives409 conflictwithreasonsession_changed; rows, events and audit entries are unchanged. - AG-17,
TestAG17_TakeOverWithoutFlag: the reference is current in another thread of Support; a bind withouttake_overgives409 conflictwithreasonsession_bound_elsewhere; nothing changed. TestBindTakeOver: withtake_over: trueboth old rows end withrebind, the new row is current withstarted = trueand 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 deliversbind, in the other threadnew.TestBindTakeOverTargetRights: a target thread the actor cannot read gives404with the same status, body and headers as an unknown thread ID; a target with onlyreadgives403 forbidden.TestBindConcurrent: two binds with the sameexpected_current_session_idgive exactly one201and one409 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 gives409 conflictwithreasonsession_bound_elsewhere.TestReportConcurrent(plan acceptance): two reports with the sameIf-Matchgive one200and one412 version_conflictwithcurrent; two first reports withoutIf-Matchgive one200and one412 version_conflictwhosecurrentis 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 give404; a wrongIf-Matchgives412;If-Matchon a run without row gives412;started: falsekeepsstarted; an unchanged report gives200without version change and without event; an invalid reference gives400 validation_failedwith pointer/external_session_ref; an unknown member gives400 validation_failed; a replay with the sameIdempotency-Keyreturns the stored answer withIdempotent-Replay: true, a different body with it gives422 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 andstarted = trueappears, with one allow audit entry (causeimport, actor Alex) and oneagent_session.changed; the delivered payload hassession_mode = bindand that row;startRun; a report withbind_failed: session_missing,If-Matchand a new reference ends the bound row withrebind, makes the new row current, setsruns.agent_session_idto it and writes exactly one system line with the code of Design note 1 (author Lotse,origin = system, no run triggered); the run staysrunningand finishes; a replay with the sameIdempotency-Keywrites no second line; the next trigger deliversresumewith the new row. TestFailOpenLineKeepsThreadClosed(R-DM-42): as AG-18, but Alex closes the thread (closeThread) while the run isrunning; the report withbind_failed: session_missingwrites exactly one system lineagent_session.resume_failed(author Lotse), the thread stays closed, and neither athread.reopenedevent nor a system line withsystem_codethread.reopenedis 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 getsnew(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 getsnew.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 withagent_changed, appendsagent_session.changedand deliversnewto the new operator.TestGuestRunHasNoSession: a guest run's payload has neithersession_modenoragent_session; itsstartRunwith a reference creates no row; its report gives404.TestSessionEventsAndPayloadSchemas: everyagent_session.changedrow validates againstAgentSessionChangedEvent(resourcethread, noresource.version, receive actionthread.read, nonull); every deliveredagent.invocationpayload validates against the changedAgentInvocationPayload.- 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 docsmake check
Acceptance criteria¶
- The fail-open system line uses the code
agent_session.resume_failedthat 03 lists for it (R-DM-42), with the run's agent as author and without reopening a closed thread; the CHECK ofmessages.system_codeallows the code and is validated (new migrations only where the merged CHECK of M1-56 lacked it, Design note 1). -
listAgentSessions,resetAgentSession,bindAgentSessionandreportAgentSessionare enabled and registered; AZ-P1 passes;agent_session.resetandagent_session.bindare no longer deferred. - AG-17: a stale
expected_current_session_idand a take-over withouttake_overeach give409(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
triggerandagent.invoke(I2, SR2) on the thread and, when taking over, on the other thread; an invisible other thread gives the same404as an unknown one; every denial is audited. - Every operator invocation carries
session_modeand, where a row exists,agent_session;run.startand the first report create the row and setruns.agent_session_id; guest runs have no session. -
agent_session.changedfollows 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.mdand the CHANGELOG are updated. -
make checkis 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,messagingandauthz(andchatif changed) updated (ports, the sourceRunSource, the eventSessionAttachedand 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.mdsection 10 (one row each with reason). services/server/CHANGELOG.mdentry 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 checkgreen.- An independent code review over the diff, then the red team gate (
docs/quality/red-team-gate.md, verdictJAorNEIN) 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 apersonalagent, take-over across threads and spaces, reports by a foreign or guest agent, races on the compare-and-swaps; onNEINfix with a test per finding and have the delta checked again. Record both verdicts. - Land the change as
AGENTS.md("How work lands onmain") describes: a pull request from this branch, squash-merged only when review, red team andmake check,ci-okand the review threads are green; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}:
- First line exactly
Verdict: DONEorVerdict: BLOCKED <reason>. - 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. - Review and red team verdicts and the findings fixed.
- What is not proven (for example the move half of REUSE L01, the behaviour of real runtimes, the data-driven scenarios of M2-31).
- 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>.