M2-09: Starting, finishing and cancelling runs, orphaned runs¶
Status: ready | Depends on: M2-08 | Milestone: M2
Goal¶
An agent adapter starts a delivered run only with a valid, current invocation token whose configured billing mode equals the mode it actually uses and whose tool_class is not below the class it loaded; it resumes a waiting run only with the token of the answer, renews an expired token, puts a run into waiting_input and ends it. A person with trigger who started the run, or has manage, stops it; without the adapter's confirmation within agent.cancel_grace the server cancels it itself. A loss of trigger by the person who started a run, a disabled person and a disabled agent end the affected runs, and runs whose adapter restarted or vanished end as interrupted or stale without any automatic retry. After this ticket the operations startRun, resumeRun, renewInvocationToken, awaitRunInput, finishRun and cancelRun are live, the deferred authorization entries run.start and run.token.renew of M1-28 are real requirements, and the jobs runs.cancel_timeout and runs.stall_check run in the worker. This is the server side of I3 (10 "Invariants"), of R-SY-10, R-SY-11 and R-SY-19 (04) and of the scenarios AG-10, AG-11, AG-14 and AG-28 to AG-32 (09 "Required scenarios"); a run can never start twice and never runs on after its starter lost the permission to start it (R-AG-07, D-L23, RK-18).
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 1 and 2, Test strategy, Red team, Risks) and the rows M2-05 to M2-08 (what exists), M2-09 (this ticket), M2-10 to M2-16, M2-20 and M2-22 (who builds what next), M7-08 and M8-03.- Dependencies: the header names M2-08. Through it this ticket also builds on M2-07, M2-06, M2-05, M2-04 and M2-02 and on the M1 tickets M1-13, M1-14, M1-16, M1-17, M1-19, M1-20, M1-24, M1-28, M1-29, M1-32, M1-33, M1-44, M1-45, M1-51, M1-53, M1-55, M1-56 and M1-65 (all implied, so not in the header).
docs/spec/09-agents.md: 2.1 (a disabled agent's active runs endcancelled,agent_disabled), 2.4, 4.3 "Connection and cursor", 4.5 "Invocation token" (server check at start, the table ofreasonvalues, consumed token), 4.5.1 "Fresh token for a waiting run" (steps 1 to 5,runs.renewed_from_jti), 4.7 "Adapter operations" (rowsrun.start,run.token.renew,run.finish), 4.8 "Heartbeat and reported state" (active_runs), 4.9, 4.10, 5.1 "State machine", 5.2 "Reasons (runs.state_reason)", 5.5 "Stall, interruption, orphaned runs", 6 "Trigger state (trigger_state)", 7 (end of run, owned by M2-11), 8 "Stop", 15 "Reliability", 16.2 "Required scenarios" (AG-08, AG-10, AG-11, AG-14, AG-28 to AG-32), 19 (R-AG-07, R-AG-08, R-AG-17, R-AG-24, R-AG-25), 21 "Configuration values of this chapter".docs/spec/04-sync-protocol.md: 2.2 "Envelope" (personal events,actor, rule 3: no tokens in payloads), 8.2, 8.3 "What the client receives and does on access removal" point 5, 15.2, 15.3 "Invocation and run start" points 3 to 6, 15.5 "Cancellation", 17 (R-SY-07, R-SY-10, R-SY-11, R-SY-19).docs/spec/03-data-model.md:runsin 4.8 "Runs and interactions (runs)" (columnsstate,state_reason,version,billing_mode_expected,billing_mode_used,billing_evidence,invocation_jti,started_jti,renewed_from_jti,metrics,summary,started_at,finished_at,duration_ms; transitions; unique index on active runs, R-DM-14),agents(last_heartbeat_at,reported_details, the note onagent.heartbeat_persist_interval),messages.trigger_state,audit_events.details(only free text: the mandatory reason of an administrative action), 6.3 "Lock order".docs/spec/05-actions.md: rule 1.1, rule 1.6 (I3 atrun.start), rule 1.8, 2.4 (column Auditdenials), 2.5 "Decision", table 4.10 (rowsrun.start,run.token.renew,run.finish,run.cancel,agent.invoke), 5 "Agent invocationagent.invoke" (claims), 6 (rowsrun.state_changed,run.cancel_requested), R-AZ-04, R-AZ-07.docs/spec/07-permissions.md: 4.1 "Decisions and reasons" (invocation_token_invalid,billing_mode_mismatch,tool_class_mismatch,not_self), 4.2 "Flow" (the paragraph on deferred entries), 8.2 "Immediate effect on revocation" points 4, 5 and 8, 8.3, test rows AZ-M51, AZ-P1, AZ-P3, AZ-P6, AZ-P13.docs/spec/10-backend-policy.md: 3 "Invariants" (I2, I3), 4 "Overview of check points" (rowrun.start), 7 "Adapter check (I3)" (key_type = unknown), 8, 14 "Audit trail" (row I3 mismatch), 15 "Tests" (BR-06).docs/spec/08-identity.md: 11 "Disabling a person" point 7.docs/spec/06-api.md: 2 "Basic rules" (rows "No effect", "Nonull"), 5 "Errors" (points 3 and 7;errors[].codenot_enabled), 7 "Idempotency" (point 3agent_runtime, point 5), 16 "Endpoints" (groups "Agent runtime" and "Runs").docs/spec/02-architecture.md: 4.3 "Ports, events and sources per module" (rowruns: subscriptionsauthorize.AccessChangedandagents.AgentDisabled; rowauthz:user_disabled), 4.4, 8.1 "Flow" (subscribers in phase 3.1), 8.2, 8.3, 12.1, 12.3 "Job catalogue" (rowsruns.cancel_timeout,runs.stall_check), 12.4 "Rules for jobs".docs/spec/19-security.md: SEC-IN-4, SEC-LOG-2, SEC-AUD-5.docs/api/openapi.yaml: operationsstartRun,resumeRun,renewInvocationToken,awaitRunInput,finishRun,cancelRun(withx-authz-action,x-rate-limit-class,x-idempotency-exempt); schemasRunStart,InvocationTokenRenew,InvocationTokenGrant,RunAwaitInput,RunFinish,CostReport,RunCancel,Run,RunSummary,RunState,RunMetrics,BillingEvidence,ToolClass,TriggerState,RunStateChangedEvent,RunStateChangedPayload,RunCancelRequestedEvent,RunCancelRequestedPayload,Problem(x-problem-codes), headerIdempotent-Replay, responsesForbidden,Conflict,UnprocessableEntity.docs/api/asyncapi.yaml: messagesevent.run.state_changed,event.run.cancel_requested.docs/spec/actions.yaml: entriesrun.start(rightagent-self and token valid),run.token.renew(rightagent-self and token current),run.finish,run.cancel.- ADRs 0017, 0021, 0022, 0023, 0024, 0025, 0052, 0053, 0067.
docs/DECISIONS.md: D-L23, D-L24, D-W-15, D-W-16, D-W-44, D-W-100, D-AG-06, D-AG-07, D-AG-16; section 10 for the format of new rows.docs/security/threat-model.md: T-TK-05 (reuse of an invocation token), T-AI-08 (misuse of a personal subscription).docs/RISKS.md: RK-09, RK-13, RK-18. No item ofdocs/OPEN-QUESTIONS.mdconcerns this ticket.docs/observability/signals.md4.3 "Agents and runs":bothy.run.start.latency,bothy.run.duration,bothy.run.finished,bothy.agent.billing_mode_mismatch.docs/operations/configuration.md:agent.invocation_token_ttl,auth.clock_leeway,agent.ack_timeout,agent.cancel_grace,agent.presence_timeout,agent.heartbeat_persist_interval,run.orphan_timeout,run.queued_max_age,audit.reason_max_chars.- Briefs: M1-13 (unit of work,
txn.Publish,txn.Subscribe, golden registrations), M1-14 (internal/platform/jobs:Args,Handler,PeriodicJob,Inserter.InsertTxwithInsertOptions.ScheduledAt, argument types), M1-16 (eventlog.Event,eventlog.Appender,internal/sync/adapters/jobsas the job adapter pattern), M1-17 (api/operations.yaml), M1-20 (Idempotency-Key), M1-24 (auditing.Recorder,Entry,Attrsvalue kinds), M1-28 (descriptor, requirement table, deferred entries, decision mapping), M1-29 (authorize.AccessChanged,DiffVisible), M1-32 (instance.Keys.VerificationKey), M1-33 (kernel/jose), M1-45 (user.disable), M1-51 (agents.AgentDisabled, the agents port), M1-55 (chat.Threads.Info), M1-56 (messages), M1-65. - The briefs of the M2 tickets this one builds on:
docs/briefs/M2-05-runs-state-machine.md,M2-06-invocation-token.md,M2-07-agent-consumers.md,M2-08-invocation-delivery.md, and the next ones that extend this ticket:M2-10-run-document-live-box.md,M2-11-run-followups.md,M2-12-agent-sessions.md. - Code on the base branch:
internal/runs(M2-05 state machine, reads andrun.state_changed; M2-06 token issuer and reference verifier; M2-08 portDelivery),internal/agents(M1-51, M2-07),internal/authz,internal/kernel/authorize,internal/kernel/auditing,internal/kernel/eventlog,internal/kernel/txn,internal/kernel/clock,internal/kernel/errs,internal/kernel/textcheck,internal/platform/jobs,internal/wire(configkeys_*.go,configchecks.go,configchapters.go),architecture/modules.yaml,api/operations.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:
startRun(queued -> running) andresumeRun(waiting_input -> running) check the invocation token (signature,kid,typ,alg,iss,aud, run), its currency (jtiequal toruns.invocation_jti, not consumed), its expiry, then the reported billing mode against the configured mode andtool_class_usedagainst thetool_classclaim; the transition is a compare-and-swap that storesruns.started_jtiand setsruns.renewed_from_jtito NULL (09 4.5, 04 15.3 points 4 and 5, 03runs, R-SY-10).- Every token error at a start or a renewal is
403 invocation_token_invalidwithreasonexpired,supersededorinvalid(09 4.5, 06 "Errors", 07 4.1, D-W-15). - A repeated start with the same token answers
200withIdempotent-Replay: truewhile the run isrunning; inwaiting_inputthat token is consumed (superseded); a token of another delivery for a started run gets409 already_started(06 "Idempotency" point 5, R-SY-19, D-W-44). billing_mode_useddifferent from the configured mode gives409 billing_mode_mismatchand the runfailed;tool_class_usedabove the claim gives409 tool_class_mismatchand the runfailed(10 "Adapter check (I3)", BR-06, AG-11, AG-30, D-W-16).renewInvocationTokenruns steps 1 to 5 of 09 4.5.1 in this order and repeats the checks before a delivery with current permissions; on their failure the run becomesfailedand the answer is403 agent_invocation_denied(09 4.5.1, 04 15.3 point 3, R-AG-25, AG-29, AG-32).awaitRunInputmovesrunning -> waiting_input;finishRunends a run withcompleted,failedorcancelled; the same final state repeated answers200(09 4.7, 5.1, 05run.finish).cancelRunneedstriggerand (triggering person of the run ormanage), is audited (Audityes), usesrl.triggerandIdempotency-Key, and writesrun.cancel_requested; without confirmation withinagent.cancel_gracethe server setscancelledwithcancel_timeout, and later changes by the adapter are refused (09 8, 04 15.5, R-AZ-07, AG-14).- A loss of
triggerby the starter cancels the starter's active runs in the same transaction throughrun.cancel_requested(access_revoked;user_disabledafteruser.disable;channel_read_onlyafter an archive), for all agents (R-SY-11, 04 8.3 point 5, 07 8.2 point 4, 08 11 point 7, D-L23). - A disabled agent's active runs end
cancelledwithagent_disabled(09 2.1, 02 4.3). - A run missing from the adapter's
active_runslonger thanagent.presence_timeoutfails withinterrupted; a run without heartbeat for longer thanrun.orphan_timeoutfails withstale; neither is ever retried automatically (09 4.8, 5.5, 15, R-AG-17, AG-10). - No JSON
null; tokens never appear in events, logs, audit details, problem bodies or test output (ADR 0053, 04 2.2 rule 3, SEC-LOG-2, T-TK-05). - The deferred entries
run.startandrun.token.renewof M1-28 become real requirements and leave the golden list (07 4.2, AZ-P3).
Scope¶
All paths under services/server/ unless noted. Names of M2-05 to M2-08 below are the expected ones; where the merged code names them differently, follow the code (Boundaries).
Create:
internal/runs/domain/token_check.go: pure functionsStartVerdict(c TokenClaims, r RunTokenState, op StartOp, now time.Time, leeway time.Duration) StartCheckandRenewVerdict(c TokenClaims, r RunTokenState) RenewCheckwith the outcomes of Design notes 3 and 4.TokenClaimsis the parsed, signature-checked claim set;RunTokenState{State, InvocationJTI, StartedJTI, RenewedFromJTI};StartOpisstartorresume.internal/runs/domain/finish.go: pureFinishDecision(current RunState, req FinishRequest) (FinishOutcome, error)(Design note 6).internal/runs/domain/cancel.go: pureCancelDecision(current RunState) CancelOutcome,CancelReasonFor(action authorize.Action) stringandLostTriggerRuns(changes []authorize.PrincipalAccessChange, runs []ActiveRun) []ids.ID(Design notes 7 and 8).internal/runs/domain/orphans.go: pureOrphanVerdict(run ActiveRun, hb HeartbeatState, now time.Time, w OrphanWindows) OrphanOutcome(Design note 10).internal/runs/app/start.go(Start,Resume),renew.go(RenewToken),finish.go(AwaitInput,Finish),cancel.go(Canceland the in-modulerequestCancel),subscribers.go(OnAccessChanged,OnAgentDisabled),timeouts.go(cancel timeout use case),orphans.go(stall check use case). Outgoing app portCancelTimeoutScheduler { Schedule(ctx context.Context, runID ids.ID, at time.Time) error }.internal/runs/adapters/jobs/: argsCancelTimeout{RunID ids.ID}with kindruns.cancel_timeoutandStallCheck{}with kindruns.stall_check, their handlers (queuemaintenance), and the scheduler that implementsCancelTimeoutSchedulerwithjobs.Inserter.InsertTxandInsertOptions.ScheduledAt.internal/runs/adapters/postgres/queries/run_lifecycle.sql: lock of one runFOR UPDATE; the compare-and-swap updates of this ticket (through the M2-05 transition function where it exists); active runs by starter (state IN ('queued','running','waiting_input') AND triggered_by = ANY($1) ORDER BY id FOR UPDATE); active runs by agent; orphan candidates.- Server-side token verification, only if M2-06 did not build it:
internal/runs/domain(claims and checks) plusinternal/runs/app(key lookup throughinstance.Keys.VerificationKey(ctx, invocation, kid)), parsing withkernel/jose; never a second JOSE library (ADR 0067). A fuzz targetFuzzParseInvocationTokennext to the parser.
Change:
internal/runsport: onRunsaddStart(ctx context.Context, a actor.Principal, cmd StartRun) (StartResult, error),Resume(ctx context.Context, a actor.Principal, cmd StartRun) (StartResult, error),RenewToken(ctx context.Context, a actor.Principal, cmd RenewToken) (TokenGrant, error),AwaitInput(ctx context.Context, a actor.Principal, cmd AwaitInput) (Run, error),Finish(ctx context.Context, a actor.Principal, cmd FinishRun) (Run, error),Cancel(ctx context.Context, a actor.Principal, cmd CancelRun) (RunSummary, error), withStartRun{RunID ids.ID; Token string; BillingModeUsed, ToolClassUsed, Model string; BillingEvidence *BillingEvidence; ExternalSessionRef string},StartResult{Run Run; Replay bool},RenewToken{RunID ids.ID; Token string},TokenGrant{Token string; ExpiresAt time.Time},AwaitInput{RunID ids.ID; StatusLine string},FinishRun{RunID ids.ID; State, Reason, Summary string; Metrics *RunMetrics; CostsPresent bool},CancelRun{RunID ids.ID; Reason string}.actor.Principalstands for the request principal type ofkernel/actor.internal/runs/adapters/httpapi: strict-server methods of the six operations;Idempotent-Replay: trueon a start replay.internal/kernel/authorize:Resource.Token *TokenFactswithTokenFacts{Verdict TokenVerdict; BillingModeUsed, BillingModeExpected, BillingKeyType, ToolClassUsed, ToolClassClaim string}, constantsTokenValid,TokenExpired,TokenSuperseded,TokenInvalid, andDecision.Detail string(Design note 2).internal/authz: requirement entries ofrun.startandrun.token.renewinstead ofDeferred; two rows of the "Permission" text mapping; atomToken; guard ofrun.start; row of the decision mapping forinvocation_token_invalid; the golden file of deferred actions without both ids (Design note 2).internal/agentsport: if no read of the persisted heartbeat exists, addAgents.HeartbeatStates(ctx context.Context, agentIDs []ids.ID) ([]HeartbeatState, error)withHeartbeatState{AgentID ids.ID; LastHeartbeatAt time.Time; ActiveRunIDs []ids.ID}(zero time when no heartbeat) readingagents.last_heartbeat_atandagents.reported_details.active_runs, with its sqlc query ininternal/agents/adapters/postgres.internal/wire: subscriptionsauthorize.AccessChanged -> runs.OnAccessChangedandagents.AgentDisabled -> runs.OnAgentDisabled(method values of therunsapp type, never closures); job registration for the rolesworkerandall(handlerruns.cancel_timeout; periodicruns.stall_checkwithInterval = agent.presence_timeout,RunOnStart = true); config keys this ticket is the first consumer of, amongagent.invocation_token_ttl,auth.clock_leeway,agent.cancel_grace,agent.presence_timeout,agent.heartbeat_persist_interval,run.orphan_timeout,run.queued_max_age,audit.reason_max_chars(defaults from their chapter tables, chapter constants inconfigchapters.go); the cross-checkheartbeat_persist_below_orphan_timeoutinconfigchecks.go; regenerateinternal/wire/testdata/txn_registrations.golden.api/operations.yaml: movestartRun,resumeRun,renewInvocationToken,awaitRunInput,finishRun,cancelRunfromlatertoenabled; rerun the route generator (M1-17).architecture/modules.yaml:run.token.renewin theactionsofrunsif missing (02 4.6).docs/api/openapi.yaml: headerIdempotent-Replayon the200ofstartRunandresumeRun(06 "Idempotency" point 5); valuechannel_read_onlyinRunCancelRequestedPayload.reason(04 8.3 point 5, 05 rule 1.8, 09 5.2). Thenmake api-lint,make api-checks,make api-tablesand the server code generation.docs/spec/09-agents.md(English, like the chapter): 5.2 rowuser_disabled(cancelled, Server, Triggering person disabled, 08 11); 5.5 the evaluation byruns.stall_check(Design note 10); 8 point 2 the immediate stop of aqueuedrun (Design note 7).docs/spec/02-architecture.md12.3: rowruns.cancel_timeout"scheduled,agent.cancel_graceafterrun.cancel_requested"; rowruns.stall_check"periodic,agent.presence_timeout" withinterrupted,staleand, if built here,agent_unavailable.docs/operations/configuration.md: regenerate withmake config-referenceif the schema text changes.- READMEs of
runs,authzand (if changed)agents;services/server/CHANGELOG.md;docs/DECISIONS.mdsection 10 rowsD-M2-09-<n>for every Ruling below. - Metrics (signals.md 4.3, closed attribute sets):
bothy.run.start.latencywhen a run leavesqueued(started,failed,cancelled),bothy.run.durationandbothy.run.finishedat every end state (bothy.reasonfrom the fixed list, elseother),bothy.agent.billing_mode_mismatchat every I3 mismatch. Do not register an instrument twice if M2-05 already records it.
This ticket creates no migration and no new top-level make target.
Out of scope¶
- State machine,
run.state_changedwriter, readslistThreadRuns,getRun,listRunSteps,listActiveRuns, the mapping of reasons totrigger_stateand of unknown adapter reasons toadapter_error(M2-05). - Claims, signing and rotation of the invocation token, AG-22 (M2-06); consumer cursors,
ack, redelivery (M2-07). - Checks before a delivery, issuing the token per delivery,
trigger_state = queued, AG-07, AG-09, AG-27 at delivery time (M2-08). The periodic part ofagent_unavailablebelongs here only if M2-08 has none (Design note 10). - Live-box patches,
patchRun, gateway oprun.updateand the rejection of patches after the end; the patch part of AG-14 is asserted there (M2-10). - Follow-ups
deferredordiscardedat the end of a run,agent.followupdeliveries (M2-11); componentsdisabledat the end,interaction.createddeliveries (M2-15). Both hook into the end-of-run function of Design note 6. - Agent sessions and binding
external_session_refat start (M2-12); run contextgetRunContext(M2-13); SR9 (M2-14);/stop(M2-16, reusesrequestCancel); agent replies (M2-19). sendHeartbeat, presence, configured versus reported mode warnings, BR-05 (M2-20); the heartbeat rows this ticket reads come from there.- Cost booking from
RunFinish.costsintocost_entries, BR-09 (M2-22). channel.read_only.set,chat.ChannelReadOnlySetand itsrunssubscription (M8-03; it reusesrequestCancelwithchannel_read_only).- The stall warning
stall_warned_at(09 5.5 "No progress"): the core contract has no time of the lastrun.update(spec gap in the report). - Push "Answer ready" at the end of a run (M3-17, M3-19); maintenance mode exceptions (M7-08); fencing over several nodes (M7-17); the conformance suite with the fake agent (M2-31).
- Changes to the core contract.
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M2-09-run-lifecycle; 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>. - The same holds for M2-05 to M2-08: their merged names win over the expected names here; list each difference. If the base branch's
docs/spec/actions.yamllacksrun.token.renew, the spec state is missing on the base branch: stop withVerdict: BLOCKED <reason>. - No real agent runtime and no model call: the adapter in the tests is plain Go HTTP code with an agent API token; no third-party service.
- Time only through
kernel/clock; tests move a fake clock and call job handlers directly, they never sleep for a window. - Invocation tokens and API tokens never appear in logs, audit details, problem bodies, event payloads or test failure messages (print a hash prefix of the
jtiif needed).
Design notes¶
-
Descriptors. Every operation loads the run first (unknown id gives the same
404as an invisible run) and callsauthorizewithResource{Kind: run, ID, ThreadID: runs.thread_id, ...}. For the agent-self actionsrun.start,run.token.renewandrun.finishthe fieldOwnerIDisruns.agent_id; forrun.cancelit isruns.triggered_by(05 "triggering person of the run"). Ruling for docs/DECISIONS.md:OwnerIDof a run is the principal the action reserves it to. A run is visible to a principal with at leastreadon the thread's path (M1-28 visibility); the operator agent has it through the operator rule. -
Token facts in
authz(05 rule 1.1, 07 4.1 and 4.2: the "Permission" text "agent-self and token valid" is the requirement, andinvocation_token_invalid,billing_mode_mismatchandtool_class_mismatchare decisions ofauthorize).runsverifies the token and computes the verdict (it owns keys access and thejticolumns);authzdecides on these facts, as M1-28 does withInvokeFacts. a. Requirement entries:run.start:All(AgentSelf, Token(TokenValid));run.token.renew:All(AgentSelf, Token(TokenValid, TokenExpired)). Mapping rows:agent-self and token validandagent-self and token currentto these expressions.Token(...)fails withforbiddenand reasoninvocation_token_invalid, and setsDecision.Detailto the verdict (expired,superseded,invalid). A missingResource.Tokenis an internal error. b. Guard ofrun.start(07 4.2 step 9), only whenBillingModeUsedis set:BillingKeyType = unknownorBillingModeUsed != BillingModeExpectedgivesinvariant_violationbilling_mode_mismatch(10 7: an unknown reported mode counts as unequal to every configured mode; RK-13); then aToolClassUsedaboveToolClassClaimin the orderchat<scoped<hostgivesinvariant_violationtool_class_mismatch. c.Decision.Err():forbiddenwithinvocation_token_invalidmaps to problem codeinvocation_token_invalidwithreasonequal toDetail; the audit deny entry keepsdeny_reason = invocation_token_invalidand recordstoken_verdict(a code) in its details. Ruling for docs/DECISIONS.md:Decision.Detailcarries the sub-reason of a decision whose problemreasondiffers from the audit reason. d. Denials are recorded by the authz layer (Auditdenials, aggregated peraudit.deny_aggregate_window, R-AZ-04). Forbilling_mode_mismatchthe details carrybilling_mode_expected,billing_mode_usedandbilling_evidence(provider,key_type,method, never values) as codes (10 14). e. Replace both entries and remove them from the deferred golden list in the same change;VerifyRegistrationsthen accepts the routes (AZ-P1). -
startRunandresumeRunin one unit of work, run rowFOR UPDATEfirst: a. Parse and verify the token: compact JWS;algexactlyEdDSA;typexactlyagent-invocation+jwt;kidknown throughinstance.Keys.VerificationKey(ctx, invocation, kid)(statesnext,active,retiring); valid Ed25519 signature;issequal toinstance.instance_id;audequal to the calling agent;run_idequal to the path; required claims present. Any failure is verdictinvalid(09 4.5 table). b.StartVerdictfor a token that passed 3a, first match wins; each branch names the verdict that goes toauthorizeand what follows anallow:jti = started_jtiand staterunning: replay;TokenValidwithout mode facts; answer as in 3f. The expiry is not checked, because the start already happened (06 "Idempotency" point 5, R-SY-19).jti = started_jtiand statewaiting_input:TokenSuperseded(consumed, 09 4.5).- state neither
queuednorwaiting_input:TokenValidwithout mode facts; then409 already_started. jti != invocation_jti:TokenSuperseded.now > exp + auth.clock_leeway:TokenExpired. Ruling for docs/DECISIONS.md: the server's expiry tolerance isauth.clock_leeway, the upper bound of the adapter's tolerance (09 4.5 "Keys and rotation" point 2).startRunonwaiting_inputorresumeRunonqueued:TokenValidwithout mode facts; then409 invalid_state_transition. Ruling for docs/DECISIONS.md: each endpoint performs only its own transition (06 16 namesqueued -> runningforstartRunandwaiting_input -> runningforresumeRun).- otherwise
TokenValidwith the mode facts of 3c. c. Callauthorize(agent, run.start, descriptor with TokenFacts); the mode facts areBillingModeUsed,BillingModeExpected(runs.billing_mode_expected, equal to thebilling_modeclaim of a current token),BillingKeyType(billing_evidence.key_type, empty when absent),ToolClassUsedandToolClassClaim. Resulting order of answers:404(unknown or invisible run),403 forbidden(not_self,subject_kind_not_allowed),403 invocation_token_invalidwithinvalid, the replay200,409 already_started,403 invocation_token_invalidwithsuperseded, then withexpired,409 invalid_state_transition,409 billing_mode_mismatch,409 tool_class_mismatch,200. d. Onbilling_mode_mismatchortool_class_mismatchthe run becomesfailedwith thatstate_reasonin the same unit of work, which commits; the use case returns the409after the commit (return the outcome from theDofunction, then the error). Countbothy.agent.billing_mode_mismatchfor I3. e.RunStart.external_session_ref:400 validation_failedwitherrors[].codenot_enabledand pointer/external_session_refuntil M2-12 enables it (06 5 point 3; the session row it binds is M2-12's). Ruling for docs/DECISIONS.md.RunStart.billing_evidenceis stored inruns.billing_evidence,modelinruns.model. A schema-valid but emptyinvocation_tokenis verdictinvalid; an absent one is400 validation_failedfrom the schema. f. On success through the M2-05 transition function: compare-and-swap onstateandversion;started_jti = jti,renewed_from_jti = NULL,billing_mode_used;started_atonly onqueued -> running;version + 1;trigger_state = working(unchanged on resume);run.state_changed.invocation_jtistays. Answer200with theRun; a replay answers the currentRunwithIdempotent-Replay: trueand changes nothing. g. A token rejected asinvalidchanges nothing on the server; the adapter reportsfailedwithinvalid_invocation_token(09 5.2 names the adapter). Ruling for docs/DECISIONS.md: 04 15.3 point 4 "(runfailed)" is the consequence through the adapter'sfinishRun.
-
renewInvocationTokenin one unit of work with the run rowFOR UPDATE(09 4.5.1): a. Step 1: parse and verify as 3a without the expiry; failure isinvalid. b.RenewVerdictfor a token that passed step 1: step 2, state neitherqueuednorwaiting_inputgivesTokenValidand then409 already_started(checked before step 3, AG-32); step 3,jtiequal toinvocation_jtior torenewed_from_jti, andstarted_jti IS DISTINCT FROM jti, givesTokenValid, elseTokenSuperseded. The expiry is never checked here, soTokenExpireddoes not occur. c.authorize(agent, run.token.renew, descriptor with the verdict, no mode facts). Resulting order of answers:404,403 forbidden,403 invocation_token_invalidwithinvalid,409 already_started,403 invocation_token_invalidwithsuperseded,403 agent_invocation_denied,200. d. Step 4: the checks of M2-08'sDeliverybefore a delivery, for the starter (sub) with the triggering object of the presented token and current permissions (agent.invoke, I2, SR2, SR3). On failure the run becomesfailedwith the samestate_reasonthe delivery check sets (07 AZ-M51 namesaccess_revoked), the denial is audited as there, the unit commits, and the answer is403 agent_invocation_deniedwithreasonequal to thatstate_reason. Ruling for docs/DECISIONS.md: the problemreasonof a refused renewal is the run's newstate_reason. e. Step 5: issue a new token through M2-06 with the same triggering object,trigger_kindand place, currentbilling_mode,tool_class,levelandpermission_version, a newjti,exp = iat + agent.invocation_token_ttl; setinvocation_jtito it,billing_mode_expectedto the current configured mode, andrenewed_from_jtito the presentedjtionly when the presented token was the current one (a retry withrenewed_from_jtikeeps the value); changeversionexactly as M2-08's delivery does when it replacesinvocation_jti(same repository function). Answer200InvocationTokenGrant{invocation_token, expires_at}. No event, no audit on success (Auditdenials). -
Locks and concurrency. One run row
FOR UPDATEper operation; several runs in ascendingid(03 6.3). Deadlocks are retried by the unit of work (database.tx_retry_max). Two starts with the same token give one transition and one replay; a start racing a cancel ends eitherrunningwith a cancel request orcancelledwith409 already_startedfor the start; a job and an adapter call on the same run serialize on the row. -
awaitRunInputandfinishRun(actionrun.finish, agent-self, Auditno): a.awaitRunInput:running -> waiting_inputwithstatus_linestored; onwaiting_input200without change (06 2 "No effect"); from any other state409 invalid_state_transition.trigger_statestaysworking;run.state_changed. b.finishRuntargets:completedfromrunning;failedfromqueued,running,waiting_input;cancelledfromqueued,running,waiting_input. The same final state again answers200with the currentRun, no new version, no event; another target on an ended run, orcompletedfromqueuedorwaiting_input, gives409 invalid_state_transition. c. Reasons (09 5.2):failedandcancelledneedreason, else400 validation_failed(errors[].coderequired, pointer/reason). Forfailedthe adapter reasonsbilling_mode_mismatch,tool_class_mismatch,invalid_invocation_token,runtime_error,adapter_error,runtime_not_authenticatedare stored; every other code goes through M2-05's mapping toadapter_error. Forcancelledonly the reasons of a cancel request are accepted (user_cancelled,access_revoked,agent_disabled,channel_read_only,user_disabled), else400 validation_failedwith pointer/reason. Forcompletedareasonis not stored. Ruling for docs/DECISIONS.md: these three reason rules. d.metrics, when present, replacesruns.metrics(schemaRunMetrics);summarygoes toruns.summary.costsgives400 validation_failedwitherrors[].codenot_enabledand pointer/costsuntil M2-22 books them intocost_entries(06 5 point 3). Ruling for docs/DECISIONS.md. e.failedwithbilling_mode_mismatchortool_class_mismatchwrites one audit entry in the same unit of work: actionrun.finish, decisiondeny,deny_reasonthat code, detailsbilling_mode_expectedandtool_classof the run as codes (10 14 row "I3 mismatch", reported by the adapter); countbothy.agent.billing_mode_mismatch. Ruling for docs/DECISIONS.md. f. Every end state goes through one end-of-run function ininternal/runs/app: transition (finished_at,duration_ms,trigger_statedone,failedorcancelledwithtrigger_state_reason),run.state_changed, metrics. M2-11 and M2-15 extend this function; leave a documented extension point, no empty stubs. -
cancelRun(05run.cancel:trigger and (triggering person of the run or manage),All(Level(trigger), Any(Self, Cap(manage)))from M1-28; owners have both; an agent token fails withforbidden): a. BodyRunCancel.reasonis optional; when present it passestextcheck.DisplayTextand has at mostaudit.reason_max_charscharacters, else400 validation_failedwith pointer/reason. It is stored nowhere: 03audit_events.detailsallows free text only for the mandatory reason of an administrative action, and no table or event carries it. Ruling for docs/DECISIONS.md (spec gap in the report). b.queued: the run becomescancelledwithuser_cancelledat once (03 allowsqueued -> cancelled; 09 5.1 names the person'srun.cancelbefore the start), andrun.cancel_requestedis written as well, because the core contract keeps no delivery marker on a run; an adapter that never got the invocation ignores the request, one that holds it in its inbox drops it, and its later start gets409 already_started. Ruling for docs/DECISIONS.md; update 09 8 point 2. c.runningorwaiting_input: writerun.cancel_requestedand scheduleruns.cancel_timeoutatnow + agent.cancel_gracewithInsertTx; the state stays. A further cancel writes a further request and job (no pending marker in 03). d. Alreadycancelled:202with the currentRunSummary, no event, audit withno_change: true(06 2 "No effect");completedorfailed:409 invalid_state_transition. e. Answer202withRunSummary. Audit allow entry in the transaction: actionrun.cancel, resource the run, detailsstate_before,immediate(bool) andcancel_reasonuser_cancelled.Idempotency-Keyis handled by the middleware of M1-20. f.run.cancel_requested(05 6): personal event to the agent (AudiencePrincipalID = runs.agent_id, receive action as in the catalogue),scope {space_id, channel_id, thread_id}of the thread now,resource {kind: run, id}withoutversion, payloadRunCancelRequestedPayload {run_id, thread_id, reason, requested_by};requested_byonly foruser_cancelled. Ruling for docs/DECISIONS.md. g.requestCancel(ctx, runID, reason, requestedBy)ininternal/runs/appis the one function behind 7b, 7c, 8 and 9; M2-16 and M8-03 reuse it. h. Ruling for docs/DECISIONS.md:runs.cancel_timeoutis a job scheduled per request (InsertTx,ScheduledAt = now + agent.cancel_grace, args only the run id) instead of a periodic job, because 03runskeeps no time of a cancel request; update 02 12.3. The handler sets a run that is stillqueued,runningorwaiting_inputtocancelledwithcancel_timeout(end-of-run function, no actor) and does nothing for an ended run; repeated execution is harmless (02 12.4 rule 1). After it,startRun,resumeRun,awaitRunInputand afinishRunwith another state are refused (409). -
Revocation (R-SY-11):
OnAccessChanged(ctx, authorize.AccessChanged)runs in phase 3.1 inside the revoking transaction (02 8.1), never callsauthorize(the snapshot cache does not see uncommitted permissions, M1-29) and works only on the diff: a. For every principal inChanges, a channel is lost when it is inRemoved, or inChangedwith a newlevelbelowtrigger(07 2.3). Foruser.disableevery active run started by the person is affected. b. Load the active runs (queued,running,waiting_input) withtriggered_byamong these principals in ascendingidFOR UPDATE, resolve each thread's current channel throughchat.Threads.Infoand keep the runs whose channel is lost. c. Reason byAccessChanged.Action:user.disablegivesuser_disabled(08 11 point 7, 02 4.3),channel.archiveandspace.archivegivechannel_read_only(09 5.2 "read-only or archived", 07 4.5), every other action givesaccess_revoked. Ruling for docs/DECISIONS.md. d. For every kept run:run.cancel_requestedwith that reason andruns.cancel_timeoutscheduled (7c). The state stays, also forqueued: undelivered calls fail at delivery (07 8.2 point 5) and a renewal fails at step 4 (AG-29), while a delivered token can no longer outliveagent.cancel_grace. Ruling for docs/DECISIONS.md. e. Adduser_disabledto 09 5.2 andchannel_read_onlytoRunCancelRequestedPayload.reason(Scope). -
Disabled agent:
OnAgentDisabled(ctx, agents.AgentDisabled)ends every active run of the agent at once withcancelledandagent_disabledthrough the end-of-run function and writesrun.cancel_requestedwithagent_disabled(04 15.5); no timeout job, because the agent's tokens are revoked in the same transaction (09 2.1). Ruling for docs/DECISIONS.md. -
runs.stall_check(periodic,Interval = agent.presence_timeout, the window ofinterrupted, instead ofrun.stall_afternamed in 02 12.3 today, because the stall warning is not built here (Out of scope); one unit of work per affected run, so no batch key is needed; no actor on its events). Ruling for docs/DECISIONS.md: the interval; update 02 12.3. It reads the persisted heartbeat of each agent with active runs through theagentsport: a.interrupted: the run isrunningorwaiting_input, the agent's last persisted heartbeat lacks it inactive_runs, and that heartbeat is more thanagent.presence_timeoutafterruns.started_at. Ruling for docs/DECISIONS.md: 09 4.8 compares "On every heartbeat", but only the last persisted heartbeat is visible to the worker (03agents); update 09 5.5. b.stale: the run isrunningorwaiting_input, the agent has no heartbeat or its last one is older thanrun.orphan_timeout, andruns.started_atis older thanrun.orphan_timeout. c.agent_unavailableforqueuedruns older thanrun.queued_max_age(09 5.5, D-AG-16): build it here only if M2-08 has no periodic check; never twice. d. Each outcome setsfailedwith its reason through the end-of-run function; no new run, no newagent.invocation, no retry (R-AG-17). e. Cross-check at load:agent.heartbeat_persist_intervalbelowrun.orphan_timeout, else start error naming both keys (the persisted heartbeat lags by up to the persist interval). Ruling for docs/DECISIONS.md. -
Permission version, events, audit. No action here raises the permission version or writes
access.changed. Events are written througheventlog.Appenderonly;message.updatedfortrigger_statecomes frommessaging(02 4.7). No event, log line, audit entry or problem body contains a token, ajtiin clear or message content. -
Rulings for docs/DECISIONS.md, one row each with reason: notes 1, 2a and 2c, 3b (tolerance, endpoint), 3e, 3g, 4d, 6c, 6d, 6e, 7a, 7b, 7f, 7h, 8c, 8d, 9, 10 (interval), 10a, 10e. Facts already in the spec (order of 4.5.1, replay,
supersededfor a consumed token,key_type = unknown) are not repeated there.
Tests (write them first)¶
Unit:
internal/runs/domain:TestStartVerdictwith a row per branch of note 3b (replay after expiry, consumed inwaiting_input,already_startedforrunningand every end state,supersededby a newer delivery,expiredexactly atexp + auth.clock_leewayis still valid and just after is expired, endpoint mismatch both ways, valid);TestRenewVerdict(current, renewed-from, consumed, replaced,runningbefore a replacedjtigivesalready_started);TestFinishDecision(every transition of note 6b and every reason rule of 6c);TestCancelDecision;TestCancelReasonFor(user.disable,channel.archive,space.archive,grant.revoke,grant.expire,group.member.remove,owner.remove);TestLostTriggerRuns(removed channel, level changed toreadand towrite, capability-only change, change on another channel);TestOrphanVerdict(heartbeat exactlyagent.presence_timeoutafter start is not yetinterrupted, later is; run listed gives none;staleboundaries; no heartbeat at all;queuedoverrun.queued_max_ageif built here).internal/runs/...:FuzzParseInvocationToken: any input givesinvalidor a parsed token, never a panic, never a valid verdict without a signature that verifies.internal/authz:TestRunStartRequirement(the run's agent with a valid token: allow; another agent:forbiddennot_self; a human:forbiddensubject_kind_not_allowed; invisible run:not_found; each ofexpired,superseded,invalid:forbiddeninvocation_token_invalidwithDetail; configured and reported mode differ orkey_type = unknown:invariant_violationbilling_mode_mismatch;hostagainstscoped:tool_class_mismatch;chatagainstscoped: allow);TestRunTokenRenewRequirement(valid and expired allow, the others deny);TestDecisionErr_InvocationTokenInvalid(problem code andreason, audit reason unchanged);TestDeferredActionsGolden(golden withoutrun.startandrun.token.renew); the source test of M1-28 for both new "Permission" texts.
Integration (PostgreSQL 18 via pgtest, real modules through internal/wire, HTTP through the real chain, agent tokens and people from the identity test helpers, fake clock; world: space Support with channel Allgemein whose operator is the agent Lotse (organization, tool class scoped), space Marketing with the agent Texter; Robin is the owner, Alex has trigger on Support, Sam's grants vary per test; a run is triggered by Alex and delivered through M2-08 and pollAgentEvents, so the adapter holds token T1):
TestStartRun_HappyPath:200Runinrunning;started_jti,started_at,billing_mode_used,billing_evidence,modelstored;renewed_from_jtiNULL;versionraised by one;trigger_state = working; onerun.state_changed;bothy.run.start.latencywithstarted.TestStartRun_Replay: the same T1 again gives200withIdempotent-Replay: true, also after the clock passed itsexp; no second event; T1 afterawaitRunInputgives403 invocation_token_invalidsuperseded.TestStartRun_TokenInvalid_AG08: empty token string (an absent field is400 validation_failedfrom the schema), garbled JWS, wrong signature,algnoneandHS256,typof an access token, unknownkid,audof Texter,run_idof another run, otheriss: each403 invocation_token_invalidwithreason: invalidatstartRunand atrenewInvocationToken; the run staysqueuedand unchanged; one aggregated deny entry withinvocation_token_invalid(AG-08 server part, AZ-P6, T-TK-05).TestStartRun_Expired: T1 afteragent.invocation_token_ttlplusauth.clock_leewaygives403 invocation_token_invalidwithreason: expired.TestStartRun_BillingModeMismatch_BR06:billing_mode_used = personalagainstorganizationgives409 billing_mode_mismatch; the run isfailedwithbilling_mode_mismatch,trigger_state = failedwith that reason; the deny entry carries configured mode, reported mode and evidence without values; the counter rises. Same withbilling_evidence.key_type = unknownand a matching mode (AG-11 server part, BR-06).TestStartRun_ToolClassMismatch_AG30:tool_class_used = hostagainst a token withscopedgives409 tool_class_mismatchand the runfailedwithtool_class_mismatch;chatis accepted (AG-30 server part).TestStartRun_OtherDeliveryAlreadyStarted: after a start with T2, T1 gives409 already_started.TestResumeAndStartEndpoints:resumeRunonqueuedandstartRunonwaiting_inputwith a current token give409 invalid_state_transition.TestAG28_SlowStartRenewAndRestart: Lotse acks T1; afteragent.ack_timeoutandagent.invocation_token_ttlno redelivery comes;startRun(T1)gives403 invocation_token_invalidwithreason: expired;renewInvocationToken(T1)gives200T2;startRun(T1)gives403 invocation_token_invalidwithreason: superseded; a restarted server instance on the same database with the consumer cursor before the event (as a restart withinagent.ack_persist_intervalleaves it) redelivers with T3;startRun(T2)gives403 invocation_token_invalidwithreason: superseded;startRun(T3)gives200, again200replay; exactly one transition torunning.TestAG29_RenewRefusals: a token replaced by a redelivery gives403 invocation_token_invalidwithreason: superseded; renewal for arunningrun gives409 already_started; after Alex's grant is revoked, renewal gives403 agent_invocation_deniedand the run isfailedwith the reason of M2-08's delivery check (access_revoked, AZ-M51), with its deny entry (AG-29 server part).TestAG31_ConsumedStartToken: start with S,awaitRunInput;renewInvocationToken(S)andresumeRun(S)both give403 invocation_token_invalidwithreason: supersededand the run stayswaiting_input; a start-allowing delivery prepared through M2-08'sDelivery(trigger kindinteractionorfollowup; if the merged port needs rows of M2-11 or M2-15, use the internal function it calls to issue the token and replaceinvocation_jti) yields A;resumeRun(A)gives200running,started_atunchanged.TestAG32_LostRenewAnswer: T1 expired;renewInvocationToken(T1)answer discarded (T2); retry with T1 gives200T3 andrenewed_from_jtistays T1'sjti;startRun(T2)gives403 invocation_token_invalidwithreason: superseded;startRun(T3)gives200; one start; thenrenewInvocationToken(T1)gives409 already_started; afterawaitRunInputit gives403 invocation_token_invalidwithreason: superseded.TestAwaitInput:running -> waiting_inputwithstatus_line; again200without change; fromqueued409 invalid_state_transition.TestFinishRun: every transition and reason rule of note 6 over HTTP; a server-only reason such asstalefrom the adapter is stored asadapter_error;costsgives400 validation_failedwitherrors[].codenot_enabled;metricsreplaces; same final state again gives200without a new version or event;bothy.run.finishedandbothy.run.durationrecorded.TestFinishRun_AdapterReportsMismatch_AG11:failedwithbilling_mode_mismatchfromqueuedends the run, writes the deny entry of note 6e and counts the mismatch.TestCancelRun_Queued:202with statecancelled,state_reason = user_cancelled,trigger_state = cancelled;run.cancel_requestedwithrequested_byAlex; a laterstartRun(T1)gives409 already_started; no timeout job.TestCancelRun_Running_AG14:202with staterunning;run.cancel_requesteduser_cancelled; oneruns.cancel_timeoutjob scheduled atagent.cancel_grace; running the handler after the clock passed it givescancelledwithcancel_timeout; thenfinishRun(completed)andawaitRunInputgive409 invalid_state_transition,resumeRungives409 already_started,finishRun(cancelled)gives200without change (AG-14 server part).TestCancelRun_AdapterConfirms:finishRun(cancelled, user_cancelled)before the window givescancelleduser_cancelled; the later job changes nothing.TestCancelRun_Rights: Alex (starter) and Robin (owner) may; Sam withtriggerbut neither starter normanagegets403 forbidden; Sam withmanagemay; Sam withoutreadgets404like an unknown id; Lotse's token gets403; denials are audited, allows have one entry with the details of note 7e.TestCancelRun_TerminalAndIdempotency: oncancelled202without change and auditno_change: true; oncompleted409 invalid_state_transition; a replay with the sameIdempotency-Keygives the stored answer and no second event; an invalidreasongives400.TestRevocation_RSY11: Alex has arunningrun in one thread of Allgemein and a deliveredqueuedrun in a second thread, and a run in Marketing through a separate grant; revoking her Support grant commitsrun.cancel_requestedwithaccess_revokedfor both Support runs in the same transaction as heraccess.changed(same commit, samecorrelation_id), schedules two timeout jobs, leaves both states and the Marketing run untouched; Lotse'sfinishRun(cancelled, access_revoked)ends the first; the job ends the second withcancel_timeout. Variants:disableUserof Alex givesuser_disabled;archiveChannelof Allgemein giveschannel_read_only; a change that keeps hertrigger(capability only) writes nothing.TestAgentDisabled_CancelsRuns:disableAgentof Lotse ends its active runscancelledagent_disabledin the same transaction and writesrun.cancel_requested.TestStallCheck_Interrupted_AG10: run started; the agents test helper persists a heartbeat without the run more thanagent.presence_timeoutafterstarted_at; the handler setsfailedinterrupted,trigger_state = failed; the thread still has exactly one run and no newagent.invocationfollows.TestStallCheck_Stale: no heartbeat for longer thanrun.orphan_timeoutgivesfailedstaleforrunningandwaiting_input; a recent heartbeat listing the run changes nothing.TestConcurrency: parallel starts with the same token (one transition, one replay); parallelcancelRunandstartRunonqueued(never bothrunningandcancelled); parallel job andfinishRun(one end state).TestEventsValidate: everyrun.state_changedandrun.cancel_requestedrow validates againstRunStateChangedEventandRunCancelRequestedEvent; no JSONnull; no token orjtitext inevent_log,audit_eventsor captured logs.TestWire_JobsAndSubscriptions:runs.cancel_timeouton queuemaintenance;runs.stall_checkperiodic withagent.presence_timeout, rolesworkerandallonly; the golden registrations list both subscriptions after the existing ones.TestConfig_HeartbeatPersistBelowOrphanTimeout: a configuration withagent.heartbeat_persist_intervalnot belowrun.orphan_timeoutaborts with a message naming both keys.TestAZP1_RunRoutes: the six operations are registered with their catalogue actions and classes, and the server starts.
Contract: responses of the six operations validate against Run, RunSummary, InvocationTokenGrant and Problem in the integration tests; problems carry code and, where named above, reason.
Red team focus of the milestone (PLAN 8 "Red team": forging or reusing the invocation token; faking the reported mode) is covered by TestStartRun_TokenInvalid_AG08, TestStartRun_Replay, TestAG31_ConsumedStartToken, TestAG32_LostRenewAnswer and TestStartRun_BillingModeMismatch_BR06; name them in the red team request.
Run FuzzParseInvocationToken once locally with go test -run=^$ -fuzz=FuzzParseInvocationToken -fuzztime=<duration of your choice> and report duration and result; commit any crashing input as a seed after the fix.
Commands that must pass, from the repository root:
cd services/server && go test -race -count=1 ./internal/runs/... ./internal/authz/... ./internal/agents/... ./internal/kernel/... ./internal/wire/... ./api/...make codegen-check,make arch,make api-lint,make api-checks,make api-tables,make register-checkmake config-referencethenmake config-doc-checkmake docsmake check
Acceptance criteria¶
-
startRun,resumeRun,renewInvocationToken,awaitRunInput,finishRunandcancelRunare enabled and registered; AZ-P1 passes;run.startandrun.token.reneware real requirements and no longer in the deferred golden list. - Every token error at start or renewal gives
403 invocation_token_invalidwithreasonexpired,supersededorinvalid(AG-08 server part). - A repeated start with the same token gives
200withIdempotent-Replay: truewhilerunning; a consumed token givessupersededinwaiting_input; another delivery's token gives409 already_started. - BR-06 and AG-11 (server part): a different reported mode gives
409 billing_mode_mismatchand afailedrun with audit; AG-30 (server part):tool_class_usedabovetool_classgives409 tool_class_mismatchand afailedrun. - AG-28: slow start with expiry, renewal and redelivery after a restart starts the run exactly once.
- AG-29 (server part): renewal with a replaced token, of a running run and after the loss of
triggergivesuperseded,409 already_startedand403 agent_invocation_deniedwith afailedrun. - AG-31 and AG-32: a consumed start token resumes nothing; a lost renewal answer is recovered with
runs.renewed_from_jti; the run starts once. - AG-14: a stop without the adapter's answer ends
cancelledwithcancel_timeout, and later changes by the adapter are refused. - R-SY-11: a loss of
triggerwritesrun.cancel_requestedin the revoking transaction (access_revoked,user_disabled,channel_read_only); a disabled agent's runs endagent_disabled. - AG-10: a run missing from the adapter's
active_runsendsinterrupted, one without heartbeat endsstale, and none is retried. -
run.cancel_requestedandrun.state_changedvalidate against their schemas; no token in events, logs, audit or problems; no JSONnull. -
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,authzand (if changed)agentsupdated (port methods, subscriptions, jobs, token facts, the end-of-run extension point, the cancel and orphan rules). - Rulings of note 12 recorded in
docs/DECISIONS.mdsection 10 (one row each with reason); the changes to 02, 09 andopenapi.yamlof the Scope made in the same change. 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); 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>.