M2-04: Trigger decision as a pure function¶
Status: ready | Depends on: M2-02 | Milestone: M2
Goal¶
After this ticket the package internal/runs/domain holds the rules of 09 "Operator and trigger rules" as side-effect-free functions with a documented decision table. Given the facts
about one new message (origin, kind, channel and thread, the operator, the channel switches, the
resolved agent mentions, the author's trigger permission, an active run in the thread and the
transcription mode), Decide returns exactly one outcome: no trigger (trigger_state stays
empty), a rejection at acceptance with a reason code of 09 3.5, an invocation of one candidate
(operator or guest, in the thread or in a new auto thread), or a follow-up to the active run.
DecideCommand applies 09 10.4 to agent commands, and Settle turns the result of the
agent.invoke (or run.followup.create) check into trigger_state and trigger_state_reason.
The functions read no database, call no authorize, use no clock and write nothing, so the rules
are proven by table tests: AG-01 to AG-06, the guest rule, and one case for every row of the
rejection table in 09 3.5. The tickets that apply the decision (M2-05 acceptance, M2-11
follow-ups, M2-16 commands, M3-09 voice) call these functions instead of re-implementing them.
Read first¶
docs/spec/09-agents.md: 1 "Principles" (point 4 fail closed); 2.2 "Features" (effective feature, R-AG-01,voice_input); 2.3 "Grants of an agent"; the whole of 3 "Operator and trigger rules": 3.1 "Operator", 3.2 "Origin (messages.origin)", 3.3 "Channel switches", 3.4 "Decision per message" (steps 1 to 7 and the flowchart), 3.5 "Reasons for rejection at acceptance", 3.6 "Guest agents", 3.7 "Auto-thread from a channel message"; 6 "Trigger state (trigger_state)"; 7 "Follow-ups during a run" (first paragraph and last sentence); 10.4 "Execution"; 13 "Voice messages"; 16.2 "Required scenarios" rows AG-01 to AG-06; 19 "Decisions of this chapter" (R-AG-02, R-AG-03, R-AG-04, R-AG-05, R-AG-16, R-AG-22).docs/spec/05-actions.md: 2.3 "Effective level" (operator rule); 2.4 (column "Audit", valuedenials); 4.10 rowsagent.invoke,run.followup.create,command.invoke; 5 "Agent invocationagent.invoke" points 1 to 8 and the paragraphs "Consequences onallow" and "Consequences on denial"; 8 "Decisions of this chapter" (R-AZ-13, R-AZ-19, R-AZ-20).docs/spec/03-data-model.md:channels(switch columns, "Effective operator" and "Mention rule" after the table),threads(kind,operator_agent_id,title,title_source,parent_message_id),messages(origin,kind,trigger_state,trigger_state_reasonand the checkstrigger_state IS NULL OR origin = 'human',trigger_state_reason IS NULL OR trigger_state IN ('failed','cancelled')),runs(state, the partial unique index for at most one active run per thread),agents(billing_mode),principals(status); R-DM-11, R-DM-12, R-DM-14, R-DM-21.docs/spec/07-permissions.md: 2.3 "Levels" (triggerincludeswrite), 2.9 "Agents as principals" (point 2 operator rule, point 3 agents never trigger), 4.1 "Decisions and reasons" (deny reasonsorigin_not_human,agent_not_reachable,billing_mode_personal_not_holder,tool_class_owner_only,dev_environment_not_ready,project_not_ready,run_active_other_agent,run_active,level_insufficient,no_grant), 4.6 "Resolving mentions", "SR4 Only humans trigger", 11.1 "Test matrix" rows AZ-M30 and AZ-M53 (owned by M2-08 and M2-27; this ticket mirrors them at decision level only).docs/spec/02-architecture.md: 4.1 "Rules" point 4 (messagingpublishesMessagePosted,runsdecides), 4.2 "Module list" (runsowns the trigger decision), 4.3 rowruns, 4.4 rowruns, 5 "Layers within a module" (domain imports onlykernel-core), 6 "Code layout".docs/spec/04-sync-protocol.md: 15.3 "Invocation and run start" point 1 "Acceptance" and point 2 "Delivery"; 15.4 "Follow-ups" point 1.docs/spec/12-files.md: 8.2 "Transcript" (values ofvoice.transcription_mode).docs/spec/01-product.md: 6.3 "Trigger state as icons".docs/spec/00-glossary.md: "Operator", "Operator rule", "Main thread", "Follow-up", "Trigger state", "Control command".docs/spec/23-quality.md: "Documentation in code" (Go: revive rulesexportedandpackage-comments).docs/api/openapi.yaml: schemasMessage(trigger_state,trigger_state_reason),TriggerState,MessageOrigin,MessageKind,ChannelBehavior; operationcreateMessage(description: invocation evaluated after storing); problem codeagent_invocation_denied.- ADRs 0008 (hexagonal modules and checked dependencies), 0017 (one authorization function), 0022 (agents outside the server process), 0024 (backend policy with invariant).
docs/DECISIONS.md: D-B7 (onlyauthzholds permission logic), D-AG-02, D-AG-03, D-AG-04, D-AG-12, D-AG-15, D-W-100; section 10 "Rulings of the build milestones" (format of the rows you add).docs/security/threat-model.md: T-AI-07 (loops: onlyorigin = humantriggers, one active run per thread) and T-AI-08 (personal subscription used by a second person).docs/PLAN.md: section 3 "Way of working per ticket"; section 5 "Test strategy" (row "Unit"); section 8 "M2 Agent platform, extensions and terminal": "Goal", "Scope", "Exit criteria" point 1, "Test strategy", "Red team" (personal subscription triggered by another person over every path), rows M2-02, M2-03, M2-04, M2-05, M2-08, M2-11, M2-12, M2-16, M2-19, M2-31; section 9 row M3-09.- M1 briefs whose interfaces this decision mirrors (the domain imports none of them):
docs/briefs/M1-56-messaging-send.md(in-transaction eventMessagePostedwithOrigin,ThreadKind,Kind,MentionedIDs; mentions resolve only to active principals the author may see, D5),docs/briefs/M1-55-chat-threads.md(chat.Threads.InforeturnsThreadInfowithChannelKind,Kind,OperatorAgentID),docs/briefs/M1-54-chat-operators.md(pure function for the effective operator of a channel),docs/briefs/M1-51-agents-records.md(agent status andbilling_mode),docs/briefs/M1-28-authz-core.md(theagent.invokeguard indomain/guards.go,InvokeFacts{AgentID, TriggerOrigin, MentionsAgent, ActiveRunAgentID},Decision{Outcome, Reason}, filter mode, deferred entryrun.followup.create),docs/briefs/M1-05-server-skeleton.md(layers,kernel/ids,kernel/errs). - Dependencies: the header names M1-56 and M2-02, both minimal. Through them the closure contains
M1-28, M1-51, M1-54 and M1-55, which this brief reads but does not repeat in the header. M2-02
owns the API of the channel switches (read its brief in
docs/briefs/, its plan row and the schemaChannelBehavior); the columns themselves exist since M1-53. - Code on the base branch (merged names win, D-W-100):
services/server/internal/runs/doc.goandREADME.md(port package only, nodomain/yet),services/server/internal/kernel/ids(ids.ID),services/server/internal/kernel/errs(errs.New,errs.CodeInternalError),services/server/architecture/modules.yaml(moduleruns),services/server/.go-arch-lint.yml(componentruns-domainmay depend only on itself andkernel-core).
Fixed requirements that apply¶
Owner's fixed build rules (apply verbatim):
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
Rules for every brief:
- Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
- No en dash and no em dash in any file you write; the
text-rulespre-commit hook rejects them. Use a hyphen or rephrase. - White label: examples and test data use
example.comand the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory. - Limits, windows and thresholds only through named configuration keys listed in
docs/operations/configuration.md; code comments and docs name the key, never the number. - Specification first: a change to behaviour, API, event, permission or configuration updates
docs/spec/ordocs/api/in the same change. The core contract (docs/spec/00-glossary.md,03-data-model.md,04-sync-protocol.md,05-actions.md) is not edited by this ticket.
Product requirements implemented here:
- Only a message with
origin = 'human'triggers; agents, apps, imports and system lines never do, and DM channels have no agents (09 3.2, 3.4 steps 1 and 2; 07 SR4; 05 section 5 points 1 and 2). - The operator of a thread (
kind = 'thread') is its fixedthreads.operator_agent_id; the operator of the main thread is the channel's effective operator at trigger time; without an operator no agent answers unless an allowed guest is mentioned (09 3.1, R-DM-12, R-DM-21, R-AG-02). - Mentioned agents E: more than one gives
multiple_agents_mentioned; exactly one is the candidate if it is the operator or the guest rule holds, elseagent_not_allowed_here; none makes the operator the candidate only iffree_response, or main thread and notrequire_mention, or thread and notthread_require_mention; otherwise nothing happens (09 3.4 step 3, 03channels"Mention rule", R-AG-03). - Guest rule: a mentioned agent that is not the operator is a guest only if
allow_mentioned_agentsis set, it has at leastwriteon the channel itself and itsbilling_modeisorganization; a guest run has no agent session (09 3.6, 05 section 5 point 3, R-AZ-19, R-AG-04, R-AG-22). - Without effective
triggeron the channel the author getsno_trigger_rightonly for an explicit mention; otherwise nothing happens (09 3.4 step 4, R-AG-05). - Target: in the main thread with
auto_threadthe target is a new thread withparent_message_id = M,operator_agent_id= effective operator of the channel,title = '',title_source = 'auto';trigger_statestays on M (09 3.4 step 5, 3.7). Otherwise the target is the thread of M. - Active run in the target: of the candidate, M becomes a follow-up; of another agent,
run_active_other_agent(09 3.4 step 6, 09 7, 05 section 5 point 8, R-AZ-20, R-DM-14). - The
agent.invokecheck runs after the decision;allowgivestrigger_state = received, a denial givestrigger_state = failedwith the reason; a rejection at acceptance creates no run (09 3.4 step 7, 09 6, 05 section 5 "Consequences on denial"). - A voice message without a transcription path is rejected at acceptance with
voice_unsupported(09 13, 12 8.2, R-AG-16). - A model command during an active run is rejected with
run_active; a control command goes to the agent at once and is never a follow-up (09 10.4, 09 7 last sentence, 05 section 5 point 8, D-AG-12). - Every row of the rejection table 09 3.5 has a test case, and AG-01 to AG-06 and the guest rule are table tests (PLAN row M2-04).
- The decision is a pure function in
internal/runs/domain: no I/O, noauthorize, no clock (PLAN row M2-04; 02 5 "Layers within a module", row "Domain").
Scope¶
All paths under services/server/ unless noted. This ticket creates no migration, enables no
operation, appends no event, schedules no job, records no metric and adds no configuration key.
Create:
internal/runs/domain/doc.go: package comment of the domain layer ofruns(what lives here, that it imports onlykernel-core).internal/runs/domain/trigger.go: value types andDecide(design notes 2 to 6).internal/runs/domain/trigger_command.go:DecideCommand(design note 7).internal/runs/domain/trigger_settle.go:Verdict,SettlementandSettle(design note 8).internal/runs/domain/trigger_reasons.go: the typeReasonwith one constant per code of 09 3.5, and the constants ofOutcome,Step,Check,TranscriptSource.- Tests (design of each in "Tests"):
internal/runs/domain/trigger_test.go,trigger_matrix_test.go,trigger_command_test.go,trigger_settle_test.go,trigger_spec_test.go,trigger_fuzz_test.go.
Change:
internal/runs/README.md(English, like the file): status line, section "Code" (domain/now exists), a new section "Trigger decision" with the decision table of design note 3, the input table of design note 2 (which module or port supplies each fact, for M2-05), the mapping table of design note 8 and the rulings of design note 12.services/server/CHANGELOG.md: entry under "Unreleased", "Added".docs/spec/09-agents.md(not core contract): in 3.4 add the position of the voice rule (design note 6) and, after step 7, the mapping ofauthorizedeny reasons totrigger_state_reason(design note 8); in 3.5 a sentence that the codes of the table are the values ofmessages.trigger_state_reasonand how other deny reasons appear. English, like the chapter.docs/DECISIONS.md, section 10: rowsD-M2-04-1toD-M2-04-5(design note 12).
No new top-level Makefile target.
Out of scope¶
- Applying the decision on
messaging.MessagePosted: gathering the facts fromchat,agents,authzandruns, creating the auto thread through thechatport, writingtrigger_state,trigger_state_reasonandtrigger_state_atthroughmessaging(SetTriggerState),message.updated, the runqueuedand the live box anchor (04 15.3 point 1): M2-05, the first ticket withrunstables and the mapping totrigger_state. - The
agent.invokeguard itself and its audit: M1-28 (built); the requirement ofrun.followup.create, which M1-28 keeps deferred, and therun_followupsrows withsteerandqueue: M2-11. - Delivery, invocation token, the re-check before each delivery (I2),
trigger_state = queuedand the metricbothy.agent.invocation.denied: M2-06 and M2-08. - Agent sessions, including the end of the main-thread session with
agent_changedafter an operator change: M2-12. - Command registration, visibility,
required_level, thecommand.invokerequirement inauthzand the HTTP answer forrun_active: M2-16; app commands withoutagent.invoke: M2-25. - Voice messages (
MessageCreate.kind = voice), transcripts and the hold of an invocation until the transcript or a scan is final (ReleaseInvocation): M3-09 and M3-07. - Agent replies (
origin = agent) end to end and AG-26: M2-19; incoming webhooks and AZ-M53: M2-27; AZ-M30 against the real server: M2-08. - Channel behaviour storage and its API (
getChannelBehavior,putChannelBehavior): M2-02. Project binding readiness (project_not_readypasses throughSettle): M2-03. - Interactions (05 section 5 point 6): M2-15. Auto title: M2-18.
- Running AG-01 to AG-06 against the real server: M2-31.
- Changes to the core contract.
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M2-04-trigger-decision; 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).
- This ticket has unit tests only: no listener, no container, no PostgreSQL, no network. The one file a test reads is
docs/spec/09-agents.mdin the working tree. - Production code in
internal/runs/domainimports only standard library packages without I/O andkernel-core(kernel/ids,kernel/errs);make archenforces the layer. Nocontext.Context, no logger, no clock, no randomness in production code of this package. - No real model call and no third-party service; nothing in this ticket talks to an agent or adapter.
- 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>.
Design notes¶
-
Purity and place. The decision lives in
internal/runs/domain(02 4.2:runsowns "trigger decision"; 02 5: the domain layer holds pure functions and imports onlykernel-core). Its input types are value types of this package that mirror the data other modules hand over through their ports (02 5, row "Domain": no foreign module). The functions are deterministic, never mutate their input (copy slices before sorting or deduplicating) and never callauthorize: every permission fact is an input that the caller gathers fromauthz(D-B7, ADR 0017). The operator rule and the guest rule therefore appear twice by design: here they choose the candidate and the reason the person sees (09 3.4), and theagent.invokeguard of M1-28 re-checks them under the permission snapshot (05 section 5 point 3). When the two disagree (a concurrent change),authzwins throughSettle(note 8). -
Inputs. Signatures (names may follow the merged code, D-W-100; the fields are binding):
type Origin string // "human", "agent", "app", "import", "system" (03 messages.origin)
type ChannelKind string // "text", "forum", "dm" (03 channels.kind)
type ThreadKind string // "main", "thread" (03 threads.kind)
type BillingMode string // "personal", "organization" (03 agents.billing_mode, the target)
type VoiceMode string // "off", "agent", "service" (voice.transcription_mode, 12 8.2)
// AgentFacts are the facts about one agent that the decision needs.
type AgentFacts struct {
ID ids.ID
Active bool // principals.status = 'active'
BillingMode BillingMode // agents.billing_mode
WriteOnChannel bool // own effective level on the channel is at least write
VoiceInput bool // effective feature voice_input (09 2.2, R-AG-01)
}
type Switches struct { // 03 channels; history_backfill_limit has no influence here
RequireMention, ThreadRequireMention, FreeResponse, AutoThread, AllowMentionedAgents bool
}
type ActiveRun struct{ RunID, AgentID ids.ID } // a run in queued, running or waiting_input
type MessageTrigger struct {
MessageID, ThreadID, ChannelID ids.ID
Origin Origin
Voice bool // messages.kind = 'voice'
ChannelKind ChannelKind
ThreadKind ThreadKind
ThreadOperator *AgentFacts // threads.operator_agent_id; nil when NULL; main: always nil
ChannelOperator *AgentFacts // effective operator of the channel now; nil when none
Switches Switches
Mentioned []AgentFacts // resolved agent mentions of M; people are not in it
AuthorHasTrigger bool // the author has effective trigger on the channel
ActiveRun *ActiveRun // active run of the thread of M; nil when none
VoiceMode VoiceMode
}
Where each fact comes from (write this table into the README for M2-05): Origin, MessageID,
ThreadID, ChannelID, ThreadKind, Voice and the mention IDs from messaging.MessagePosted
(M1-56); M1-56 resolves a mention only to an active principal the author may see (07 4.6), so a
disabled agent is never in Mentioned. ChannelKind and ThreadOperator from
chat.Threads.Info (M1-55). ChannelOperator from the effective-operator function of chat
(M1-54; 03 channels "Effective operator"). Switches from the channel behaviour (M2-02;
schema ChannelBehavior). Active and BillingMode from agents (M1-51). AuthorHasTrigger
and WriteOnChannel from authz (07 2.3, 07 2.9; WriteOnChannel is the effective level of
05 section 5 point 3, "at least write", which includes the operator rule; 09 2.3 says
"grant with write" for the usual guest, and the core contract wins). VoiceInput from
agents (owner permission supports_voice and adapter report
voice_input). ActiveRun from runs (M2-05). VoiceMode from the configuration key
voice.transcription_mode.
Decide(in MessageTrigger) (Decision, error): the decision table, evaluated in this order; the first row that decides wins, andDecision.Stepnames it:
| Step | Condition | Outcome | Reason |
|---|---|---|---|
| validate | input invalid (note 10) | error, zero Decision |
|
origin |
Origin is not human |
none |
|
direct_message |
ChannelKind is dm |
none |
|
mentions |
E (distinct IDs of Mentioned) has more than one element |
reject |
multiple_agents_mentioned |
mentions |
E = {A}, A is the operator of the thread (09 3.1) | candidate A, mentioned | |
mentions |
E = {A}, A is not the operator and the guest rule holds (note 5) | candidate A, mentioned, guest | |
mentions |
E = {A}, otherwise | reject |
agent_not_allowed_here |
mentions |
E empty, an operator exists and (FreeResponse or (main and not RequireMention) or (thread and not ThreadRequireMention)) |
candidate operator, not mentioned | |
mentions |
E empty, otherwise | none |
|
trigger_right |
not AuthorHasTrigger and the candidate was mentioned |
reject |
no_trigger_right |
trigger_right |
not AuthorHasTrigger and the candidate was not mentioned |
none |
|
voice |
Voice and no transcription path (note 6) |
reject |
voice_unsupported |
| target | main thread and AutoThread: target is a new thread (note 4); otherwise the thread of M |
||
active_run |
target is the thread of M, ActiveRun set, ActiveRun.AgentID is the candidate |
followup, RunID set |
|
active_run |
target is the thread of M, ActiveRun set, another agent |
reject |
run_active_other_agent |
invoke_check |
otherwise | invoke |
Step takes the values origin, direct_message, mentions, trigger_right, voice,
active_run, invoke_check and, in DecideCommand, command; the rows "validate" and
"target" decide no outcome of their own.
The operator of the thread is ThreadOperator for kind = 'thread' and ChannelOperator for
the main thread (09 3.1); a later operator change of the channel never changes the operator of
an existing thread (R-DM-12). A new thread has no active run, so step active_run does not
apply to it. A message without trigger stays a normal message even while a run is active (09
7, first paragraph).
Decision(output; every field documented in code):
type Decision struct {
Outcome Outcome // none, reject, invoke, followup, control
Step Step // the row of the table that decided
Reason Reason // only with OutcomeReject
TriggerKind string // "message" or "command" (03 runs.trigger_kind)
TriggerMessageID ids.ID // M; carries trigger_state, also with a new thread (3.7)
CommandID ids.ID // DecideCommand only
Agent *AgentFacts // the candidate: invoke, followup, control
Guest bool // guest run without agent session (3.6, R-AG-04)
Mentioned bool // the candidate was mentioned explicitly
NewThread *NewThread // target is a new thread; nil: the thread of M
RunID ids.ID // followup: the active run
Check Check // "" or CheckInvoke or CheckFollowUp
Transcript TranscriptSource // voice only: "agent" or "service"
}
type NewThread struct { // 09 3.7
ChannelID ids.ID
ParentMessageID ids.ID // = M
OperatorAgentID ids.ID // effective operator of the channel; zero when none
Title string // always ""
TitleSource string // always "auto"
}
Check tells the caller which authorization runs next: CheckInvoke (agent.invoke) for
invoke and control, CheckFollowUp (run.followup.create, "write and agent.invoke") for
followup, empty for none and reject. The domain keeps no action names of the catalogue;
the app layer maps Check to the action constants. Every outcome that can lead to a run or a
delivery carries a check, so no path of this package invokes an agent without agent.invoke
(T-AI-08; M2 "Red team": personal subscription triggered by another person over every path).
-
Guest rule (09 3.6, 05 section 5 point 3): a mentioned agent A that is not the operator of the thread is a guest candidate if and only if
AllowMentionedAgents,A.WriteOnChannelandA.BillingMode == organization(R-AZ-19, R-AG-22: a personal agent is never a guest, because I1 does not count guest channels among its targets, 03 6.4 "I1 check"). An operator who is mentioned is never a guest, even when the guest conditions also hold. The channel's current operator mentioned in a thread whose fixed operator is another agent is not the operator of that thread, so the guest rule decides for it (R-DM-12). After a guest run ends, a message without mention triggers the operator again (09 3.6 last sentence), which the table yields because a guest is chosen only by mention.A.Activeis not part of the guest rule (09 3.4 checks activity in step 7); note 8 turns a denial for an inactive candidate intoagent_disabled. -
Voice rule (09 13; Ruling for docs/DECISIONS.md,
D-M2-04-2). 13 says "Rejection at acceptance" but 3.4 does not place it. It runs after steptrigger_rightand before the target is chosen, because the transcription path depends on the candidate (voice_inputof the agent in modeagent) and because a message that cannot be understood must create neither an auto thread nor a follow-up. Transcription path:VoiceMode == service, orVoiceMode == agentand the candidate'sVoiceInput;offhas none. With a path,Transcriptisserviceoragent(M3-09 and M3-07 use it for the hold of 04 15.3 point 1). A voice message that would not trigger at all staysnone. -
DecideCommand(in CommandTrigger) (Decision, error)for commands owned by an agent (09 10.4):
type CommandTrigger struct {
CommandID, ThreadID ids.ID
Owner AgentFacts // the agent that registered the command
Control bool // commands.control
ActiveRun *ActiveRun // active run of the thread; nil when none
}
Control true: outcome control with CheckInvoke, also during a run, never a follow-up (09
7 last sentence, 05 section 5 point 8). Control false and ActiveRun set (any agent):
reject with run_active. Otherwise invoke with TriggerKind = "command" and CommandID
set (03 runs.triggering_command_id). App commands are not decided here (05 section 5, last
paragraph before "Consequences on allow"; M2-25).
Settle(d Decision, v Verdict) (Settlement, error)(09 3.4 step 7, 09 6; Ruling for docs/DECISIONS.md,D-M2-04-3):
type Verdict struct { Allowed bool; Reason string } // outcome of the check of note 4
type Settlement struct {
Accepted bool
State TriggerState // "received", "failed", or "" (stays NULL; also for commands)
Reason Reason
}
For none and reject the verdict is ignored: none gives State "", reject gives
failed with its reason. For invoke, followup and control: Allowed gives Accepted
and, for message decisions, received. A denial gives failed (message decisions) and the
reason by this table, because the closed deny list of 07 4.1 (kernel/auditing.Reason, M1-24)
has no agent_disabled and the M1-28 guard answers agent_not_reachable for an inactive
agent, while 09 3.5 shows the person "agent disabled":
Deny reason from authorize (07 4.1) |
trigger_state_reason |
|---|---|
agent_not_reachable, candidate Active false |
agent_disabled |
agent_not_reachable, candidate Active true |
agent_not_allowed_here |
level_insufficient, no_grant |
no_trigger_right |
every other reason (billing_mode_personal_not_holder, project_not_ready, run_active_other_agent, tool_class_owner_only, dev_environment_not_ready, channel_read_only, ...) |
unchanged |
A denial with an empty reason is an internal error (errs.CodeInternalError). The audit of the
denial is written by authz (05 2.4, denials); Settle only names the reason the person sees.
-
Descriptor of the acceptance check (Ruling for docs/DECISIONS.md,
D-M2-04-4). The caller builds theagent.invokeresource on the thread of M (also when the target is a new thread) withInvokeFacts.AgentID= the candidate,TriggerOrigin = human,MentionsAgent = Decision.MentionedandActiveRunAgentIDzero for a new thread. Reason: 05 section 5 point 3 names "the effective operator of the channel in the main thread and for new threads", and the mention rule that chose the candidate is the one of the main thread. What the re-check before each delivery uses (04 15.3 point 2) belongs to M2-08 and is not decided here; do not change the M1-28 guard in this ticket. -
Invalid input (Ruling for docs/DECISIONS.md,
D-M2-04-5).DecideandDecideCommandvalidate first and return an error that wrapserrs.CodeInternalErrorwith the zeroDecision(outcomenone, no check) for: an unknown value ofOrigin,ChannelKind,ThreadKind,BillingModeorVoiceMode;ThreadOperatorset on a main thread (R-DM-21); a main thread in a forum channel (R-DM-11); an operator on a DM channel (03channelschecks); a zero ID inMessageID,ThreadID,ChannelID, a mentioned agent, an operator orActiveRun. Fail closed (09 1 point 4): a broken input never yieldsinvoke,followuporcontrol. The error text names the field, never a value from a client. -
trigger_statesemantics.nonekeepstrigger_stateNULL, so the checktrigger_state IS NULL OR origin = 'human'of 03messagesholds for every non-human message;rejectand a denied check givefailedwith a reason, and only then is a reason set (trigger_state_reason IS NULL OR trigger_state IN ('failed','cancelled')); an allowed check givesreceived(09 6, 04 15.3 point 1). This package never yieldsqueued,working,done,cancelledorfollowup_delivered(M2-08, M2-09, M2-11). -
Rulings for
docs/DECISIONS.mdsection 10, one row each with reason and place:D-M2-04-1the decision is split intoDecide/DecideCommand(steps 1 to 6 of 09 3.4, 13 and 10.4) andSettle(step 7), and the domain never callsauthorize(note 1);D-M2-04-2position of the voice rule (note 6);D-M2-04-3mapping of deny reasons (note 8);D-M2-04-4descriptor of the acceptance check (note 9);D-M2-04-5invalid input (note 10). Rulings 2 and 3 also go into 09 3.4 and 3.5 (scope, "Change").
Tests (write them first)¶
Unit tests in internal/runs/domain (PLAN 5, row "Unit"). The labels below come from the world of
07 10 "Examples": space Support with operator Lotse (organization), Texter (organization, with
write on Support/Allgemein in the guest cases), Assistent (personal, with write on
Support/Allgemein in the guest cases); Alex has trigger on Support, Sam only write. In a pure
test they are facts, not database rows. Test IDs from 09 16.2 stand in the test names.
TestDecide_AG01_NonHumanOriginNeverTriggers(AG-01: "Message withoriginagent,app,import,systemin a free-response channel" gives no run): for each of the four origins in a channel withfree_response, operator Lotse present, author withtrigger, once without mention, once with Lotse mentioned and once with two agents mentioned: outcomenone, steporigin, no reason, no check. Mirrors AZ-M30 (Lotse mentions Texter withorigin = agent) and AZ-M53 (app origin,free_response) at decision level.TestDecide_DirectMessageNeverTriggers: DM channel, human origin, agent mentioned:none, stepdirect_message.TestDecide_AG02_SwitchMatrix(AG-02: switches of 3.3 x main/thread x with/without mention give exactly the candidates of 3.4), intrigger_matrix_test.go: every combination ofRequireMention,ThreadRequireMention,FreeResponse,AutoThread,AllowMentionedAgentsx main thread and thread x the mention variants none, the operator, an eligible guest, a guest withoutwrite, apersonalguest withwrite, and two agents; author withtrigger, no active run. The expected result comes from an oracle written in the test from the 03channels"Mention rule" formula and the guest rule of 05 section 5 point 3 as plain boolean expressions (no call into production helpers): candidate,Guest,Mentioned, reason, and the target (new thread exactly for main thread withAutoThread).TestDecide_AG03_MultipleAgentsMentioned(AG-03:failed,multiple_agents_mentioned): two agents including the operator; two guests; an author withouttriggerstill getsmultiple_agents_mentioned(step 3 before step 4); the same agent twice inMentionedcounts once and is not a rejection.TestDecide_AG04_GuestNotAllowed(AG-04: guest withallow_mentioned_agents = falseor withoutwritegivesagent_not_allowed_here): both cases, plus Assistent (personal) withwriteand guests allowed (R-AG-22), plus the channel's current operator mentioned in a thread whose fixed operator is another agent with guests not allowed (R-DM-12); an author withouttriggermentioning a not allowed agent getsagent_not_allowed_here(step 3 before step 4).TestDecide_AG05_AutoThread(AG-05: new thread, run there,trigger_stateon M): main thread withAutoThread:invoke,NewThread{ChannelID, ParentMessageID: M, OperatorAgentID: channel operator, Title: "", TitleSource: "auto"},TriggerMessageID == M; without operator and a guest mentioned:NewThread.OperatorAgentIDzero,Guesttrue; main thread withoutAutoThread: target is the thread of M; a thread (kind = thread) withAutoThread: target is the thread of M; main thread withAutoThreadand an active run of another agent in the main thread: stillinvokeinto the new thread.TestDecide_AG06_ActiveRunOtherAgent(AG-06: guest mentioned during the operator's run givesrun_active_other_agent): that case; the operator as candidate during a guest run gives the same; the operator during its own run givesfollowupwithRunIDandCheckFollowUp; a guest mentioned during its own guest run givesfollowup.TestDecide_GuestRule(the guest rule as table test, PLAN row): every combination ofAllowMentionedAgentsxWriteOnChannelxBillingModex "is the operator": a guest exactly for allowed,write,organization, not operator;Guestfalse for the operator even when the guest conditions hold; after a guest run (no active run) a message without mention in a thread withoutThreadRequireMentionchooses the operator.TestDecide_TriggerRight(R-AG-05): mention withouttriggergivesno_trigger_right; no mention withFreeResponseand withouttriggergivesnone; withouttriggerand with an active run of the operator givesnone, not a follow-up (09 7).TestDecide_OperatorRules(09 3.1): a thread uses its fixed operator and ignores a differentChannelOperator; the main thread usesChannelOperator; a thread without operator and without mention in aFreeResponsechannel givesnone.TestDecide_Voice(09 13):offgivesvoice_unsupported;agentwith candidateVoiceInputgivesinvokewithTranscript = agent;agentwithout it givesvoice_unsupported;servicegivesTranscript = service; a voice message without trigger staysnone; a voice message that would be a follow-up and has no path givesvoice_unsupported.TestDecide_InvalidInput(note 10): each listed case returns an error whose public code isinternal_errorand a decision with outcomenoneand no check.TestDecideCommand(09 10.4): control command during a run of the same and of another agent givescontrolwithCheckInvoke; model command during any active run givesrun_active; model command without run givesinvoke,TriggerKind = "command",CommandIDset.TestSettle(note 8): allow givesreceivedandAccepted; each row of the mapping table; passthrough ofbilling_mode_personal_not_holder,project_not_ready,run_active_other_agent,tool_class_owner_only;noneandrejectignore the verdict; command decisions giveState ""; a denial with empty reason gives an error.TestRejectionTableCoversSpec(PLAN row: every row of the rejection table in 09 has a test case), intrigger_spec_test.go: walks up from the package directory to the repository root, readsdocs/spec/09-agents.md, takes the first cell of every row under the heading "3.5 Reasons for rejection at acceptance" and asserts that (a) the set equals the set ofReasonconstants of this package and (b) each code has a named case in a map in the test that produces it throughDecide,DecideCommandorSettle:multiple_agents_mentioned,agent_not_allowed_here,no_trigger_right,run_active_other_agent(fromDecide),agent_disabled,billing_mode_personal_not_holder,project_not_ready(fromSettle),voice_unsupported(fromDecide),run_active(fromDecideCommand). A row added to 3.5 later fails this test until it has a case.FuzzDecide(seed corpus from the table cases; runs ingo test): builds aMessageTriggerfrom the fuzz input, skips inputs for whichDecidereturns an error, and asserts: non-human origin or DM givesnone; a reason is set exactly withrejectand is one of the 3.5 codes;invokeandfollowupalways carry a check; aninvokeorfollowupcandidate is the operator of the thread or the only mentioned agent; a guest isorganization, hasWriteOnChanneland the channel allows guests;NewThreadonly for main thread withAutoThread;followuponly when the active run belongs to the candidate;Settlewith a denial never yieldsreceived; two calls with the same input give equal results and the input is unchanged.
No integration, contract or end-to-end test: this ticket has no I/O. M2-05 adds the PostgreSQL tests when it applies the decision, and M2-31 runs AG-01 to AG-06 against the real server.
Commands that must pass, from the repository root:
cd services/server && go test -race -count=1 ./internal/runs/...make arch,make lint,make docsmake check
Acceptance criteria¶
-
internal/runs/domainexists withDecide,DecideCommandandSettle, a doc comment on every public symbol, and imports only the standard library andkernel-core;make archandmake lintpass. - AG-01 to AG-06 pass as table tests (
TestDecide_AG01_*toTestDecide_AG06_*), the AG-02 matrix covers every switch combination of 3.3 for main thread and thread, with and without mention. - The guest rule passes as a table test, including "a
personalagent is never a guest" and "a mentioned operator is never a guest". - Every row of the rejection table in 09 3.5 has a test case;
TestRejectionTableCoversSpecreads the table fromdocs/spec/09-agents.md. -
Settlemaps deny reasons as in design note 8; invalid input never yields an invocation. -
FuzzDecideseed corpus passes ingo test. - The runs README holds the decision table, the input sources and the mapping; 09 3.4 and 3.5
name the voice position and the mapping;
D-M2-04-1toD-M2-04-5are indocs/DECISIONS.md; the CHANGELOG has the entry. -
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.
- README of
runsupdated (status, code layout, the decision table of note 3, the input sources of note 2, the mapping of note 8). - Rulings of note 12 recorded in
docs/DECISIONS.md(one row each with reason); rulings 2 and 3 also indocs/spec/09-agents.md. 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>.