M2-19: Agent replies and announcements¶
Status: ready | Depends on: M2-09 | Milestone: M2
Goal¶
Agents write into the chat only where their rights allow it, and nothing they write ever sets an agent in motion. An agent with its API token answers in the thread of its run and posts announcements without a human trigger (09 "Reports without a trigger": a new thread per report or a fixed thread) only where it has write through a grant or the operator rule; anywhere else it gets 404 (no read right) or 403 (read right only), which is AG-26. Every agent message carries origin = agent and an author of kind agent, set by the server and present in every representation (REST answer, replay, message.created, catch-up, gateway), so clients can show the origin badge (19 SEC-OUT-3); a client can never set origin or write a system line. No agent message creates a run, a follow-up, a trigger_state or an agent.invocation, with or without mentions, in free channels and in threads with an active run (AG-01 for origin = agent, 07 SR4, T-AI-07). MessageCreate.run_id, rejected with 400 validation_failed and errors[].code not_enabled since M1-56, is enabled: an agent may name an active run of its own in the target thread, every other run is refused with one uniform answer, and a run cannot end while such an answer is being written. After this ticket the server side of AG-01 (agent part) and AG-26 is proven by tests that were red first, as M2-31 requires.
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 10, Test strategy, Red team, Risks, block "agents and extensions"); the row M2-19 (this ticket); the rows M2-04, M2-05, M2-08 and M2-09 (what exists), M2-11, M2-13, M2-14, M2-15, M2-18, M2-20, M2-27, M2-29, M2-31 and M2-33 (who builds what next to this ticket); the rows M3-17 to M3-19 (notifications of agent messages) and M8-03 (read-only channels).- Dependencies: the header names M2-09. Implied through it and therefore not in the header: M2-08, M2-07, M2-06, M2-05, M2-04, M2-02 and the M1 tickets M1-13, M1-16, M1-18, M1-19, M1-24, M1-28, M1-29, M1-31, M1-44, M1-51, M1-53, M1-54, M1-55, M1-56, M1-61, M1-63, M1-65 and M1-68 (
bothy.message.created). Not in the closure, used only if merged: M1-57 (history reads), M1-70 (SQL visibility lint), M2-10 (shares the reasonrun_not_active), M2-11 (run_followups), M2-15 (cards). docs/spec/09-agents.md: 1 "Principles"; 2.3 "Grants of an agent" (operator rule,writethrough a grant for guests and report channels); 3.1 "Operator"; 3.2 "Origin (messages.origin)"; 3.4 "Decision per message" point 1; 3.6 "Guest agents"; 4.1 "Overview" (answers over the normal API with the agent token); 4.7 "Adapter operations" (row "Send reply":message.create,client_msg_id,origin = agent); 5.1 "State machine"; 5.4 "Live box content" (the anchor is the only message withrun_id); 7 "Follow-ups during a run"; 11.5 "Approval buttons" (a card with the run asrun_id, built by M2-15); 12 "Auto-title"; 14 "Reports without a trigger" (points 1 to 5 and the limit); 15 "Reliability" (row "Answer lost while sending", the adapter outbox); 16.2 "Required scenarios" (AG-01, AG-26); 17.9 "Reports from cron jobs".docs/spec/05-actions.md: rules 1.1, 1.2 and 1.8; 2.3 "Effective level" points 4, 5 and 6; 2.4 (column Audit, aggregated denials, R-AZ-04); 2.6 "Scopes for apps" (agents have the scopes implicitly); 4.6 rowthread.create; 4.7 rowmessage.create(write,rl.message, Auditno); 5 "Agent invocationagent.invoke" point 1.docs/spec/07-permissions.md: 2.9 "Agents as principals"; 3 "Principal context" (acting_for); 4.1 "Decisions and reasons" (no_grant,level_insufficient,channel_archived,origin_not_human); 4.5 "Archived spaces and channels"; 4.6 "Resolving mentions"; SR4 "Only humans trigger"; SR9 "Acting-for binding (confused deputy)" (exception: answers in the thread of the run); 10 "Examples" (the world); 11.1 rows AZ-M27, AZ-M28, AZ-M29, AZ-M30; 11.2 rows AZ-P1, AZ-P4, AZ-P13.docs/spec/03-data-model.md(core contract, read only):messages(origin,kind,run_id"forkind = 'run'", the checks(kind = 'run') = (run_id IS NOT NULL)andtrigger_state IS NULL OR origin = 'human', the unique pair(author_principal_id, client_msg_id), the partial unique index onrun_id),runs(state,agent_id,thread_id, the partial unique index of active runs, R-DM-14),threads(operator_agent_id,title_source), 6.3 "Lock order".docs/spec/04-sync-protocol.md: 2.2 "Envelope"; 5.1 "Messages:client_msg_id" (point 3: the parts of the canonical request); 10 "Ordering"; 15.3 "Invocation and run start"; 15.5 "Cancellation"; 16.5 "Agent run with follow-up" (the adapter posts its answer beforefinish).docs/spec/06-api.md: 2 "Basic rules" (rows "Unknown values", "Nonull"); 4 "Authorization in the API" points 3 and 6; 5 "Errors" points 3, 4 and 7; 7 "Idempotency" (row "Send a message").docs/spec/02-architecture.md: 4.1 "Rules" point 4 (source port or in-transaction event); 4.3 "Ports, events and sources per module" (rowsmessaging:MessagePosted;runs: subscribesmessaging.MessagePosted); 4.4 "Allowed dependencies" (messagingmay not useruns); 8.1 "Flow", 8.2 "Lock order", 8.3 "In-Tx events" (a subscriber may veto).docs/spec/13-push-notifications.md: 2 "Kinds of notifications and recipients" (rowsmessage,run_done), 2.1 "Agent messages during a run", 2.2 "Report channels" (only to know what M3 consumes; nothing of it is built here).docs/spec/19-security.md: SEC-OUT-3, SEC-AI-1, SEC-IN-1.docs/api/openapi.yaml: operationscreateMessage,createThread,listEvents,pollAgentEvents,startRun,awaitRunInput,finishRun,cancelRun,createGrant,setChannelOperator,archiveChannel,closeThread; schemasMessageCreate(run_id: "Agents only; the run this answer belongs to."),Message(origin,author,run_id,trigger_state),MessageOrigin,MessageKind,PrincipalRef,PrincipalKind,ThreadCreate(first_message),ThreadCreated,OperatorSet,MessageCreatedEvent,MessagePayload,Problem(x-problem-codes),FieldError; responseConflict; headerIdempotent-Replay.docs/api/asyncapi.yaml: messageevent.message.created.- ADRs 0008, 0009, 0013, 0015, 0017, 0022, 0025, 0053, 0054.
docs/DECISIONS.md: D-W-100; section 10 for the format of new rows.docs/security/threat-model.md: T-AI-04 (agent writes into foreign channels), T-AI-07 (loops), T-AI-01, T-TK-02.docs/RISKS.md: RK-01 ("agents never trigger agents"). No item ofdocs/OPEN-QUESTIONS.mdconcerns this ticket.docs/observability/signals.md4.2:bothy.message.createdwithbothy.message.origin(recorded by M1-68, which is in the closure; assert it).docs/operations/configuration.md:ratelimit.rl.message.per_minute,ratelimit.rl.message.burst,database.tx_retry_max,audit.deny_aggregate_window,message.max_content_bytes.- M1 briefs: M1-13 (
txn.Publish, subscriber veto returns the error unchanged, golden registrations), M1-16 (eventlog.Appender), M1-18 (problem writer,StageValidateand itserrors[].codevocabulary), M1-19 (rate-limit classes per principal), M1-24 (deny aggregation), M1-28 (descriptor,message.createrequirementAll(Level(write), CapIf(upload, HasAttachments)), operator rule in the effective rights, decision to error mapping), M1-31 (response validator inservices/server/test/apitest, test authenticator builders ininternal/platform/httpserver/httpservertest), M1-44 (agent API tokens), M1-51 (agent records), M1-53 (archiveChannel), M1-54 (setChannelOperator,OperatorSet.mode), M1-55 (chat.Threads, title source: agents always giveauto), M1-56 (createMessage,createThread,MessagePosted, D1 flow, D2 body rules with thenot_enabledrow forrun_id, D3 request hash, D7 events), M1-61 (thesyncsubscriber ofMessagePosted), M1-63 (listEvents), M1-65 (gateway delivery, test client). - M2 briefs:
docs/briefs/M2-04-trigger-decision.md(AG-01 at decision level),M2-05-runs-state-machine.md(acceptance subscriberOnMessagePosted, its first step "return unlessOrigin = human",CreateRunAnchor, event order of note 7),M2-07-agent-consumers.md(pollAgentEvents),M2-08-invocation-delivery.md(delivery, AZ-M30),M2-09-run-lifecycle.md(start, wait, finish, cancel, the end-of-run function and its locks), andM2-10-run-document-live-box.mdif present (reasonrun_not_activefor patches). - Code on the base branch:
internal/messaging(port,domain,app,adapters/httpapi),internal/runs(port,domain,app,adapters/postgres),internal/authz,internal/kernel/authorize,internal/kernel/errs,internal/kernel/txn,internal/kernel/eventlog,internal/kernel/clock,internal/wire(golden registrations, the test hook forMessagePostedsubscribers of M1-56).
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, right 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:
- An agent writes a message (
message.create) or creates a thread (thread.create) only with effectivewriteon the target: from a grant (at mostwriteandupload) or from the operator rule (effective operator of the channel, fixed operator of a thread). Without read right the answer is404, identical to an unknown thread; with read right only403 forbiddenwithreasonlevel_insufficient; in an archived channel403 channel_read_onlywithreasonchannel_archived, also for the operator (05 2.3 points 4 and 5, 4.6, 4.7, rule 1.2; 07 2.9, 4.1, 4.5; 09 2.3; AG-26; AZ-M27 to AZ-M29). originis set by the server from the principal: an agent token givesagent; the request cannot carryorigin, andkindcannot besystemorrun(09 3.2; 19 SEC-OUT-3; 06 2 "Unknown values"; SEC-IN-1).- An agent message never triggers: no run, no follow-up, no
trigger_state, noagent.invocation, whatever it mentions and in whatever channel (09 3.2 and 3.4 point 1; 05 5 point 1; 07 SR4; 03 checktrigger_state IS NULL OR origin = 'human'; AG-01; T-AI-07). MessageCreate.run_id("Agents only; the run this answer belongs to", openapi) is accepted only from an agent and only for an active run (queued,running,waiting_input) of that agent in the target thread; it is never written tomessages.run_id, which names only the run of a live-box anchor (03messages, 09 5.4). Design notes 3 to 6 fix the answers.- Announcements without a trigger: the agent writes with
message.createinto a channel where it haswrite, either one new thread per report (thread.create, title from the first line,title_source = auto) or a fixed thread; there is no run and no invocation token; the agent's messages count againstrl.messageof the agent; a human answer in such a thread triggers by the normal rules (09 14 points 1 to 5; 09 12 point 1). - Messages are idempotent by
(author_principal_id, client_msg_id); a repeated answer returns the original with200andIdempotent-Replay: true, also after the run ended (04 5.1; 06 7; 09 15 "Answer lost while sending"). - No JSON
nullin answers and events; every response validates againstopenapi.yaml(ADR 0053; 06 2 "Nonull").
Scope¶
All paths under services/server/ unless noted. Names of M1 and of M2-04 to M2-09 below are the expected ones; where the merged code names them differently, follow the code (Boundaries).
Create:
internal/runs/domain/answer.go:type AnswerRun struct{ ID, ThreadID, AgentID ids.ID; State State }and the pure functionAnswerVerdict(run AnswerRun, found bool, agentID, threadID ids.ID) error, returning nil,ErrAnswerRunMismatchorErrAnswerRunNotActive(Design note 4).Stateis the run state type of M2-05.internal/runs/app/answer.go:checkAnswer(ctx context.Context, ev messaging.MessagePosted) erroron the merged app type ofruns; it loads the run through the repository, callsAnswerVerdictand maps the domain errors tokernel/errs(Design note 4).internal/runs/adapters/postgres/queries/run_answer.sql:LockRunForAnswer(SELECT id, thread_id, agent_id, state FROM runs WHERE id = $1 FOR SHARE) and the regenerated sqlc code; the repository method behind it.- Tests as listed under "Tests (write them first)", in the test packages the merged modules already use for unit and integration tests.
Change:
internal/messagingport:MessagePostedgetsRunID(optional, the same optional-ID form the merged struct uses forReplyToID), documented as "the run this message belongs to: forkind = runthe anchored run (messages.run_id); for an agent message of any other kind the run the request named inrun_id, after the body rules of Design note 3; absent otherwise".internal/messaging/domain(body rules of M1-56 D2):run_idis no longernot_enabled; arun_idfrom a principal that is not an agent gives400 validation_failedwith pointer/run_idanderrors[].coderun_mismatch;first_message.run_idincreateThreadgives the same with pointer/first_message/run_id(Design note 5). Thenot_enabledrows forkind = card,components,kind = voiceandattachment_file_idsstay as they are on the base.internal/messaging/app:createMessageputs the validatedrun_idof an agent intoMessagePosted.RunID;CreateRunAnchor(M2-05) setsRunIDto the anchored run. Nothing else in the write path changes;run_idis not part of the request hash (Design note 6).internal/runs/appacceptance subscriberOnMessagePosted(M2-05accept.go, extended by M2-08; follow the merged file): a first branch for agent messages that callscheckAnswerand then returns without any trigger work (Design note 2). No new subscription:internal/wire/testdata/txn_registrations.goldenstays unchanged.- The M1-56 test that asserts
not_enabledforrun_id(TestCreateMessage_NotEnabledMembers400or its merged name): change only therun_idrow to the new rule of Design note 3; keep every other row and the test. docs/api/openapi.yaml(descriptions only, no schema change):MessageCreate.run_idnames the rule of Design notes 3 and 4 (agents only; an active run of the calling agent in this thread; validated, not stored;400 validation_failedwitherrors[].coderun_mismatch,409 conflictwithreasonrun_not_active; not part of the canonical request ofclient_msg_id);Message.run_id: "Set only forkind = run: the run whose live box this anchor holds.";ThreadCreatedescription:first_message.run_idis never valid because a new thread has no run. Then the server code generation (M1-17) and the API gates.docs/spec/09-agents.md(English, like the chapter, not core contract): 4.7 row "Send reply": optionalrun_idonly for an active run of the agent itself in the target thread, otherwise400 validation_failedwitherrors[].coderun_mismatch, or for an ended run409 conflictwithreasonrun_not_active; checked, not stored; the adapter sends replies beforerun.finish(04 16.5) and sends a reply refused after the end of the run again withoutrun_id. 14 point 1: "withoutrun_id".docs/security/threat-model.md: T-AI-04, column Mitigations:rl.messageinstead ofrl.agent_runtime(05 4.7 givesmessage.createthe classrl.message, 09 14 point 4 says the same); add AG-26 to its tests; add AG-01 to the tests of T-AI-07.internal/messaging/README.mdandinternal/runs/README.md: therun_idrule,MessagePosted.RunID, the answer branch ofOnMessagePosted, theFOR SHARElock and why (Design note 7);services/server/CHANGELOG.mdunder "Unreleased";docs/DECISIONS.mdsection 10 rowsD-M2-19-<n>for every Ruling below.- If
tools/lint/sql_visibility.pyis on the base (M1-70): listLockRunForAnsweras an exception of categoryafter_authorizewith the reason of Design note 4e.
This ticket creates no migration, no new operation (createMessage and createThread are enabled since M1-56), no new action, event, job, metric or configuration key and no new top-level make target.
Out of scope¶
- Cards, components,
kind = card,message_components.run_id,restricted_toand approval cards (M2-15); if M2-15 is already merged, see Design note 9. - SR9,
acting_forand the invocation token in the request header (M2-14); this ticket reads no token header. - The run context, the trust marks of agent messages in the context of other runs and
role(M2-13); follow-ups andrun_followups(M2-11); agent sessions (M2-12); titles set by the operator agent withthread.title.update, AG-19 (M2-18). - Files from agents (
attachment_file_ids,+upload, capabilityfiles_out) (M3-07); voice messages (M3-09). - Notifications of agent messages: no
messagehint during an active run,run_donepointing at the last agent message,channels.default_notification_levelof report channels (M3-17 to M3-19). - App messages, incoming webhooks and AG-01 for
origin = app(M2-24, M2-27); imports and AG-01 fororigin = import, read-only channels (channels.read_only) (M8-03). - Reports from cron jobs of Hermes Agent (
cron_deliver_env_var,standalone_sender_fn, 09 17.9) (M2-33); the SDK outbox for answers (M2-29); the conformance suite with the fake agent (M2-31). - The live-box anchor (M2-05), start, finish and cancel of runs (M2-09), patches and
run_not_activefor patches (M2-10). - Rendering of the origin badge in the apps (M4).
- Changes to the core contract.
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M2-19-agent-messages; 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-02 and M2-04 to M2-09: their merged names win over the expected names here; list each difference. If the base has no acceptance subscriber of
MessagePostedinrunsor nostartRunandfinishRun, a dependency is missing: 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 never sleep for a window. - Invocation tokens and API tokens never appear in logs, problem bodies, event payloads or test failure messages.
- A mutation used to prove a red run (Tests, last paragraph) is never committed.
Design notes¶
-
Where the rules live.
authzalready decides who may write (M1-28: requirement ofmessage.createandthread.create, operator rule, limits for agents, archived channels); this ticket changes no rights code and proves it end to end.messagingsetsorigin, applies the body rules and carriesrun_idinMessagePosted.runschecks the named run, becausemessagingmay not importruns(02 4.4) andrunsalready subscribesMessagePosted(02 4.3). Ruling for docs/DECISIONS.md: the check ofrun_idis a branch of therunssubscriber ofMessagePostedthat vetoes the unit of work (02 4.1 point 4, 8.3), not a new source port. -
createMessagefor an agent, extending M1-56 D1 and M2-05 note 7, in this order: a. Decode with the generated decoder: a memberoriginorsystem_codegives400 validation_failed(unknown_member);kindother thantext,voiceorcardgives400 validation_failed(invalid_enum), so neithersystemnorruncan be sent (19 SEC-OUT-3: system lines come only from the server). b.chat.Threads.Info(thread_id); unknown gives404. c.authorize(agent, message.create, thread). No read right gives404with exactly the body and headers of an unknown thread id (AZ-M28, AZ-P4); read right withoutwritegives403 forbiddenwithreasonlevel_insufficient; an archived channel or space gives403 channel_read_onlywithreasonchannel_archived. Every denial is recorded by the authz layer, aggregated peraudit.deny_aggregate_window(05 2.4, AZ-P13); an allow writes no audit entry (Auditno). d. Body rules (M1-56 D2 plus Design note 3). e. Unit of work as M1-56 D1 step 5: lookup of(author, client_msg_id)first; a replay answers the original and publishes nothing, so no run check runs on a replay (Design note 6). A new message:AppendToThread(reopens a done thread with the system line of M1-56 D6, author the agent,origin = system), insert withorigin = agent,trigger_stateNULL, mentions after 07 4.6,message.createdwithactor {id, kind: agent}(M1-56 D7), thenMessagePostedwithOrigin = agent,AuthorKind = agentandRunID. f.runs.OnMessagePosted, first branch:Origin = agentandKindneitherrunnorsystemandRunIDset:checkAnswer(note 4); an error vetoes and rolls everything back, including the thread sequence. Then, for every message withOriginother thanhuman, return without any trigger work, as M2-05 note 6 step 1 already does (no run, no follow-up, noSetTriggerState, no audit). A message withOriginother thanagentandRunIDset andKindother thanrunis an internal error, becausemessagingnever builds it. g. Commit;201with the storedMessage. -
Body rules for
run_id. Absent: nothing to check. Present on a request whose principal is not an agent (person, app):400 validation_failed, pointer/run_id,errors[].coderun_mismatchand thedetailof note 4b (a run belongs to an agent, so it can never be theirs). Present on an agent request: carried unchanged intoMessagePosted.RunID; the run itself is checked inruns(note 4). -
The answer check (
checkAnswer,AnswerVerdict). a.LockRunForAnswer(run_id)withFOR SHARE(note 7). b. Not found,runs.thread_iddifferent fromev.ThreadID, orruns.agent_iddifferent fromev.AuthorID:ErrAnswerRunMismatch, mapped to400 validation_failedwith pointer/run_id,errors[].coderun_mismatchand the samedetailfor every case. Reason: an unknown run, a run in another thread and a run of another agent answer alike, sorun_idcannot probe runs outside the authorized thread (05 rule 1.2). c. Ownership first, state second: only for an own run in this thread does an end state (completed,failed,cancelled) giveErrAnswerRunNotActive, mapped to409 conflictwithreasonrun_not_active. Active meansqueued,runningorwaiting_input, as in 03runs(R-DM-14) and 13 2.1. Ruling for docs/DECISIONS.md:run_mismatchandrun_not_activewith these statuses;run_not_activeis the same reason M2-10 uses for patches of an ended run. d. Noauthorizecall for the run: a run of the thread is readable by every principal withwriteon that thread (05 2.2: a run's path is its thread), and runs outside the thread answer like unknown ones. Ruling for docs/DECISIONS.md. e.LockRunForAnswerreads one row by primary key inside the unit of an authorizedmessage.create, and only a verdict leaves the module; this is its reason as anafter_authorizeexception of the SQL visibility lint (07 7.4 rule 1) where that lint exists. f. A validrun_idchanges nothing on the run: no newversion, norun.state_changed, no step, no follow-up, nomessage.updatedof the triggering message. -
createThreadwithfirst_message.run_id: always400 validation_failedwith pointer/first_message/run_idanderrors[].coderun_mismatch, checked in the body rules before the unit of work, because a thread created by the request has no run. Ruling for docs/DECISIONS.md. -
Not stored; idempotency.
messages.run_idstays NULL for every message except the anchor (03 check(kind = 'run') = (run_id IS NOT NULL)), soMessage.run_idof an answer is absent in the201, the replay, events and history.run_idis not part ofclient_payload_sha256: 04 5.1 point 3 names the parts of the canonical request (thread, content, reply, attachments), and M1-56 D3 keeps its version prefix. Consequences: a replay of an answer returns the original with200andIdempotent-Replay: trueeven after its run ended (09 15); an answer refused withrun_not_activestored nothing, so the adapter can send the sameclient_msg_idagain withoutrun_id, which then is a normal agent message. Ruling for docs/DECISIONS.md:run_idis validated, carried inMessagePostedand never stored; it is not part of the request hash. The gap in the core contract goes into the report (spec gap). -
Lock and order. The answer check takes the run row
FOR SHAREaftermessagingholds the thread row (M1-55AppendToThread), the same order as the acceptance of M2-05 (thread, then run).finishRun,cancelRun, the cancel timeout and the stall check of M2-09 update the run row and so wait for the answer's commit, or the answer waits for theirs and then sees the end state. Result: every answer stored with arun_idcommits while its run is active, and itsmessage.createdhas a lowerseqthan therun.state_changedwith the end state (04 10). Deadlocks with an end-of-run path that locks the run first and a thread later (M2-11's next run) are retried by the unit of work (database.tx_retry_max). Ruling for docs/DECISIONS.md. -
Announcements (09 14). A report channel is a normal channel; the server has no flag for it (13 2.2). a. New thread per report:
createThreadwithtitle(the adapter takes the first line of the report) andfirst_message, authorized asthread.createon the channel (write) and thenmessage.createon the new thread (M1-56 D8). The thread getstitle_source = autobecause an agent set the title (M1-55, 09 12 point 1),created_bythe agent andoperator_agent_id= the effective operator of the channel, none if the channel has none. b. Fixed thread:createMessageinto a thread the adapter keeps. c. Both write with the agent's own rights: a grant withwriteor the operator rule (09 2.3: report channels need a grant unless the agent operates them). d. No run, no invocation token, norun_id;rl.messagecounts per agent principal (05 4.7, M1-19). e. A human answer in a report thread is decided by M2-05 like any other human message: with the agent as fixed operator of the thread it triggers by the switches of 09 3.3; in a channel without operator nothing triggers without an allowed guest mention. f. The limit of 09 14 ("the server checks only where the agent writes, not what it knows") is documented in the messaging README; nothing in the server checks content. -
Cards (M2-15). This ticket handles
run_idonkind = text(and on any kind the base enables). If M2-15 is merged first and already validatesrun_idforkind = card, keep one check: generalize the merged function to the rules of notes 3 to 7 instead of adding a second one, and list it in the report. M2-15 takesMessagePosted.RunIDintomessage_components.run_id. -
SR9. Answers in the thread of the run are an exception of SR9: only the agent's rights apply (07 SR9). This ticket does not read
acting_forand does not intersect rights; if M2-14 is merged, its tests stay green unchanged. -
Rights version, events, audit. No action here raises the rights version or writes
access.changed; no new event type;message.createdandthread.createdas M1-56 and M1-55 write them. Denials are audited by the authz layer; allows are not (05 4.7 and 4.6, Auditno). -
Rulings for
docs/DECISIONS.mdsection 10, one row each with reason: notes 1, 4c, 4d, 5, 6 and 7. Facts already in the spec (originfrom the principal, agents never trigger, the operator rule, title sourceautofor agents) are not repeated there.
Tests (write them first)¶
Unit:
internal/messaging/domain:TestCreateRules_RunID: absent passes; agent request carries it; a person and an app give400 validation_failedwith/run_idandrun_mismatch;createThreadwithfirst_message.run_idgives/first_message/run_idandrun_mismatchfor every principal kind; thenot_enabledrows forcard,components,voiceandattachment_file_idsare unchanged where the base still has them.internal/runs/domain:TestAnswerVerdictwith a row per case: not found; another thread; another agent; own run in each ofqueued,running,waiting_input(nil); own run in each ofcompleted,failed,cancelled(ErrAnswerRunNotActive); an ended run of another agent givesErrAnswerRunMismatch(ownership before state).internal/runs/app:TestOnMessagePosted_AnswerBranchwith a fake repository:Origin = agent,Kind = text,RunIDset callsLockRunForAnsweronce and maps the verdicts to the problem of note 4 (validation_failedwith the field error;conflictwithreasonrun_not_active);Kind = runandKind = systemnever call it; an agent message withoutRunIDcalls nothing and writes nothing;Origin = humanwithRunIDandKind = textreturns an internal error; no branch for agent messages calls the trigger decision orSetTriggerState.
Integration (PostgreSQL 18 via pgtest, real modules through internal/wire, HTTP through the real chain, every response checked by the response validator of test/apitest, people and agent tokens from the identity test helpers, fake clock). World after 07 10, without the grant G-5 of 07 11.1, so that Lotse writes in Support only through the operator rule unless a test grants more: space Support with default operator Lotse (organization), channels Allgemein (text, free_response and allow_mentioned_agents set through M2-02) and Eskalation (text, auto_thread off through M2-02); forum Reports in Support with operator mode none and a grant write on it for Lotse; forum Bulletin in Support that inherits Lotse; space Marketing with default operator Texter (organization), text channel Kampagnen and forum Ideas; Texter has a grant write on Support/Allgemein for the guest cases; Robin is the owner, Alex has trigger on Support, Sam trigger on Marketing. A run of Lotse is triggered by a message of Alex, delivered through pollAgentEvents and started with startRun with the delivered token, as in the tests of M2-09.
TestAG01_AgentMessagesNeverTrigger(AG-01 fororigin = agent): table over Lotse writing in Allgemein without mention, mentioning@lotse, mentioning@texter; Texter (guest) mentioning@lotse; Lotse writing in a thread where its own run isrunning(a human message there would be a follow-up); Texter writing in that thread (a human mention there would giverun_active_other_agent); Lotse in the main thread withauto_thread. Each gives201withorigin = agentand notrigger_state; afterwards no newrunsrow, noagent.invocationinevent_log, nomessage.updated, no new thread, the active run unchanged (state,version), and norun_followupsrow if that table exists on the base. The stored row hastrigger_stateNULL.TestAG26_AgentWithoutWrite(AG-26): Lotse without any grant on Marketing:createMessageinto a thread of Kampagnen gives404with the same status, body (ignoringcorrelation_idandinstance) and headers as an unknown thread id, andcreateThreadon forum Ideas gives404like an unknown channel id (AZ-M28); aftercreateGrantofreadon space Marketing for Lotse, both give403 forbiddenwithreasonlevel_insufficient; nothing is stored; one aggregated deny entry per principal, action and reason (no_grant,level_insufficient). AfterarchiveChannelof Allgemein, Lotse as its operator gets403 channel_read_onlywithreasonchannel_archived.TestAgentWritesWithGrantOrAsOperator: Lotse writes in Support/Allgemein through the operator rule (as AZ-M29) and in forum Reports through its grant (as AZ-M27); a guest run of Texter in a thread of Allgemein (Alex mentions@texter) lets Texter answer withrun_id; after its grant on Allgemein is revoked, Texter's next answer gives404; a run of Lotse in the main thread of Eskalation (Alex mentions@lotse,auto_threadoff): aftersetChannelOperatorof Eskalation to Texter, Lotse's answer in that main thread gives404, because the operator rule follows the operator and a main thread has no fixed operator (05 2.3 point 4, R-DM-21).TestReport_NewThreadPerReport: Lotse callscreateThreadon forum Reports with a title andfirst_message:201ThreadCreated;title_source = auto,operator_agent_idabsent,created_byLotse; the first message hasorigin = agent,thread_seq1; eventsthread.createdthenmessage.created; no run, noagent.invocation; a replay with the samefirst_message.client_msg_idgives200withIdempotent-Replay: true.TestReport_FixedThread: Lotse posts three reports into one thread of forum Bulletin: three messages, consecutivethread_seq, no run.TestReport_HumanReplyTriggersNormally: Alex answers in Lotse's report thread in Bulletin: one run of Lotse in that thread (fixed operator, default switches); Alex answers in the report thread in Reports: no run and notrigger_state; with@lotsethere:trigger_state = failedwithagent_not_allowed_here(guests not allowed in Reports).TestAgentMessage_RateLimitClass: withratelimit.rl.message.burstandratelimit.rl.message.per_minuteset low in the test configuration, Lotse's message beyond the burst gives429 rate_limitedwithRetry-After; a message of Alex at the same moment succeeds (own bucket).TestAgentMessage_OriginEverywhere(SEC-OUT-3): for one answer of Lotse,origin = agentandauthor {kind: agent, handle: lotse}in the201, in the replay200, in themessage.createdrow ofevent_log, inlistEventsread by Alex and in the gateway dispatch to Alex's connection (M1-65 test client); also ingetMessageandlistThreadMessagesif M1-57 is on the base;bothy.message.createdcounts it withbothy.message.origin = agent(M1-68 is in the closure). No JSONnullanywhere; the event payload validates againstMessageCreatedEvent.TestAgentMessage_CannotForgeOriginOrKind: bodies withorigin: human, withsystem_code, withkind: systemand withkind: rungive400 validation_failed; nothing is stored.TestAgentMessage_ReopensDoneThread:closeThread, then an answer of Lotse: eventsthread.reopened, systemmessage.created(author Lotse,origin = system,system_code = thread.reopened), then Lotse'smessage.created; no run.TestAnswer_RunIDActiveRun: Lotse answers withrun_idwhile the run isrunning, afterawaitRunInputwhile it iswaiting_input, and in another thread for a run of Lotse that is stillqueued: each201; theMessagehas norun_id; a test subscriber registered through the wire test hook seesMessagePosted.RunID; the run'sstateandversionare unchanged; the triggering message has no new version. Red before the change:400 validation_failedwithnot_enabled.TestAnswer_RunIDMismatch: Lotse posts into a thread of Allgemein with an unknown UUIDv7, with its own active run of another thread, and with the active guest run of Texter in this thread; Alex posts with that run of Texter: each400 validation_failedwith pointer/run_idanderrors[].coderun_mismatch; the four bodies are equal ignoringcorrelation_idandinstance; nothing stored,thread_seqof the thread unchanged, nomessage.created.TestAnswer_RunIDEndedRun: afterfinishRunwithcompleted,failed(runtime_error) andcancelled(viacancelRunand the adapter's confirmation):409 conflictwithreasonrun_not_active; nothing stored; the sameclient_msg_idwithoutrun_idthen gives201.TestAnswer_ReplayAfterRunEnded: an answer withrun_idstored whilerunning;finishRun; the same request again gives200withIdempotent-Replay: trueand the original; no second event.TestAnswer_FirstMessageRunIDRejected:createThreadwithfirst_message.run_idset to Lotse's active run gives400 validation_failedwith pointer/first_message/run_idandrun_mismatch; no thread is created.TestAnswer_ConcurrentWithFinish: many rounds of an answer withrun_idandfinishRun(completed)released behind a barrier: either the answer is201and itsmessage.createdhas a lowerseqthan therun.state_changedwithcompleted, or the answer is409 conflictrun_not_activeand nothing is stored; never an answer withrun_idcommitted after the end event; no deadlock reaches the client.
Contract: every response of createMessage and createThread in these tests validates against Message, ThreadCreated and Problem; problems carry code and, where named above, reason or errors[].
Red team focus of the milestone (PLAN 8 "Red team": confused deputy through the server API; imitating a card header) and T-AI-04 and T-AI-07 are covered by TestAG01_AgentMessagesNeverTrigger, TestAG26_AgentWithoutWrite, TestAgentMessage_CannotForgeOriginOrKind and TestAnswer_RunIDMismatch; name them in the red team request.
Red runs for M2-31 (its acceptance: every scenario was red before it was built, shown in the report of the ticket that builds it): run TestAG01_AgentMessagesNeverTrigger and TestAG26_AgentWithoutWrite first and keep the output. Where a case is already green on the base, because M1-28, M1-56 or M2-05 built the guard, prove that the test can fail: apply a one-line local mutation to the guard it covers (for example let the acceptance branch treat origin = agent like human, or give agents write without grant and operator rule), run the test, keep the red output, revert the mutation, and record in the report which case needed it.
Commands that must pass, from the repository root:
cd services/server && go test -race -count=1 ./internal/messaging/... ./internal/runs/... ./internal/authz/... ./internal/kernel/... ./internal/wire/... ./api/...make codegen-check,make arch,make api-lint,make api-checksmake docsmake check
Acceptance criteria¶
- AG-01 for
origin = agent: no agent message creates a run, a follow-up, atrigger_stateor anagent.invocation, with or without mentions, in a free channel and in a thread with an active run. - AG-26: an agent without
writegets403 forbidden(level_insufficient) with read right and404identical to an unknown id without it, for messages and report threads; the denials are audited. - Agents write answers and announcements with a grant or as operator; a report thread has
title_source = auto, no run, and a human answer in it triggers by the normal rules; agent messages count againstrl.message. - Agent messages carry
origin = agentand anauthorof kindagentin every representation;origin,system_code,kind = systemandkind = runcannot be sent. -
MessageCreate.run_idis enabled: an active own run in the thread gives201; any other run gives400 validation_failedwithrun_mismatchand one uniform body; an ended own run gives409 conflictwithreasonrun_not_active;run_idis not stored; a replay is unaffected;first_message.run_idis rejected. - Every answer stored with
run_idcommits before the end event of its run. -
openapi.yaml, 09, the threat model, the READMEs anddocs/DECISIONS.mddescribe the behaviour; the report contains the red runs of AG-01 (agent part) and AG-26. -
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
messagingandrunsupdated (therun_idrule,MessagePosted.RunID, the answer branch ofOnMessagePosted, the lock of note 7, the limit of note 8f). - Rulings of note 12 recorded in
docs/DECISIONS.md(one row each with reason); the changes to 09,openapi.yamland the threat model of 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>.