Skip to content

M2-11: Follow-ups with steer and queue

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

Goal

After this ticket a human message that would trigger the agent of the thread's active run becomes a follow-up of that run (run.followup.create through createMessage) instead of a second run: a row in run_followups with state queued and trigger_state = received on the message. When the agent's effective capability followups is steer, the server writes the personal event agent.followup, which M2-08 delivers with a fresh invocation token after re-checking agent.invoke for the follow-up's author; the adapter acknowledges delivered (the message shows followup_delivered) and then absorbed through a run patch, forward only. With queue or without the capability the server sends nothing, except to a run in waiting_input, where the follow-up is the answer to the open question and resumes the run with the token of its delivery. When the run ends, every follow-up still queued or delivered without absorbed becomes deferred, and the first of them, in thread order, triggers a new run whose follow-ups are the rest; after a stop by a person (cancelled with user_cancelled) they become discarded and nothing runs. This is 09 "Follow-ups during a run" and AG-13: people can add to a running turn without losing what they wrote, a stop really stops, and no follow-up of another person ever reaches an agent on a personal subscription.

Read first

  • docs/spec/09-agents.md: 2.2 "Features" (row "Follow-ups", effective capability = owner permission and adapter report, R-AG-01); 3.4 "Decision per message" (point 6); 3.5 "Reasons for rejection at acceptance"; 4.3 "Connection and cursor"; 4.4 "Events to the adapter" (row agent.followup); 4.5 "Invocation token" (claim trigger_kind = followup); 4.6 "Run context" (already_delivered); 4.7 "Adapter operations" (row "Acknowledge follow-up"); 4.8 "Heartbeat and reported state" (capabilities); 5.1 "State machine" (which deliveries replace runs.invocation_jti); 5.2 "Reasons (runs.state_reason)"; 5.3 "Run document (run_document.v1)" (path /followups/<message_id>/state); 5.4 "Live box content" (row "Follow-ups"); 5.5 "Stall, interruption, orphaned runs"; 6 "Trigger state (trigger_state)"; the whole of 7 "Follow-ups during a run"; 8 "Stop" (point 5); 11.6 "Questions"; 15 "Reliability" (row "Adapter dies during the run"); 16.2 "Required scenarios" (AG-13, AG-14, AG-31); 17.5 "Follow-ups: busy_input_mode" and 18.5 "Follow-ups via stdin" (context for the reference adapters); 19 (R-AG-01, R-AG-08, R-AG-09, R-AG-10, R-AG-17).
  • docs/spec/04-sync-protocol.md: 2.2 "Envelope" (payload rules 1, 3 and 4: agent.followup names the message only by id); 4 "Thread sequence"; 9 "Delivery guarantee" point 3; 11.1 "Run document"; 11.3 "From the server to the clients"; 15.2 "Ack and redelivery"; 15.3 "Invocation and run start" (points 2, 5 and 6); 15.4 "Follow-ups" (all six points); 15.5 "Cancellation"; 16.5 "Agent run with follow-up"; 17 (R-SY-05, R-SY-07, R-SY-17, R-SY-22).
  • docs/spec/03-data-model.md (core contract, read only): 4.8 "Runs and interactions (runs)" with runs (trigger_kind, triggering_message_id "for a run from deferred follow-ups the first of these messages", triggered_by, version, invocation_jti, the partial unique index of active runs, the transition list) and run_followups (all columns, CHECK, PK, FKs, unique (message_id), index (run_id, state), retention); 4.6 messages (trigger_state, trigger_state_reason, the checks trigger_state IS NULL OR origin = 'human' and trigger_state_reason IS NULL OR trigger_state IN ('failed','cancelled'), deleted_at, version); 4.4 agents (operator_config, reported_details, the paragraph on heartbeat persistence); 6.3 "Lock order"; R-DM-14.
  • docs/spec/05-actions.md: 4.10 rows agent.invoke, run.followup.create, run.update, run.start, run.cancel; 5 "Agent invocation agent.invoke" (points 1 to 8, the claim table, "Consequences on denial"); 6 rows agent.followup, message.updated, run.state_changed and the ephemeral row run.updated.
  • docs/spec/06-api.md: 2 "Basic rules" (row "No effect": unknown transition gives 409 invalid_state_transition); 4 "Authorization in the API" points 6 and 7 (run.followup.create is checked inside createMessage); 5 "Errors" (points 3 and 7); 7 "Idempotency" (client_msg_id).
  • docs/spec/07-permissions.md: 2.3 "Levels" (trigger includes write); 2.9 "Agents as principals"; 4.1 "Decisions and reasons"; "SR1 Backend policy (I1, I2)"; "SR4 Only humans trigger"; 7.4 "Query pattern" rule 1; 8.1 "Delivery filter" (row "Agent consumers"); 8.2 "Immediate effect on revocation" (points 4 and 5); 10 "Examples" and 11.1 "Test matrix" (the test world).
  • docs/spec/02-architecture.md: 4.3 "Ports, events and sources per module" (rows runs and messaging: Messages.SetTriggerState); 4.4 "Allowed dependencies" (runs may use instance, chat, agents, messaging, billing); 4.7 (paragraph: message.updated for trigger_state comes from messaging); 5 "Layers within a module"; 6 "Code layout" (test helpers in internal/<module>/adapters/<module>test, cross-module tests in test/); 8.1 "Flow"; 8.2 "Lock order"; 8.3 "In-Tx events"; 11.6 "Ephemeral events" (row run.updated).
  • docs/spec/01-product.md: 6.2 "Live box" (bullet "Follow-ups"), 6.3 "Trigger state as icons". docs/spec/20-privacy.md: row run_followups (indirect, class content, deleted with the run).
  • docs/api/openapi.yaml: operations createMessage, getRun, patchRun, startRun, resumeRun, awaitRunInput, finishRun, cancelRun, pollAgentEvents, ackAgentEvents, putChannelBehavior, sendHeartbeat; schemas Run (followups), RunFollowup, RunPatch, JsonPatchOperation, RunSummary, RunFinish, RunCancelRequestedPayload, AgentFollowupPayload, AgentFollowupEvent, EventEnvelope (audience, invocation_token, redelivered), Message, MessagePayload, MessageUpdatedEvent, TriggerState, RunStateChangedPayload, Heartbeat (details carries capabilities), ChannelBehavior; x-problem-codes of Problem (invalid_state_transition, base_version_conflict, not_found).
  • docs/api/asyncapi.yaml: message event.agent.followup; operation receive.run.update. docs/api/webhooks.md: row agent.followup (agent subscriptions only; the webhook transport is not built here).
  • ADRs 0025 (run as its own object with run_followups), 0023 (invocation token), 0022 (agents outside the server), 0017 (one authorization function), 0052 (one envelope, delivery fields), 0053 (no JSON null), 0054 (trust marks: follow-ups of other people reach the runtime as marked data, M2-13), 0008 (module layers), 0007 (migrations).
  • docs/DECISIONS.md: D-B7, D-AG-07, D-AG-08, D-AG-09 (R-AG-10), D-K-D3, D-K-D34, D-W-44, D-W-100; section 10 "Rulings of the build milestones" (row format D-M2-11-<n>).
  • docs/security/threat-model.md: T-AI-01 (content of other people), T-AI-07 (one active run per thread), T-AI-08 (personal subscription used by a second person). docs/RISKS.md: RK-01, RK-12 (Hermes Agent internals behind steer), RK-18 (a run twice). docs/OPEN-QUESTIONS.md: OQ-01 (default: strict I1 without owner exception, so no second person ever has trigger where a personal agent operates).
  • docs/observability/signals.md 4.3: the run metrics belong to M2-05 and the counter bothy.agent.invocation.denied to M2-08; the catalogue names no metric for follow-ups, so this ticket adds none.
  • docs/operations/configuration.md: database.tx_retry_max, agent.cancel_grace, agent.ack_timeout, agent.invocation_token_ttl, run.queued_max_age, agent.heartbeat_persist_interval (all read, none new).
  • docs/PLAN.md: section 3 "Way of working per ticket"; section 5 "Test strategy"; section 8 "M2 Agent platform, extensions and terminal" (Goal, Scope, Out of scope, Exit criteria points 1, 5 and 10, Test strategy, Red team: "another person triggering a personal subscription through every path (... follow-up ...)", Risks) and the rows M2-04, M2-05, M2-08, M2-09, M2-10, M2-11, M2-12, M2-13, M2-15, M2-16, M2-20, M2-29, M2-31, M2-33, M2-35.
  • M2 briefs this ticket builds on (all in docs/briefs/): M2-04-trigger-decision.md (Decide, outcome followup with RunID, Check = CheckFollowUp, Settle), M2-05-runs-state-machine.md (Open, Transition, ErrActiveRunExists, the acceptance OnMessagePosted, Messages.SetTriggerState, Run.followups empty until this ticket, the pinning test TestAcceptance_ConcurrentMessagesOneRun), M2-06-invocation-token.md (claims for trigger_kind = followup: message_id is the follow-up message), M2-07-agent-consumers.md (pollAgentEvents, DeliveryHook), M2-08-invocation-delivery.md (port runs.Delivery with Prepare, the delivery matrix row agent.followup, the resolver "trigger = author of the follow-up message", the re-check principal of its note 4, the reason mapping of its note 6, the acceptance of its note 10 with branch e left to this ticket, the payload builder for agent.followup, the pinning test TestAcceptance_FollowupBranchUntilM211, job runs.queued_expiry), M2-09-run-lifecycle.md (startRun, resumeRun, awaitRunInput, finishRun, cancelRun, requestCancel, the jobs runs.cancel_timeout and runs.stall_check, the one end-of-run function of its note 6f with its extension point), M2-10-run-document-live-box.md (patchRun, op run.update, the path /followups/<message_id>/state, the outgoing port FollowupStates with its empty default, note 7).
  • Dependencies: the header names only M2-10 (D-W-107). Through it the closure holds M2-09, M2-08, M2-07, M2-06, M2-05, M2-04, M2-02, M1-65, M1-56, M1-55, M1-54, M1-51, M1-28, M1-24, M1-16, M1-14, M1-13, M1-12, M1-06, M1-03, M1-01, and also M1-68, M1-69 and M1-71 through M2-02.
  • M1 briefs whose interfaces you use: M1-12 (pgtest, migration numbering), M1-13 (unit of work, txn.Registry, database.tx_retry_max), M1-16 (eventlog.Event with AudiencePrincipalID and ReceiveAction, eventlog.Appender), M1-24 (auditing.Recorder), M1-25 (action catalogue in code), M1-28 (authorize.Resource, InvokeFacts{AgentID, TriggerOrigin, MentionsAgent, ActiveRunAgentID}, the deferred entry run.followup.create, the guard of agent.invoke, TestDeferredActionsGolden), M1-51 (agents port Agents, operator_config schema agent_operator_config.v1 with supports_followups), M1-55 (chat.Threads.Info), M1-56 (messaging.MessagePosted with MentionedIDs), M1-64 (wstest); M1-58 (messaging.MessageDeleted) is not in the closure and is used only if merged (Design note 9).
  • Code on the base branch: services/server/internal/runs (port, domain, app, adapters), internal/authz, internal/agents, internal/messaging, internal/gateway, internal/kernel/eventlog, internal/kernel/txn, internal/wire, internal/platform/pg/pgtest, architecture/modules.yaml (module runs already lists table run_followups, action run.followup.create and event agent.followup), migrations/, docs/spec/actions.yaml (entry run.followup.create: right: write and `agent.invoke`, audit: denials; type agent.followup: resource_version: false).

Fixed requirements that apply

Owner's fixed build rules (apply verbatim):

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

Rules for every brief:

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

Product requirements implemented here:

  1. Only a message that would trigger the agent of the thread's active run becomes a follow-up; a message without a trigger (no trigger, mention rule not met) stays a normal message and reaches the agent only as context of the next run; a message for another agent is rejected with run_active_other_agent; control commands are never follow-ups (09 7, 09 3.4 point 6; 04 15.4 points 1 and 6; 05 5 point 8; R-SY-22, D-K-D34).
  2. run.followup.create: resource thread, right "write and agent.invoke" (so I2, SR2 and SR4 hold for every follow-up), Audit denials, class rl.message through createMessage, no route of its own (05 4.10; 06 4 point 7). It replaces the deferred entry of M1-28.
  3. A follow-up is one row in run_followups (state = queued, unique per message) and trigger_state = received on its message (03 run_followups; 09 6; 04 15.4 point 1).
  4. The effective capability followups is the owner's permission supports_followups (absent means allowed) and the adapter's report steer or queue (09 2.2, R-AG-01). With steer the server writes the personal event agent.followup (payload run_id, thread_id, message_id, never content); with queue or none it sends nothing and the follow-up stays queued until the run ends (09 7; 04 2.2 rule 4, 15.4 point 1).
  5. A follow-up to a run in waiting_input is the answer to the open question: the adapter hands it over and resumes the run with the token of its delivery (resumeRun, action run.start) (09 7 last paragraph, 5.1; 04 15.3 point 5, 15.4 point 5; R-AG-08).
  6. The adapter acknowledges delivered (handed to the runtime) and absorbed (taken up by the runtime) through run.update on /followups/<message_id>/state, forward only; at delivered the message gets trigger_state = followup_delivered (09 4.7, 5.3; 04 15.4 point 3; 01 6.2).
  7. At the end of a run every follow-up in queued or delivered without absorbed becomes deferred after completed, failed, and cancelled with any reason other than user_cancelled; the first becomes the triggering message of a new run and the rest its follow-ups; after cancelled with user_cancelled they become discarded, and the message shows "not processed, run stopped" (09 7, 8 point 5, 15; 04 15.4 point 4; 03 runs.triggering_message_id; R-AG-10, D-AG-09).
  8. Every delivery of agent.followup re-checks agent.invoke for the follow-up's author with the rights of that moment and carries a fresh token with trigger_kind = followup and the follow-up message as message_id; a denial delivers nothing and never ends the run of another person (04 15.4 point 2, 15.3 point 2; R-SY-07; 05 5 claims; 07 8.1).
  9. The run document lists the follow-ups keyed by message id with their state (09 5.3 and 5.4; 04 11.1; openapi Run.followups, RunFollowup).
  10. No follow-up of another person reaches an agent on a personal subscription: I1 keeps trigger with the holder and I2 is part of every check of this ticket (05 5 point 4; 07 SR1; T-AI-08; OQ-01; M2 Red Team).

Scope

All paths under services/server/ unless noted. Where M2-05, M2-08, M2-09 or M2-10 already created a file, type or function named here under another name, extend theirs (Boundaries).

Create:

  • migrations/<next free number>_runs_run_followups.sql (goose, numbering rule of M1-12): table run_followups exactly as 03 (columns run_id, message_id, state with default 'queued' and CHECK (state IN ('queued','delivered','absorbed','deferred','discarded')), created_at and updated_at with default now(); PK (run_id, message_id); FK run_id -> runs(id) ON DELETE CASCADE; FK message_id -> messages(id) ON DELETE CASCADE; unique (message_id); index (run_id, state)), privileges for the application role following the pattern of the existing migrations, a comment naming the retention class content, and a Down section that drops the table. Then make migrations-lock.
  • internal/runs/domain/followup.go (pure, kernel-core only): FollowupState with the five values; FollowupMode (steer, queue, none); EffectiveFollowupMode(permitted *bool, reported string) FollowupMode; FollowupEventNeeded(mode FollowupMode, runState State) bool; AdvanceFollowup(from, to FollowupState) (changed bool, err error) (Design notes 2 and 5).
  • internal/runs/domain/followup_end.go (pure): RunEnd{State State; Reason string}, OpenFollowup{MessageID ids.ID; ThreadSeq int64; State FollowupState; Deleted bool}, PlanRunEnd(end RunEnd, open []OpenFollowup) (RunEndPlan, error) with RunEndPlan{Deleted []ids.ID; Stopped []ids.ID; Candidates []ids.ID} (Design note 7).
  • internal/runs/adapters/postgres/queries/run_followups.sql: insert of a follow-up, lock of a run's open follow-ups in ascending message_id, states of a run (document), states of given message ids of a run, lock and update of one row, the move of rows to another run, the lookup by message id (deletion), and the raise of runs.version if the M2-05 or M2-08 repository has no function for it.
  • Tests: internal/runs/domain/followup_test.go, followup_end_test.go, followup_fuzz_test.go; app tests next to the service; integration tests under test/integration/followups/ (component testsupport, 02 "Code layout"), or next to the integration tests of M2-08 and M2-09 if they chose another place.

Change:

  • internal/runs/app/followups.go (M2-10 created it with the port FollowupStates and its empty default): the service Followups with Accept, OnRunEnded, ForDelivery, RefuseDelivery, OnMessageDeleted, Document and the methods of FollowupStates (Design notes 3 to 10). Keep M2-10's port signature.
  • internal/runs/app acceptance (OnMessagePosted of M2-05 and M2-08): the follow-up branch (M2-05 note 6 step 3, M2-08 note 10 e), the lock of the active run, and the follow-up outcome after ErrActiveRunExists (Design note 3). Replace the pinning tests TestAcceptance_ConcurrentMessagesOneRun (M2-05) and TestAcceptance_FollowupBranchUntilM211 (M2-08) by the follow-up behaviour.
  • internal/runs/app end-of-run function of M2-09 note 6f: call Followups.OnRunEnded at its extension point (Design note 7).
  • internal/runs/app delivery (Delivery.Prepare of M2-08): the follow-up facts and the row state on a denial (Design note 6).
  • internal/runs/app run document projection of M2-05 (used by getRun, the run in startRun, resumeRun, awaitRunInput, finishRun answers, current of base_version_conflict and LiveBox of M2-10): followups from run_followups (Design note 10).
  • internal/runs (port): the DTO of one follow-up (Followup{MessageID ids.ID; State string}) inside the run document DTO of M2-05; no new in-transaction event.
  • internal/authz: the requirement of run.followup.create instead of the deferred entry, the mapping row for the right text "write and agent.invoke", the agent.invoke guard applied to it, the deferred golden list without it, README (Design note 11).
  • internal/agents (port, adapter, README): a read of the effective capability followups if the base branch has none, and a test helper in internal/agents/adapters/agentstest that sets the reported capabilities (Design note 15).
  • internal/messaging: only if the merged port lacks it, a read that returns for given message ids the author, thread_seq, origin, the mention ids and whether the message is deleted (Design notes 6 to 9); README if changed.
  • internal/wire: bind Followups as FollowupStates instead of M2-10's empty default; when messaging.MessageDeleted exists on the base branch (M1-58), register messaging.MessageDeleted -> runs.OnMessageDeleted (or extend the runs subscriber of MessageDeleted if one exists); update the golden files of subscriptions and bindings.
  • tools/lint/sql_visibility.yaml (only if it exists on the base branch, M1-70): each read query of run_followups under category after_authorize with its reason (Design note 14).
  • docs/spec/09-agents.md (not core contract, English, like the chapter): 6 (diagram and table: the transitions of a follow-up message of Design note 13) and 7 (the API-visible rulings of Design notes 3, 4, 5, 6, 7, 8 and 9).
  • docs/api/openapi.yaml: the description of patchRun gets one sentence on follow-up states (forward only; a backward change gives 409 invalid_state_transition); run make api-checks (regenerate with make api-tables if it reports drift in 06).
  • docs/security/threat-model.md: name TestFollowupNeverReachesPersonalAgentForOthers in the test column of T-AI-08.
  • READMEs of runs (English, like the file: follow-ups, states, end of run, the follow-on run, versions, queries and their visibility category), authz, agents; services/server/CHANGELOG.md under "Unreleased"; docs/DECISIONS.md section 10, rows D-M2-11-1 and following (Design note 16).

This ticket enables no operation in api/operations.yaml (run.followup.create has no route), adds no configuration key, no job, no metric and no new top-level make target.

Out of scope

  • The trigger decision function (M2-04), the run creation Open, the transition primitive and the acceptance of invocations (M2-05); the invocation token issuer (M2-06); agent consumers, ack and redelivery (M2-07); the delivery check itself, its audit, its counter and the agent.followup payload builder (M2-08); start, resume, renewal, finish, cancel, revocation and the jobs (M2-09); the patch engine, the path rules, run.updated and the coalescer (M2-10). This ticket fills the follow-up parts these tickets left open.
  • Disabling the open card when a follow-up answers a waiting run first (09 11.6 "Whatever arrives first counts; the card then becomes disabled") and every other component rule: M2-15, which owns message_components (plan gap reported).
  • already_delivered in the run context (09 4.6) and the trust marks of follow-up content: M2-13 (it reads run_followups through this ticket's FollowupStates; plan gap reported).
  • Agent sessions and context_floor_seq of a follow-on run: M2-12. The heartbeat that reports capabilities (sendHeartbeat): M2-20.
  • The control command /stop and model commands during a run (run_active): M2-16.
  • Follow-ups whose attachments or transcript are not final (the hold of 04 15.3 point 1, ReleaseInvocation): M3-07 and M3-09.
  • Delivery of agent.followup over an agent webhook subscription (04 15.6, webhooks.md): not owned here; this ticket only writes the log event, every transport goes through runs.Delivery.
  • The data-driven scenario AG-13 with the fake agent (M2-31), the reference adapters with busy_input_mode and stdin (M2-33, M2-35), the SDK helpers that patch follow-up states (M2-29, M2-30).
  • Personal data participants for run_followups (M7-06). The live-box view in the apps (M4, M5).
  • Changes to the core contract.

Boundaries

  • A new migration needs its line in migrations.sha256: run make migrations-lock after creating the file and before make check; never edit a merged migration (M1-01, ADR 0007).
  • Work in a dedicated git worktree on the branch feat/M2-11-run-followups; one writer per worktree.
  • Conventional Commits with the module as scope (for example feat(runs): ..., feat(authz): ...) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md).
  • Never push to main, never tag and never create remote repositories; the only remote steps are the branch push and the pull request of AGENTS.md.
  • Do not modify files outside the scope above, except the CHANGELOG, README and docs/DECISIONS.md entries named in this brief.
  • Follow the host rules supplied by the launcher (ports, machine rules).
  • Tests bind only 127.0.0.1 and port 0; PostgreSQL runs only as a throwaway container on a random port through pgtest (P-09, P-10). Without a Docker-compatible runtime the integration tests fail; they never skip.
  • Stop only processes you started, by PID. Temporary directories only via mktemp -d, removed at the end.
  • Never read or print secrets. Never log, print or persist an invocation token; tests compare tokens in memory. No network calls except package downloads for pinned tools and Go modules.
  • No agent runtime, no model call and no third-party service in any test: the adapter side is plain Go HTTP code with the agent's API token (REST poll of M2-07, the run operations of M2-09, patchRun of M2-10) or the gateway test client wstest of M1-64. Time only through kernel/clock; tests move a fake clock and call job handlers directly, they never sleep for a window.
  • Never lower a threshold, disable a gate or delete a test (replacing the two pinning tests named in Scope by tests of the specified behaviour is required, not a deletion).
  • 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 the merged code of M2-04 to M2-10: follow its names and list each difference. Where it lacks a function this brief expects from it (for example the end-of-run extension point or the port FollowupStates), add it in the module that owns it and report it.
  • M2-10 must be merged into the base branch before this ticket starts, because the acknowledgement delivered and absorbed is a run patch on /followups/<message_id>/state and the port FollowupStates that this ticket implements is M2-10's (M2-10 note 7). If patchRun is not enabled on the base branch, stop with Verdict: BLOCKED M2-10 not merged (patch path /followups/<message_id>/state and port FollowupStates).
  • The make target of the visible-channel lint (M1-70) belongs to a ticket outside this closure: do not call it; the lint file is updated only if it exists (Scope). The signal check of M1-68 is in the closure; make check runs whatever the base branch has.

Design notes

  1. Placement. Follow-ups belong to runs (02 4.2 and 4.3; modules.yaml lists run_followups, run.followup.create and agent.followup there). Pure rules live in internal/runs/domain; the service Followups in internal/runs/app uses the unit of work of its caller (txn, never its own Do when called from a subscriber, the end-of-run function or Prepare). Other modules are reached only through their ports: messaging (SetTriggerState, message facts), chat (Threads.Info for scope), agents (capability, status), authz through kernel/authorize. No other module changes a follow-up state (Ruling for docs/DECISIONS.md, D-M2-11-1).

  2. Domain rules (table tests in Tests):

    • EffectiveFollowupMode(permitted, reported): none when permitted is false; otherwise steer or queue when reported is exactly that value; none for an absent or unknown report (fail closed, 09 1 point 4). permitted nil means the key is absent in operator_config, which allows (09 2.2).
    • FollowupEventNeeded(mode, runState): true for steer in queued, running and waiting_input; true for queue and none only in waiting_input (requirement 15); false for every end state.
    • AdvanceFollowup(from, to) for adapter patches: queued -> delivered, queued -> absorbed, delivered -> absorbed change; to == from for delivered or absorbed is no change (06 2 "No effect"); every other pair (backward, or out of deferred or discarded) is ErrFollowupTransition, which wraps errs.CodeInvalidStateTransition. to other than delivered or absorbed never reaches here (M2-10's value check).
  3. Acceptance of a follow-up (in OnMessagePosted, inside the unit of work of createMessage): a. The active run of the target thread is read with SELECT ... FOR UPDATE before M2-04's Decide is called, so the fact stays true until the commit (the thread row lock of createMessage already serializes messages of one thread, M1-55; the run row lock serializes against the end of the run). If the merged acceptance reads it without a lock, add the lock. b. Decide returns followup with RunID and Check = CheckFollowUp (M2-04). Where M2-05 and M2-08 return without writing (M2-05 note 6 step 3, M2-08 note 10 e), continue here. ErrActiveRunExists from Open (a concurrent acceptance created the run, M2-05 note 6 step 7) leads here as well when the active run belongs to the candidate. c. Check authorize(P, run.followup.create, Resource{Kind: thread, ID: thread of M, Invoke: &InvokeFacts{AgentID: run.agent_id, TriggerOrigin: human, MentionsAgent: the decision's Mentioned, ActiveRunAgentID: run.agent_id}}) in the same mode and with the same audit pattern the merged acceptance uses for agent.invoke (M2-08 notes 8 and 10 d: one deny entry with stage = acceptance), with action run.followup.create in the entry. d. Denial: map the reason with the acceptance mapping of M2-08 note 10 d (for example level_insufficient without mention writes nothing, with mention no_trigger_right; billing_mode_personal_not_holder stays) and call Messages.SetTriggerState(M, failed, reason); no row, no event. e. Allow, in this order: INSERT INTO run_followups (run_id, message_id) (state queued); UPDATE runs SET version = version + 1 (note 10); Messages.SetTriggerState(M, received); read the effective mode (note 15); when FollowupEventNeeded(mode, run.state), append agent.followup (note 4). Nothing else happens to the run: its state, invocation_jti and billing_mode_expected change only at a delivery (M2-08). f. A replay of createMessage publishes no MessagePosted (M1-56), so it creates no second row; the unique (message_id) is the last line. Ruling for docs/DECISIONS.md (D-M2-11-2): the mode is evaluated once at acceptance under the run lock; a follow-up to a waiting_input run gets its event also with queue or without the capability, because 09 7 makes it the answer and only a delivery's token can resume the run (R-AG-08); a follow-up written while the run was queued or running with queue is not delivered later, even if the run then waits for input (it was not an answer).

  4. Event agent.followup through the payload builder of M2-08 (note 14): eventlog.Event with type agent.followup, schema_version from the event register, actor P, scope {space_id, channel_id, thread_id} of the run's thread now (chat.Threads.Info), resource {kind: run, id: run_id} without version (04 9 point 3; actions.yaml resource_version: false), AudiencePrincipalID = run.agent_id, receive action from the catalogue (agent.events.consume), the request's correlation_id, payload AgentFollowupPayload {run_id, thread_id, message_id}: no content, no prompt, no JSON null (04 2.2 rules 1 and 4, ADR 0053). The token is never in the log row (R-SY-17); M2-08 adds it per delivery.

  5. Acknowledgement by the adapter (implementation of M2-10's port FollowupStates, called inside M2-10's patch unit of work with the run row locked):

    • States(runID, messageIDs): the stored states; ids that are not follow-ups of this run are absent, so M2-10 answers patch_target_missing.
    • Set(runID, messageID, to): lock the row (FOR UPDATE), AdvanceFollowup; on change UPDATE run_followups SET state = $to, updated_at = now(); when the row leaves queued (to delivered, or straight to absorbed), Messages.SetTriggerState(message, followup_delivered), which writes message.updated (02 4.7). absorbed after delivered changes no trigger_state.
    • ErrFollowupTransition reaches the client as 409 invalid_state_transition (06 2 "No effect"); if the merged patch use case turns every port error into 400 validation_failed, add this exception and its test. The patch is all or nothing.
    • The message.updated of followup_delivered is a durable event inside a patch: its count is bounded by the number of follow-ups, not by the patch rate, so R-SY-05 and M2-10's load test still hold. Ruling for docs/DECISIONS.md (D-M2-11-3): the forward-only transitions including queued -> absorbed, no change on the same state, 409 invalid_state_transition for every other pair, and followup_delivered when a follow-up first leaves queued.
  6. Delivery of agent.followup (the row agent.followup of M2-08's matrix; M2-08 note 3 f leaves "the state of the run_followups row" to this ticket):

    • ForDelivery(runID, messageID) gives the facts for M2-08's resolver: author (the trigger, sub of the token), origin, mention of the run's agent, and whether the follow-up is deliverable. Deliverable means the row exists for this run in queued or delivered and the message is not deleted; otherwise Prepare returns Send = false without check, token or audit (the event counts as checked, M2-07 DeliveryHook), like a terminal run in M2-08's matrix. Ruling for docs/DECISIONS.md (D-M2-11-4).
    • Denial (M2-08 note 3 f: the message gets failed with the mapped reason of M2-08 note 6, audit, counter, run unchanged): RefuseDelivery(runID, messageID) sets the row to discarded, updated_at = now(), and raises runs.version by one (note 10), in the same unit of work. Reason: the follow-up will never be processed, and run_followups has no failed state; trigger_state = failed shows the reason. Ruling for docs/DECISIONS.md (part of D-M2-11-4).
    • Allow: nothing changes here; M2-08 issues the token with trigger_kind = followup, message_id = the follow-up and sub = its author, and records the jti only while the run waits in waiting_input (M2-08 note 2, 09 5.1). A delivery of agent.followup never sets trigger_state = queued: 09 6 sets queued only for agent.invocation (M2-08 note 12). If the merged resolver takes sub from runs.triggered_by or sets queued for follow-ups, correct it and list it.
  7. End of a run (OnRunEnded, called at the extension point of M2-09's end-of-run function, in the same unit of work, after the transition with version + 1 and run.state_changed; every path to completed, failed or cancelled passes there: finishRun, cancelRun on a queued run, the job runs.cancel_timeout, runs.stall_check with interrupted and stale, OnAgentDisabled, M2-08's denial of a queued invocation and its expiry runs.queued_expiry; route any path that bypasses it through it): a. Lock the open follow-ups: SELECT ... FROM run_followups WHERE run_id = $1 AND state IN ('queued','delivered') ORDER BY message_id FOR UPDATE (03 6.3). None: return. b. Load their facts through messaging (author, thread_seq, deleted). PlanRunEnd: a follow-up whose message is deleted goes to Deleted. End cancelled with reason user_cancelled: every other open follow-up goes to Stopped. Every other end: all others go to Candidates ordered by ascending thread_seq (04 4; Ruling for docs/DECISIONS.md, D-M2-11-5). absorbed, deferred and discarded rows are never touched. c. Deleted: state = discarded, no trigger_state change (note 9). Stopped: state = discarded; Messages.SetTriggerState(message, cancelled, user_cancelled). Reason: the message must show "not processed, run stopped" (09 7), 09 6 defines cancelled as "Run stopped" with the reason in trigger_state_reason, and 03 lists user_cancelled as such a reason. Ruling for docs/DECISIONS.md (part of D-M2-11-5). d. Defer: state = deferred for all candidates; then the follow-on run of note 8. e. No extra runs.version step and no run.updated for the ended run: the follow-up changes are part of the stored change of the final transition, and clients load the ended run per REST (04 11.3 point 6). This is literal 09 7: cancelled with cancel_timeout, access_revoked, agent_disabled, channel_read_only or user_disabled defers; failed with interrupted or stale defers (09 15). Deferred follow-ups are new, unprocessed requests (09 7 "Reason"), so a follow-on run is no retry of the failed run's own trigger (R-AG-17 holds). The surprise after a stop that ends with cancel_timeout is a spec gap (reported), not changed here.

  8. Follow-on run (04 15.4 point 4; 03 runs.triggering_message_id), in the same unit of work, for the candidates in order: a. For candidate M with author P: authorize with the re-check principal of M2-08 note 4 for P (P is not the actor of this unit), action agent.invoke, Resource{Kind: thread, ID: run.thread_id, Invoke: &InvokeFacts{AgentID: run.agent_id, TriggerOrigin: origin of M, MentionsAgent: M mentions the agent, ActiveRunAgentID: zero}} (the ended run is no longer active), in the mode and with the audit pattern of M2-08's delivery check (note 8, attribute stage = run_end). Reason: the follow-on run is an acceptance of M as a trigger (09 3.4 point 7); without the check a run would be created for a disabled agent or in a read-only channel. b. Denial: state = discarded; Messages.SetTriggerState(M, failed, reason) with the reason mapping of M2-08 note 6; continue with the next candidate. c. Allow: M is the trigger. Create the run through the invoke step of the acceptance (M2-05 Open with TriggerKind = message, TriggeringMessageID = M, TriggeredBy = P, AgentID = run.agent_id, ThreadID = run.thread_id; the anchor; SetTriggerState(M, received); job runs.queued_expiry; agent.invocation last, M2-08 note 10 f). M's row stays deferred on the ended run. d. The remaining candidates after M become follow-ups of the new run: UPDATE run_followups SET run_id = <new>, state = 'queued', updated_at = now() (one row per message; 03 unique (message_id) allows no second row), Messages.SetTriggerState(message, received) (a no-op when already received, M2-05 ruling 17); when FollowupEventNeeded(mode, queued) (mode read now), append agent.followup for each, in thread_seq order, after the new run's agent.invocation. Ruling for docs/DECISIONS.md (D-M2-11-6): the first stays deferred on the ended run, the rest move. e. No candidate allowed: no run; all candidates are discarded with failed on their messages. f. The new run is an ordinary run: its own end runs this note again, so the process ends after at most one run per open follow-up. ErrActiveRunExists cannot occur, because the ended run is no longer active and the thread's acceptances wait at the run row lock (note 12); treat it as an internal error.

  9. Deleted follow-up message (OnMessageDeleted(messaging.MessageDeleted), in the deleting unit of work; M1-58 is not in this ticket's closure: if MessageDeleted is not on the base branch, leave this subscriber out and name it as a follow-up in the report; notes 6 and 7 treat a deleted message as not deliverable and discard it at the end in any case): if the message has a row in queued or delivered, lock the run row, set the row discarded, raise runs.version (note 10); no trigger_state change (the tombstone keeps its representation, 03 messages). Reason: deleted content must never become the trigger of a new run, and redaction (03 6.5) would leave the adapter an empty text. Ruling for docs/DECISIONS.md (D-M2-11-7; spec gap reported). A deleted triggering message of an active run is not handled here.

  10. Run document and versions. The projection of M2-05 fills followups with every row of the run ({message_id, state} keyed by message id; rows moved to another run belong to that run). runs.version "rises with every stored change" (03) and the document contains the follow-ups (09 5.3, 04 11.1), so every server-side change of a follow-up of an active run raises runs.version by one: acceptance (note 3 e), refused delivery (note 6), deletion (note 9). The adapter's next patch then meets 409 base_version_conflict and rebases on current (04 11.2; the SDK handles it, M2-29). These server-side changes publish no run.updated: they happen inside the unit of work of another use case, and run.updated may only leave after a commit (M2-10 note 1h). Focused clients see the follow-up at once through message.created and message.updated (trigger_state), and the box catches up with the next frame of the run, which M2-10's coalescer sends as a snapshot after a version step without patch (M2-10 note 9), or with getRun. Ruling for docs/DECISIONS.md (D-M2-11-8). The adapter's own acknowledgement goes out as run.updated through M2-10.

  11. Rights (05 4.10, 07 2.3, M1-28):

    • Requirement of run.followup.create: All(Level(write), Level(trigger)) plus the agent.invoke guard of M1-28 with the descriptor's Invoke (required, else internal error); mapping row "write and agent.invoke" in the requirement source table. Level(trigger) is what "agent.invoke" requires (05 4.10 row agent.invoke); Level(write) is kept so the requirement reads like the catalogue.
    • Guard step 7 passes because ActiveRunAgentID equals the agent; another agent's active run gives run_active_other_agent (05 5 point 8). I2 (billing_mode_personal_not_holder), SR2 (tool_class_owner_only), SR4 (origin_not_human), channel_read_only and the project checks apply unchanged.
    • Remove run.followup.create from the deferred entries and from the golden list of TestDeferredActionsGolden. The action has no route (06 4 point 7), so AZ-P1 is unaffected; no rights version, no access.changed (05: Permissions no). Ruling for docs/DECISIONS.md (D-M2-11-9): the expression and the mapping row.
  12. Locks and concurrency (03 6.3, 02 8.2): acceptance holds the thread row (M1-55), then the run row (note 3 a), then inserts the follow-up, then updates messages. The end of a run holds the run row (M2-05 Transition), then the follow-up rows in ascending message_id, then messages, and for a follow-on run the thread row through the anchor. An acceptance and the end of the same run can therefore deadlock; PostgreSQL detects it and the unit of work retries the whole unit up to database.tx_retry_max (02 8.2). Every interleaving ends in one of two consistent outcomes: the follow-up exists before the end (it is settled by note 7) or the message comes after the end (it triggers a new run or becomes a follow-up of the follow-on run). Never a queued follow-up on an ended run. Set (note 5) runs under M2-10's run row lock; Prepare (note 6) under M2-08's.

  13. Trigger state of a follow-up message (09 6; every change through Messages.SetTriggerState):

    Moment trigger_state trigger_state_reason
    accepted as follow-up received
    refused at acceptance failed acceptance mapping (M2-08 note 10 d)
    first leaves queued by the adapter's patch followup_delivered
    refused at delivery failed delivery mapping (M2-08 note 6)
    discarded at the end after a stop cancelled user_cancelled
    trigger of the follow-on run received, then as any run trigger (queued, working, done, ...)
    moved to the follow-on run received
    refused as trigger of the follow-on run failed delivery mapping (M2-08 note 6)
    message deleted unchanged

    Update the diagram and table of 09 6 with the edges followup_delivered -> received, followup_delivered -> failed, received -> cancelled and followup_delivered -> cancelled for follow-ups.

  14. Queries, audit, telemetry. Reads of run_followups have no visible-channel predicate by design (03 gives the table no channel_id): each reads the rows of one run (or one message) whose run was authorized in the same use case or is being changed by it (07 7.4 rule 1, exception "loading a resource that has already been checked"; the rule M2-05 note 10 wrote into 07 7.4). Name them in the runs README and, if the lint file exists, under after_authorize (Scope). Audit: only denials (denials), written as described in notes 3 c, 6 and 8; allowed follow-ups and state changes write none. No new metric (signals.md has none for follow-ups); M2-08 counts delivery denials. No log line, span, audit detail or problem body contains message content or a token.

  15. Effective capability source (09 2.2): the owner's operator_config.supports_followups (M1-51) and the adapter's reported capabilities.followups from the heartbeat (reported_details, schema agent_heartbeat.v1, 09 4.8; written by sendHeartbeat of M2-20, which may hold newer state in memory between persists, 03 agents). Read it through the agents port: if M2-05, M2-08 or M2-20 already added a capability read (M2-04 lists VoiceInput as such a fact), add followups to it; otherwise add Agents.EffectiveCapabilities(ctx, agentID ids.ID) (agents.Capabilities, error) with Capabilities{Followups string} that returns the reported value only when permitted, and document that M2-20 keeps it current. Until M2-20 is merged no adapter reports anything, so the mode is none, the safe default. Tests set the report through agentstest (or sendHeartbeat when present).

  16. Rulings for docs/DECISIONS.md section 10, one row each with reason and place: D-M2-11-1 placement (note 1); D-M2-11-2 mode at acceptance and the waiting_input exception (note 3); D-M2-11-3 forward-only transitions and their error (note 5); D-M2-11-4 deliverable rows and the refused follow-up (note 6); D-M2-11-5 thread order and the trigger_state of discarded follow-ups (note 7); D-M2-11-6 the check of the follow-on run, its stage run_end, and the move of the rest (note 8); D-M2-11-7 deleted follow-ups (note 9); D-M2-11-8 version steps without run.updated (note 10); D-M2-11-9 the requirement of run.followup.create (note 11). Rulings 2 to 7 also go into 09 6 and 7 (Scope).

Tests (write them first)

Test world: 07 11.1 (spaces Support with channels Allgemein and Eskalation, Marketing, Research with channel Notizen; agents Lotse operating Support (organization), Texter operating Marketing, Assistent operating Research in mode personal with the Owner as holder; G-1 Alex trigger on Support; group Leser with Sam, G-4 read on Support/Allgemein; the Owner), extended for this ticket by: Sam with trigger on Support/Allgemein (a second person who writes follow-ups), Robin with write on Support/Eskalation, Texter with write on Support/Eskalation, allow_mentioned_agents = true and thread_require_mention = true on Support/Eskalation (putChannelBehavior), Sam with write on Research/Notizen. Lotse's effective followups is set per test (steer, queue, or none by supports_followups = false). A run is triggered by Alex in a thread of Support/Allgemein and delivered through M2-08 and pollAgentEvents; the adapter side is plain HTTP with Lotse's token (startRun, resumeRun, awaitRunInput, finishRun, patchRun). Tokens are verified with the reference verifier of M2-06. Payloads and responses validate against their openapi schemas with the validator the M1 and M2 event tests use; none contains JSON null.

Unit, internal/runs/domain:

  • TestEffectiveFollowupMode: permission absent, true and false times report steer, queue, absent and an unknown value; steer or queue only when permitted and reported exactly; otherwise none.
  • TestFollowupEventNeeded: every mode times queued, running, waiting_input and the three end states; true for steer in the active states and for every mode in waiting_input only.
  • TestAdvanceFollowup: all pairs of the five states as from with delivered and absorbed as to: change exactly for queued -> delivered, queued -> absorbed, delivered -> absorbed; no change for delivered -> delivered and absorbed -> absorbed; ErrFollowupTransition with code invalid_state_transition for the rest (absorbed -> delivered, out of deferred and discarded).
  • TestPlanRunEnd: ends completed, failed (stale, interrupted, runtime_error, agent_unavailable), cancelled with user_cancelled, cancel_timeout, access_revoked, agent_disabled, channel_read_only, user_disabled; open sets with queued and delivered rows in shuffled order and one deleted message: the deleted one is always in Deleted; user_cancelled puts all others into Stopped; every other end puts all others into Candidates by ascending thread_seq; an empty set gives an empty plan; a non-end state is an error.
  • FuzzPlanRunEnd (seed corpus from the table): every open follow-up appears in exactly one of Deleted, Stopped and Candidates; Candidates is sorted by thread_seq; Stopped is non-empty only for user_cancelled and Candidates only for every other end; the input is unchanged.

Unit, internal/authz:

  • TestRunFollowupCreateRequirement (fake sources of M1-28): Alex with trigger, Lotse's own active run: allow; Robin with write only: forbidden level_insufficient; Sam with only G-4: forbidden level_insufficient; a thread of Marketing for Alex: not_found; active run of Texter: forbidden run_active_other_agent; Assistent personal and a fake snapshot that gives Sam trigger on Research/Notizen (I1 bypassed in the fake): forbidden billing_mode_personal_not_holder; disabled Lotse: forbidden agent_not_reachable; read-only channel: forbidden channel_read_only; origin = agent: forbidden origin_not_human; missing Invoke: internal error.
  • TestDeferredActionsGolden: the golden list no longer contains run.followup.create.

Unit, internal/runs/app with fakes:

  • TestAccept_FollowupBranch: decision followup calls authorize with action run.followup.create and the descriptor of note 3 c; allow inserts the row, raises the run version, sets received and appends agent.followup exactly when FollowupEventNeeded; denial sets failed with the mapped reason and writes nothing else.
  • TestOnRunEnded_ChecksAuthorOfCandidate: the fake authorizer records the re-check principal of the first candidate's author (not runs.triggered_by) and ActiveRunAgentID zero; a denial of the first makes the second the trigger.

Integration (PostgreSQL 18 via pgtest, real modules through internal/wire, HTTP through the real chain, fake clock):

  • TestMigration_RunFollowups: columns, defaults, CHECK (an unknown state fails), PK, unique (message_id) (a second row for the same message under another run fails), both FKs cascade (deleting the run removes its rows), index (run_id, state) exists; migrations.sha256 has the line.
  • TestAG13_Steer: with steer, Sam writes in the thread of Alex's running run: one row queued, trigger_state = received, run version raised by one, exactly one agent.followup in event_log with audience Lotse, resource {kind: run, id} without version, payload {run_id, thread_id, message_id} and no content; the poll delivers it with a token whose claims are trigger_kind = followup, sub Sam, message_id the follow-up, run_id, and invocation_jti of the run is unchanged; patchRun replace /followups/<id>/state delivered (after rebasing on the conflict) gives the row delivered and one message.updated with followup_delivered (followup_delivered in the trigger state); absorbed gives the row absorbed and no further message.updated; finishRun(completed) leaves it absorbed, the triggering message done, and creates no new run.
  • TestAG13_Queue: with queue, two follow-ups by Sam and Alex: two rows queued, no agent.followup; finishRun(completed): both deferred first, then the earlier one by thread_seq is triggering_message_id of a new queued run for Lotse with triggered_by its author, an anchor, trigger_state = received, agent.invocation and a runs.queued_expiry job; its row stays deferred on the ended run; the later one is now a row of the new run in queued with trigger_state = received; getRun of the ended run lists only the first as deferred, getRun of the new run lists the second as queued.
  • TestAG13_QueueWithoutCapability: supports_followups = false with a steer report behaves as queue.
  • TestAG13_DeliveredNotAbsorbedIsDeferred: with steer, a follow-up acknowledged delivered and a run ending failed with runtime_error: the follow-up becomes deferred and triggers the new run; a second follow-up that was absorbed stays absorbed and moves nowhere.
  • TestAG13_FollowOnRunMovesRestWithSteer: with steer, three follow-ups, run ends completed before the adapter acknowledged any: new run with the first as trigger; the other two move as queued with trigger_state = received (also the one that had followup_delivered), and two new agent.followup events for the new run follow its agent.invocation in thread_seq order.
  • TestAG13_StopDiscardsOpenFollowups (AG-13 "stop with open follow-ups"): Alex calls cancelRun on a running run with two open follow-ups; the adapter answers finishRun(cancelled, user_cancelled): both rows discarded, both messages trigger_state = cancelled with user_cancelled, no new run, no agent.invocation; variant: cancelRun on a queued run without delivery ends it at once with user_cancelled and discards its follow-ups the same way.
  • TestAG13_CancelForOtherReasonDefers: with queue, Alex loses trigger on Support (G-1 revoked) while Lotse runs her run and Sam has a queued follow-up; Lotse answers finishRun(cancelled, access_revoked): Sam's follow-up becomes the trigger of a new run with triggered_by Sam; variant with the job runs.cancel_timeout after agent.cancel_grace on the fake clock: the same (literal 09 7, spec gap in the report).
  • TestAG13_AnswerToWaitingInput (AG-13 "answer to waiting_input"), once with steer and once with queue: the adapter starts with token S and calls awaitRunInput; Alex writes a follow-up: agent.followup is written in both modes; its delivery records the token's jti in runs.invocation_jti; resumeRun with that token gives 200 running; resumeRun with S gives 403 invocation_token_invalid with reason: superseded; with queue a follow-up written while the run was still running gets no event and is deferred at the end.
  • TestFollowupStatesForwardOnly: absorbed -> delivered gives 409 invalid_state_transition and changes nothing (row, run version, events); delivered twice gives no second message.updated; a message id that is no follow-up of this run gives 400 validation_failed with patch_target_missing (M2-10); the same over the gateway op run.update answers error with the same code and keeps the connection.
  • TestFollowupDeliveryRechecksAuthor: with steer, Sam's follow-up is accepted, then Sam's trigger grant is revoked before the adapter polls: the poll does not deliver it, the row is discarded, the message failed with access_revoked (M2-08 note 6), one audit deny entry, the run stays running and its version rose by one; Alex's run is not cancelled.
  • TestFollowupNotDeliverableAfterEnd: with steer, the run ends before the adapter polls the agent.followup: the poll skips it without token and without audit, and the later cumulative ack covers it.
  • TestMessageWithoutTriggerIsNoFollowup: in a thread of Support/Eskalation (thread_require_mention = true) with Lotse's active run, Alex writes without mention: no row, trigger_state NULL, no event; Robin (no trigger) mentions Lotse: trigger_state = failed with no_trigger_right, no row; Alex mentions Texter (guests allowed there, Texter has write): failed with run_active_other_agent, no row.
  • TestFollowupNeverReachesPersonalAgentForOthers (M2 Red Team, T-AI-08, OQ-01): the Owner's run of Assistent is running in a thread of Research/Notizen with steer; Sam (write) writes with a mention of Assistent: failed with no_trigger_right, no row, no agent.followup in event_log; Sam without mention: no trigger_state, no row; the Owner's own follow-up becomes a row and an event; no event in the log ever has audience Assistent and a follow-up message by Sam.
  • TestFollowupDeletedIsDiscarded (with M1-58 merged): with queue, Sam deletes his queued follow-up: row discarded, run version raised, the tombstone's trigger_state unchanged; at the end of the run no new run is created from it.
  • TestRunEndSettlesOnEveryEndPath (table, each with one open queue follow-up): finishRun completed, failed and cancelled (user_cancelled); cancelRun on a queued run; job runs.cancel_timeout; job runs.stall_check with interrupted and with stale; agent disabled (OnAgentDisabled); M2-08's denial of a queued invocation; runs.queued_expiry: each ends with the follow-up discarded or deferred exactly as PlanRunEnd says, and a follow-on run where deferred and allowed.
  • TestFollowOnRunRefusedWhenAgentDisabled: Lotse disabled while two follow-ups are open: the run ends cancelled with agent_disabled, both follow-ups end discarded with failed agent_disabled, no new run.
  • TestRepeatedFinishCreatesOneFollowOnRun: finishRun(completed) twice: the second answers 200 without change and no second follow-on run exists.
  • TestFollowupRacesRunEnd: in a loop, Sam's createMessage and the adapter's finishRun(completed) run concurrently: afterwards no row in queued or delivered belongs to an ended run, at most one active run exists in the thread, Sam's message is either a follow-up (of the ended run, settled, or of the follow-on run) or the trigger of a run, and both requests succeeded (deadlocks retried).
  • TestConcurrentMessagesOneRunOneFollowup (replaces M2-05's pinning test): Alex and the Owner write in the same thread without a run at the same time: one run, the other message is its follow-up with trigger_state = received.
  • TestCreateMessageReplayNoSecondFollowup: the same client_msg_id again returns the original and leaves one row.
  • TestRunDocumentListsFollowups: getRun lists followups keyed by message id with the states of note 13, validates against Run and RunFollowup, ETag equals the version; the snapshot sent on focus (M2-10 LiveBox) carries the same followups.
  • TestFocusedClientSeesFollowupAfterVersionStep: Alex focuses the thread (wstest); a follow-up raises the version without run.updated; the adapter's next patch reaches Alex as a run.updated snapshot that contains the follow-up.

Contract:

  • TestFollowupSchemas: agent.followup validates against AgentFollowupEvent, message.updated against MessageUpdatedEvent, getRun against Run; the delivered envelope carries invocation_token only as a delivery field and the event_log row has none.
  • TestTxnRegistrationsGolden (M1-13) shows the subscription of messaging.MessageDeleted to runs after the existing ones (with M1-58 merged).

Commands that must pass, from the repository root:

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

Acceptance criteria

  • Migration run_followups matches 03 (columns, CHECK, PK, unique (message_id), cascading FKs, index) and has its line in migrations.sha256.
  • AG-13 with steer: a follow-up during a run is a queued row with received, agent.followup is written and delivered with a fresh token (trigger_kind = followup, sub the author), the adapter's delivered and absorbed change the row forward only.
  • followup_delivered in the trigger state: the patch to delivered sets trigger_state = followup_delivered with one message.updated.
  • AG-13 with queue (and without the capability): nothing is sent; at the end of the run the open follow-ups become deferred, the first in thread order triggers a new run, the rest are its follow-ups.
  • AG-13 stop with open follow-ups: cancelled with user_cancelled makes them discarded with cancelled / user_cancelled on the messages and starts nothing.
  • AG-13 answer to waiting_input: a follow-up to a waiting run is delivered in every mode, its token resumes the run, the consumed start token does not.
  • A refused follow-up (at acceptance, at delivery, or as trigger of the follow-on run) never ends the run of another person and is never processed; no follow-up of another person reaches a personal agent.
  • Every path that ends a run settles its follow-ups exactly once; concurrent follow-ups and run ends leave no open follow-up on an ended run.
  • run.followup.create is a real requirement and no longer deferred; the run document lists the follow-ups; 09 6 and 7, the runs, authz and agents READMEs, the CHANGELOG and D-M2-11-1 to D-M2-11-9 are written.
  • make check is green.

Definition of done

  • Code and tests as specified; doc comment on every public symbol (purpose, parameters, return value, errors, example where useful); comments explain why.
  • READMEs of runs, authz and agents (and messaging if changed) updated (follow-up states and transitions, end of run and follow-on run, version steps, the capability read, the requirement of run.followup.create, the query visibility of note 14).
  • Rulings of note 16 recorded in docs/DECISIONS.md (one row each with reason); rulings 2 to 7 also in docs/spec/09-agents.md 6 and 7.
  • services/server/CHANGELOG.md entry under 'Unreleased'.
  • An ADR only if you take an architecture decision beyond this brief (MADR 4 as in docs/adr/0000-template-madr-4.md, headings and text in English, next free number, make adr-index).
  • make check green.
  • An independent code review over the diff, then the red team gate (docs/quality/red-team-gate.md, verdict JA or NEIN); on NEIN fix with a test per finding and have the delta checked again. Record both verdicts.
  • Land the change as AGENTS.md ("How work lands on main") describes: a pull request from this branch, squash-merged only when review, red team and make check, ci-ok and the review threads are green; then git worktree remove.

Report

Write to {{REPORT_PATH}}:

  1. First line exactly Verdict: DONE or Verdict: BLOCKED <reason>.
  2. Commits (SHA and subject), changed files with line counts, test results with counts (the red run before the change and the green run after), the summary of make check.
  3. Review and red team verdicts and the findings fixed.
  4. What is not proven.
  5. 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>.