M2-16: Slash commands¶
Status: ready | Depends on: M2-08 | Milestone: M2
Goal¶
After this ticket agents keep their slash commands in a server register and people run them in a channel or thread. The module agents owns the register (table commands, 09 "Slash commands"): registerCommand, updateCommand, removeCommand and getCommand refuse the reserved built-in names with 409 conflict and reason: command_name_reserved (AG-24), allow a scope only on targets the owner serves or may write, keep at most command.max_per_principal live commands per owner, and write one audit entry and one command.changed event per change; listChannelCommands shows exactly the commands that 09 "Visibility and name conflicts" offers in a channel, the operator's first. The module runs executes them with invokeCommand: a control command (commands.control = true) goes at once to its owner as the personal event command.invoked, also during a run, and never creates a run, a follow-up or a model turn; a model command is triggered like a message (a run queued with trigger_kind = 'command' and triggering_command_id, the live-box anchor and agent.invocation with the parsed options) and is refused with 403 agent_invocation_denied and reason: run_active while the thread has an active run. Every invocation passes command.invoke and, for agent commands, agent.invoke with I2, SR2 and SR4; every delivery of a command to an agent re-checks agent.invoke with current rights and carries a fresh invocation token. This closes the command path of the M2 red-team item "another person triggering a personal subscription through every path" (T-AI-08), keeps control commands away from the model (R-AG-13, D-AG-12), and gives M2-17 (autocomplete) and M2-25 (app commands) the register they build on.
Read first¶
docs/PLAN.md: section 3 "Way of working per ticket"; section 8 "M2 Agent platform, extensions and terminal": "Goal", "Scope", "Out of scope", "Exit criteria" (points 1 to 3 and 10), "Test strategy", "Red team" (first paragraph: personal subscription over every way including "command", confused deputy), "Risks" (prompt injection), the block "agents and extensions" and its rows M2-02 to M2-09, M2-11, M2-12, M2-13, M2-15, M2-17, M2-19, M2-24, M2-25, M2-29, M2-31 and M2-33 (who builds what next to this ticket); row M4-21 (the client side of commands); section 18.2 row "I2 before every invocation"; section 18.3 row "Slash commands with suggestions".- Dependencies: M2-08 is in the header. Implied through it and therefore not in the header: M2-07 (agent consumers, REST poll), M2-06 (invocation token issuer and reference verifier), M2-05 (runs,
Open,Transition, live-box anchor), M2-04 (DecideCommand), M2-02 (channel switches andsettings_version), M1-65 (gateway hub), M1-64 (gateway test clientwstest), M1-63 (listEvents), M1-56 (messages), M1-55 (threads), M1-54 (operators), M1-53 (channels), M1-51 (agent records,agentsport), M1-44 (agent API tokens), M1-28 (authorize), M1-24 (audit), M1-20 (Idempotency-Key), M1-19 (rate limits), M1-18 (HTTP chain,If-Match), M1-17 (route generator,api/operations.yaml), M1-16 (event log), M1-14 (jobs), M1-13 (unit of work), M1-69 (canary scenario), M1-71 (contract tests andservices/server/test/harness). Not in the closure but used if merged: M1-70 (leak tests), M2-09 (cancelRun,requestCancel). docs/spec/09-agents.md: 10 "Slash commands" completely (10.1 "Built-in commands", 10.2 "Registration", 10.3 "Visibility and name conflicts", 10.4 "Execution"; 10.5 "Autocomplete" only to know what M2-17 adds); 1 "Principles"; 3.1 "Operator"; 3.4 "Decision per message"; 3.5 "Reasons for rejection at acceptance" (rowrun_active); 3.6 "Guest agents"; 3.7 "Auto-thread from a channel message"; 4.1 "Overview" (row "Commands"); 4.3 "Connection and cursor"; 4.4 "Events to the adapter" (rowsagent.invocation,command.invoked); 4.5 "Invocation token" (claims, "Check in the adapter" point 6); 5.1 "State machine"; 5.4 "Live box content"; 7 "Follow-ups during a run"; 8 "Stop"; 16.2 "Required scenarios" (AG-24, and AG-07 and AG-09 for the delivery machinery you extend); 17.3 "Mapping of the adapter methods" (row "Hermes commands": Hermes Agent registers its commands as control commands); 19 R-AG-13 and R-AG-14; 20 E-AG-08, E-AG-09, E-AG-17, E-AG-20; 21 "Configuration values of this chapter".docs/spec/03-data-model.md(core contract, read only): 1 "Principles" points 6, 10, 12 and 13;commands(all columns, checks, FKs, the unique partial index, indexes, retentionconfig, the last paragraph on name conflicts);runs(trigger_kind,triggering_message_id,triggering_command_id, FKtriggering_command_id -> commands(id) ON DELETE SET NULL, the check on thetriggering_*columns, the partial unique index of R-DM-14);principals(kind,status);channels(kind,allow_mentioned_agents, effective operator);threads(kind,operator_agent_id,parent_message_id); 6.3 "Lock order".docs/spec/05-actions.md(core contract, read only): 1 rules 1, 2, 6 and 8; 2.4 'Columns "Owner", "Step-up", "Audit", "Permissions"'; 2.6 "Scopes for apps" (commands:write;command.readwithout scope); 3 "Rate-limit classes"; 4.10 rowscommand.read,command.register,command.remove,command.invoke,agent.invoke,run.cancel; 5 "Agent invocationagent.invoke" (points 1 to 8, the claim table, "Consequences on denial"); 6 rowscommand.changed,command.invoked,agent.invocation; 8 R-AZ-07, R-AZ-12, R-AZ-18, R-AZ-19, R-AZ-20, R-AZ-25.docs/spec/04-sync-protocol.md(core contract, read only): 2.2 "Envelope" with the payload rules 1 to 4; 7.2 "Flow" (the predicate for events without scope); 9 "Delivery guarantee" point 3 (command.invokedhas noresource.version); 15.1 "Connection and cursor" point 4; 15.2; 15.3 "Invocation and run start" steps 1 and 2; 15.4 "Follow-ups" point 6 (control commands are never follow-ups).docs/spec/02-architecture.md: 4.1 "Rules" (point 4, source ports); 4.3 "Ports, events and sources per module" (agents:Commands(register);runs:CommandExec); 4.4 "Allowed dependencies" (runsmay useagents,chat,messaging;agentsmay useidentity,authz,chat); 4.6 "Actions per module" (the paragraph oncommand.invokeand the register); 4.7; 8.1 to 8.3; 9.2 "404 instead of 403"; 9.3 "Proof of the check"; 11.4 "Hub and delivery".docs/spec/07-permissions.md: 2.3 "Levels" (triggercovers "run commands"); 2.9 "Agents as principals"; 2.10 "Apps"; 2.11 "Tool class of an agent"; 4.1 "Decisions and reasons" (rowsrun_active,agent_not_reachable,billing_mode_personal_not_holder,tool_class_owner_only,origin_not_human,channel_read_only,channel_archived); 4.2 "Flow" (order, deferred entries, filter mode); 4.3 "Visibility"; 4.5 "Archived spaces and channels"; SR1, SR2, SR4; 7.4 "Query pattern" points 3 and 4 (suggestions such as commands follow list rules); 8.1 "Delivery filter" (row "Agent consumers").docs/spec/06-api.md: 4 "Authorization in the API" points 3, 6 and 7 (agent.invokeis checked ininvokeCommand; commands without the right answer403 agent_invocation_deniedwithreason); 5 "Errors" (closed code list,errors[],reason); 7 "Idempotency" (POST withIdempotency-Key, PUT and DELETE idempotent, DELETE of a removed resource204); 8 "Concurrency" points 1 and 2 (commands needIf-Match); 16 "Endpoints", group "Commands"; 17 rowscommand.changed,command.invoked.docs/spec/01-product.md: the row "Slash commands with suggestions and options" and wireframe "D5 Slash autocomplete".docs/spec/11-extensions.md: 3.1 (manifest fieldcommands), 3.2 point 5, 4.3 (row "Commands"), 9 "Commands and interactions for apps", 11.2 rowcommands(only to know what M2-24, M2-25 and M2-29 build).docs/spec/19-security.md: SEC-IN-4 (control and bidi characters), SEC-AUD-5 (audit_details.v1), SEC-T10 (canary).docs/api/openapi.yaml: operationsregisterCommand,updateCommand,removeCommand,getCommand,listChannelCommands,invokeCommand(andautocompleteCommand,answerAutocomplete, which stay disabled); schemasCommand,CommandRegister,CommandOption,CommandChoice,CommandList,CommandLevel,CommandOptionValue,CommandInvoke,CommandInvocation,CommandChangedPayload,CommandChangedEvent,CommandInvokedPayload,CommandInvokedEvent,AgentInvocationPayload,PrincipalRef,TargetKind,FieldError; parametersCommandIdPath,ChannelIdPath,IdempotencyKey,IfMatchRequired; responsesConflict,Forbidden,PreconditionFailed,PreconditionRequired; problem codesagent_invocation_denied,channel_read_only,conflict,validation_failed,version_conflictinx-problem-codes. For the built-in commands:cancelRun,resetAgentSession,bindAgentSession,setThreadTitle,closeThread,getRun.docs/api/asyncapi.yaml: messagesevent.command.changed(x-receive-action: command.read) andevent.command.invoked(x-receive-action: agent.events.consume,x-audience: self).docs/api/webhooks.md: rowscommand.changedandcommand.invoked(apps, M2-25 and M2-26).docs/spec/actions.yamlon the base: entriescommand.read,command.register,command.remove,command.invoke(auditdenials,rl.trigger) and the event typescommand.changed(resource_version: true) andcommand.invoked(resource_version: false).- ADRs 0013 (spec first), 0017 (one authorization function, 404 instead of 403), 0021 (audit), 0022 (agents outside the server), 0023 (signed invocation token), 0024 (backend policy), 0025 (run as own object), 0049 (action catalogue), 0050 (module register), 0052 (one envelope), 0053 (no JSON
null). docs/DECISIONS.md: D-LS-07, D-AG-11, D-AG-12, D-K-D8, D-K-D17, D-K-D20, D-W-100; section 10 (format of the rows you add).docs/security/threat-model.md: T-AI-02, T-AI-07, T-AI-08, T-AZ-01, T-AZ-02.docs/RISKS.md: RK-13, RK-18.docs/OPEN-QUESTIONS.md: OQ-01 (default: strict I1 without owner exception; the I2 tests assume it).docs/observability/signals.md:bothy.agent.invocation.deniedand spanagent.dispatch(both recorded by M2-08'sPrepare, which this ticket extends); no command-specific metric exists.docs/operations/configuration.md:command.max_per_principal,command.description_max_chars,command.autocomplete_max_choices,sync.event_payload_max_bytes,http.max_json_body_bytes,run.queued_max_age,agent.invocation_token_ttl,database.tx_retry_max, the keysratelimit.rl.admin.*,ratelimit.rl.read.*,ratelimit.rl.trigger.*.- M1 briefs: M1-13 (
txn.UnitOfWork.Do,txn.Publish,txn.Subscribe, golden registrations), M1-14 (jobs.Inserter.InsertTx), M1-16 (eventlog.Event,eventlog.Appender,sync.DeliveryFields), M1-17 (api/operations.yaml, route generator), M1-18 (StageValidateerror codes,StagePreconditions,FormatETag,ParseIfMatch,errs.VersionConflict, proof enforcement), M1-20 (idempotency middleware), M1-24 (auditing.Recorder.Record,auditing.Entry,Attrsvalue kinds), M1-25 (authorize.Registry.Event), M1-26 (kernel/textcheck.DisplayText, principal kinds), M1-28 (Design notes "Public contract", "Requirement table", "Deferred entries", "Visibility", "Guards", filter mode,Decision.Errmapping, the fake sources and the test world), M1-44 (agent tokens), M1-51 (theagentsport,AgentPolicySource), M1-53 and M1-54 (channel facts and effective operator), M1-55 (chat.Threads.Info,AppendToThreadholds the thread row lock, text-channel threads need a parent message), M1-63 (listEvents,authorize.ResourceFromEvent), M1-64 (internal/gateway/adapters/wstest), M1-65 (hub, receive checks). - M2 briefs in
docs/briefs/: M2-04 (design note 7DecideCommand,CommandTrigger), M2-05 (Open,OpenCmd,ErrActiveRunExists,Messages.CreateRunAnchor, the deferred FK ofruns.triggering_command_id), M2-06 (InvocationTokens.Issue,InvocationGrant, the claim table per trigger kind and its note on control commands (R-AZ-25),runstest.ReferenceVerifier), M2-07 (consumers,pollAgentEvents), M2-08 (portruns.Delivery,Prepare, the delivery matrix rowcommand.invoked, the resolver table, the re-check principal, audit and counter of a delivery denial, theagent.invocationpayload builder, jobruns.queued_expiry). - Code (merged names win, D-W-100):
services/server/architecture/modules.yaml(commandsandcommand.changedbelong toagents;command.invokeandcommand.invokedtoruns),internal/agents,internal/runs,internal/authz,internal/kernel/authorize,internal/kernel/actor,internal/kernel/auditing,internal/kernel/textcheck,internal/chat,internal/messaging,internal/identity,internal/gateway,internal/platform/pg/pgtest,internal/wire,api/operations.yaml.
Fixed requirements that apply¶
Owner's fixed build rules (apply verbatim):
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
Rules for every brief:
- Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
- No en dash and no em dash in any file you write; the
text-rulespre-commit hook rejects them. Use a hyphen or rephrase. - White label: examples and test data use
example.comand the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory. - Limits, windows and thresholds only through named configuration keys listed in
docs/operations/configuration.md; code comments and docs name the key, never the number. - Specification first: a change to behaviour, API, event, right or configuration updates
docs/spec/ordocs/api/in the same change. The core contract (docs/spec/00-glossary.md,03-data-model.md,04-sync-protocol.md,05-actions.md) is not edited by this ticket.
Product requirements implemented here:
- The names of the built-in commands (
stop,status,new,resume,title,close) are reserved:command.registerrefuses them with409 conflictandreason: command_name_reserved; built-in and other control commands never reach a model as a prompt (09 10.1, R-AG-13, D-AG-11, AG-24). - Registration: fields after 03
commands(name,descriptionup tocommand.description_max_chars,optionsschemacommand_options.v1,scope_kindand target,required_level,control); a scope only on targets the owner serves or on which it haswrite; at mostcommand.max_per_principalcommands per agent or app; every change iscommand.registerorcommand.removewith an audit entry andcommand.changed(09 10.2, 05 4.10 Audityes, 11 3.2 point 5). - Visibility:
command.readgives for a channel the commands of the operator, of guest agents only withallow_mentioned_agents, and of apps withwriteon the channel; with equal names the operator's come first, the others are qualified with the owner's handle on display (09 10.3, 03commands, 05 4.10, 07 7.4 point 4). - Control commands (
commands.control = true):command.invokedwith an invocation token at once to the owner, also during a run; no run, never to the model, never a follow-up; an answer is optional and comes as a message of the agent (09 10.4, 04 15.4 point 6, 05 5 point 8, D-AG-12). - Model commands (
commands.control = false): like a triggering message, a runqueuedandagent.invocationwith trigger kindcommandand the parsed options; the run carriestrigger_kind = 'command'andtriggering_command_id; during an active run the invocation is refused withrun_active, not turned into a follow-up (09 10.4, 03runs, 05 5 point 8, 07 4.1, D-K-D20, E-AG-08). - Agent commands check
required_levelandagent.invoke(active human,trigger, operator or permitted guest, I2, SR2, project readiness); a refusal answers403 agent_invocation_deniedwithreason; refusals are audited (05 4.10command.invokeAuditdenials, 05 5, 06 4 points 6 and 7, 07 SR1, SR2, SR4). - Every delivery of
command.invokedand of theagent.invocationof a command run re-checksagent.invokefor the invoking human with the rights of that moment and carries a fresh invocation token withtrigger_kind = commandandcommand_id(04 15.3 step 2, 05 5 claims, 05 6 rowcommand.invoked, 07 8.1, R-SY-07). - A read-only or archived channel refuses every command for everyone, owners included (05 rule 1.8, R-AZ-12, 07 4.5).
- Events:
command.changed(resourcecommandwith its new version, receive actioncommand.read, changeregistered,updatedorremoved) andcommand.invoked(resourcecommandwithout version, audience the owner, receive actionagent.events.consume); payloads without JSONnulland belowsync.event_payload_max_bytes(05 6, 04 2.2, 04 9 point 3, ADR 0053). - Rate-limit classes
rl.admin(register, update, remove),rl.read(get, list) andrl.trigger(invoke);Idempotency-KeyonregisterCommandandinvokeCommand;If-MatchonupdateCommand;removeCommandis idempotent (05 4.10, 06 7, 06 8).
Scope¶
All paths under services/server/ unless they start with docs/. No new top-level Makefile target.
Create:
migrations/<next free number>_agents_commands.sql(plan: migrationcommands): tablecommandsexactly as 03commands(columns, types, defaults, nullability, thenamecheck, thescope_kindcheck, the check that the scope columns matchscope_kind, therequired_levelcheck, FKs with theirON DELETE, the unique partial index withNULLS NOT DISTINCT(Design note 2), the two indexes); a comment naming the retention classconfig; in the same file the FKruns.triggering_command_id -> commands(id) ON DELETE SET NULLthat M2-05 deferred to this ticket. Down migration drops the FK, then the table.internal/agents/commands.go(port): the interfaceCommandsand its DTOs (Design note 1).internal/agents/domain/command.go: reserved names, definition validation, scope coverage and specificity (Design notes 3 and 9).internal/agents/app/commands.go: the use casesRegister,Update,Remove,Get,ListForChannel,Resolve(Design notes 4 to 9).internal/agents/adapters/postgres/queries/commands.sqlwith the sqlc output and repository methods.internal/agents/adapters/httpapi/commands.go: strict-server methods forregisterCommand,updateCommand,removeCommand,getCommand,listChannelCommands.internal/runs/commands.go(port): the interfaceCommandExecwithInvoke(Design note 1).internal/runs/domain/command_exec.go:ValidateOptionValuesand the payload builders ofcommand.invokedand of the command variant ofagent.invocation(Design notes 10 to 12).internal/runs/app/command_exec.go: the use caseInvoke(Design notes 10 to 12).internal/runs/app/delivery_command.go: the delivery resolvers forcommand.invokedand foragent.invocationwithtrigger_kind = command(Design note 13).internal/runs/adapters/httpapi/commands.go: the strict-server method forinvokeCommand.internal/wire/configkeys_commands.go, only for keys not yet registered:command.max_per_principal(int, minimum 1),command.description_max_chars(int, minimum 1),command.autocomplete_max_choices(int, minimum 1), chapter09-agents.md, following the existingconfigkeys_*.gofiles.
Change:
internal/kernel/authorize:CommandFactsand the fieldResource.Command(Design note 8), with doc comments.internal/authz: replace the deferred entry ofcommand.invokewith its requirement and guard and remove it from the golden list of deferred actions; requirement ofcommand.readby resource kind; guard ofcommand.register; the visibility row of the resource kindcommand; the facts gathering for a command owner (Design note 8); tests; README.internal/runs(merged code of M2-05, M2-06, M2-08):OpenCmdgetsTriggeringCommandID; the issuer accepts acommandgrant without run for a control command (Design note 13);Preparehandlescommand.invokedwithout a run lock; theagent.invocationpayload builder gets the command variant; register the resolvers.internal/chatport, only if the base has no method that locks a thread row:Threads.Lock(ctx context.Context, threadID ids.ID) (ThreadInfo, error)(SELECT ... FOR UPDATEin the ambient unit; doc comment: serializes run creation per thread with message acceptance).internal/identityport, only if the base has no such method:LockPrincipal(ctx context.Context, principalID ids.ID) error(SELECT ... FOR UPDATEonprincipalsin the ambient unit).internal/messagingport, only if the base offers no way forrunsto render aPrincipalRefof an arbitrary principal:PrincipalRefs(ctx context.Context, principalIDs []ids.ID) (map[ids.ID]PrincipalRef, error)(Design note 14).internal/wire: handlers; event registrationscommand.changed(moduleagents) andcommand.invoked(moduleruns) throughauthorize.Registry.Event; the delivery resolvers; the bindings ofagents.Commandsintoruns; update the golden files of registrations.api/operations.yaml: movegetCommand,invokeCommand,listChannelCommands,registerCommand,removeCommand,updateCommandfromlatertoenabled(sorted); rerun the route generator.autocompleteCommandandanswerAutocompletestay inlater(M2-17).docs/api/openapi.yaml(spec first):AgentInvocationPayloadgets the optional memberscommand_id(Uuid),command_name(string) andoptions(array ofCommandOptionValue), each described as "set exactly fortrigger_kind = command" (Design note 12); descriptions oflistChannelCommands(Design note 9: registered commands only, order, shadowing), ofCommandInvoke.thread_id(Design note 10 b; new text: "Absent: in a text channel the main thread for a control command, and for a model command only whenauto_threadis false; otherwise400 validation_failed(/thread_id,required), because a command has no message from which theauto_threadthread could start."), ofCommandInvocation.invocation_idandaccepted(Design note 10 h), ofregisterCommandandupdateCommand(409 conflictwithreason: command_name_reserved,409 conflictwithout reason for a taken name), ofCommandInvokedPayload.run_id(Design note 11). Thenmake api-lint,make api-checks,make api-tablesand the server code generation of M1-17;make codegen-checkshows no diff.docs/spec/09-agents.md(English, like the chapter; not core contract): 4.4 rowagent.invocation(command ID, name and options fortrigger_kind = command) and rowcommand.invoked(run_idas R-AZ-25 says; the claim row of 4.5 already follows it, and check point 6 needs no change); 10.2 (target rule, limit, duplicate, definition rules of Design notes 3 and 4); 10.3 (built-ins come from the client list, operator first, order and shadowing of Design note 9); 10.4 (target withoutthread_id: main thread for control commands, and for model commands only withauto_threadfalse, otherwise400 validation_failedwith/thread_idrequired, Design note 10 b; order of checks,run_idof control commands,invocation_id).docs/spec/07-permissions.md4.3 "Visibility": one bullet for the resource kindcommand(Design note 8 b).docs/operations/configuration.md: runmake config-referenceif a chapter table changed;make config-doc-checkgreen.services/server/test/canary(M1-69, in the closure through M2-02): add the six operations to its scenario as its README describes, with a canary value in a command description and in a string option value of an invocation (SEC-T10); the scan stays clean. If the leak tests of M1-70 keep a list of reading routes, addgetCommandandlistChannelCommands.- READMEs of
agents,runs,authzand of every module whose port you extend;services/server/CHANGELOG.mdunder "Unreleased";docs/DECISIONS.mdsection 10 rowsD-M2-16-<n>(Design note 17).
Out of scope¶
- Autocomplete:
autocompleteCommand,answerAutocomplete, the ephemeralcommand.autocomplete_requested, the opcommand.autocomplete_result, the deferred entry ofcommand.autocompleteand tokens withtrigger_kind = autocomplete: M2-17. - Apps as command owners: commands from the manifest at installation, upgrade, suspension and uninstallation (11 4), invocation of app commands (
command.invokedto apps,interactions_url, scope check withoutagent.invokeand I2, R-AZ-18), the installation-state filter of the list: M2-25 with M2-24. This ticket builds the register and the list rule generically for both owner kinds (Design notes 4 and 8) but tests app owners only with the authz fakes, because no app token exists before M2-24. - The built-in commands of 09 10.1 run through their own operations from the client (
cancelRunof M2-09,resetAgentSessionandbindAgentSessionof M2-12,setThreadTitleandcloseThreadof M1-55, reads for/status); the composer and the list in the client: M4-21 (Design note 9 a). - The answer of an agent to a control command as a message outside a run: M2-19.
- Start, resume, renewal, finish and cancel of a command run (generic for every trigger kind): M2-09. Follow-ups: M2-11 (commands never become follow-ups). Agent sessions in the payload: M2-12.
- The run context of a command run (
run.context.read): the schemaRunContextrequirestrigger_message, which a command run does not have, and M2-13 (not in this ticket's dependency closure) answers an internal error for runs withtrigger_kindother thanmessageuntil the command case is specified. The case needs an owner decision onrun_context.v1first; this ticket builds and tests everything up to the deliveredagent.invocationand names the missing context under "What is not proven" and as a spec gap. - Command options of type
file: invoking them answers400 validation_failedwitherrors[].code = not_enabled(Design note 10 i); files arrive in M3 and no M3 row names this yet (reported as a plan gap). - An operation that lists the caller's own commands or reconciles a full list in one request (09 10.2 "in one request"): no operation exists in
openapi.yaml; reported as a spec gap. The SDK side of the reconciliation: M2-29 and M2-30. - The conformance scenario AG-24 with the fake agent: M2-31. Adapter checks of the token (AZ-P6) and the reference adapters: M2-29, M2-33, M2-34.
- Delivery to agents over webhooks (04 15.6): no ticket owns it yet (reported by M2-08).
- Changes to the core contract.
Boundaries¶
- A new migration needs its line in
migrations.sha256: runmake migrations-lockafter creating the file and beforemake check; never edit a merged migration (M1-01, ADR 0007). - Work in a dedicated git worktree on the branch
feat/M2-16-slash-commands; one writer per worktree. - Conventional Commits with the module as scope (for example
feat(agents): ...,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 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
wstestof M1-64 with an agent token and a consumer name (M2-07), or the REST pollpollAgentEvents; tokens are checked with the reference verifier of M2-06. Real runtimes run only behind an explicit switch (09 16.1), and this ticket has none. - Never lower a threshold, disable a gate or delete a test. Existing tests that pinned the deferred entry of
command.invokeor the disabled command operations are adapted to the specified behaviour and listed in the report. - If the core contract lacks something this ticket needs, stop with
Verdict: BLOCKED <reason>instead of building a deviation (P-11). - This brief was written before milestone M1 closed (D-W-100). Where the merged code of an M1 ticket names a package, type, function, file or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with
Verdict: BLOCKED <reason>. - The same holds for the merged code of M2-04 to M2-08: follow its names; where it lacks a function this brief expects from it (for example
OpenCmd, the resolver table or the payload builder), add it in the module that owns it and report it.
Design notes¶
-
Start checks, in this order; each failure ends the ticket with
Verdict: BLOCKED <reason>: a.make checkis green on the fresh worktree. b. M2-08 is merged (runs.Deliveryand itsPrepareexist), with M2-04'sDecideCommand, M2-05'sOpenand M2-06's issuer on the base (names may differ, see Boundaries). c. Core contract (amended by R-AZ-25 under D-W-104; no check any more): 05 section 5, claim table rowrun_id, says that the claim is absent forcommand.invokedwhen the owner of the control command has no active run in the thread at invocation time, and that otherwise payload and token carry that run, never the run of another agent; 09 4.5 mirrors the row. Notes 11 and 13 c build exactly this. -
Ports and placement (02 4.3, 4.6).
agentsowns the register;runsowns execution;authzowns every rights rule (05 rule 1.1).// package agents // Commands is the register of slash commands (09 10, 03 commands). type Commands interface { Register(ctx context.Context, cmd RegisterCommand) (Command, error) // command.register Update(ctx context.Context, cmd UpdateCommand) (Command, error) // command.register, If-Match Remove(ctx context.Context, commandID ids.ID) error // command.remove Get(ctx context.Context, commandID ids.ID) (Command, error) // command.read ListForChannel(ctx context.Context, channelID ids.ID) ([]Command, error) // command.read on the channel // Resolve returns the live command that is usable in the channel (scope covers it and no // more specific command of the same owner and name shadows it, note 9). It does not // authorize: the caller authorizes command.invoke. Unknown, removed, not covered or // shadowed gives the same not_found error. Resolve(ctx context.Context, commandID, channelID ids.ID) (Command, error) } type Command struct { ID, OwnerPrincipalID ids.ID OwnerKind string // "agent" or "app" (principals.kind) Name, Description string ScopeKind string // "instance", "space", "channel" ScopeID ids.ID // zero for instance Options []CommandOption RequiredLevel string // "write" or "trigger" Control bool Version int64 } type CommandOption struct { Name, Description, Type string; Required bool; Choices []CommandChoice; Autocomplete bool } type CommandChoice struct { Name, Value string } type RegisterCommand struct { Name, Description, ScopeKind string; ScopeID ids.ID; Options []CommandOption; RequiredLevel string; Control bool } type UpdateCommand struct { CommandID ids.ID; ExpectedVersion int64; Definition RegisterCommand } // package runs // CommandExec executes slash commands (09 10.4). type CommandExec interface { Invoke(ctx context.Context, cmd InvokeCommand) (CommandInvocation, error) } type InvokeCommand struct { ChannelID, CommandID, ThreadID ids.ID /* zero: absent */; Options []OptionValue } type OptionValue struct { Name, Value string } type CommandInvocation struct { InvocationID, CommandID, ChannelID, ThreadID, RunID ids.ID /* RunID zero for control */ }The actor comes from the context, as in the other use cases. Port DTOs mirror the openapi schemas; domain types stay inside the modules.
-
Migration. Follow 03
commandsliterally, with one point the text leaves implicit: the unique partial index is(owner_principal_id, lower(name), scope_kind, coalesce(scope_space_id, scope_channel_id)) NULLS NOT DISTINCT WHERE removed_at IS NULL. WithoutNULLS NOT DISTINCTtwo live instance-scoped commands with the same name and owner would pass, because thecoalesceis NULL forinstanceand PostgreSQL treats NULLs as distinct; 03 uses the clause forstats_minutesfor the same reason. Ruling for docs/DECISIONS.md; the report lists it as a spec gap for 03. The FK ofruns.triggering_command_idjoins the same migration. Rows are never deleted by the product: removal setsremoved_at(03 1 point 7 by analogy, FK fromruns). -
Definition rules (
agents/domain, pure; codes oferrors[]from M1-18's list pluslimit_reachedandnot_enabledof 06 5;detailnever repeats a submitted value): a. Name: the pattern of 03 andCommandRegister(StageValidatealready rejects others). Reserved names:stop,status,new,resume,title,close, compared exactly (names are lower case by the pattern):409 conflictwithreason: command_name_reserved. A unit test reads the first column of the table in 09 10.1 and compares it with the constant, so a new built-in fails the test until the list follows. b. Description: a plain string (Design note 17, ruling on the translation map), at least one rune that is not white space (400,/description,too_short),kernel/textcheck.DisplayText(control and bidi characters:400,/description,invalid, SEC-IN-4), at mostcommand.description_max_charsUnicode characters (400,/description,too_long). c. Scope:scope_kindone ofinstance,space,channel(TargetKindalso hashost:400,/scope_kind,invalid_enum);scope_idabsent forinstance(400,/scope_id,invalid) and required for the others (400,/scope_id,required). Defaults when absent:scope_kind = instance,options = [],required_level = trigger,control = false(03 defaults). d. Options (command_options.v1is exactly the array of openapiCommandOption; Ruling for docs/DECISIONS.md): option names follow the command name pattern and are unique within the command (/options/<i>/name,pattern_mismatchorinvalid); option descriptions follow rule b;choicesonly forstring,integerandchoice(/options/<i>/choices,invalid); achoiceoption needs non-emptychoicesorautocomplete = true;autocompleteonly forstring,integerandchoice; each choice name follows rule b, each choice value is valid for the type (note 10 i) and unique; at mostcommand.autocomplete_max_choicesstatic choices per option (too_many_items), because the client shows them in the same list as the suggestions of the owner (09 10.5 point 4). e. Size: the encodedCommandChangedPayloadof the command must fitsync.event_payload_max_bytes, else400, pointer empty,too_large. Reason: 04 2.2 rule 2, the appender rejects a too large payload, and a command whose change cannot be announced must not be stored. No key limits the number of options otherwise;http.max_json_body_bytesbounds the body. -
Register, one unit of work (txn.UnitOfWork.Do; deadlocks retried bydatabase.tx_retry_max): a. Definition rules of note 3 including the defaults of 3 c (400). They are pure and read no stored state, so they run before authorization without revealing anything; the scope facts of b come from the validated definition, because the guard of note 8 c knows onlyinstance,spaceandchannelwith a scope ID. b.authorize(actor, command.register, Resource{Kind: command, OwnerID: actor, Command: &CommandFacts{OwnerKind, ScopeKind, ScopeSpaceID or ScopeChannelID}})with the facts of a (note 8). Humans get403 forbidden(subject_kind_not_allowed); an invisible or unknown scope target gives404; a visible target without the right of note 8 c gives403 forbidden(level_insufficient). Denials are audited by the authz layer (05 Audityes, R-AZ-04). Then reserved names (409). c. Lock the owner'sprincipalsrow through the identity port (LockPrincipal), then count the owner's live commands; atcommand.max_per_principalanswer400 validation_failedwith pointer empty anderrors[].code = limit_reached. Reason: without the lock two parallel registrations at the limit both pass (READ COMMITTED, 03 1 point 11); the owner row serializes them for agents and apps alike (03 6.3 point 2). d. Insert with a new UUIDv7 andversion = 1. A violation of the unique index answers409 conflictwithoutreason(06 5 point 7: a reason only where one is defined; Ruling for docs/DECISIONS.md). e. Appendcommand.changed(note 15) with changeregistered; audit throughauditing.Recorder.Record: actioncommand.register, decisionallow, resourcecommandand its ID, attributeschange(registered),scope_kind,scope_space_idorscope_channel_id,control,required_level,version; never name, description, options or choices (SEC-AUD-5 allows no names and no free text; Ruling for docs/DECISIONS.md). f. Answer201withCommand. A replay with the sameIdempotency-Keyis answered by the M1-20 middleware. -
Update(updateCommand, PUT,IfMatchRequired): first the rules of note 3 on the new definition including the defaults of 3 c (400), before the command is loaded, so an invisible command and an unknown ID answer alike; then load the live command (missing or removed:404); firstauthorize(actor, command.read, Resource{Kind: command, ID, OwnerID: stored owner, Command: facts of the stored command})(visibility of note 8 b on what exists;not_foundgives the same404as an unknown ID); thenauthorize(actor, command.register, Resource{Kind: command, ID, OwnerID: stored owner, Command: facts of the validated new definition})(another principal that sees the command gets403 forbiddenwithnot_selforsubject_kind_not_allowed; for the owner the target guard of note 8 c checks the new scope only, so an owner that lost the stored target can still move its command);ParseIfMatch: not equal toversiongives412 version_conflictwithcurrent(errs.VersionConflict); reserved names (409); a definition identical to the stored one is a no-op (200, no write, no event, no audit; Ruling for docs/DECISIONS.md); otherwise compare-and-swap onversion,version + 1, the unique index as in note 4 d,command.changedwith changeupdated, audit as note 4 e withchange = updated;200withCommandandETag(FormatETag). The owner and the ID never change. -
Remove(removeCommand, DELETE): load including removed rows (unknown:404);authorize(actor, command.remove, Resource{Kind: command, ID, OwnerID, Command: facts of the stored command})(also for removed rows; not visible gives the same404as an unknown ID); a command already removed answers204without write, event or audit (06 7: DELETE of a deleted resource with a deletion mark); otherwiseremoved_at = now(),version + 1,command.changedwith changeremovedand the command as it was with the new version, audit actioncommand.removewithchange = removed;204. Runs keeptriggering_command_id(soft removal). -
Get(getCommand): load the live command (removed or unknown:404),authorize(actor, command.read, Resource{Kind: command, ID, OwnerID, Command: scope facts});not_foundreturns the same error value as an unknown ID (02 9.2);200withCommandandETag. -
Authorization in
authz(05 rule 1.1; M1-28 owns the function; this ticket extends it): a.kernel/authorize:// CommandFacts describes a slash command for command.read, command.register and // command.invoke (09 10). Zero values mean "not applicable". type CommandFacts struct { OwnerKind string // "agent" or "app" ScopeKind string // "instance", "space", "channel" ScopeSpaceID ids.ID ScopeChannelID ids.ID OfferedInChannelID ids.ID // command.read per item of a channel list (note 9) RequiredLevel string // command.invoke Control bool // command.invoke ActiveRun bool // command.invoke: the target thread has a run in queued, running or waiting_input }Resource.Command *CommandFacts; the owner isResource.OwnerID. b. Visibility of the resource kindcommand(replaces the row of M1-28 ruling D4; add the bullet to 07 4.3; Ruling for docs/DECISIONS.md):- Without
OfferedInChannelID(getCommand,updateCommand,removeCommand,registerCommand, and the receive check ofcommand.changed, whose resourceResourceFromEventbuilds from the event scope): visible when the principal isOwnerID, or is an effective owner, or sees the scope target:readon the channel for a channel scope, the space visible after 07 4.3 for a space scope, and any authenticated principal for the instance scope. The path comes from the scope facts, or from the event scope when there are no facts; the event scope ofcommand.changedis therefore exactly the scope target (note 15). Reason: 05 2.6 calls command data metadata, and 04 7.2 already delivers events without scope to everyone before the per-event check. In request mode a missingResource.Commandon kindcommandis an internal error; only the filter-mode receive check takes the path from the event scope (same rule as M3-03 forfile). - With
OfferedInChannelID= C (per item oflistChannelCommands): visible when the principal hasreadon C and the command is offered in C after 09 10.3: C is not a DM channel, the owner principal is active, and either the owner is C's effective operator; or the owner is an agent, C hasallow_mentioned_agents, the owner's own effective level on C is at leastwrite(operator rule included, 07 2.9) and its billing mode isorganization(05 5 point 3, R-AZ-19); or the owner is an app with effective level at leastwriteon C (07 2.10). The owner's level comes from the owner's snapshot without session, the same mechanism theagent.invokeguard of M1-28 uses for guests. The scope coverage is filtered by the caller's query (note 9) and checked here again. c.command.registerguard (09 10.2 "Scope only on targets that the agent operates or on which it haswrite"; Ruling for docs/DECISIONS.md):instance: no further condition, because the list rule of b decides where an instance-scoped command appears;spaceS: S exists and is visible to the principal (elsenot_found), and the principal is S's default operator or has effectivewriteon the space path (elseforbidden,level_insufficient);channelC: C exists, is not a DM channel and is readable (elsenot_found), and the principal's effective level on C is at leastwrite(operator rule included; elseforbidden,level_insufficient). The requirement itself staysAny(AgentSelf, AppSelf)(M1-28 text mapping); the app scopecommands:writeis checked by M1-28's scope table. d.command.readrequirement by kind:ByKind{channel: Level(read), command: Auth}; oncommandthe visibility of b decides. e.command.invoke: replaceDeferredwithCommandLevel(new atom:Level(r.Command.RequiredLevel); a missingr.Commandor an unknown level is an internal error) and the guard:OwnerKind == appis an internal error until M2-25 replaces it (fail closed; no app owner can exist before M2-24); a model command (Control == false) withActiveRungivesforbiddenwithrun_active(07 4.1). The read-only rule of 05 1.8 applies through M1-28's generic step (channel_read_only,channel_archived). Removecommand.invokefrom the golden list of deferred actions;command.autocompletestays deferred (M2-17). f. Tests ininternal/authzwith the M1-28 fakes, including the app Tracker of its test world for the app branch of b.
- Without
-
List (
ListForChannel, 09 10.3, 07 7.4 points 3 and 4): a. Built-in commands are not part of the list (Ruling for docs/DECISIONS.md; update 09 10.3 and the description oflistChannelCommands):Commandrequiresid,owner_principal_idandversion, which a built-in does not have (03commandsstores commands "by agents or apps"); each built-in maps to an action with its own operation (09 10.1 column "Action"),/statusexplicitly needs no server call, and their names and descriptions are translated in the client. The client shows the fixed list of 09 10.1 to everyone withcommand.read(M4-21). With this reading the clause "The control commandstopadditionally checksrun.cancel" of 05command.invokeholds by construction:/stopiscancelRun. b.authorize(actor, command.read, Resource{Kind: channel, ID: C})in request mode (route proof, 02 9.3);not_foundgives the same404as an unknown channel. c. Query the live commands whose scope covers C (instance;spacewith C's space;channelwith C; a DM channel is covered by no command, because agents never act in DMs, 05 5 point 2) and, per owner and lower-case name, keep only the most specific one (channelbeforespacebeforeinstance; Ruling for docs/DECISIONS.md: two entries with the same owner handle could not be told apart, and the narrower registration states the owner's intent for that channel).Resolveapplies the same rule. d. Per remaining commandauthorize(actor, command.read, Resource{Kind: command, ID, OwnerID, Command: &CommandFacts{OwnerKind, ScopeKind, ScopeSpaceID, ScopeChannelID, OfferedInChannelID: C}})in filter mode (authorize.Filter(ctx)); the result depends only on owner and C, so memoize it per owner within the request. Anot_foundhere is the normal outcome of the list rule and not afilter_mismatch, because the query filters only by scope. e. Order (Ruling for docs/DECISIONS.md): the commands of C's effective operator first, then the others; within each group by lower-case name, then owner principal ID, then command ID. The client qualifies equal names of other owners with the handle (/handle:name, 03commands). 09 10.3 speaks of the list "for a thread", butlistChannelCommandshas no thread parameter; "operator" here is C's effective operator as in the last paragraph of 03commands. In a thread whose fixed operator differs (R-DM-12), the invocation still follows the thread's operator throughagent.invoke(note 10 f); the report lists this as a spec gap. f.200withCommandList; no paging and no totals (06 4 point 2); a DM channel gives an empty list. -
Invoke(invokeCommand), one unit of work, in this order: a.agents.Commands.Resolve(CommandID, ChannelID); not usable gives404with the same body as an unknown ID. b. Target thread: withthread_id,chat.Threads.Info; missing, or its channel is notchannel_id, gives the same404. Withoutthread_id: in a forum channel, or in a text channel for a model command while the channel'sauto_threadis set, first checkchannel.readon C in filter mode (an invisible channel gives the same404, 05 rule 2). Then answer400 validation_failedwith pointer/thread_idand coderequired. Otherwise the target is the main thread of the text channel. Readauto_threadin this unit of work from the same channel switches the message acceptance of M2-05/M2-08 reads (Switches.AutoThread,chatport). Reason (Ruling for docs/DECISIONS.md; change the description ofCommandInvoke.thread_idand 09 10.4 accordingly): 03channels.auto_threadrequires that a triggering in the main thread creates a thread, and 05 rule 6 counts a command as a triggering. 09 3.7 starts that thread from the triggering message (parent_message_id = M), and 01 and M1-55 let a text-channel thread grow only from a message. A command has no message, so a model command needs an explicit thread there. A control command creates no run, anchor or session (09 10.4), which is the only thingauto_threadplaces (09 3.7; same reason as M2-25). Its event therefore keeps the main thread. c. For a model command, lock the target thread row (chatport) before reading the active run, so the order of locks equals message acceptance (thread, thenruns; 03 6.3) and the active-run fact cannot change before the run is opened. d. Facts: the active run of the target thread (any agent; this module's repository); the owner's agent facts (agentsport); thenDecideCommandof M2-04 withCommandTrigger{CommandID, ThreadID, Owner, Control, ActiveRun}. e.authorize(actor, command.invoke, Resource{Kind: thread, ID: T, ChannelID: C, Command: &CommandFacts{OwnerKind, RequiredLevel, Control, ActiveRun}})in request mode (route action, proof). f. For agent commandsauthorize(actor, agent.invoke, Resource{Kind: thread, ID: T, Invoke: &InvokeFacts{AgentID: owner, TriggerOrigin: "human" for a principal of kind user (else its kind), MentionsAgent: true, ActiveRunAgentID: zero, NewThread: false}})in request mode (06 4 point 7). Ruling for docs/DECISIONS.md: a command addresses its owner explicitly, like a mention, so the operator's commands work regardless ofrequire_mentionand a guest's only under the guest rule (allow_mentioned_agents,write,organization), which matches the list of 09 10.3; the active-run rule of 05 5 point 8 is applied by e, so guard 7 of M1-28 is not used for commands, and a control command reaches its owner during the run of any agent. g. Outcome mapping (06 4 point 6; Ruling for docs/DECISIONS.md):not_foundof e or f gives404;forbiddenwithchannel_read_onlyorchannel_archivedgives403 channel_read_only(M1-28 mapping); every otherforbiddenof e or f (level_insufficient,run_active,agent_not_reachable,origin_not_human,billing_mode_personal_not_holder,tool_class_owner_only,project_not_ready, ...) gives403 agent_invocation_deniedwithreasonequal to the decision's reason. Reuse the mapping M2-09 added forrenewInvocationTokenif it exists. WhenDecideCommandrejects withrun_activebut e allows, fail closed with403 agent_invocation_deniedandreason: run_activeand logcommand_decision_mismatchwithout IDs (same rule as M2-05 ruling 14). The audit of every denial is written by the authz layer (05 Auditdenials). h. Option values (note 10 i), then execution (notes 11 and 12), then202withCommandInvocation{invocation_id, command_id, channel_id, thread_id, run_id (model only), accepted: true}.invocation_idis a new UUIDv7 per accepted invocation; it is not stored in a table (03 has none) and appears asinvocation_idinCommandInvokedPayloadand ascommand_invocation_idinAgentInvocationPayload.acceptedis alwaystrue; a refusal is an error response (Ruling for docs/DECISIONS.md). A replay with the sameIdempotency-Keyreturns the stored202and writes nothing (M1-20). i.ValidateOptionValues(runs/domain, pure; Ruling for docs/DECISIONS.md): every required option present (/options,required,detailnames the option, never a value); unknown option name (/options/<i>/name,unknown_member); an option twice (/options/<i>/name,invalid); per type:stringafterkernel/textcheck.DisplayText(invalid);integerthe canonical decimal form of a signed 64-bit integer (invalid_format);booleanexactlytrueorfalse(invalid_enum);choiceone of the static values unlessautocompleteis set, then anystringvalue (the owner validates;invalid_enum);usera UUID in canonical form, existence not checked because the check would reveal accounts and the owner resolves the ID with its own rights (invalid_format);filegivesnot_enableduntil files arrive (06 5 point 3). The values keep the order of the definition. The encoded event payload of note 11 or 12 must fitsync.event_payload_max_bytes, else/options,too_large. -
Control command (09 10.4 row
true): appendcommand.invoked:scope {space_id, channel_id, thread_id}of T,resource {kind: command, id}withoutversion(04 9 point 3),audiencethe owner,receive_actionagent.events.consume(catalogue),actorthe invoker,correlation_idof the request; payloadCommandInvokedPayload{invocation_id, command_id, command_name, control: true, channel_id, thread_id, run_id, invoked_by, options}whererun_idis the owner's active run in T when there is one and absent otherwise (05 section 5 rowrun_id, R-AZ-25: the adapter learns which run the command concerns; token and payload carry the same value, 09 4.5 check point 6). Nothing else is written: no run, no anchor, norun_followupsrow, notrigger_state. An answer comes from the agent as a message (M2-19). -
Model command (09 10.4 row
false), in the locked unit of note 10 c: a.Open(OpenCmd{ThreadID: T, AgentID: owner, TriggeredBy: actor, TriggerKind: "command", TriggeringCommandID: command ID, BillingModeExpected: the owner's current billing mode})of M2-05, extended byTriggeringCommandID. It writes the runqueued, the anchor in T (CreateRunAnchor),run.state_changedand, after M2-08, the jobruns.queued_expiry. Notrigger_statechanges, because the run has no triggering message (M2-05 note 4). b.ErrActiveRunExists(a path that opened a run without the thread lock): call e of note 10 again withActiveRun = true; its denial (run_active) is audited by the authz layer and answered as in note 10 g; nothing of this unit is written. c. Appendagent.invocationwith M2-08's builder: audience the owner,receive_actionagent.events.consume,resource {kind: run, id}withoutversion,scopeof T,actorthe invoker; payloadAgentInvocationPayload{run_id, thread_id, channel_id, space_id, trigger_kind: command, command_invocation_id, command_id, command_name, options, triggered_by, billing_mode, settings_version}withoutmessage_id.command_id,command_nameandoptionsare the new members ofAgentInvocationPayload(Scope; 09 4.4 "trigger(kind, IDs, triggering person)", 09 10.4 "the parsed options", 09 4.5 check point 6 compares the triggering object with the payload).agent_sessioncomes with M2-12 through the same builder. d. The token is never written: the delivery issues it (note 13). -
Delivery (extend M2-08's
Prepareand M2-06's issuer; 04 15.3 step 2, 05 5, 07 8.1): a. Resolvercommand.invoked: trigger =invoked_by.idof the payload; place = the thread of the payload, resolved now (07 4.2);InvokeFacts{AgentID: audience, TriggerOrigin: human, MentionsAgent: true, ActiveRunAgentID: zero, NewThread: false}; run = the payload'srun_idwhen present. No run row is locked and nojtiis recorded (M2-08 matrix: "jti never recorded"). The check isagent.invokewith M2-08's re-check principal in filter mode, notcommand.invoke: 04 15.3 step 2 namesagent.invoke, and the active-run rule belongs to the invocation, not to the delivery. b. Resolveragent.invocationwithruns.trigger_kind = command: trigger =runs.triggered_by; triggering object = the payload'scommand_id(equal toruns.triggering_command_idat creation; the payload is immutable, while the column may become NULL byON DELETE SET NULL);InvokeFacts{..., MentionsAgent: true, NewThread: false, ActiveRunAgentID: runs.agent_id}. Everything else follows M2-08 (recordjtiwhilequeued; a denial fails the run with the mapped reason; notrigger_state, because there is no triggering message). c. Token claims (05 5):trigger_kind = command,command_id, nomessage_idorinteraction_id;run_idfor a command run, and forcommand.invokedexactly when the payload names a run (05 section 5, R-AZ-25). Give M2-06'sInvocationGranta fieldControl bool: acommandgrant withoutRunIDis valid only withControl = true; every other grant keeps the M2-06 table (fail closed). Adjust the reference verifier only if it compares an absentrun_id. d. A denial ofcommand.invokedsends nothing, changes no run, writes one audit entry as M2-08 note 8 (actionagent.invoke, decisiondeny, the exact reason, resourcethread, attributescommand_id,agent_id,stage = delivery,event_type = command.invoked,trigger_kind = command,run_idwhen present) and countsbothy.agent.invocation.deniedwith the mapped reason; the event counts as checked and not in flight (M2-08 note 9). -
PrincipalRefof the invoker.invoked_byandtriggered_byarePrincipalRef(id,kind,handle,display_name), andrunsmay not useidentity(02 4.4). Use the port through which M2-08 buildstriggered_by; if it only renders authors of messages, addPrincipalRefsto themessagingport (Scope), becausemessagingmay useidentityandrunsmay usemessaging. Ruling for docs/DECISIONS.md if you add it. -
command.changed(05 6, catalogueresource_version: true):scopeequal to the scope target (instance: no member;space:space_id;channel:space_idandchannel_id);resource {kind: command, id, version: new version};receive_actioncommand.read; no audience;actorthe owner; payloadCommandChangedPayload{command, change}. The receive check uses the visibility of note 8 b withoutOfferedInChannelID. -
Concurrency. Registrations of one owner serialize on the owner's
principalsrow (note 4 c); updates are compare-and-swap onversion; removal is idempotent; two invocations of model commands in one thread serialize on the thread row, and the partial unique index of R-DM-14 is the last line (the loser getsrun_active); control commands write only an event and take no lock. Deliveries of one command event to two consumers of the same agent give two tokens with differentjti(M2-08, AG-09). -
Rulings for
docs/DECISIONS.md(section 10, one rowD-M2-16-<n>each, with reason and place):NULLS NOT DISTINCTon the unique index (note 2); definition and option rules including the static-choice cap and the size rule (notes 3 d, 3 e);descriptionis a plain string as inCommandRegisterand 03, and the translation map of 09 10.2 and the manifest example of 11 3.3 wait for the owner (reported as a spec gap); duplicate gives409 conflictwithout reason, the limit gives400 validation_failedwithlimit_reached, registrations serialize on the owner row (note 4); audit details without names or texts (note 4 e); identical PUT is a no-op, DELETE of a removed command204(notes 5, 6); visibility of the resource kindcommand(note 8 b); target rule ofcommand.register(note 8 c); built-ins are a client list (note 9 a); shadowing and order of the list (notes 9 c, 9 e); target withoutthread_id: main thread for control commands, and for model commands only withauto_threadfalse, otherwise400/thread_idrequired(note 10 b); command invocation counts as an explicit address and the order of checks (notes 10 e, 10 f); outcome mapping toagent_invocation_denied(note 10 g);invocation_idandaccepted(note 10 h); option value rules (note 10 i);AgentInvocationPayloadgetscommand_id,command_nameandoptions(note 12 c); delivery re-checksagent.invokeonly (note 13 a); themessagingport method if added (note 14).
Tests (write them first)¶
The test world is 07 "Examples" as the harness of M1-71 builds it (services/server/test/harness, BuildMatrixWorld; M1-71 is in the closure through M2-02): spaces Support (Allgemein, Eskalation), Marketing (Kampagnen), Engineering (forum Projekt-A, text Werkzeuge with operator Runner), Research (Notizen); agents Lotse (operator of Support, organization, chat), Texter (operator of Marketing, organization, scoped), Runner (operator of Engineering/Werkzeuge, organization; the SR2 test sets its tool class to host), Assistent (operator of Research, personal, holder the Owner); G-1 (Alex, trigger on Support), G-2 (Sam, trigger on Marketing), G-3 (Robin, trigger on Engineering/Projekt-A). The test clock of kernel/clock drives time. The adapter side is wstest with the agent's API token and a consumer name, or pollAgentEvents; tokens are verified with M2-06's runstest.ReferenceVerifier against getJwks.
Unit:
internal/agents/domain:TestReservedNamesMatchChapter09(reads the first column of the table in 09 10.1 between the headings "### 10.1" and "### 10.2" and compares it with the constant);TestValidateDefinition(a table over every rule of note 3 with pointer and code, and thatdetailnever contains the submitted value);TestScopeCoversAndSpecificity(coverage per scope kind; channel before space before instance per owner and name).internal/runs/domain:TestValidateOptionValues(a table over every rule of note 10 i, includingfilewithnot_enabled, a choice with and withoutautocomplete, the canonical integer form, and a string with a bidi character);TestCommandInvokedPayloadandTestAgentInvocationPayloadCommand(validate againstCommandInvokedPayloadand the extendedAgentInvocationPayload, no JSONnull,run_idpresent exactly as note 11 says, nomessage_id);FuzzValidateOptionValues(no panic; a value with a control or bidi character is never accepted).internal/runs/domain(M2-06 issuer):TestCommandGrantWithoutRunOnlyForControl: acommandgrant withoutRunIDsigns withControl = trueand is an internal error without it; the claims carrycommand_idand nomessage_id.internal/authz:TestCommandVisibility(both modes of note 8 b: owner, effective owner, channel, space and instance scope; offered as operator, as guest with and withoutallow_mentioned_agents, apersonalagent never as guest, a disabled owner never, the app Tracker with and withoutwrite, a DM channel never; acommandresource withoutCommandfacts in request mode is an internal error);TestCommandRegisterTargetGuard(each case of note 8 c with its outcome and reason);TestCommandInvokeRequirement(required_levelwriteandtrigger;run_activeonly for a model command withActiveRun; a control command withActiveRunallowed; read-only and archived channels givechannel_read_only; an app owner is an internal error);TestDeferredActionsGoldenupdated (nocommand.invoke);TestCommandReadByKind.
Integration (PostgreSQL 18 via pgtest, real modules through internal/wire, HTTP and gateway through the real chain):
TestAG24_ReservedNamesRefused(AG-24): Lotse registers each ofstop,status,new,resume,title,close:409 conflictwithreason: command_name_reserved; no row, nocommand.changed, no allow audit entry; renaming an existing command tostopwithupdateCommandgives the same answer and leaves the command unchanged. Keep the red run of this test for the report (M2-31 asks for it).TestRegisterCommand_Created: Lotse registersescalate(instance scope, model command, required optionticketof typestring, optionpriorityof typechoicewith static choices):201withversion = 1; exactly onecommand.changedwith changeregistered,resource.version = 1, no scope member, receive actioncommand.read, payload valid againstCommandChangedPayload; exactly one audit entrycommand.register(allow) whose details hold onlychange,scope_kind,control,required_level,version(the stringsescalateand the description appear nowhere inaudit_events).TestRegisterCommand_Validation: every400case of note 3 through HTTP with pointer and code (description too long and with a bidi character,scope_kind = host,scope_idmissing and superfluous, duplicate option names, choices onboolean, achoicewithout choices and without autocomplete, more static choices thancommand.autocomplete_max_choices, a payload larger thansync.event_payload_max_bytes); no row is written.TestRegisterCommand_DuplicateAndNulls: a second liveescalateof Lotse with instance scope gives409 conflictwithoutreason(provesNULLS NOT DISTINCT); the same name with channel scope on Support/Allgemein is201; afterremoveCommandthe instance name can be registered again.TestRegisterCommand_LimitPerPrincipal: withcommand.max_per_principalset low through the test configuration, the next registration gives400 validation_failedwitherrors[].code = limit_reached; removed commands do not count; two parallel registrations at one below the limit give exactly one201.TestRegisterCommand_TargetRule: Lotse on Support/Allgemein (operator)201; on Marketing/Kampagnen404with the body of an unknown target; on space Support (default operator)201; on space Marketing404; Texter with awritegrant on Support/Eskalation201there; Texter with only areadgrant on Support/Allgemein gets403 forbiddenwithlevel_insufficientthere; instance scope201for every agent.TestRegisterCommand_HumansRefused: Alex and the Owner get403 forbidden(subject_kind_not_allowed) and a deny audit entry.TestUpdateCommand: withoutIf-Match428; with a stale version412 version_conflictwithcurrent; with the current version200,version + 1,ETag,command.changedwith changeupdated, one audit entry; an identical body gives200without event and audit; Texter updating Lotse's instance-scoped command gets403 forbidden(not_self), and Lotse's channel-scoped command on Support/Allgemein, which Texter cannot see,404. Texter's PUT on Lotse's command scoped to Support/Allgemein with a body withoutscope_kindand with a Marketing/Kampagnen scope gives404with the same status, body and headers as an unknown ID; Texter, afterrevokeGrantof itswritegrant on Support/Eskalation, moves its Eskalation command to instance scope with200.TestRemoveCommand:204,removed_atset,version + 1,command.changedwith changeremoved, one audit entrycommand.remove; a secondremoveCommandby Lotse gives204without event or audit;getCommandthen gives404; a run that referenced the command keepstriggering_command_id. Texter removing Lotse's Support/Allgemein command gives404equal to an unknown ID; Texter removing Lotse's instance-scoped command gives403 forbidden(not_self).TestGetCommand_Visibility: Lotse sees its commands; Alex sees an instance-scoped command and a command scoped to Support/Allgemein, but a command scoped to Marketing/Kampagnen gives404with the same status, body and headers as an unknown ID;ETagequalsversion.TestCommandChanged_Delivery: throughlistEvents(and awstestconnection of Alex): Alex receivescommand.changedof Lotse's instance-scoped and Support-scoped commands; Robin does not receive the Support-scoped one; Lotse receives its own.TestListChannelCommands_Rule10_3: Support/Allgemein lists Lotse's instance, space-Support and channel-Allgemein commands but not its command scoped to Eskalation; Texter's command appears only afterputChannelBehaviorsetsallow_mentioned_agentsand Texter has awritegrant; Assistent with awritegrant never appears as guest; afterdisableAgentfor Texter its commands disappear; equal names of Lotse and Texter appear both, Lotse's first; Lotse'sdeploywith instance and channel scope appears once, the channel one; no built-in name appears; Robin gets404with the body of an unknown channel.TestInvokeModelCommand_CreatesRun: Alex invokes Lotse'sescalatein a thread T of Support/Allgemein with valid options:202withinvocation_id,run_id,thread_idT,accepted: true; the run isqueuedwithtrigger_kind = command,triggering_command_id,triggered_byAlex, notriggering_message_id; one anchorkind = runin T;run.state_changed; oneagent.invocationwhose payload hastrigger_kind = command,command_invocation_idequal toinvocation_id,command_id,command_name, the options in definition order and nomessage_id; nocommand.invoked; no message changed itstrigger_state; aruns.queued_expiryjob exists. Lotse's consumer receivesagent.invocationwith a token whose claims carrytrigger_kind = command,command_id,run_id,subAlex, and whosejtiequalsruns.invocation_jti.TestInvokeModelCommand_RunActive(plan: "a model command during a run yieldsrun_active"): with an active run of Lotse in T (opened by Alex's message), the model command gives403 agent_invocation_deniedwithreason: run_active; with an active run of Texter (guest) the same; nothing is written (no run, no event, norun_followupsrow, no anchor) and exactly one deny audit entrycommand.invokewith reasonrun_active.TestInvokeModelCommand_ConcurrentOneRun: two parallel invocations in T give exactly one202with a run and one403 agent_invocation_deniedwithrun_active; one active run in T (R-DM-14).TestInvokeModelCommand_ThreadResolution: withoutthread_idin Support/Allgemein withauto_threadset:400 validation_failed,/thread_id,required, no run, no anchor, no thread; a control command withoutthread_idwithauto_threadset:202,command.invokedscoped to the main thread; afterputChannelBehaviorsetsauto_thread = false:202, the run lives in the main thread and no thread is created; withoutthread_idin Engineering/Projekt-A400 validation_failedwith pointer/thread_idand coderequired; athread_idof another channel gives404.TestInvokeControlCommand_NeverToModel(plan: "control commands never go to the model"): Lotse's control commandusagewithout a run:202withoutrun_id; no run, no anchor, noagent.invocation, norun_followupsrow; exactly onecommand.invokedwith audience Lotse,control: true,resource.kind = commandwithout version, receive actionagent.events.consume, norun_id. During an active run of Lotse:202, the event carries thatrun_id, and the run keeps its state,versionandinvocation_jti. During an active run of Texter:202, delivered withoutrun_id. Lotse's consumer receives eachcommand.invokedwith a token:trigger_kind = command,command_id,run_idexactly as in the payload, verified by the reference verifier.TestInvokeCommand_Rights: Robin gets404equal to an unknown command ID; Alex invoking Lotse's command in Marketing/Kampagnen404; Sam with only awritegrant on Support/Eskalation gets403 agent_invocation_deniedwithlevel_insufficientfor atriggercommand and for awritecommand (agent commands needtrigger, 05 5 point 2); after a raw SQL statement on thepgtestconnection setschannels.read_only = truefor Support/Allgemein (asTestInvokeCommand_I2does; the operationsetChannelReadOnlycomes with M8-03, and the authz structure source readsread_onlyper request, M1-53 D7),403 channel_read_only; afterarchiveChannel403 channel_read_onlywithreason: channel_archived; Texter's command in Support/Allgemein without guests allowed gives403 agent_invocation_deniedwithagent_not_reachable; a command scoped to Eskalation invoked in Allgemein, and a shadowed command ID, give404. Each denial writes exactly one audit entry by the authz layer.TestInvokeCommand_I2(M2 red team: personal subscription over the command path, T-AI-08): 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, as M2-08's BR-04 test does): Alex's invocation gives403 agent_invocation_deniedwithbilling_mode_personal_not_holderand a deny audit entry; the Owner invoking Assistent's command in Research/Notizen gets202.TestInvokeCommand_SR2: Runner's tool class set tohostthroughsetAgentToolClass(M1-51) and atriggergrant for Alex on Engineering/Werkzeuge (createGrant): Alex invoking Runner's command there gets403 agent_invocation_deniedwithtool_class_owner_only; the Owner gets202.TestInvokeCommand_SR4: Lotse invokes a command with its agent token:403 agent_invocation_denied, no event, no run.TestInvokeCommand_OptionValidation: every400case of note 10 i through HTTP with pointer and code; no event and no run; the submitted values appear in no error body.TestInvokeCommand_Idempotency: a replay with the sameIdempotency-Keyreturns the stored202withIdempotent-Replay: trueand writes no second run or event; the same key with another body gives422 idempotency_key_reuse.TestInvokeCommand_RecheckAtDelivery(I2 before every delivery): Lotse offline; Alex invokes the control command and, in another thread, the model command; the Owner revokes G-1 (revokeGrant); Lotse connects: nocommand.invokedand noagent.invocationframe; the model run isfailedwithaccess_revoked; two deny audit entries withstage = deliveryand the event types;bothy.agent.invocation.deniedrose by two.TestCommandsNeverLeakValues: a string option value and a description chosen as unique markers appear in no log line, no span, no audit row and no error body; they appear only in the owner's events (command.changed,command.invoked,agent.invocation). The scenario ofservices/server/test/canary(M1-69) covers the same (Scope).internal/wire: golden files of event registrations, resolvers and route list updated;TestAZP1_RoutesMatchEnableList(M1-17) passes with the six enabled operations.
Commands that must pass, from the repository root:
cd services/server && go test -race -count=1 ./internal/agents/... ./internal/runs/... ./internal/authz/... ./internal/kernel/... ./internal/chat/... ./internal/messaging/... ./internal/identity/... ./internal/gateway/... ./internal/wire/...make migrations-lock,make migrations-lintmake codegen-check,make arch,make api-lint,make api-checks,make api-tables,make register-check,make catalog-check,make config-doc-check,make docsmake check
Acceptance criteria¶
- Migration
commandsexactly as 03 withNULLS NOT DISTINCTon the unique index and the FK ofruns.triggering_command_id;make migrations-lintgreen. -
registerCommand,updateCommand,removeCommand,getCommand,listChannelCommandsandinvokeCommandare enabled and registered; AZ-P1 passes;command.invokeis no longer a deferred entry. - AG-24: every reserved name gives
409 conflictwithreason: command_name_reservedon registration and on update; nothing is stored. - Scope only on served or writable targets, at most
command.max_per_principallive commands per owner (also under concurrency), duplicates409 conflict. - Every register, update and remove writes exactly one audit entry and one
command.changed; audit details carry no name, description or option. -
listChannelCommandsfollows 09 10.3: operator, permitted guests and apps withwrite, operator first, no built-ins, invisible channel404. - A model command during an active run gives
403 agent_invocation_deniedwithreason: run_active; otherwise a runqueuedwithtrigger_kind = commandandtriggering_command_id, the anchor andagent.invocationwith command ID and options. - A control command never creates a run, an anchor, a follow-up or
agent.invocation; it produces exactly onecommand.invokedto its owner, also during a run. -
agent.invoke(I2, SR2, SR4) is checked at invocation and again before every delivery; each delivery carries a fresh token withtrigger_kind = commandandcommand_id; denials are audited. - Read-only and archived channels refuse every command with
403 channel_read_only. - Payloads validate against their schemas and contain no JSON
null; option values and descriptions appear in no log, trace, audit row or error body. -
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
agents,runsandauthzupdated (the register and its rules, the list rule, the execution paths of control and model commands, the delivery resolvers, thecommandvisibility, the guards ofcommand.registerandcommand.invoke), and the README of every module whose port you extended. - Rulings of Design note 17 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>.