M2-05: Runs, state machine and trigger state¶
Status: ready | Depends on: M2-04 | Milestone: M2
Goal¶
A human message that the trigger decision of M2-04 turns into an invocation now creates a run: in the transaction of the message the server writes the run in queued, the live-box anchor message and trigger_state = received on the triggering message, or, on a rejection, no run and trigger_state = failed with the reason of 09 "Reasons for rejection at acceptance". Every later state change of a run goes through one compare-and-swap primitive on state and version that rejects every transition 03 runs does not allow, validates the reason against 09 "Reasons (runs.state_reason)" (an unknown adapter reason becomes adapter_error), appends run.state_changed and moves trigger_state as 09 "Trigger state (trigger_state)" maps it. At most one active run exists per thread (R-DM-14), and of two concurrent starts exactly one wins. People read runs, steps and "Running now" over listThreadRuns, getRun, listRunSteps and listActiveRuns, filtered by run.read, and the snapshot names the running runs through the port that M1-63 wired empty. This is the run object of ADR 0025 on which delivery (M2-08), start, finish and cancel (M2-09), patches (M2-10) and follow-ups (M2-11) build.
Read first¶
docs/spec/09-agents.md: 3 "Operator and trigger rules" with 3.1 "Operator", 3.2 "Origin (messages.origin)", 3.4 "Decision per message" (point 7 is the acceptance this ticket builds), 3.5 "Reasons for rejection at acceptance", 3.6 "Guest agents", 3.7 "Auto-thread from a channel message"; 5 "Run" with 5.1 "State machine", 5.2 "Reasons (runs.state_reason)" (last sentence: unknown adapter reasons), 5.3 "Run document (run_document.v1)", 5.4 "Live box content" (anchor message); 6 "Trigger state (trigger_state)"; 16.2 "Required scenarios" (AG-03 to AG-06, AG-09 for orientation); 19 R-AG-03, R-AG-05, R-AG-09.docs/spec/03-data-model.md:runs(all columns, checks, the partial unique index, the transition list after the indexes),run_steps,run_followups(only to know it comes with M2-11),messages(kind = 'run',run_idwith the deferred FK,trigger_state,trigger_state_reason,trigger_state_at,silent,version, the checks),threads(operator_agent_id,parent_message_id),agent_sessions(moduleagents, M2-12), 1 "Principles" points 1, 10, 11, 12, 13; 6.3 "Lock order"; 8 R-DM-12, R-DM-14, R-DM-21.docs/spec/04-sync-protocol.md: 2.2 "Envelope"; 6.3 "Lifecycle" step 4 (the snapshot names running runs); 9 "Delivery guarantee" point 3; 10 "Ordering" (per thread strictly bythread_seq); 11 "Live box streaming" (11.1, 11.3 point 1: every state change is a durablerun.state_changed); 15.3 "Invocation and run start" steps 1, 2 and 6; 15.4 "Follow-ups" point 1; 16.5 "Agent run with follow-up" (the201already carriestrigger_state received).docs/spec/05-actions.md: 4.10 "Agents, commands, runs, interactions" (rowsagent.invoke,run.read,run.overview.read); 5 "Agent invocationagent.invoke" (points 1 to 8, "Consequences onallow", "Consequences on denial"); 6 "Durable event types and receive action" (rowsrun.state_changed,message.updated,thread.created).docs/spec/02-architecture.md: 4.3 "Ports, events and sources per module" (rowsruns,messagingwithSetTriggerStateand anchor,sync,authzwith the reasonuser_disabled); 4.4 "Allowed dependencies"; 4.7 "Event types and WebSocket ops per module" (last paragraph:message.updatedfortrigger_statecomes frommessaging); 5 "Layers within a module" (rule 5: metrics port); 8.1 "Flow", 8.2 "Lock order", 8.3 "In-Tx events" (creating a run is a consequence that must be atomic with its cause); 9.2 "404 instead of 403"; 9.3 "Proof of the check" (lists prove the route action throughScope).docs/spec/07-permissions.md: 4.1 "Decisions and reasons" (deny reasonslevel_insufficient,agent_not_reachable,billing_mode_personal_not_holder,tool_class_owner_only,project_not_ready,dev_environment_not_ready,run_active_other_agent); 4.2 "Flow" (filter mode); 4.3 "Visibility"; 7.4 "Query pattern" rules 1, 3 and 6; 10 "Examples" and 11.1 "Test matrix" (test world, AZ-M30); 11.2 AZ-P4, AZ-P5.docs/spec/06-api.md: 5 "Errors" (codesnot_found,cursor_invalid,validation_failed,invalid_state_transition,already_started); 6 "Pagination" points 1 to 4; 8 "Concurrency" (ETag); 16 "Endpoints", table "Runs".docs/spec/10-backend-policy.md: 12.2 "Display" (amounts per billing mode belong to M2-22; this ticket returns stored values).docs/api/openapi.yaml: operationslistThreadRuns,getRun,listRunSteps,listActiveRuns,createMessage,getSnapshot,listEvents; schemasRun,RunSummary,RunSummaryPage,RunStep,RunStepPage,RunState,RunFollowup,RunMetrics,RunPlan,Money,TriggerState,Message,MessagePayload,RunStateChangedEvent,RunStateChangedPayload,MessageUpdatedEvent,Snapshot,Problem(x-problem-codes); parametersThreadIdPath,RunIdPath,Cursor,Limit.docs/api/asyncapi.yaml: channelgateway, messageevent.run.state_changed.docs/api/webhooks.mdis not changed by this ticket.- ADR 0025 (run as its own object; "Confirmation" names the state machine test), ADR 0017 (one authorization function, 404 instead of 403), ADR 0009 (commit-ordered event log), ADR 0053 (no JSON
null), ADR 0007 (migrations), ADR 0050 (module register), ADR 0052 (one envelope). docs/observability/signals.md4.3 "Agents and runs":bothy.run.active,bothy.run.start.latency,bothy.run.duration,bothy.run.finished;bothy.authz.errorswithbothy.reason = filter_mismatch(07 7.4 rule 3).docs/observability/alerting.mdis not changed.docs/operations/configuration.md:api.page_size_default,api.page_size_max,run.step_detail_max_bytes,authz.not_found_timing_tolerance.docs/security/threat-model.md: T-AZ-01 (route without check), T-AZ-02 (leak over counters, lists or timing), T-AZ-04 (who triggered:runs.triggered_by), 5.10 "Agent adapters and runtime".docs/RISKS.md: RK-18 (a run executes twice; the start is a compare-and-swap).docs/OPEN-QUESTIONS.md: no open question blocks this ticket; OQ-22 (default: spec-first according to ADR 0013) applies to the generated handlers.docs/DECISIONS.md: D-G-280 (active_runsempty until M2-05), D-W-100 (M2 briefs written before M1 closed), section 10 format ofD-M2-05-<n>.docs/PLAN.md: section 3 "Way of working per ticket"; section 8 "M2 Agent platform, extensions and terminal" (Goal, Scope, Exit criteria point 1, Test strategy, Red team, Risks); rows M2-02, M2-03, M2-04, M2-05, M2-06, M2-08, M2-09, M2-10, M2-11, M2-12, M2-15, M2-16, M2-22 and M1-63.- Dependencies: the header names M2-04 only. Implied through it: M2-02 (channel behaviour), M1-56 (
MessagePosted,createMessage), M1-54 (operators), M1-55 (chat.Threads), M1-51 (agents andAgentPolicySource), M1-28 (authorize,Scope, filter mode,agent.invokeguard), M1-24 (audit recorder), M1-18 (cursor codec, problem details), M1-17 (api/operations.yaml), M1-16 (eventlog.Appender), M1-13 (unit of work,txn.Publish,txn.Subscribe), M1-12 (pgtest), M1-10 (meter provider), and through M2-02 also M1-63 (RunSummarySourceand itsnorunsbinding, see note 0), M1-68, M1-69 and M1-71. - M1 briefs whose interfaces this ticket uses:
M1-13-unit-of-work.md,M1-16-event-log.md,M1-18-http-chain.md,M1-28-authz-core.md,M1-51-agents-records.md,M1-55-chat-threads.md,M1-56-messaging-send.md(D1, D3, D6, D7 and the deferred FKmessages.run_id),M1-57-messaging-history.md(per-item filter pattern),M1-61-sync-personal-data.md(subscriber order),M1-63-sync-catch-up.md(note 9active_runs),M1-68-core-metrics.md(metrics registry) andM1-70-leak-tests.md(only if merged on the base: the SQL visibility lint). - Code (read the merged state;
git ls-tree -r --name-only <base> services/server/internal/<module>):internal/runs(port,domainfrom M2-04),internal/messaging,internal/chat,internal/agents,internal/sync(RunSummarySource,adapters/noruns),internal/kernel/authorize,internal/kernel/txn,internal/kernel/eventlog,internal/kernel/errs(CodeInvalidStateTransition,CodeAlreadyStarted),internal/platform/httpserver(cursor codec),internal/platform/pg/pgtest,internal/wire(txn registrations golden file,configkeys_*.go),architecture/modules.yaml,api/operations.yaml,sqlc.yaml.
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:
- The run states and transitions are exactly those of 03
runs:queued -> running | failed | cancelled,running -> waiting_input | completed | failed | cancelled,waiting_input -> running | failed | cancelled; every other pair (self loops and anything out of an end state included) is rejected with409 invalid_state_transitionand changes nothing. Each transition is a compare-and-swap onstateandversion(03runs, 09 5.1, ADR 0025, RK-18). failedandcancelledcarry a reason from 09 5.2; the other states carry none. An adapter reason that 09 5.2 does not list for the adapter is stored asadapter_error, with the original code kept in the details (09 5.2, last sentence).trigger_stateof the triggering message follows 09 6:receivedat acceptance,workingat the start,doneatcompleted,failedandcancelledwithtrigger_state_reasonequal to the run reason; every change raisesmessages.versionand writesmessage.updated, produced bymessagingthroughMessages.SetTriggerState(03messages, 02 4.7, 09 6).- At most one run in
queued,runningorwaiting_inputper thread, guarded by the partial unique index (03runs, R-DM-14). - Acceptance (09 3.4 point 7, 04 15.3 step 1, 05 section 5 "Consequences"): in the transaction of the message, on
allowofagent.invoke, a run inqueued, the live-box anchor (kind = 'run', author the agent,origin = 'agent', empty content, no notification, 09 5.4) andtrigger_state = received; withauto_threadin the main thread the run, the anchor and later answers live in a new thread withparent_message_id= the message (09 3.7). On a rejection the message stays, gets no run and getstrigger_state = failedwith the reason (09 3.5). Rejections ofagent.invokeare audited (05 4.10, Auditdenials). - Every state change of a run,
queuedat creation included, appends the durable eventrun.state_changed(resourcerunwith the new version, receive actionrun.read, payloadRunStateChangedPayload) (04 11.3 point 1, 05 section 6, 04 9 point 3). - Reads:
listThreadRuns,getRunandlistRunStepsneedrun.read;listActiveRunsneedsrun.overview.readand lists only runs the caller may read withrun.read; all use rate-limit classrl.read(05 4.10,openapi.yaml). A run or thread without read right gives the same404as an unknown ID (R-AZ-01, 02 9.2, AZ-P4). - Lists filter through the visible channels of
Authorizer.Scopeand thenauthorizeper element; no totals (07 7.4 rules 1 and 3, 06 6 point 4, AZ-P5, T-AZ-02). - The snapshot lists the active runs the actor may read as
RunSummary(04 6.3 step 4,Snapshot.active_runs). - No JSON
nullanywhere: an absent column value is an absent field (03 principle 13, ADR 0053). - Run metrics use exactly the names, units and attributes of signals.md 4.3.
Scope¶
All paths under services/server/ unless they start with docs/.
Create:
migrations/<next number>_runs_runs.sqlandmigrations/<next number + 1>_runs_run_steps.sql(plan: migrationsruns,run_steps), with the deliberate deviations of note 1, each commented in the file.internal/runs/domain/state.go(states,CanTransition, active and end states),internal/runs/domain/reason.go(the reason table of 09 5.2 plususer_disabled, setter per reason,NormalizeAdapterReason),internal/runs/domain/triggerstate.go(mapping of note 4), with tests. M2-04's decision function stays where M2-04 put it.- Port additions in
internal/runs(note 3): interfaceRunswith the read methods, DTOsRun,RunSummary,RunStep, pages and queries. internal/runs/app/lifecycle.go(Open,Transition),internal/runs/app/accept.go(OnMessagePosted),internal/runs/app/read.go,internal/runs/app/metrics.go(outbound metrics port with a no-op implementation),internal/runs/app/ports.go(repository and clock interfaces).internal/runs/adapters/postgres/queries/runs.sql,internal/runs/adapters/postgres/queries/run_steps.sql, the sqlc output and the repository.internal/runs/adapters/httpapi/runs.go: strict-server methods forlistThreadRuns,getRun,listRunSteps,listActiveRuns.internal/runs/adapters/otelmetrics/metrics.go(note 11).internal/sync/adapters/runsummary/: implementation of M1-63'sRunSummarySourceover therunsport (note 9).internal/wire/configkeys_runs.goifrun.step_detail_max_bytesis not yet registered (note 12).
Change:
internal/messaging(port, app, adapters):Messages.SetTriggerStateandMessages.CreateRunAnchor;MessagePosted.Kinddocumentsrun; the order "appendmessage.created, then publishMessagePosted"; the201and replay bodies ofcreateMessagebuilt from the stored row after the unit of work (note 7).internal/chatport, additive and only if absent:Threads.Locate(ctx, threadIDs []ids.ID) ([]ThreadInfo, error)with one queryid = ANY($1)(note 8).internal/wire: handlers; the subscriptionmessaging.MessagePosted -> runs.OnMessagePostedafter the existing subscribers; the binding ofRunSummarySourcetointernal/sync/adapters/runsummary; the metrics adapter and the observable gauge in roleworker; updatetestdata/txn_registrations.golden.- Remove
internal/sync/adapters/norunsand its tests once nothing references it. api/operations.yaml: movelistThreadRuns,getRun,listRunSteps,listActiveRunsfromlatertoenabled; rerun the route generator.sqlc.yaml: entry forruns.architecture/modules.yaml: the vendor groups the newrunsadapters need inadapter_vendors(as the merged modules do for postgres and OpenTelemetry adapters); regenerate.go-arch-lint.ymlwithgo generate ./tools/archgen.docs/spec/07-permissions.md7.4 rule 1 (note 10) anddocs/spec/09-agents.md3.4 (note 6 step 4) and 5.2 (note 2), in English like the chapters.docs/adr/0025-run-as-its-own-object-for-the-live-box.md: add the test names of this ticket to "Confirmation" without changing the decision.- If
tools/lint/sql_visibility.py(M1-70) is on the base: its exception list and category set (note 10). - READMEs of
runs,messaging,chat,sync;services/server/CHANGELOG.md;docs/DECISIONS.mdsection 10 rowsD-M2-05-<n>.
No new top-level Makefile target.
Out of scope¶
- Writing the personal event
agent.invocationat acceptance and delivering it, the checks before delivery (agent.invoke, I2, SR2, SR4 with current rights), the fresh invocation token,trigger_state = queuedat the first delivery, AG-07, AG-27, AZ-M30 and AZ-M51 as owned tests: M2-08 (reason: ruling 1 of note 14). - The invocation token with EdDSA and JWKS: M2-06. Agent consumers and acknowledgements: M2-07.
startRun,resumeRun,renewInvocationToken,awaitRunInput,finishRun,cancelRun, the jobsruns.cancel_timeoutandruns.stall_check, writing the columnsstarted_jti,renewed_from_jti,billing_mode_used,billing_evidence, the cancel subscribers (authorize.AccessChanged,agents.AgentDisabled,chat.ChannelReadOnlySet) and the reason rule for acancelledreported by the adapter: M2-09. They callTransitionof this ticket.patchRun,run.update,run.updated, steps written by adapters, step compaction atrun.max_steps_persisted,bothy.run.steps: M2-10.- The table
run_followups, the follow-up outcome of the acceptance,followup_delivered,deferredand the next run from deferred follow-ups: M2-11. Until thenRun.followupsis an empty object. agent_sessions,runs.agent_session_idand its FK: M2-12.getRunContextandrun.context.read: M2-13. SR9acting_forinAuthorizeandScope: M2-14.message_components,interactions, the FK ofruns.triggering_interaction_id,trigger_kind = interaction, themessaging.MessageDeletedsubscriber andmessaging.ComponentReader: M2-15.commands, the FK ofruns.triggering_command_id,trigger_kind = command,run_active: M2-16. Auto title of the new thread: M2-18.MessageCreate.run_idfor agent answers: M2-19. Heartbeat andinterruptedfromactive_runs: M2-20.- Cost bookings,
cost.read.thread, hiding amounts in modepersonal(BR-11): M2-22. Project binding readiness: M2-03 (until thenStructureSourcereports no binding). - Attachments and the hold of an invocation (
ReleaseInvocation,files.FileFinalized): M3-07; voice messages andvoice_unsupported: M3. kernel/personaldata.Participantfor runs: M7-06. FK ofruns.dev_environment_idand SR3: V2-01.- The conformance suite AG-01 to AG-30: M2-31; load tests of the agent path: M2-36; statistics over runs (
getRunStats): M6. - Gateway delivery code: unchanged; the hub of M1-65 delivers
run.state_changedlike every durable event. - 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-05-runs-state-machine; one writer per worktree. - Conventional Commits with the module as scope (for example
feat(runs): ...) 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.
- 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). - 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>. - No agent adapter, agent runtime, model or third-party service takes part in any test: runs are driven through the port and the HTTP chain only; real runtimes run only behind an explicit switch (09 16.1), and this ticket has none.
- Existing M1 tests whose worlds now trigger runs (note 13) are adapted to the specified behaviour, never deleted, skipped or weakened; each adaptation is listed in the report.
Design notes¶
-
Start check:
make checkis green on the fresh worktree, elseVerdict: BLOCKED base branch red. M2-04 is merged (its decision function exists ininternal/runs/domain), elseVerdict: BLOCKED dependency M2-04 missing. M1-63 is merged (RunSummarySourceandinternal/sync/adapters/norunsexist), elseVerdict: BLOCKED dependency M1-63 missing: the plan row requires its port, and M1-63 is in the dependency closure of this ticket through M2-02. -
Migrations.
runsexactly as 03: all columns with types, defaults and nullability; checksstate IN (...),trigger_kind IN ('message','command','interaction'),billing_mode_expected IN ('personal','organization'), end states havefinished_at, at most one of the threetriggering_*columns set and only the one matchingtrigger_kind; uniqueinvocation_jti; partial unique index on(thread_id) WHERE state IN ('queued','running','waiting_input')(R-DM-14); indexes(thread_id, queued_at DESC),(state, agent_id) WHERE state IN ('queued','running','waiting_input'),(agent_id, finished_at DESC). FKs now:thread_id,agent_id -> agents(principal_id),triggering_message_id -> messages(id) ON DELETE SET NULL,triggered_by -> principals(id), with theON DELETEof 03.- Deliberate deviations, commented: the FKs of
agent_session_id(table of M2-12),triggering_command_id(M2-16),triggering_interaction_id(M2-15) anddev_environment_id(V2-01) are added by the ticket that creates the referenced table; the columns exist now. - Same file:
ALTER TABLE messages ADD CONSTRAINT messages_run_id_fkey FOREIGN KEY (run_id) REFERENCES runs(id) ON DELETE SET NULL DEFERRABLE INITIALLY DEFERRED(03 marks it "deferred" likethreads.parent_message_id; M1-56 left it to this ticket). run_stepsexactly as 03 (unique(run_id, ordinal), partial index(run_id) WHERE status = 'running', FKs withON DELETE CASCADE).- Retention class
contentfor both, as the module register and 03 state.
-
Domain (
internal/runs/domain, pure,kernel-coreonly).type State stringwith the six values ofRunState;Active()forqueued,running,waiting_input;Ended()for the three end states;CanTransition(from, to State) booltrue exactly for the nine pairs of requirement 11.- Reason table, one row per code of 09 5.2 with its state (
failedorcancelled) and its setter (server, adapter, or both), plususer_disabled(cancelled, server), which 02 4.3 names for runs cancelled byuser.disable(ruling 7).ValidateServerReason(to, code) error: the code must be a server or both row of stateto; anything else is an internal error (a programming error of the caller).NormalizeAdapterReason(code string) (reason, original string)forfailed: a code whose row has the adapter as setter (billing_mode_mismatch,tool_class_mismatch,invalid_invocation_token,runtime_error,adapter_error,runtime_not_authenticated) is kept with an empty original; every other value, including a server-only code such asstaleorinterrupted, givesadapter_errorand the original code (ruling 8). An empty code isErrReasonRequired(M2-09 maps it to400 validation_failed, pointer/reason). A reason forrunning,waiting_inputorcompletedis an internal error. Add the rowuser_disabledand the place of the original code (rulings 7 and 8) to 09 5.2 in English. OriginalReasonDetail(code string, maxBytes int) map[string]any:{"original_reason": code}afterkernel/textcheck(no control or bidi characters), cut at the longest valid UTF-8 prefix so the encoded detail fitsrun.step_detail_max_bytes; a code thattextcheckrejects gives{}.- Trigger mapping (note 4) as a pure function.
-
Port
internal/runs(DTOs, never domain types; names may follow the merged style):
// Runs is the read side of the runs module for handlers and other modules.
type Runs interface {
// Get returns the run document for the actor in ctx; an unknown or invisible run gives the
// same not_found error.
Get(ctx context.Context, runID ids.ID) (Run, error)
// ListByThread pages the runs of one thread, newest first.
ListByThread(ctx context.Context, q ThreadRunsQuery) (RunSummaryPage, error)
// ListSteps pages the steps of one run by ordinal.
ListSteps(ctx context.Context, q RunStepsQuery) (RunStepPage, error)
// ListActive pages "Running now": runs in queued, running or waiting_input the actor may read.
ListActive(ctx context.Context, q ActiveRunsQuery) (RunSummaryPage, error)
// ActiveForActor lists all active runs the actor may read, for the snapshot.
ActiveForActor(ctx context.Context) ([]RunSummary, error)
}
RunSummary mirrors the schema RunSummary, Run the schema Run (steps keyed by step ID, followups keyed by message ID). Open and Transition (note 5) are exported from internal/runs/app for the later tickets of this module (M2-08, M2-09, M2-11, M2-15, M2-16), not from the port: no other module changes run states.
-
Trigger state mapping (09 6), applied when the run has
triggering_message_id:Event of the run trigger_statetrigger_state_reasoncreated ( queued, acceptance)receivednone queued -> running,waiting_input -> runningworkingnone running -> waiting_inputunchanged ( workingcoverswaiting_input)-> completeddonenone -> failedfailedthe run's state_reason-> cancelledcancelledthe run's state_reason
queued after the first delivery (M2-08) and followup_delivered (M2-11) are set by those tickets through the same SetTriggerState. Runs without a triggering message (command, interaction) change no trigger_state.
-
Lifecycle (
internal/runs/app/lifecycle.go), always inside the caller's unit of work (txn.InUnit, else internal error).Open(ctx, OpenCmd{ThreadID, AgentID, TriggeredBy, TriggeringMessageID ids.ID; TriggerKind string; BillingModeExpected string}) (RunSummary, error):INSERT INTO runs (...) VALUES (...) ON CONFLICT (thread_id) WHERE state IN ('queued','running','waiting_input') DO NOTHING RETURNING ...with a new UUIDv7id,state = 'queued',version = 1,invocation_jti= a new UUIDv7 that no token carries (ruling 4; M2-08 replaces it at the first delivery),billing_mode_expectedfrom the agent's current policy (ruling 5). No row returned: load the active run of the thread and returnErrActiveRunExists{Active RunSummary}; nothing else is written.messaging.Messages.CreateRunAnchorin the run's thread (note 7).- Append
run.state_changed(note 5 last bullet). messaging.Messages.SetTriggerState(M, received)whenTriggeringMessageIDis set.
Transition(ctx, TransitionCmd{RunID ids.ID; From, To domain.State; Version int64; Reason string; SetBy domain.Setter}) (Transitioned, error):CanTransition(From, To)false: error with codeinvalid_state_transition(409), nothing written.- Reason: server setter through
ValidateServerReason; adapter setter withTo = failedthroughNormalizeAdapterReason; any other combination is an internal error until M2-09 defines the adapter rule forcancelled. UPDATE runs SET state = $to, state_reason = $reason (NULL unless failed or cancelled), version = version + 1, started_at = COALESCE(started_at, now()) when $to = running, finished_at = now() and duration_ms = milliseconds between started_at and finished_at (NULL when never started) when $to is an end state WHERE id = $id AND state = $from AND version = $version RETURNING .... No row: load the run; missing givesnot_found; otherwiseErrConcurrentChange{Current RunSummary}, whose default mapping is409 invalid_state_transition; M2-09 maps a lost start race to409 already_startedor to the replay of the same token. Later tickets add their columns to this same statement (M2-09:started_jti,renewed_from_jti,billing_mode_used,billing_evidence,model,summary,metrics).- When an adapter reason was replaced: insert one
run_stepsrow, server-generated UUIDv7, nextordinalof the run (under the row lock theUPDATEholds),kind = 'status',name = 'adapter_reason',status = 'failed',detail = OriginalReasonDetail(...),finished_at = now()(ruling 8). This goes through the one step-append function that M2-10 later extends with compaction. - Append
run.state_changed; thenSetTriggerStateper note 4. - Return
Transitioned{Run RunSummary; From, To State; QueuedFor, Ran time.Duration; Started bool}; the unit owner records metrics after the commit (note 11).
- Locks: the acceptance runs inside the unit of
createMessage, which holds the thread row of the message (M1-55AppendToThread);Openthen writesruns, the anchor appends to the target thread (the same row or a new one), andSetTriggerStateupdatesmessageslast.Transitionlocks therunsrow through itsUPDATEbefore it touchesrun_stepsandmessages. Both orders takerunsbeforemessages; deadlocks are retried by the unit of work (database.tx_retry_max). run.state_changed:typerun.state_changed,schema_version1,actorof the unit,scope {space_id, channel_id, thread_id}of the run's thread now (fromchat.Threads.Info; a run follows its thread when the thread moves, M1-62),resource {kind: run, id, version: runs.version}, receive actionrun.read, no audience, payloadRunStateChangedPayload {run: RunSummary, triggering_message_id, trigger_state}with the absent fields omitted.RunSummary.step_countcountsrun_stepsof the run;costisMoney{amount_micros: cost_micros, currency, is_estimate: cost_is_estimate}.
-
Acceptance (
internal/runs/app/accept.go,OnMessagePosted(ctx, messaging.MessagePosted) error), registered after the existing subscribers ofMessagePosted. Business outcomes never veto: only infrastructure errors return an error (they roll back the message, which 02 8.3 intends).- Return without writing unless
Origin = humanandKind = text(system lines and anchors never trigger, 09 3.2; voice comes with M3). DM channels give no trigger (09 3.4 point 2). - Gather the facts M2-04's input type asks for, through ports only: thread and channel kind, fixed operator of a non-main thread or effective operator of the channel (
chat), the channel switches of 09 3.3 (the chat port of M2-02), mentioned agents amongMentionedIDs(agentsport), agent status, billing mode, credential holder and tool class (agentsport), the active run of the target thread (this module's repository). Facts about rights come fromauthorizein filter mode (authorize.Filter(ctx)), never from a query of grants. - Run M2-04's decision. Outcomes:
- no trigger: return.
- follow-up to the active run of the same agent: return without writing; M2-11 adds the follow-up here (ruling 12).
- rejection
multiple_agents_mentioned:SetTriggerState(M, failed, multiple_agents_mentioned); noauthorizecall and no audit, because there is no single agent to check (ruling 12). - a candidate agent A (invocation, or any other rejection that names A): continue with step 4.
- Trigger right without mention (09 3.4 point 4, R-AG-05): when M does not mention A, call
authorize(P, agent.invoke, d)in filter mode first; a denial with reasonlevel_insufficientmeans P lackstrigger: return without writing and without audit (ruling 13).d={Kind: thread, ID: thread of M, Invoke: {AgentID: A, TriggerOrigin: human, MentionsAgent, ActiveRunAgentID: agent of the active run of the target, or zero}}. For the auto thread the descriptor names the thread of M, not the new thread, so the mention rule of 09 3.4 point 3 and the guard of M1-28 judge the same thread, and the new thread is created only onallow(ruling 2). Write this order into 09 3.4 in English: the check of point 7 names the thread of M, and the thread of point 5 is created only after itsallow. authorize(P, agent.invoke, d)in request mode: it is the truth and its recorder writes the denial audit (05 Auditdenials). Map a denial totrigger_state_reason:level_insufficientgivesno_trigger_right;agent_not_reachablegivesagent_disabledwhen A's principal status is notactive, elseagent_not_allowed_here;billing_mode_personal_not_holder,tool_class_owner_only,project_not_ready,dev_environment_not_readyandrun_active_other_agentstay as they are; any other reason is stored unchanged and logged at error level without IDs. ThenSetTriggerState(M, failed, reason); no run, no new thread.- M2-04 and
authorizemust agree. When M2-04 rejected butauthorizeallows, fail closed: no run,trigger_state = failedwith M2-04's reason, and a log entrytrigger_decision_mismatchwithout IDs and contents (ruling 14). When M2-04 invoked butauthorizedenies, step 5 applies. - On
allow: with the auto thread,chat.Threads.Create(ctx, CreateThreadCmd{ChannelID: C, Parent: &ParentMessage{MessageID: M, ThreadID: main thread}})without title (09 3.7:title = '',title_source = 'auto', operator = effective operator of C); target = the new thread, else the thread of M. ThenOpenwithTriggerKind = message,TriggeringMessageID = M,TriggeredBy = P.ErrActiveRunExists(another path opened a run in the meantime) re-runs steps 3 to 5 with that active run: the same agent gives the follow-up no-op, another agent givesrun_active_other_agentthroughauthorize. - Event order inside the unit, fixed by test:
message.created(M), then for the auto threadthread.created, thenmessage.created(anchor),run.state_changed(queued),message.updated(M withtrigger_state = received). Per thread the events followthread_seq(04 10).
- Return without writing unless
-
messagingchanges.- Order:
createMessageappends themessage.createdevents of the unit (system line, then the message) before it publishesMessagePosted(system line first, then the message). Reason: a subscriber that appends a message to the same thread (the anchor) must not overtake the message in the log (04 10, ruling 3). Adapt M1-56 and M1-61 tests that pinned the old order to this order. - The
201body and the replay body ofcreateMessageare the stored row after the unit of work, through the single representation function of M1-56, so they carrytrigger_state,trigger_state_atand the version after the acceptance (04 16.5). SetTriggerState(ctx, SetTriggerStateCmd{MessageID ids.ID; State string; Reason string}) error: inside the ambient unit only; noauthorize(the caller decided; doc comment says so).Stateis aTriggerStatevalue;Reasononly forfailedandcancelled(03 check); the message must haveorigin = 'human'(03 check; otherwise internal error). Same state and reason as stored: no write, no event (ruling 17). Otherwise set the three columns (trigger_state_at = now()), raiseversionby one and appendmessage.updated(resourcemessagewith the new version, receive actionmessage.read, payloadMessagePayloadwith the representation and truncation rule of M1-56 D7; a deleted message keeps its tombstone representation).CreateRunAnchor(ctx, RunAnchorCmd{ThreadID, RunID, AgentID ids.ID}) (AnchorRef, error):AppendToThreadas M1-56 does for originagent; insertkind = 'run',origin = 'agent', author the agent,content_md = '',silent = true(09 5.4: the anchor notifies nobody),run_id,client_msg_id= a new server UUIDv7,client_payload_sha256with the M1-56 D3 encoding over the anchor's fields; no mentions; appendmessage.created, then publishMessagePostedwithKind = run. The deferred FK lets the anchor and the run be written in either order;Openwrites the run first.
- Order:
-
Reads (
internal/runs/app/read.go, handlers inadapters/httpapi).- Descriptor of a run:
{Kind: run, ID: run.id, ThreadID: run.thread_id, OwnerID: run.triggered_by};authorizeresolves the thread's path at decision time (07 4.2). getRun: load the run (missing:404),authorize(actor, run.read, run);not_foundreturns the same error value as a missing run (02 9.2).200withRun: all steps of the run as an object keyed by step ID (order fromordinal),followups{}until M2-11,metricsas stored,planonly when it has items,costasMoney, optional columns absent when NULL; headerETag: "<version>"with the M1-18 helper.listRunSteps: the same checks asgetRun; keyset byordinalascending; cursor bound to the run ID.listThreadRuns:chat.Threads.Info(thread_id)(missing:404);Scope(actor, run.read)(proves the route action, 02 9.3); a thread whose channel is not inScope.ChannelIDsgives the same404as an unknown thread; query the runs of the thread with keyset(queued_at DESC, id DESC); thenauthorize(actor, run.read, run)per item in filter mode.listActiveRuns:authorize(actor, run.overview.read, instance), thenScope(actor, run.read); read active runs (optionalagent_id, partial index) with keyset(queued_at DESC, id DESC)in batches of the page size; resolve their threads withchat.Threads.Locate; keep rows whose channel is inScope.ChannelIDs;authorize(actor, run.read, run)per row in filter mode; repeat until the page is full or no rows remain.next_cursoris the keyset of the last examined row when the page is full and another row exists, absent otherwise (ruling 11). An unknown or invisibleagent_idgives an empty page, never404(no probe).- Paging for all lists:
limitdefaults toapi.page_size_defaultand is capped atapi.page_size_max(06 6 point 3); cursors through the codec ofinternal/platform/httpserver(M1-18), bound to list name, filters and principal; a foreign cursor gives400 cursor_invalid. No totals. - A per-item denial after the channel filter drops the item, logs at error level with only action and resource kind, and increments
bothy.authz.errorswithbothy.reason = filter_mismatch(07 7.4 rule 3). step_countfor a page: one queryrun_id = ANY($1) GROUP BY run_id.channel_idof a summary: the thread's channel now.
- Descriptor of a run:
-
Snapshot.
internal/sync/adapters/runsummaryimplementsRunSummarySourceexactly as M1-63 declared it and callsruns.Runs.ActiveForActor, which runs the pipeline oflistActiveRunswithout paging (same set of states aslistActiveRuns;run.overview.readis not checked because the snapshot's own actionsync.snapshot.readwas).internal/wirebinds it instead ofnoruns; removenoruns; the sync README names the binding. The gatewayreadyframe gets the same list through M1-63's snapshot. -
Visibility of thread-scoped tables (Ruling for docs/DECISIONS.md, ruling 9). 07 7.4 rule 1 asks every query over runs for
channel_id = ANY($visible), but 03 givesrunsandrun_stepsnochannel_id(and M1-62 relies on that when threads move). Rule used here: a read ofrunsorrun_stepseither (a) reads the rows of exactly one thread or run whose visibility was checked first (getRun,listRunSteps,listThreadRuns, the active run of a thread at acceptance), or (b) for the active-run lists reads the active runs, resolves their threads through thechatport, keeps only threads whose channel is inScope.ChannelIDsand runsauthorizeper row. The system query of the gauge in note 11 has no principal. Write this into 07 7.4 rule 1 (English, like the chapter). Iftools/lint/sql_visibility.pyis on the base: list the queries of case (a) under categoryafter_authorize, the gauge query undersystem_job, and add the categorythread_scopefor case (b) to the closed set, its documentation and its tests (a valid exception, an unknown category still fails); if it is not on the base, the 07 text is the record for M1-70. -
Metrics. Declare the instruments of signals.md 4.3 with their units, attributes and buckets in
internal/runs/adapters/otelmetricsover the meter provider of M1-10, or in the registryinternal/platform/otel/metricsof M1-68, which is in the closure through M2-02 (prefer the registry). The app portMetricshasRunTransitioned(Transitioned):bothy.run.start.latencywhen leavingqueued(queued_atto now,bothy.outcomestarted,failedorcancelled,bothy.billing_mode=billing_mode_usedwhen set, elsebilling_mode_expected);bothy.run.durationon an end state of a started run (bothy.run.state);bothy.run.finishedon every end state (bothy.reasonfrom the reason table,otherfor any value outside it, not set forcompleted). The unit owner calls it after its unit of work committed, never inside (a retried unit would count twice).bothy.run.activeis an observable gauge registered in roleworker, counting active runs per agent handle and state with one grouped query.bothy.agent.handlecomes from theagentsport. Never IDs as attributes. -
Configuration keys used:
api.page_size_default,api.page_size_max(06),run.step_detail_max_bytes(03),authz.not_found_timing_tolerance(AZ-P4 test). Registerrun.step_detail_max_bytesininternal/wire/configkeys_runs.go(chapter 03) if absent, keepmake config-doc-checkgreen. -
Effect on existing tests. From this ticket on, a human message in a thread of a channel with an operator (default switches of 09 3.3) or a mention of the operator in a main thread opens a run and an anchor message. M1 tests whose worlds have operators (for example Lotse on Support in the 07 11.1 world) may then see an extra message, another
thread_seq,trigger_stateor a new thread. Adapt their expectations to this specified behaviour or let their fixtures use a channel without operator where the test is about something else; list every adapted test in the report. -
Rulings for
docs/DECISIONS.md(one rowD-M2-05-<n>each, with reason):- The acceptance of 09 3.4 point 7 is built here; the personal event
agent.invocationis written by M2-08 together with its delivery check, because a written but unchecked invocation could otherwise reach an agent connection before I2 is checked. - With the auto thread,
agent.invokeis checked on the thread of M and the new thread is created only onallow: no empty thread after a denial, and the same mention rule as 09 3.4 point 3. message.createdof a message is appended beforeMessagePostedis published; the acceptance setsreceivedthroughSetTriggerState(version plus one,message.updated), and the201carries the stored state.runs.invocation_jtiof a new run is a fresh UUIDv7 that no token carries until the first delivery replaces it (03 requires the column, no token exists at acceptance).billing_mode_expectedof a new run is the agent's billing mode at acceptance; each delivery sets it again (03).- The anchor is
silent, written in the target thread after the run row. - The reason table is 09 5.2 plus
user_disabled(cancelled, server) from 02 4.3; 09 5.2 gets the row. - An adapter
failedkeeps its reason only when 09 5.2 lists the adapter as setter; otherwiseadapter_error, and the original code is the detail of a server stepkind = status,name = adapter_reason; 09 5.2 names this place. - Thread-scoped tables without
channel_idfollow note 10; 07 7.4 rule 1 says so. listThreadRunsproves its route action throughScope(run.read); an invisible thread gives the same404as an unknown one.listActiveRunspages by(queued_at DESC, id DESC)and fills a page across batches; the cursor is the last examined row.- Until M2-11 the follow-up outcome writes nothing;
multiple_agents_mentionedwrites no audit. - A candidate without mention is first checked in filter mode: a person without
triggergets notrigger_stateand no audit entry (R-AG-05). - A disagreement between M2-04's decision and
authorizefails closed. - Run metrics are recorded after the commit by the unit owner;
bothy.run.activeis a worker gauge. - The FKs of
runstoagent_sessions,commands,interactionsanddev_environmentscome with M2-12, M2-16, M2-15 and V2-01;messages.run_idgets its deferred FK here. SetTriggerStatewith the stored state and reason is a no-op.
- The acceptance of 09 3.4 point 7 is built here; the personal event
Tests (write them first)¶
Test world: 07 11.1 (spaces Support with channels Allgemein and Eskalation, Marketing, Engineering, Research; agents Lotse operating Support, Texter operating Marketing, Assistent operating Research in mode personal with the owner as holder; grants G-1 to G-6; the owner), extended for this ticket by: Robin with write (no trigger) on Support/Eskalation; Texter with write on Support/Eskalation; allow_mentioned_agents = true on Support/Eskalation. Messages are sent through createMessage over the real HTTP chain with people from the identity test helpers.
Unit, internal/runs/domain:
TestCanTransition_AllPairs: all pairs of the six states; true exactly for the nine pairs of requirement 11, false for self loops and every pair out of an end state.TestReasonTable: every row of 09 5.2 plususer_disabledwith state and setter; reasons exist only forfailedandcancelled.TestNormalizeAdapterReason: the six adapter codes are kept; an unknown code and the server-only codesstale,interrupted,agent_unavailable,billing_mode_personal_not_holdergiveadapter_errorwith the original; an empty code givesErrReasonRequired.TestOriginalReasonDetail: cut on a UTF-8 boundary withinrun.step_detail_max_bytes; a code with a bidi character gives{}.TestTriggerStateMapping: every row of note 4, checked against the table of 09 6.
Unit, internal/runs/app with fakes:
TestAccept_Outcomes(table): originagent,app,import,system, kindrunandsystem, DM channel give no write; no candidate gives no write;multiple_agents_mentionedwritesfailedwithout anyauthorizecall; follow-up writes nothing;allowcallsOpenwith the right target; each mapping of note 6 step 5;level_insufficientwithout mention writes nothing and makes no request-mode call; a disagreement fails closed and logstrigger_decision_mismatch.TestAccept_AutoThreadOnlyOnAllow: a denial creates no thread;allowcreates it beforeOpen.TestListActive_FilterMismatchDropped: the channel filter keeps a row thatauthorizedenies; the item is dropped, the log has no IDs,bothy.authz.errorswithfilter_mismatchrises by one.TestListActive_PageFilledAcrossBatches: invisible rows between visible ones; the page is full,next_cursorpoints after the last examined row, the next page starts there and repeats nothing.TestTransition_MetricsAfterCommitOnly: a vetoed unit records nothing.
Integration (PostgreSQL 18 via pgtest, real modules through internal/wire):
TestMigration_RunsAndRunSteps: columns, defaults and checks as 03; a second active run in a thread fails on the partial unique index; a run in an end state withoutfinished_atfails; twotriggering_*columns fail;messages.run_idFK exists and is deferrable; the deferred FKs of note 1 are absent and commented.TestOpen_AtMostOneActivePerThread(R-DM-14): two units callOpenfor the same thread at the same time; exactly one run exists, the other getsErrActiveRunExistswith that run; after the first run reaches an end state a newOpensucceeds.TestTransition_EveryDisallowedPairRejected: for each persisted state and each target outside the allowed pairs,409 invalid_state_transition, unchanged row and version, no event.TestTransition_RaceOfTwoStartsOneWins: two goroutines move the samequeuedrun torunningwith the same expected version; exactly one succeeds, the other getsErrConcurrentChangewith staterunning; exactly onerun.state_changedwithrunningand onemessage.updatedwithworking.TestTransition_TriggerStatePerEndState:completedgivesdone;failedwithstalegivesfailedand reasonstale;cancelledwithuser_cancelledgivescancelledand reasonuser_cancelled; each raisesmessages.versionby one with onemessage.updated;running -> waiting_inputchanges no message.TestTransition_UnknownAdapterReasonBecomesAdapterError: adapterfailedwithquota_exhaustedstoresadapter_errorand one status stepadapter_reasonwhose detail holdsquota_exhausted; adapterruntime_erroris kept without a step; adapterstalebecomesadapter_error.TestTransition_EndStateTimes:finished_atset on every end state,duration_msonly for started runs,started_atkept onwaiting_input -> running.TestAcceptance_ThreadMessageOpensRun: Alex writes in a thread of Support/Allgemein without mention; one runqueuedfor Lotse withtriggered_byAlex andtriggering_message_idM; anchor in the same thread withkind = run, author Lotse,origin = agent, empty content,silent,run_id; M hastrigger_state = receivedand version 2; the201body shows both; events in the order of note 6 step 8 with consecutivethread_seqin the thread.TestAcceptance_MainThreadAutoThread(server part of AG-05): Alex mentions Lotse in the main thread of Support/Allgemein; a new thread withparent_message_idM,title_source = auto, operator Lotse; run and anchor in the new thread;trigger_stateon M in the main thread.TestAcceptance_Rejections: two agents mentioned givesmultiple_agents_mentionedand no audit entry; Texter mentioned in Support/Allgemein (no guests there) givesagent_not_allowed_here; Robin mentions Lotse in Support/Eskalation withouttriggergivesno_trigger_rightwith a deny audit entry; Robin without mention in a thread of Support/Eskalation gives notrigger_stateand no audit entry; with an active Lotse run in a thread of Support/Eskalation, Alex mentioning Texter there givesrun_active_other_agent; a disabled Lotse givesagent_disabled. No run, no new thread, no anchor in any case.TestAcceptance_AgentMessageNeverTriggers: Lotse writes a message mentioning Texter in a free channel; no run, notrigger_state(mirrors AZ-M30, owned by M2-08).TestAcceptance_ConcurrentMessagesOneRun: Alex and the owner write in the same thread of Support/Allgemein at the same time; one run exists; the other message has notrigger_state(follow-up, M2-11).TestGetRun_Document: all fields ofRun, steps keyed by ID,followups{},ETagequals the version, no JSONnull.TestListRunSteps_OrderandTestListThreadRuns_NewestFirst: keyset order,limitcapped atapi.page_size_max, foreign cursor400 cursor_invalid.TestListActiveRuns_Visibility: Alex sees the active runs of Support only; Sam sees those of Marketing and of Support/Allgemein (read through group Leser, G-4) but not those of Support/Eskalation; the owner sees all spaces and no DM; end states are absent; filteragent_idworks; an unknownagent_idgives an empty page.TestRunReads_NotFoundEquality(AZ-P4 forgetRun,listRunSteps,listThreadRuns): a foreign run or thread and an unknown ID give the same status, body and headers, and the medians of the response times differ by at mostauthz.not_found_timing_tolerance.TestAZP5_RunsOverviewAndSnapshot: property test over random worlds with active runs (seed logged, fixed number of worlds inmake test, reuse the world generator of M1-63 if exported): no item oflistActiveRunsand no entry ofSnapshot.active_runsrefers to a channel without read right.TestSnapshot_ActiveRuns:getSnapshotof Alex lists the active Support runs asRunSummary; an ended run disappears;internal/sync/adapters/norunsno longer exists.TestRunStateChanged_CatchUpReceivers: for a run in a thread of Support/Eskalation,listEventsgivesrun.state_changedto Alex and the owner and not to Sam; the envelope hasresource.versionequal toruns.version.TestRunActiveGauge: the worker gauge reports per agent handle and state.
Contract:
TestRunSchemas: responses of the four operations validate againstRunSummaryPage,Run,RunStepPageandProblem;run.state_changedvalidates againstRunStateChangedEvent,message.updatedagainstMessageUpdatedEvent; no JSONnullin any of them.TestTxnRegistrationsGolden(M1-13) showssubscribe messaging.MessagePosted -> runs ...after the existing subscribers.- AZ-P1: the route test of M1-17 and M1-28 passes with the four new routes.
Commands that must pass, from the repository root:
cd services/server && go test -race -count=1 ./internal/runs/... ./internal/messaging/... ./internal/chat/... ./internal/sync/... ./internal/authz/... ./internal/kernel/... ./internal/wire/... ./migrations/...make migrations-lint,make codegen-check,make arch,make register-check,make api-lint,make api-checks,make config-doc-check,make docsmake check
Acceptance criteria¶
-
listThreadRuns,getRun,listRunStepsandlistActiveRunsare enabled and registered; AZ-P1 passes; an invisible run or thread gives the same404as an unknown one (AZ-P4). - Migrations
runsandrun_stepsmatch 03 except the documented deferred FKs;messages.run_idhas its deferred FK. - Every transition outside 03
runsis rejected with409 invalid_state_transitionand changes nothing. - A race of two starts on one run gives exactly one success, one
run.state_changedand onetrigger_statechange. -
trigger_stateper end state:done,failedwith reason,cancelledwith reason;workingat the start; each change raises the message version and writesmessage.updated. - An unknown adapter reason is stored as
adapter_errorwith the original code in the detail of a status step. - At most one active run per thread (R-DM-14), also under concurrent
Open. - A triggering human message opens a run, an anchor and
trigger_state = receivedin its transaction, in the event order of note 6; rejections writefailedwith the 09 3.5 reason and no run; denials ofagent.invokeare audited. - The snapshot names the running runs the actor may read through
RunSummarySource; thenorunsbinding is gone; AZ-P5 holds for "Running now" and the snapshot. - Run metrics follow signals.md 4.3.
-
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
runs,messaging,chatandsyncupdated (ports, tables, events, subscriptions, the visibility rule of note 10, theRunSummarySourcebinding). - Rulings of note 14 recorded in
docs/DECISIONS.md(one row each with reason); 07 7.4 rule 1 and 09 3.4 and 5.2 updated as notes 10, 6 and 2 say. services/server/CHANGELOG.mdentry under 'Unreleased'.- ADR 0025 "Confirmation" names the tests of this ticket, then
make adr-index. Another ADR only if you take an architecture decision beyond this brief (MADR 4 as indocs/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); 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), the summary of
make check. - Review and red team verdicts and the findings fixed.
- What is not proven.
- Follow-ups that could not be done in this scope, each with its reason, and spec gaps found.
Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.