M2-08: Delivery with I2, guard rules and a fresh token¶
Status: ready | Depends on: M2-06, M2-07 | Milestone: M2
Goal¶
A message of a human that triggers an agent (09 'Decision per message') is accepted in its own transaction: run queued, live-box anchor, trigger_state = received and the personal event agent.invocation without a token. Immediately before every hand-over of an invocation-bearing event (agent.invocation, agent.followup, interaction.created, command.invoked) to an agent, over the gateway or the REST poll, the port runs.Delivery checks agent.invoke for the triggering human with the permissions of that moment, including I2 (personal subscription only for its holder), SR2 (tool class host only for owners) and SR4 (only humans trigger). A denial delivers nothing, fails a queued run with a reason, sets trigger_state = failed and writes one audit entry; an allow issues a fresh EdDSA invocation token for exactly this delivery, records its jti where the run may start with it, and moves trigger_state from received to queued on the first successful delivery. A run nobody starts within run.queued_max_age fails with agent_unavailable. This is invariant I2 "before every invocation" (10, PLAN 18.2), R-SY-07 (a fresh token per delivery) and the server half of ADR 0023: no adapter ever receives an invocation that the current permissions would refuse.
Read first¶
docs/PLAN.md: section 8 "M2 Agent platform, extensions and terminal" (Goal, Scope, Out of scope, Exit criteria 1 to 3 and 6, Test strategy, Red team, Risks), the rows M2-02 to M2-07, M2-09, M2-11 to M2-17, M2-19, M2-20, M2-25, M2-26, M2-29, M2-31, M2-36, M3-07, V2-02 and M7-17 (who builds what next to this ticket), section 18.2 row "I2 before every invocation", "Way of working per ticket".- Dependencies: M2-06 (token issuer) and M2-07 (agent consumers) are in the header; implied through them and therefore not in the header: M2-05 (runs, state machine,
trigger_statemapping), M2-04 (trigger decision function), M2-02 (channel behaviour), M1-65 (gateway hub), M1-56 (MessagePosted), M1-54 (operators), M1-51 (agent records), M1-32 (signing keys), M1-28 (authorize), M1-24 (audit), M1-16 (event log), M1-14 (jobs), M1-13 (unit of work). M1-68 (metric declaration pattern) is in the closure as well, through M2-02. Not in the closure but used if merged: M2-03 (project binding, for AZ-M19). docs/spec/09-agents.md: 1 "Principles" (points 3 and 4), 2.4 "Tool class", 3.1 "Operator", 3.2 "Origin (messages.origin)", 3.4 "Decision per message", 3.5 "Reasons for rejection at acceptance", 3.6 "Guest agents", 3.7 "Auto-thread from a channel message", 4.2 "Transports", 4.3 "Connection and cursor", 4.4 "Events to the adapter", 4.5 "Invocation token" (claims, "Check in the adapter", "Keys and rotation"), 4.5.1 "Fresh token for a waiting run" (point 4 names the same checks as before a delivery), 5.1 "State machine", 5.2 "Reasons (runs.state_reason)", 5.4 "Live box content" (anchor), 5.5 "Stall, interruption, orphaned runs", 6 "Trigger state (trigger_state)", 7 "Follow-ups during a run", 15 "Reliability", 16.2 "Required scenarios" (AG-05, AG-07, AG-09, AG-27), 19 (R-AG-09, R-AG-23, R-AG-24), 21 "Configuration values of this chapter".docs/spec/04-sync-protocol.md: 2.2 "Envelope" (delivery fieldsinvocation_token,redelivered; payload rules 1, 3 and 4), 7.1 "Cursor", 7.4 "REST variant", 8.1 "Basic rule", 8.3 "What the client receives and does on access removal" (point 5), 9 "Delivery guarantee" (point 3:agent.invocation,agent.followupandcommand.invokedcarry noresource.version), 15.1 to 15.4 and 15.6, 16.5 "Agent run with follow-up", 17 (R-SY-07, R-SY-08, R-SY-10, R-SY-11, R-SY-17, R-SY-19).docs/spec/05-actions.md: 1 rules 1, 4, 6 (bullet "I2 is part ofagent.invoke") and 8; 2.3 "Effective level" (point 1: owner permissions only in a session, with the exception of R-AZ-29); 3 "Rate-limit classes"; 4.10 rowsagent.invokeandrun.token.renew; 5 "Agent invocationagent.invoke" (points 1 to 8, the paragraph "Check without a request", claims, "Consequences on denial"); 6 rowsagent.invocation,agent.followup,interaction.created,command.invoked,run.state_changed,message.updated; 8 (R-AZ-04, R-AZ-14, R-AZ-18, R-AZ-19, R-AZ-20, R-AZ-29); 9 test 6.docs/spec/07-permissions.md: 2.7 "Owner", 2.9 "Agents as principals", 2.11 "Tool class of an agent", 3 "Principal context", 4.1 "Decisions and reasons", 4.2 "Flow" (guards, filter mode, audit paragraph), 5 "SR1 Backend policy (I1, I2)", "SR2 Tool class", "SR3 Dev isolation" (V2), "SR4 Only humans trigger", "SR5 Administration only by humans with confirmation" (the exception of R-AZ-29), "SR7 Disabled means no permissions", 10 "Examples" (the test world), 11.1 rows AZ-M19, AZ-M20, AZ-M30, AZ-M51; 11.2 AZ-P10, AZ-P13.docs/spec/10-backend-policy.md: 3 "Invariants" (I2, I3), 4 "Overview of check points", 6 "Dispatch check (I2)", 9.2 "What happens when switching" (point 4), 14 "Audit trail" (row "I2 rejection"), 15 "Tests" (BR-04).docs/spec/03-data-model.md(core contract, read only):runs(invocation_jti,renewed_from_jti,billing_mode_expected,state_reason,version,queued_at, the partial unique index of R-DM-14, the transitions),messages(origin,kind = 'run',trigger_state,trigger_state_reason,silent, checks),threads(parent_message_id,operator_agent_id),agents(billing_mode,credential_holder_id,tool_class,reported_tool_class),event_log(audience_principal_id,receive_action,correlation_id).docs/spec/02-architecture.md: 4.1 point 4 ("Source port", "In-tx event"), 4.3 "Ports, events and sources per module" (runs:Delivery), 4.4 "Allowed dependencies", 4.6 (agent.invokebelongs toruns), 4.7 (the note thatmessage.updatedfortrigger_stateis produced bymessaging), 10 "Gateway" (last paragraph), 11.4 "Hub and delivery", 12.3 "Job catalogue".docs/api/openapi.yaml: operationscreateMessage,createThread,pollAgentEvents,getJwks; schemasAgentInvocationPayload,AgentInvocationEvent,AgentFollowupPayload,AgentFollowupEvent,InteractionCreatedPayload,CommandInvokedPayload,RunCancelRequestedPayload(reasonuser_disabled),RunStateChangedPayload,RunSummary,Run,EventEnvelope(invocation_token,redelivered),EventPage(next_cursor),Message,TriggerState,PrincipalRef.docs/api/asyncapi.yaml: messagesevent.agent.invocation,event.agent.followup,event.interaction.created,event.command.invoked(x-receive-action: agent.events.consume,x-audience: self).- ADRs 0017, 0021, 0022, 0023, 0024, 0025, 0052, 0053, 0066, 0067.
docs/DECISIONS.md: D-L22, D-L23, D-L24, D-AG-16, D-W-15, D-W-16, D-W-44, D-W-100; section 10 (format of the rows you add).docs/OPEN-QUESTIONS.md: OQ-01 (default: strict I1 without owner exception; the I2 tests assume it).docs/security/threat-model.md: T-AI-02, T-AI-07, T-AI-08, T-TK-05, T-AZ-03, T-AZ-04.docs/RISKS.md: RK-09, RK-13.docs/observability/signals.md:bothy.agent.invocation.denied,bothy.run.start.latency,bothy.run.finished, spanagent.dispatch, 5.1 point 5 (fan-out traces link the request).docs/operations/configuration.md:agent.invocation_token_ttl,agent.ack_timeout,agent.max_inflight_events,run.queued_max_age,auth.clock_leeway,sync.event_payload_max_bytes.- M1 briefs: M1-13 (unit of work,
txn.Subscribe,txn.Publish, nested publish), M1-14 (jobs.Inserter.InsertTxwithInsertOptions.ScheduledAt,jobs.Register), M1-16 (eventlog.Event,eventlog.Appender,sync.DeliveryFields), M1-24 (auditing.Recorder.Record,auditing.Entry,audit_details.v1), M1-25 (authorize.Registry.Event), M1-28 (Design notes "Public contract",InvokeFacts, "Guards" foragent.invoke,Filter(ctx),WithPermissionVersion,instance.Versions, owner only with a session, snapshot cache key), M1-32 (Keys.Signerfor purposeinvocation), M1-51 (AgentPolicySource,AgentPolicy), M1-55 (Threads.Create,Info,Get, thread row lock inAppendToThread), M1-56 (MessagePosted), M1-64 and M1-65 (gateway test client, hub,authorize.Delivery(ctx), which is a different thing from the portruns.Delivery), M1-10 (internal/platform/otel), M1-68 (metric declaration pattern). - Code (merged names win, D-W-100):
services/server/architecture/modules.yamlonmain(runsmay useinstance,chat,agents,messaging,billing;agentsmay useidentity,authz,chat;gatewaymay usechat,agents,runs,sync,stats),internal/runs(M2-04, M2-05, M2-06),internal/agentsandinternal/gateway(M2-07),internal/messaging,internal/chat,internal/authz,internal/kernel/authorize,internal/kernel/actor,internal/kernel/auditing,internal/kernel/eventlog,internal/sync(envelope),internal/platform/jobs,internal/wire.
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:
- Immediately before every delivery of
agent.invocation,agent.followup,interaction.createdorcommand.invokedto an agent, the server runsauthorize(triggering human, agent.invoke, ...)with the permissions of that moment, including I2 (05 5 point 4), SR2 (05 5 point 5: the higher ofagents.tool_classandagents.reported_tool_class) and SR4 (05 5 point 1) (04 15.3 step 2, 15.4 point 2; 07 SR1, SR2, SR4; 10 6; 02 10). - A denial before a delivery sends nothing (fail closed); a queued run becomes
failedwith a reason, the triggering messagetrigger_state = failedwithtrigger_state_reason, and one audit entry is written (04 15.3 step 2; 05 5 "Consequences on denial"; 10 4 and 14 row "I2 rejection"; AG-07, AZ-M51, BR-04). - Every delivery that passes, a redelivery included, carries a freshly issued invocation token (JWS,
alg = EdDSA,kid,typ = agent-invocation+jwt, claims of 05 5) valid foragent.invocation_token_ttl, signed with the active key of purposeinvocation, as the delivery fieldinvocation_token; it is never stored in the event log (04 2.2, R-SY-07, R-SY-08, R-SY-17, ADR 0023, ADR 0052). runs.invocation_jtiis replaced by a delivery ofagent.invocationand, while the run waits inwaiting_input, byagent.followupandinteraction.created; deliveries whilerunningdo not replace it; every replacement setsruns.renewed_from_jti = NULL; every delivery setsruns.billing_mode_expectedto the current configured mode (03runs, 04 15.3 steps 2 and 5, 09 5.1).trigger_statemoves fromreceivedtoqueuedwith the first successful delivery ofagent.invocation(04 15.3 step 2, 09 6, R-AG-09).- Acceptance: a message of a human that triggers an agent creates in its own transaction the run
queued, the live-box anchor (kind = 'run'),trigger_state = receivedandagent.invocationaddressed to the agent without a token; withauto_threadin a main thread the run lives in a new threadT'withparent_message_id = M; a denial keeps the message without a run and setstrigger_state = failedwith its reason (09 3.4, 3.5, 3.7, 5.4; 04 15.3 step 1; 05 5). - Only messages with
origin = 'human'trigger; agents, apps, imports and system messages never do (05 5 point 1, 07 SR4, 09 3.2; AZ-M30). - A run that is still
queuedafterrun.queued_max_agefails withagent_unavailableand is never delivered afterwards (09 5.1, 5.2, 5.5, D-AG-16; AG-27). - Tool class per the core contract: SR2 compares the higher of
agents.tool_classandagents.reported_tool_class(orderchat<scoped<host); the token carries the configuredagents.tool_classin the claimtool_class(05 5 point 5 and claims; 07 2.11; 09 2.4; AZ-M19, AZ-M20). - The four events go only to the agent they address (
audience), with receive actionagent.events.consumeand withoutresource.version; payloads carry no message content, prompt, skills or project binding (05 6, 04 2.2 rules 1 and 4, 04 9 point 3, 09 4.4). - The denial counter
bothy.agent.invocation.deniedand the spanagent.dispatch(signals.md).
Scope¶
All paths under services/server/ unless they start with docs/. No migration: the tables runs (M2-05), messages (M1-56), agents and agent_consumer_cursors (M1-51, M2-07) exist. No operation is enabled in api/operations.yaml by this ticket (pollAgentEvents, getConsumerState, ackAgentEvents come with M2-07). No new top-level Makefile target.
Create:
internal/runs/delivery.go(port): the interfaceDeliveryand its types (signature in Design note 1),InvocationBearing(eventType string) bool.internal/runs/domain/delivery.go: pure rules: the delivery matrix (event type x run state, Design note 2), the reason mapping (Design note 6), the claim assembly from facts (Design note 7), the queued-age test.internal/runs/app/delivery.go: the use casePrepareand the table of trigger resolvers per event type (Design note 5) with the entries foragent.invocation(trigger kindmessage) andagent.followup.internal/runs/app/acceptance.go: the subscriberOnMessagePosted(Design note 10). If the merged code of M2-05 already contains an acceptance subscriber, a run creation function or the anchor call, extend those instead of adding second ones and list it in the report.internal/runs/app/queued_expiry.go: the jobruns.queued_expiry(Design note 11).internal/runs/adapters/postgres/queries/delivery.sql: lock of the run row, the update ofinvocation_jti,renewed_from_jti,billing_mode_expected,version; read of the active run of a thread for acceptance (if M2-05 has none).internal/runs/app/metrics.go(outbound port with a no-op default) andinternal/runs/adapters/otelmetrics/metrics.go(Design note 15).- Event payload builders in
internal/runs/domainforagent.invocationandagent.followup(the latter is called by M2-11).
Change:
internal/kernel/actor: the re-check marker on the principal type (Design note 4), with doc comment.internal/kernel/authorize:InvokeFacts.NewThread bool(Design note 5) with doc comment.internal/authz: evaluation of the re-check principal (Design note 4) and ofNewThreadin theagent.invokeguard; the snapshot cache key distinguishes the re-check principal; tests.internal/messagingport and app, each only if missing:Messages.SetTriggerState(02 4.3, writesmessage.updated), the anchor (anchorin 02 4.3: a messagekind = 'run'), and the creation of the auto threadT'throughchat.Threads.Create(messaging is the only caller ofCreate, M1-55 D1). IfcreateMessagebuilds its201answer before the subscribers run, re-read the message so that the answer carriestrigger_state(04 16.5).internal/gateway: the agent consumer send path of M2-07 callsruns.Delivery.Preparebefore it sends an invocation-bearing event (Design note 9).- The REST poll
pollAgentEventsof M2-07: the same call. If its handler lives in a module that may not importruns(02 4.4:agentsmay not), that module declares the source portDeliverySourcein its port andrunsimplements it ininternal/runs/adapters/<module>delivery(02 4.1 point 4);internal/wireconnects them. internal/wire: subscriptionmessaging.MessagePosted -> runs.OnMessagePosted(after the existing subscribers), the jobruns.queued_expiry, theDeliveryport for the gateway and the poll, event registration ofagent.invocationfor modulerunsthroughauthorize.Registry.Event; update the golden files.internal/platform/configschema:run.queued_max_ageif absent (09 21); regeneratedocs/operations/configuration.mdwithmake config-reference.docs/spec/02-architecture.md: 4.3 (callers ofruns.Delivery; the source port if you add one), 12.3 (rowruns.queued_expiry).docs/spec/09-agents.md: 5.2 (the rows of Design note 6 with statefailedat delivery, the new rowuser_disabled), 3.4 point 5 and 3.7 (Design note 10 c), 7 (Design note 3 f).docs/observability/signals.md: the value list ofbothy.reasonin the rowbothy.agent.invocation.denied.docs/architecture/seq-2-agent-run.mmd: acceptance setstrigger_state = receivedand the delivery moves it toqueued(04 15.3 steps 1 and 2; the diagram showsqueuedat acceptance today); regenerate the.svgwithmake diagrams.- READMEs of
runs,authz,messaging,gatewayand of the module that holds the REST poll;services/server/CHANGELOG.md;docs/DECISIONS.mdsection 10 rowsD-M2-08-<n>(Design note 16).
Out of scope¶
- Run start and resume with token check,
run.token.renew,run.finish,run.cancel, the jobsruns.cancel_timeoutandruns.stall_check, the cancellation of runs on revocation (R-SY-11), onagents.AgentDisabledand onchat.ChannelReadOnlySet: M2-09. M2-09 keeps AG-07, AZ-M51 and BR-04 of this ticket green (Design note 3 g). - Token issuer, claim schema, key purpose
invocation, rotation, reference verifier, AG-22: M2-06. - Consumer cursor,
ack, in-flight limit, redelivery timer,4015, the REST poll and ack operations: M2-07. - Follow-up creation at acceptance (
run_followups,steerandqueue,followup_delivered, deferred follow-ups): M2-11. Until M2-11, a message that would become a follow-up stays a normal message withouttrigger_state(Design note 10 e). - Components and interactions, their producer and the trigger resolver for
interaction.created: M2-15. Command execution, the resolver forcommand.invokedand model commands (run_active): M2-16.command.invokedandinteraction.createdfor apps withoutagent.invoke(R-AZ-18): M2-25. Autocomplete tokens (trigger_kind = autocomplete): M2-17. agent_sessionin the payload and the end of the main-thread session after an operator change: M2-12. Run context: M2-13. SR9 and the token in requests: M2-14.- Heartbeat with
reported_tool_class,reported_billing_modeand project readiness: M2-20. Project binding: M2-03. - Attachments that hold an invocation (
ReleaseInvocation,runs.invocation_hold_timeout): M3-07; voice messages andvoice_unsupported: M3. - SR3,
dev_environment_not_ready,dev_environment_foreign_thread, claimdev_environment_id: V2-02. - Fencing of agent consumers over several nodes: M7-17.
- Webhook transport to agents (04 15.6,
webhook_subscriptions.agent_id): no ticket owns it yet (reported as a plan gap); it must callruns.Deliverythe same way. - Load test
perf.invocation_delivery_p99: M2-36. Conformance suite AG-01 to AG-30 with the fake agent: M2-31. Adapter side of the token (AZ-P6): M2-29, M2-33, M2-34. - Changes to the core contract.
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M2-08-invocation-delivery; 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 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. Never log, print or persist an invocation token or a private key on the server side, not even in test output; 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 the gateway test client of M1-64 with an agent token and the REST poll of M2-07.
- Never lower a threshold, disable a gate or delete a test.
- 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-07: follow its names; where it lacks a function this brief expects from it (for example the run creation or
SetTriggerState), add it in the module that owns it and report it. - If the core contract lacks something this ticket needs, stop with
Verdict: BLOCKED <reason>instead of building a deviation (P-11). The principal of the re-check (Design note 4) is in the core contract since D-W-104: 05 section 5 "Check without a request" with 05 2.3 point 1 and 04 15.3 step 2 (R-AZ-29); build it as written.
Design notes¶
- Port
runs.Delivery(02 4.3 "delivery preparation with I2, guard rules and token"; the sequence diagramdocs/architecture/seq-2-agent-run.mmdcalls itPrepareDelivery):
// Delivery prepares the hand-over of one invocation-bearing event to one agent. Every
// transport calls Prepare immediately before it sends such an event (04 15.3 step 2).
type Delivery interface {
Prepare(ctx context.Context, in DeliveryInput) (DeliveryOutcome, error)
}
type DeliveryInput struct {
AgentID ids.ID // audience_principal_id of the event
EventID ids.ID
Seq int64
Type string // agent.invocation, agent.followup, interaction.created, command.invoked
ResourceKind string
ResourceID ids.ID
Payload json.RawMessage
CorrelationID ids.ID // event_log.correlation_id; zero when absent
Redelivered bool
}
type DeliveryOutcome struct {
Send bool // false: the event counts as checked and not admitted
InvocationToken string // set exactly when Send is true
}
The port uses only kernel types and the standard library, because sync is above runs (02 4.4). Prepare returns an error only for failures (database, signing, unknown run, a run of another agent); policy results are always an outcome. kernel/authorize.Delivery(ctx) of M1-65 is a context marker of the hub and is not used here (note 3 d).
- Delivery matrix (pure function in
runs/domain, table test). "Check" means note 3 d to h.
| Event type | queued |
running |
waiting_input |
completed, failed, cancelled |
|---|---|---|---|---|
agent.invocation |
check; allow: token, record jti; deny: run failed |
check; allow: token, jti not recorded; deny: run unchanged |
as running |
skip without check |
agent.followup |
check; allow: token, jti not recorded; deny: follow-up refused |
as queued |
check; allow: token, record jti; deny: follow-up refused |
skip without check |
interaction.created |
row of agent.followup (04 15.3 step 5); resolver from M2-15 |
|||
command.invoked |
check; jti never recorded (09 5.1 lists it under no start); resolver and the case without run from M2-16 |
Reasons: only a delivery that may start the run replaces runs.invocation_jti (09 5.1). agent.invocation records it only while queued: in waiting_input the original invocation must not resume the run without an answer of a human (D-W-44, R-AG-25); its later deliveries still carry a fresh token, and a start with it gets 409 already_started or superseded (09 4.5, M2-09). Terminal runs need no check because nothing can start (Ruling for docs/DECISIONS.md).
-
Prepare, one unit of work (txn.UnitOfWork.Do, deadlocks retried bydatabase.tx_retry_max): a. A type outsideInvocationBearingis a caller error: internal error. An invocation-bearing type or trigger kind without a registered resolver (note 5) givesSend = falseand anErrorlog naming the type, because a retry cannot help; nothing is sent (fail closed). b. Lock the run row (SELECT ... FOR UPDATE; run id fromResourceIDforagent.invocationandagent.followup, from the resolver for the other two). A missing run orruns.agent_id <> AgentIDis an internal error. c. Apply the matrix of note 2. For aqueuedrun withnow >= queued_at + run.queued_max_age: CASqueued -> failedwithagent_unavailablethrough the transition of M2-05,trigger_state = failedwithagent_unavailable, returnSend = false. No check, no token, no audit (it is not an authorization decision; 07 4.1 has no such reason). d. Read the trigger facts through the resolver (note 5). Read the current permission version once throughinstance.Versionsand pin it withauthorize.WithPermissionVersion; decide in filter mode (authorize.Filter(ctx), M1-28) with the re-check principal of note 4:Authorize(ctx, recheck(triggerID), agent.invoke, Resource{Kind: thread, ID: run.thread_id, Invoke: &InvokeFacts{AgentID: run.agent_id, TriggerOrigin, MentionsAgent, ActiveRunAgentID: run.agent_id, NewThread}}). Filter mode is used because this use case writes the one audit entry itself (note 8); a second, aggregated entry from the authz layer would count the same denial twice (Ruling for docs/DECISIONS.md). e. Denial,agent.invocationof aqueuedrun: CASqueued -> failedwith the mapped reason (note 6);Messages.SetTriggerState(triggering message, failed, reason)when the run has a triggering message; audit (note 8); counter (note 15); returnSend = false. f. Denial,agent.followup: the follow-up message getstrigger_state = failedwith the mapped reason; the run is unchanged (the run's own trigger was checked for its own invocation; another person's follow-up must not end it); M2-11 adds the state of therun_followupsrow; audit; counter;Send = false(Ruling for docs/DECISIONS.md; 09 7 gets the sentence). g. Denial ofagent.invocationwhilerunningorwaiting_input: nothing is sent, the run is unchanged, audit, counter. Ending such a run is the revocation path of M2-09 (R-SY-11). M2-09 must keep queued, undelivered runs on this path: the delivery check fails them (failed,access_revoked, AZ-M51) before the adapter handlesrun.cancel_requested. h. Allow: read the configured policy (billing_mode,tool_class) through theagentsport, issue the token through the issuer of M2-06 with the claims of note 7, then store in the same transaction:invocation_jti = jtiandrenewed_from_jti = NULLwhen the matrix says "record",billing_mode_expected= currentagents.billing_mode; raiseruns.versionby one only if one of these columns changed (03runs.version); foragent.invocationof aqueuedrun whose triggering message hastrigger_state = received,SetTriggerState(queued)(note 12). Commit, then returnSend = truewith the token. -
Principal of the re-check (core contract: 05 section 5 "Check without a request", 05 2.3 point 1 and 04 15.3 step 2, R-AZ-29; the code details below are a ruling for docs/DECISIONS.md). A delivery has no request, session, device or token of the triggering person; R-AZ-29 makes that person the principal of the check, with liveness from the principal status only and owner status from the current owner list, so a trigger that rests on owner permissions passes the re-check (AZ-M19, every
personalagent, everyhostagent). Build: the re-check uses a principal of kinduserwith the person's ID, no session, device, token,view_asoracting_for, and a re-check marker inkernel/actor(for example a fieldRecheck bool).authzevaluates it like this: liveness only from the principal status (principal_inactive,principal_pending); owner status from the current owner list without a session; the marker is allowed only with the actionagent.invokeand only with all the other fields empty, otherwise an internal error (R-AZ-29 also admitscommand.invokeandinteraction.create, which M2-25 adds, and the SR9 permissions of the triggering person, which M2-14 computes the same way); the snapshot cache key includes the marker, so no token principal of the same person ever gets the owner snapshot. Reason: acceptance already applied R-AZ-14 to the real request; the re-check re-evaluates the same human with the permissions of the moment. Residual (named in R-AZ-29): an owner who triggered with a personal API token (acceptance without owner permissions, on grants) and loses those grants before the delivery is still allowed as owner at delivery; no permission of another person is used. M2-09 (run.token.renew), M2-11 (the follow-on run of deferred follow-ups) and M2-25 (the re-check of app commands and app interactions) reuse this principal, and M2-14 computes the triggering person's permissions in SR9 the same way, all under R-AZ-29. -
Trigger facts per event type (resolver table in
runs/app; one entry per type, foragent.invocationone perruns.trigger_kind):agent.invocation,trigger_kind = message(this ticket): trigger =runs.triggered_by; triggering object =runs.triggering_message_id;TriggerOriginand the mentions from that message, read now through themessagingport; place = current thread of the run (chat.Threads);NewThread = truewhen the thread'sparent_message_idequals the triggering message (the auto thread of 09 3.7), so the guard applies the channel's main-thread mention rule "in the main thread and for new threads" (05 5 point 3; M1-28 would otherwise applythread_require_mention).agent.followup(this ticket): run =ResourceID; trigger = author of the follow-up message (AgentFollowupPayload.message_id); origin and mentions from that message;NewThread = false.agent.invocationwithtrigger_kindcommandorinteraction,interaction.created,command.invoked: M2-16 and M2-15 add their entries (05 5 point 6 for interactions). Until then such an event is never sent (note 3 a).- SR4 at delivery: guard 1 of M1-28 (principal kind
user,TriggerOrigin = human) plus liveness of the re-check principal.
-
Reason mapping at delivery (pure function; the audit keeps the exact
authorizereason, the run and the message get a code of 09 5.2; Ruling for docs/DECISIONS.md, and update 09 5.2 accordingly):
authorize reason |
runs.state_reason, trigger_state_reason, counter value |
|---|---|
billing_mode_personal_not_holder |
billing_mode_personal_not_holder |
tool_class_owner_only |
tool_class_owner_only |
project_not_ready |
project_not_ready |
channel_read_only, channel_archived |
channel_read_only |
principal_inactive |
user_disabled (02 4.3; RunCancelRequestedPayload.reason) |
agent_not_reachable and the agent is not active (agents port) |
agent_disabled |
every other reason (no_grant, level_insufficient, agent_not_reachable, origin_not_human, ...) |
access_revoked (AZ-M51) |
dev_environment_not_ready and dev_environment_foreign_thread cannot occur before V2-02, which adds their rows. In 09 5.2 add the state failed "on denial before delivery" to access_revoked, agent_disabled and channel_read_only, and add the row user_disabled (cancelled on revocation, failed at delivery).
- Claims of a delivery (05 5, 09 4.5; the issuer of M2-06 signs):
| Claim | Value |
|---|---|
iss, aud, sub |
instance.instance_id; runs.agent_id; the trigger of note 5 |
jti, iat, exp |
new UUIDv7 per delivery; now; now plus agent.invocation_token_ttl |
trigger_kind |
runs.trigger_kind for agent.invocation; followup for agent.followup; interaction for interaction.created; command for command.invoked |
run_id |
the run of the event; absent only for autocomplete (M2-17) and for a command.invoked whose payload names no run (M2-16; 05 section 5, R-AZ-25) |
message_id or interaction_id or command_id |
the triggering object of note 5 |
thread_id, channel_id, space_id |
the run's thread and its current channel and space (07 4.2 resolves the place at decision time) |
level |
trigger: an allow of agent.invoke implies effective trigger, the highest level (05 4.10, 07 2.3) |
billing_mode, tool_class |
current agents.billing_mode and agents.tool_class (configured, never the reported class) |
permission_version |
the pinned version of note 3 d |
dev_environment_id |
absent until V2-02 |
-
Audit (10 14 row "I2 rejection", 07 4.2, 19 SEC-AUD-5). One
auditing.Recorder.Recordper denial in the transaction of note 3 (notRecordDenial: the transaction commits): actionagent.invoke, decisiondeny, reason = the exactauthorizereason, resourcethreadwith the run's thread, attributesrun_id,agent_id,stage(delivery),event_type,trigger_kind. Actor in the context: the re-check principal (no device, no session); correlation ID: the event'scorrelation_id. Nothing else, never a token or ajti. Allowed deliveries write no audit entry (05: Auditdenials). -
Transports. The gateway's agent consumer path (M2-07) calls
Preparefor every event withInvocationBearing(type)addressed to the connected agent, from the consumer's own delivery goroutine, never from the hub goroutine (M1-65:Dispatchnever blocks); onSendit builds the envelope withsync.DeliveryFields{InvocationToken: ..., Redelivered: ...}. The REST poll does the same per event of a page. ASend = falseevent counts as checked and is not in flight: the REST answer omits it andnext_cursorcovers it; the gateway treats it like a non-admitted event (04 7.1cursorframe), and the adapter's later cumulativeackcovers it. An event whosePreparefailed with an error is not sent but stays in flight, so the redelivery afteragent.ack_timeoutor the next connection prepares it again (Ruling for docs/DECISIONS.md). Every redelivery callsPrepareagain and therefore carries a new token (R-SY-07).run.cancel_requestedandagent.config_changedcarry no token and do not passPrepare. -
Acceptance (
OnMessagePosted, subscriber ofmessaging.MessagePostedin the message's unit of work): a. OnlyOrigin = humanandKind = text; a DM channel never triggers (09 3.4 points 1 and 2; SR4). Voice is M3. b. Gather the facts for the decision function of M2-04 (09 3.4 points 3 to 6): thread kind, operator (fixed operator of a thread, effective operator of the channel for the main thread, 09 3.1), channel switches, mentioned agents (MentionedIDsof agent kind), auto thread, active run of the target. Permission facts come only fromauthorize(05 rule 1.1): callAuthorizein filter mode with the request principal for the candidateAon the threadTwhereMwas posted (Invoke{AgentID: A, TriggerOrigin: human, MentionsAgent, ActiveRunAgentID: active run of the target, zero for a new thread}) and feed its outcome into the decision function as M2-04 defines its inputs (follow the merged names). c. Auto thread (09 3.4 point 5, 3.7): the check of point 7 is evaluated for the targetT'beforeT'is written, onTwith the main-thread rule (05 5 point 3, "for new threads");T'is created only afterallow, in the same transaction, throughmessaging(parentM, empty title withtitle_source = auto, operator = effective operator of the channel). A denial creates no empty thread (Ruling for docs/DECISIONS.md; add the sentence to 09 3.4 point 5 and 3.7). d. Outcome mapping (09 3.5):allowwithout an active run ofAin the target: invoke (f).level_insufficient(P lackstrigger):trigger_state = failedwithno_trigger_rightwhenAwas mentioned, otherwise nothing (R-AG-05).agent_not_reachable:agent_disabledwhenAis not active,agent_not_allowed_herewhenAwas mentioned, otherwise nothing.run_active_other_agent,billing_mode_personal_not_holder,tool_class_owner_only,project_not_ready: that code. Rejections of the decision function (multiple_agents_mentioned,agent_not_allowed_herefor a guest withoutallow_mentioned_agents): that code. Everytrigger_state = failedthat comes from anauthorizedenial writes one audit entry as note 8 withstage = acceptanceandmessage_idinstead ofrun_id; silent cases and decision-function rejections write none (Ruling for docs/DECISIONS.md). e.allowwith an active run ofAin the target is a follow-up (09 3.4 point 6): M2-11. Until then the message gets notrigger_stateand nothing else happens; a test pins this and M2-11 replaces it. f. Invoke, in this order in the transaction: (auto threadT'); the run through the creation function of M2-05 (state = queued,trigger_kind = message,triggering_message_id = M,triggered_by = P,agent_id = A,thread_id= target,billing_mode_expected= current configured mode,invocation_jti= a fresh UUIDv7 that no token carries until the first delivery replaces it, Ruling for docs/DECISIONS.md), which writesrun.state_changed; the anchor in the target (kind = 'run', authorA,origin = agent, empty content,run_id,silent = truebecause the anchor never notifies, 09 5.4; it publishesMessagePostedwith originagent, which triggers nothing);SetTriggerState(M, received); the job of note 11; lastagent.invocation(note 14). g. A replay ofcreateMessagewith the sameclient_msg_idpublishes noMessagePosted(M1-56) and therefore creates no second run. h. Norl.triggeris consumed: the request iscreateMessagewithrl.message(05 3); reported as a spec gap. -
Job
runs.queued_expiry(09 5.5, D-AG-16; Ruling for docs/DECISIONS.md and row in 02 12.3: moduleruns, scheduled,run.queued_max_age, queuedefault, domain rule in 09). The run creation inserts it withjobs.Inserter.InsertTxandScheduledAt = queued_at + run.queued_max_agein the creating transaction; arguments are the run ID only (02 12.1). The handler locks the run; if it is stillqueuedand due: CAS tofailedwithagent_unavailableandtrigger_state = failedwithagent_unavailable; if it isqueuedbut not yet due (the key was raised), it schedules itself again for the new due time; otherwise it does nothing. Note 3 c covers an adapter that comes back before the worker ran the job. Runs created later by M2-11, M2-15 and M2-16 use the same creation function and get the job. -
trigger_state(09 6, R-AG-09):receivedat acceptance;queuedset byPreparein the transaction that records the first token for the run, because handing the event to the transport is the delivery the server can observe; a lost frame is delivered again afteragent.ack_timeout(Ruling for docs/DECISIONS.md). Later deliveries do not touch it.failedwith reason on every denial of note 3 e and f and on expiry. Every change goes throughMessages.SetTriggerStateand writesmessage.updated(02 4.7). -
Concurrency. Acceptance runs after
AppendToThread, which holds the thread row lock (M1-55 D2), so two messages of one thread decide one after the other; the partial unique index of R-DM-14 is the last line.Prepareserializes on the run row: two consumers of one agent (two adapter processes) or a takeover with4015give two tokens with differentjti; the laterPreparewinsinvocation_jti(AG-09). The expiry job andPrepareserialize on the same row lock; the CAS of M2-05 lets only one transition win. Signing happens inside the transaction but without network calls (02 8.5: the key store of M1-32 reads local files). -
Events.
agent.invocation:audience=A,receive_action=agent.events.consume,resource{kind: run, id}withoutversion(04 9 point 3),scope{space_id, channel_id, thread_id}of the target,actor=P,correlation_idof the request; payloadAgentInvocationPayloadwithrun_id,thread_id,channel_id,space_id,trigger_kind = message,message_id,triggered_by(the author'sPrincipalRefas themessagingport returns it, becauserunsmay not useidentity),billing_mode(configured at acceptance),settings_version(channel);agent_sessioncomes with M2-12. No content, no prompt (04 2.2 rules 1 and 4). The payload validates against the schema and contains no JSONnull(ADR 0053).agent.followup: the builder (run_id,thread_id,message_id; same envelope rules) is built here and called by M2-11.- The token is a delivery field only; the event log row never contains it (R-SY-17).
-
Telemetry (signals.md). Counter
bothy.agent.invocation.deniedwithbothy.agent.handleandbothy.reason(values: the right column of note 6) per denial of note 3 e, f and g; spanagent.dispatch(PRODUCER) perPreparethat runs the check, withbothy.run.id,bothy.agent.handle,bothy.decision, linked to the event's correlation (signals.md 5.1 point 5).bothy.run.finishedandbothy.run.start.latency(outcomefailed) are recorded by the transition of M2-05; if it does not record them, record them here for the denial and the expiry. Declare instruments ininternal/platform/otel/metrics(M1-68, in the closure through M2-02), with name, unit and attributes exactly as signals.md. -
Rulings for
docs/DECISIONS.md(section 10, one rowD-M2-08-<n>each, with reason and place): the code side of the re-check principal (note 4: marker, its checks, cache key; the rule itself is R-AZ-29); filter mode plus one own audit entry per denial (notes 3 d and 8); delivery matrix with "terminal runs are skipped" and "agent.invocationrecords itsjtionly whilequeued" (note 2); reason mapping (note 6); follow-up denial leaves the run unchanged (note 3 f); skip versus error in the transports (note 9); auto thread written only afterallow(note 10 c); audit at acceptance only forauthorizedenials (note 10 d); unissuedinvocation_jtiat creation (note 10 f); jobruns.queued_expiry(note 11);trigger_state = queuedset in the transaction of the first token (note 12);runs.versionraised only whenPreparestores a change (note 3 h); the source portDeliverySourceif you add it (Scope).
Tests (write them first)¶
The test world is 07 "Examples": spaces Support, Marketing, Engineering, Research; agents Lotse (operator of Support, organization, chat), Texter (operator of Marketing, organization, scoped), Runner (operator of the forum Engineering/Projekt-A and of the text channel Engineering/Werkzeuge, organization, host), Assistent (operator of Research, personal, holder the Owner, scoped); one Owner; G-1 (Alex, trigger on Support), G-2 (Sam, trigger on Marketing). The test clock of kernel/clock drives time. The adapter side is the gateway test client of M1-64 with the agent's API token and a consumer name (M2-07), or the REST poll. Tokens are verified with the reference verifier of M2-06 against getJwks.
Unit:
internal/runs/domain:TestDeliveryMatrix(every cell of note 2),TestDeliveryReasonMapping(every row of note 6, unknown reasons giveaccess_revoked),TestInvocationClaims(per trigger kind:sub, triggering object,level = trigger,tool_classis the configured class even when the reported class is higher,exp - iatequalsagent.invocation_token_ttl, nodev_environment_id),TestQueuedAge(exactly atqueued_at + run.queued_max_agethe run is too old),TestInvocationPayload(validates againstAgentInvocationPayload, nonull, no content).internal/authz:TestRecheckPrincipal_OwnerWithoutSession(owner status without session, AZ-M19 at the decision level),TestRecheckPrincipal_DisabledPerson(unauthenticated,principal_inactive),TestRecheckPrincipal_OnlyAgentInvoke(any other action is an internal error),TestRecheckPrincipal_RejectsSessionOrToken,TestRecheckPrincipal_CacheKeySeparate(a personal-token principal of the same owner never gets the owner snapshot),TestAgentInvoke_NewThreadUsesMainRule(thread_require_mentionset,require_mentionnot set, no mention: allowed only withNewThread).
Integration (PostgreSQL 18 via pgtest, real modules through internal/wire, HTTP and gateway through the real chain):
TestDelivery_HappyPathCarriesFreshToken(04 16.5 up to theack): Alex mentions Lotse in Support/Allgemein (auto_threadset);201withtrigger_state = received; a new threadT'withparent_message_id = Mholds the run and the anchor; Lotse's consumer receivesagent.invocationwithinvocation_token; the token hasalg = EdDSA,typ = agent-invocation+jwt, akidofgetJwks,audLotse,subAlex,jtiequal toruns.invocation_jti,run_id,message_idM,thread_idT',channel_id,space_id,level = trigger,billing_mode = organization,tool_class = chat,trigger_kind = message,permission_version;Mthen hastrigger_state = queuedand exactly onemessage.updatedfor it.TestDelivery_EveryDeliveryFreshToken(R-SY-07): noack; afteragent.ack_timeoutthe redelivery hasredelivered: trueand a token with a newjtithat replacedruns.invocation_jti;renewed_from_jtiis NULL; no secondmessage.updated.TestDelivery_TokenNeverStored: the token string appears in noevent_logrow, noaudit_eventsrow and no captured log line.TestDelivery_RESTPoll:pollAgentEventsreturnsagent.invocationwithinvocation_token; a denied invocation is not inevents, andnext_cursoris at least itsseq.TestDelivery_AG07_AZM51_RevokedBeforeDelivery(AG-07, AZ-M51, 05 test 6): Lotse offline; Alex mentions Lotse; the Owner revokes G-1 (revokeGrant); Lotse connects: noagent.invocationframe; the run isfailedwithaccess_revoked;Mhastrigger_state = failedwithaccess_revoked;run.state_changedwas written; exactly one audit entryagent.invokedenywith actor Alex, theauthorizereason, resource the thread andrun_id,agent_id,stage = delivery;bothy.agent.invocation.deniedrose by one withbothy.reason = access_revoked.TestDelivery_GrantExpiredBeforeDelivery(AZ-P10 style): Alex's grant on Support has an expiry, the clock passes it,authz.expiry_sweepdoes not run: same outcome as the test above.TestDelivery_BR04_SwitchBetweenAcceptanceAndDelivery(BR-04): (a) Alex triggers Lotse; the Owner revokes G-1 and switches Lotse topersonalwith the Owner as holder (I1 holds); delivery refused, runfailed. (b) I2 without I1: a raw SQL statement on thepgtestconnection sets Lotse topersonalwith the Owner as holder while Alex keeps G-1 (a fixture that simulates an I1 defect); delivery refused with audit reason and run reasonbilling_mode_personal_not_holder. (c) The Owner triggers Assistent, then switches Assistent toorganization: delivered, tokenbilling_mode = organization,runs.billing_mode_expected = organization(10 9.2 point 4).TestDelivery_AZM19_OwnerTriggersHostRunner(AZ-M19): the Owner opens a post in Engineering/Projekt-A; acceptance allows; the delivery to Runner passes withtool_class = hostalthough no request of the Owner exists at that moment (note 4). With M2-03 merged, Projekt-A has a ready binding (created withputProjectBinding; readiness set through the port that M2-03 offers forproject_bindings.ready, follow the merged name); without it the test runs on Projekt-A without binding and the report names this under "What is not proven".TestDelivery_AZM20_HostRunnerRefusedForNonOwner(AZ-M20): Alex with atriggergrant on Engineering/Werkzeuge mentions Runner:trigger_state = failedwithtool_class_owner_only, no run, no thread, one audit entry. Variants at delivery: Runner isscopedat acceptance and set tohost(setAgentToolClass) before the delivery; Runner isscopedwithreported_tool_class = host: both refused withtool_class_owner_only, runfailed.TestAcceptance_AZM30_AgentMessageNeverTriggers(AZ-M30): withfree_responseset on Support/Allgemein, Lotse posts with its agent token a message that mentions Texter: no run, notrigger_state, noagent.invocation, no audit entry.TestDelivery_SR4RecheckFailsClosed: insideinternal/runstests, a run is created through the creation function for a message whose origin isagent(impossible through acceptance);Preparerefuses with audit reasonorigin_not_humanand run reasonaccess_revoked; a run whose trigger was disabled (disableUser) is refused withuser_disabled.TestDelivery_AG09_TwoAdapterProcesses(AG-09, delivery part): (a) two consumersaandbof Lotse receive the sameagent.invocationwith differentjti;runs.invocation_jtiequals thejtiof the laterPrepare; two concurrent CAS starts through the transition of M2-05 give exactly one success. (b) A secondidentifyof the same consumer closes the first with4015; the unacknowledged event comes again with a new token. (c) A second server instance in the same test binary after a restart redelivers the unacknowledged event with a new token; there is still one run and onetrigger_statechange. The full scenario withstartRunruns in M2-09 and M2-31.TestQueuedExpiry_AG27(AG-27): Lotse offline; the clock passesrun.queued_max_age; the job sets the run tofailedwithagent_unavailableandtrigger_state = failedwithagent_unavailable; when Lotse connects later nothing is delivered and no audit entry is written.TestDelivery_QueuedTooOldWithoutJob: the worker did not run the job;Preparefails the run withagent_unavailableand sends nothing.TestDelivery_AuditOnEveryDenial: a table over the denials of note 3 e, f and g; each writes exactly one audit entry with the attributes of note 8 and nothing else; allowed deliveries and skips write none.TestDelivery_InvocationAfterStartNotRecorded: the run moved torunningthrough M2-05; a redeliveredagent.invocationis sent with a fresh token andruns.invocation_jtiis unchanged; inwaiting_inputthe same.TestDelivery_FollowupJtiRules: anagent.followupbuilt with the builder of note 14 for arunningrun is sent withtrigger_kind = followupand the follow-up'smessage_id, without changinginvocation_jti; for awaiting_inputrun it replacesinvocation_jtiand clearsrenewed_from_jti; after the follow-up author losttriggerit is not sent, the follow-up message isfailedwithaccess_revoked, the run is unchanged, one audit entry.TestDelivery_TerminalRunSkippedandTestDelivery_UnregisteredResolverFailsClosed(interaction.createdandcommand.invokedto an agent are not sent before M2-15 and M2-16).TestAcceptance_AutoThreadAndDenial(AG-05, integration part): allow createsT', the run and the anchor inT'andtrigger_stateonM; a denied trigger in the same channel leavesMwithfailedand creates no thread.TestAcceptance_Events: the transaction ends withagent.invocation; audience Lotse, receive actionagent.events.consume, resourcerunwithoutversion; payloads validate againstAgentInvocationPayloadandRunStateChangedPayload; the anchor haskind = run,origin = agent,silent = true.TestAcceptance_DenialReasons:no_trigger_rightwith mention and a silent message without mention (no audit entry),agent_not_allowed_here,run_active_other_agent,billing_mode_personal_not_holder(b-style fixture),multiple_agents_mentioned(no audit entry).TestAcceptance_FollowupBranchUntilM211: a second human message to Lotse in a thread with Lotse's active run creates no run and notrigger_state.TestAcceptance_ConcurrentMessagesOneRun: two messages to Lotse in one thread at the same time give exactly one run (R-DM-14).TestAcceptance_ReplayCreatesNoSecondRun.TestDeliveryMetrics: with an SDKManualReader,bothy.agent.invocation.deniedcarries only the attributes of signals.md and only the values of note 6.internal/wire: golden files of subscriptions, jobs and event registrations updated.
Commands that must pass, from the repository root:
cd services/server && go test -race -count=1 ./internal/runs/... ./internal/authz/... ./internal/kernel/... ./internal/messaging/... ./internal/chat/... ./internal/agents/... ./internal/gateway/... ./internal/wire/...make codegen-check,make arch,make api-lint,make api-checks,make register-check,make catalog-check,make config-doc-check,make docsmake check
Acceptance criteria¶
-
runs.Delivery.Prepareruns before every delivery ofagent.invocation,agent.followup,interaction.createdandcommand.invokedto an agent, on the gateway and in the REST poll; types without a resolver are never sent. - AG-07 and AZ-M51: a revocation or an expiry between acceptance and delivery gives run
failedwithaccess_revoked, no delivery, and an audit entry. - BR-04: a revocation or a switch between acceptance and delivery gives run
failedand no delivery; I2 refuses withbilling_mode_personal_not_holdereven when I1 was bypassed; a switch toorganizationis delivered with the new configured mode. - AZ-M19: the Owner's trigger of the
hostRunner passes acceptance and delivery; AZ-M20: Alex is refused withtool_class_owner_onlyat acceptance and at delivery, also when only the reported class ishost. - AZ-M30: a message with
origin = agentcreates no run; the SR4 re-check refuses a non-human trigger at delivery. - AG-09 (delivery part): two adapter processes and every redelivery get different tokens; only the newest
jtiis current; exactly one CAS start succeeds. - AG-27: a run older than
run.queued_max_agefails withagent_unavailable, by the job or at the nextPrepare, and is never delivered. - Every denial before a delivery writes exactly one audit entry with the attributes of Design note 8.
-
trigger_stategoes fromreceivedtoqueuedwith the first successful delivery ofagent.invocation, exactly once. - Every delivery carries a fresh token with the claims of Design note 7; the token is in no stored row and no log.
- Acceptance writes run, anchor,
trigger_state = receivedandagent.invocationin the message's transaction; payloads validate against their schemas. -
make checkis green.
Definition of done¶
- Code and tests as specified; doc comment on every public symbol (purpose, parameters, return value, errors, example where useful); comments explain why.
- READMEs of
runs,authz,messaging,gatewayand of the module that holds the REST poll updated (portDeliveryand its callers, the re-check principal, acceptance and anchor, the delivery matrix, skip versus error, the jobruns.queued_expiry). - Rulings of Design note 16 recorded in
docs/DECISIONS.md(one row each with reason). 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>.