Skip to content

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 and settings_version), M1-65 (gateway hub), M1-64 (gateway test client wstest), M1-63 (listEvents), M1-56 (messages), M1-55 (threads), M1-54 (operators), M1-53 (channels), M1-51 (agent records, agents port), 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 and services/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" (row run_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" (rows agent.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, retention config, the last paragraph on name conflicts); runs (trigger_kind, triggering_message_id, triggering_command_id, FK triggering_command_id -> commands(id) ON DELETE SET NULL, the check on the triggering_* 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.read without scope); 3 "Rate-limit classes"; 4.10 rows command.read, command.register, command.remove, command.invoke, agent.invoke, run.cancel; 5 "Agent invocation agent.invoke" (points 1 to 8, the claim table, "Consequences on denial"); 6 rows command.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.invoked has no resource.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" (runs may use agents, chat, messaging; agents may use identity, authz, chat); 4.6 "Actions per module" (the paragraph on command.invoke and 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" (trigger covers "run commands"); 2.9 "Agents as principals"; 2.10 "Apps"; 2.11 "Tool class of an agent"; 4.1 "Decisions and reasons" (rows run_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.invoke is checked in invokeCommand; commands without the right answer 403 agent_invocation_denied with reason); 5 "Errors" (closed code list, errors[], reason); 7 "Idempotency" (POST with Idempotency-Key, PUT and DELETE idempotent, DELETE of a removed resource 204); 8 "Concurrency" points 1 and 2 (commands need If-Match); 16 "Endpoints", group "Commands"; 17 rows command.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 field commands), 3.2 point 5, 4.3 (row "Commands"), 9 "Commands and interactions for apps", 11.2 row commands (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: operations registerCommand, updateCommand, removeCommand, getCommand, listChannelCommands, invokeCommand (and autocompleteCommand, answerAutocomplete, which stay disabled); schemas Command, CommandRegister, CommandOption, CommandChoice, CommandList, CommandLevel, CommandOptionValue, CommandInvoke, CommandInvocation, CommandChangedPayload, CommandChangedEvent, CommandInvokedPayload, CommandInvokedEvent, AgentInvocationPayload, PrincipalRef, TargetKind, FieldError; parameters CommandIdPath, ChannelIdPath, IdempotencyKey, IfMatchRequired; responses Conflict, Forbidden, PreconditionFailed, PreconditionRequired; problem codes agent_invocation_denied, channel_read_only, conflict, validation_failed, version_conflict in x-problem-codes. For the built-in commands: cancelRun, resetAgentSession, bindAgentSession, setThreadTitle, closeThread, getRun.
  • docs/api/asyncapi.yaml: messages event.command.changed (x-receive-action: command.read) and event.command.invoked (x-receive-action: agent.events.consume, x-audience: self). docs/api/webhooks.md: rows command.changed and command.invoked (apps, M2-25 and M2-26).
  • docs/spec/actions.yaml on the base: entries command.read, command.register, command.remove, command.invoke (audit denials, rl.trigger) and the event types command.changed (resource_version: true) and command.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.denied and span agent.dispatch (both recorded by M2-08's Prepare, 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 keys ratelimit.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 (StageValidate error codes, StagePreconditions, FormatETag, ParseIfMatch, errs.VersionConflict, proof enforcement), M1-20 (idempotency middleware), M1-24 (auditing.Recorder.Record, auditing.Entry, Attrs value 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.Err mapping, the fake sources and the test world), M1-44 (agent tokens), M1-51 (the agents port, AgentPolicySource), M1-53 and M1-54 (channel facts and effective operator), M1-55 (chat.Threads.Info, AppendToThread holds 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 7 DecideCommand, CommandTrigger), M2-05 (Open, OpenCmd, ErrActiveRunExists, Messages.CreateRunAnchor, the deferred FK of runs.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 (port runs.Delivery, Prepare, the delivery matrix row command.invoked, the resolver table, the re-check principal, audit and counter of a delivery denial, the agent.invocation payload builder, job runs.queued_expiry).
  • Code (merged names win, D-W-100): services/server/architecture/modules.yaml (commands and command.changed belong to agents; command.invoke and command.invoked to runs), 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):

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

Rules for every brief:

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

Product requirements implemented here:

  1. The names of the built-in commands (stop, status, new, resume, title, close) are reserved: command.register refuses them with 409 conflict and reason: 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).
  2. Registration: fields after 03 commands (name, description up to command.description_max_chars, options schema command_options.v1, scope_kind and target, required_level, control); a scope only on targets the owner serves or on which it has write; at most command.max_per_principal commands per agent or app; every change is command.register or command.remove with an audit entry and command.changed (09 10.2, 05 4.10 Audit yes, 11 3.2 point 5).
  3. Visibility: command.read gives for a channel the commands of the operator, of guest agents only with allow_mentioned_agents, and of apps with write on the channel; with equal names the operator's come first, the others are qualified with the owner's handle on display (09 10.3, 03 commands, 05 4.10, 07 7.4 point 4).
  4. Control commands (commands.control = true): command.invoked with 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).
  5. Model commands (commands.control = false): like a triggering message, a run queued and agent.invocation with trigger kind command and the parsed options; the run carries trigger_kind = 'command' and triggering_command_id; during an active run the invocation is refused with run_active, not turned into a follow-up (09 10.4, 03 runs, 05 5 point 8, 07 4.1, D-K-D20, E-AG-08).
  6. Agent commands check required_level and agent.invoke (active human, trigger, operator or permitted guest, I2, SR2, project readiness); a refusal answers 403 agent_invocation_denied with reason; refusals are audited (05 4.10 command.invoke Audit denials, 05 5, 06 4 points 6 and 7, 07 SR1, SR2, SR4).
  7. Every delivery of command.invoked and of the agent.invocation of a command run re-checks agent.invoke for the invoking human with the rights of that moment and carries a fresh invocation token with trigger_kind = command and command_id (04 15.3 step 2, 05 5 claims, 05 6 row command.invoked, 07 8.1, R-SY-07).
  8. A read-only or archived channel refuses every command for everyone, owners included (05 rule 1.8, R-AZ-12, 07 4.5).
  9. Events: command.changed (resource command with its new version, receive action command.read, change registered, updated or removed) and command.invoked (resource command without version, audience the owner, receive action agent.events.consume); payloads without JSON null and below sync.event_payload_max_bytes (05 6, 04 2.2, 04 9 point 3, ADR 0053).
  10. Rate-limit classes rl.admin (register, update, remove), rl.read (get, list) and rl.trigger (invoke); Idempotency-Key on registerCommand and invokeCommand; If-Match on updateCommand; removeCommand is 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: migration commands): table commands exactly as 03 commands (columns, types, defaults, nullability, the name check, the scope_kind check, the check that the scope columns match scope_kind, the required_level check, FKs with their ON DELETE, the unique partial index with NULLS NOT DISTINCT (Design note 2), the two indexes); a comment naming the retention class config; in the same file the FK runs.triggering_command_id -> commands(id) ON DELETE SET NULL that M2-05 deferred to this ticket. Down migration drops the FK, then the table.
  • internal/agents/commands.go (port): the interface Commands and 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 cases Register, Update, Remove, Get, ListForChannel, Resolve (Design notes 4 to 9).
  • internal/agents/adapters/postgres/queries/commands.sql with the sqlc output and repository methods.
  • internal/agents/adapters/httpapi/commands.go: strict-server methods for registerCommand, updateCommand, removeCommand, getCommand, listChannelCommands.
  • internal/runs/commands.go (port): the interface CommandExec with Invoke (Design note 1).
  • internal/runs/domain/command_exec.go: ValidateOptionValues and the payload builders of command.invoked and of the command variant of agent.invocation (Design notes 10 to 12).
  • internal/runs/app/command_exec.go: the use case Invoke (Design notes 10 to 12).
  • internal/runs/app/delivery_command.go: the delivery resolvers for command.invoked and for agent.invocation with trigger_kind = command (Design note 13).
  • internal/runs/adapters/httpapi/commands.go: the strict-server method for invokeCommand.
  • 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), chapter 09-agents.md, following the existing configkeys_*.go files.

Change:

  • internal/kernel/authorize: CommandFacts and the field Resource.Command (Design note 8), with doc comments.
  • internal/authz: replace the deferred entry of command.invoke with its requirement and guard and remove it from the golden list of deferred actions; requirement of command.read by resource kind; guard of command.register; the visibility row of the resource kind command; the facts gathering for a command owner (Design note 8); tests; README.
  • internal/runs (merged code of M2-05, M2-06, M2-08): OpenCmd gets TriggeringCommandID; the issuer accepts a command grant without run for a control command (Design note 13); Prepare handles command.invoked without a run lock; the agent.invocation payload builder gets the command variant; register the resolvers.
  • internal/chat port, only if the base has no method that locks a thread row: Threads.Lock(ctx context.Context, threadID ids.ID) (ThreadInfo, error) (SELECT ... FOR UPDATE in the ambient unit; doc comment: serializes run creation per thread with message acceptance).
  • internal/identity port, only if the base has no such method: LockPrincipal(ctx context.Context, principalID ids.ID) error (SELECT ... FOR UPDATE on principals in the ambient unit).
  • internal/messaging port, only if the base offers no way for runs to render a PrincipalRef of an arbitrary principal: PrincipalRefs(ctx context.Context, principalIDs []ids.ID) (map[ids.ID]PrincipalRef, error) (Design note 14).
  • internal/wire: handlers; event registrations command.changed (module agents) and command.invoked (module runs) through authorize.Registry.Event; the delivery resolvers; the bindings of agents.Commands into runs; update the golden files of registrations.
  • api/operations.yaml: move getCommand, invokeCommand, listChannelCommands, registerCommand, removeCommand, updateCommand from later to enabled (sorted); rerun the route generator. autocompleteCommand and answerAutocomplete stay in later (M2-17).
  • docs/api/openapi.yaml (spec first): AgentInvocationPayload gets the optional members command_id (Uuid), command_name (string) and options (array of CommandOptionValue), each described as "set exactly for trigger_kind = command" (Design note 12); descriptions of listChannelCommands (Design note 9: registered commands only, order, shadowing), of CommandInvoke.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 when auto_thread is false; otherwise 400 validation_failed (/thread_id, required), because a command has no message from which the auto_thread thread could start."), of CommandInvocation.invocation_id and accepted (Design note 10 h), of registerCommand and updateCommand (409 conflict with reason: command_name_reserved, 409 conflict without reason for a taken name), of CommandInvokedPayload.run_id (Design note 11). Then make api-lint, make api-checks, make api-tables and the server code generation of M1-17; make codegen-check shows no diff.
  • docs/spec/09-agents.md (English, like the chapter; not core contract): 4.4 row agent.invocation (command ID, name and options for trigger_kind = command) and row command.invoked (run_id as 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 without thread_id: main thread for control commands, and for model commands only with auto_thread false, otherwise 400 validation_failed with /thread_id required, Design note 10 b; order of checks, run_id of control commands, invocation_id).
  • docs/spec/07-permissions.md 4.3 "Visibility": one bullet for the resource kind command (Design note 8 b).
  • docs/operations/configuration.md: run make config-reference if a chapter table changed; make config-doc-check green.
  • 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, add getCommand and listChannelCommands.
  • READMEs of agents, runs, authz and of every module whose port you extend; services/server/CHANGELOG.md under "Unreleased"; docs/DECISIONS.md section 10 rows D-M2-16-<n> (Design note 17).

Out of scope

  • Autocomplete: autocompleteCommand, answerAutocomplete, the ephemeral command.autocomplete_requested, the op command.autocomplete_result, the deferred entry of command.autocomplete and tokens with trigger_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.invoked to apps, interactions_url, scope check without agent.invoke and 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 (cancelRun of M2-09, resetAgentSession and bindAgentSession of M2-12, setThreadTitle and closeThread of 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 schema RunContext requires trigger_message, which a command run does not have, and M2-13 (not in this ticket's dependency closure) answers an internal error for runs with trigger_kind other than message until the command case is specified. The case needs an owner decision on run_context.v1 first; this ticket builds and tests everything up to the delivered agent.invocation and names the missing context under "What is not proven" and as a spec gap.
  • Command options of type file: invoking them answers 400 validation_failed with errors[].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: run make migrations-lock after creating the file and before make check; never edit a merged migration (M1-01, ADR 0007).
  • Work in a dedicated git worktree on the branch feat/M2-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 of AGENTS.md.
  • Do not modify files outside the scope above, except the CHANGELOG, README and docs/DECISIONS.md entries named in this brief.
  • Follow the host rules supplied by the launcher (ports, machine rules).
  • Tests bind only 127.0.0.1 and port 0; PostgreSQL runs only as a throwaway container on a random port through pgtest (P-09, P-10). Without a Docker-compatible runtime the integration tests fail; they never skip.
  • Stop only processes you started, by PID. Temporary directories only via mktemp -d, removed at the end.
  • Never read or print secrets. Never log, print or persist an invocation token 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 wstest of M1-64 with an agent token and a consumer name (M2-07), or the REST poll pollAgentEvents; 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.invoke or 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

  1. Start checks, in this order; each failure ends the ticket with Verdict: BLOCKED <reason>: a. make check is green on the fresh worktree. b. M2-08 is merged (runs.Delivery and its Prepare exist), with M2-04's DecideCommand, M2-05's Open and 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 row run_id, says that the claim is absent for command.invoked when 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.

  2. Ports and placement (02 4.3, 4.6). agents owns the register; runs owns execution; authz owns 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.

  3. Migration. Follow 03 commands literally, 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. Without NULLS NOT DISTINCT two live instance-scoped commands with the same name and owner would pass, because the coalesce is NULL for instance and PostgreSQL treats NULLs as distinct; 03 uses the clause for stats_minutes for the same reason. Ruling for docs/DECISIONS.md; the report lists it as a spec gap for 03. The FK of runs.triggering_command_id joins the same migration. Rows are never deleted by the product: removal sets removed_at (03 1 point 7 by analogy, FK from runs).

  4. Definition rules (agents/domain, pure; codes of errors[] from M1-18's list plus limit_reached and not_enabled of 06 5; detail never repeats a submitted value): a. Name: the pattern of 03 and CommandRegister (StageValidate already rejects others). Reserved names: stop, status, new, resume, title, close, compared exactly (names are lower case by the pattern): 409 conflict with reason: 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 most command.description_max_chars Unicode characters (400, /description, too_long). c. Scope: scope_kind one of instance, space, channel (TargetKind also has host: 400, /scope_kind, invalid_enum); scope_id absent for instance (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.v1 is exactly the array of openapi CommandOption; Ruling for docs/DECISIONS.md): option names follow the command name pattern and are unique within the command (/options/<i>/name, pattern_mismatch or invalid); option descriptions follow rule b; choices only for string, integer and choice (/options/<i>/choices, invalid); a choice option needs non-empty choices or autocomplete = true; autocomplete only for string, integer and choice; each choice name follows rule b, each choice value is valid for the type (note 10 i) and unique; at most command.autocomplete_max_choices static 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 encoded CommandChangedPayload of the command must fit sync.event_payload_max_bytes, else 400, 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_bytes bounds the body.

  5. Register, one unit of work (txn.UnitOfWork.Do; deadlocks retried by database.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 only instance, space and channel with 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 get 403 forbidden (subject_kind_not_allowed); an invisible or unknown scope target gives 404; a visible target without the right of note 8 c gives 403 forbidden (level_insufficient). Denials are audited by the authz layer (05 Audit yes, R-AZ-04). Then reserved names (409). c. Lock the owner's principals row through the identity port (LockPrincipal), then count the owner's live commands; at command.max_per_principal answer 400 validation_failed with pointer empty and errors[].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 and version = 1. A violation of the unique index answers 409 conflict without reason (06 5 point 7: a reason only where one is defined; Ruling for docs/DECISIONS.md). e. Append command.changed (note 15) with change registered; audit through auditing.Recorder.Record: action command.register, decision allow, resource command and its ID, attributes change (registered), scope_kind, scope_space_id or scope_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. Answer 201 with Command. A replay with the same Idempotency-Key is answered by the M1-20 middleware.

  6. 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); first authorize(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_found gives the same 404 as an unknown ID); then authorize(actor, command.register, Resource{Kind: command, ID, OwnerID: stored owner, Command: facts of the validated new definition}) (another principal that sees the command gets 403 forbidden with not_self or subject_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 to version gives 412 version_conflict with current (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 on version, version + 1, the unique index as in note 4 d, command.changed with change updated, audit as note 4 e with change = updated; 200 with Command and ETag (FormatETag). The owner and the ID never change.

  7. 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 same 404 as an unknown ID); a command already removed answers 204 without write, event or audit (06 7: DELETE of a deleted resource with a deletion mark); otherwise removed_at = now(), version + 1, command.changed with change removed and the command as it was with the new version, audit action command.remove with change = removed; 204. Runs keep triggering_command_id (soft removal).

  8. Get (getCommand): load the live command (removed or unknown: 404), authorize(actor, command.read, Resource{Kind: command, ID, OwnerID, Command: scope facts}); not_found returns the same error value as an unknown ID (02 9.2); 200 with Command and ETag.

  9. 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 is Resource.OwnerID. b. Visibility of the resource kind command (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 of command.changed, whose resource ResourceFromEvent builds from the event scope): visible when the principal is OwnerID, or is an effective owner, or sees the scope target: read on 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 of command.changed is 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 missing Resource.Command on kind command is an internal error; only the filter-mode receive check takes the path from the event scope (same rule as M3-03 for file).
    • With OfferedInChannelID = C (per item of listChannelCommands): visible when the principal has read on 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 has allow_mentioned_agents, the owner's own effective level on C is at least write (operator rule included, 07 2.9) and its billing mode is organization (05 5 point 3, R-AZ-19); or the owner is an app with effective level at least write on C (07 2.10). The owner's level comes from the owner's snapshot without session, the same mechanism the agent.invoke guard of M1-28 uses for guests. The scope coverage is filtered by the caller's query (note 9) and checked here again. c. command.register guard (09 10.2 "Scope only on targets that the agent operates or on which it has write"; Ruling for docs/DECISIONS.md): instance: no further condition, because the list rule of b decides where an instance-scoped command appears; space S: S exists and is visible to the principal (else not_found), and the principal is S's default operator or has effective write on the space path (else forbidden, level_insufficient); channel C: C exists, is not a DM channel and is readable (else not_found), and the principal's effective level on C is at least write (operator rule included; else forbidden, level_insufficient). The requirement itself stays Any(AgentSelf, AppSelf) (M1-28 text mapping); the app scope commands:write is checked by M1-28's scope table. d. command.read requirement by kind: ByKind{channel: Level(read), command: Auth}; on command the visibility of b decides. e. command.invoke: replace Deferred with CommandLevel (new atom: Level(r.Command.RequiredLevel); a missing r.Command or an unknown level is an internal error) and the guard: OwnerKind == app is an internal error until M2-25 replaces it (fail closed; no app owner can exist before M2-24); a model command (Control == false) with ActiveRun gives forbidden with run_active (07 4.1). The read-only rule of 05 1.8 applies through M1-28's generic step (channel_read_only, channel_archived). Remove command.invoke from the golden list of deferred actions; command.autocomplete stays deferred (M2-17). f. Tests in internal/authz with the M1-28 fakes, including the app Tracker of its test world for the app branch of b.
  10. 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 of listChannelCommands): Command requires id, owner_principal_id and version, which a built-in does not have (03 commands stores commands "by agents or apps"); each built-in maps to an action with its own operation (09 10.1 column "Action"), /status explicitly 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 with command.read (M4-21). With this reading the clause "The control command stop additionally checks run.cancel" of 05 command.invoke holds by construction: /stop is cancelRun. b. authorize(actor, command.read, Resource{Kind: channel, ID: C}) in request mode (route proof, 02 9.3); not_found gives the same 404 as an unknown channel. c. Query the live commands whose scope covers C (instance; space with C's space; channel with 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 (channel before space before instance; 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). Resolve applies the same rule. d. Per remaining command authorize(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. A not_found here is the normal outcome of the list rule and not a filter_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, 03 commands). 09 10.3 speaks of the list "for a thread", but listChannelCommands has no thread parameter; "operator" here is C's effective operator as in the last paragraph of 03 commands. In a thread whose fixed operator differs (R-DM-12), the invocation still follows the thread's operator through agent.invoke (note 10 f); the report lists this as a spec gap. f. 200 with CommandList; no paging and no totals (06 4 point 2); a DM channel gives an empty list.

  11. Invoke (invokeCommand), one unit of work, in this order: a. agents.Commands.Resolve(CommandID, ChannelID); not usable gives 404 with the same body as an unknown ID. b. Target thread: with thread_id, chat.Threads.Info; missing, or its channel is not channel_id, gives the same 404. Without thread_id: in a forum channel, or in a text channel for a model command while the channel's auto_thread is set, first check channel.read on C in filter mode (an invisible channel gives the same 404, 05 rule 2). Then answer 400 validation_failed with pointer /thread_id and code required. Otherwise the target is the main thread of the text channel. Read auto_thread in this unit of work from the same channel switches the message acceptance of M2-05/M2-08 reads (Switches.AutoThread, chat port). Reason (Ruling for docs/DECISIONS.md; change the description of CommandInvoke.thread_id and 09 10.4 accordingly): 03 channels.auto_thread requires 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 thing auto_thread places (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 (chat port) before reading the active run, so the order of locks equals message acceptance (thread, then runs; 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 (agents port); then DecideCommand of M2-04 with CommandTrigger{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 commands authorize(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 of require_mention and 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_found of e or f gives 404; forbidden with channel_read_only or channel_archived gives 403 channel_read_only (M1-28 mapping); every other forbidden of 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, ...) gives 403 agent_invocation_denied with reason equal to the decision's reason. Reuse the mapping M2-09 added for renewInvocationToken if it exists. When DecideCommand rejects with run_active but e allows, fail closed with 403 agent_invocation_denied and reason: run_active and log command_decision_mismatch without IDs (same rule as M2-05 ruling 14). The audit of every denial is written by the authz layer (05 Audit denials). h. Option values (note 10 i), then execution (notes 11 and 12), then 202 with CommandInvocation{invocation_id, command_id, channel_id, thread_id, run_id (model only), accepted: true}. invocation_id is a new UUIDv7 per accepted invocation; it is not stored in a table (03 has none) and appears as invocation_id in CommandInvokedPayload and as command_invocation_id in AgentInvocationPayload. accepted is always true; a refusal is an error response (Ruling for docs/DECISIONS.md). A replay with the same Idempotency-Key returns the stored 202 and writes nothing (M1-20). i. ValidateOptionValues (runs/domain, pure; Ruling for docs/DECISIONS.md): every required option present (/options, required, detail names the option, never a value); unknown option name (/options/<i>/name, unknown_member); an option twice (/options/<i>/name, invalid); per type: string after kernel/textcheck.DisplayText (invalid); integer the canonical decimal form of a signed 64-bit integer (invalid_format); boolean exactly true or false (invalid_enum); choice one of the static values unless autocomplete is set, then any string value (the owner validates; invalid_enum); user a 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); file gives not_enabled until files arrive (06 5 point 3). The values keep the order of the definition. The encoded event payload of note 11 or 12 must fit sync.event_payload_max_bytes, else /options, too_large.

  12. Control command (09 10.4 row true): append command.invoked: scope {space_id, channel_id, thread_id} of T, resource {kind: command, id} without version (04 9 point 3), audience the owner, receive_action agent.events.consume (catalogue), actor the invoker, correlation_id of the request; payload CommandInvokedPayload{invocation_id, command_id, command_name, control: true, channel_id, thread_id, run_id, invoked_by, options} where run_id is the owner's active run in T when there is one and absent otherwise (05 section 5 row run_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, no run_followups row, no trigger_state. An answer comes from the agent as a message (M2-19).

  13. 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 by TriggeringCommandID. It writes the run queued, the anchor in T (CreateRunAnchor), run.state_changed and, after M2-08, the job runs.queued_expiry. No trigger_state changes, 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 with ActiveRun = 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. Append agent.invocation with M2-08's builder: audience the owner, receive_action agent.events.consume, resource {kind: run, id} without version, scope of T, actor the invoker; payload AgentInvocationPayload{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} without message_id. command_id, command_name and options are the new members of AgentInvocationPayload (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_session comes with M2-12 through the same builder. d. The token is never written: the delivery issues it (note 13).

  14. Delivery (extend M2-08's Prepare and M2-06's issuer; 04 15.3 step 2, 05 5, 07 8.1): a. Resolver command.invoked: trigger = invoked_by.id of 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's run_id when present. No run row is locked and no jti is recorded (M2-08 matrix: "jti never recorded"). The check is agent.invoke with M2-08's re-check principal in filter mode, not command.invoke: 04 15.3 step 2 names agent.invoke, and the active-run rule belongs to the invocation, not to the delivery. b. Resolver agent.invocation with runs.trigger_kind = command: trigger = runs.triggered_by; triggering object = the payload's command_id (equal to runs.triggering_command_id at creation; the payload is immutable, while the column may become NULL by ON DELETE SET NULL); InvokeFacts{..., MentionsAgent: true, NewThread: false, ActiveRunAgentID: runs.agent_id}. Everything else follows M2-08 (record jti while queued; a denial fails the run with the mapped reason; no trigger_state, because there is no triggering message). c. Token claims (05 5): trigger_kind = command, command_id, no message_id or interaction_id; run_id for a command run, and for command.invoked exactly when the payload names a run (05 section 5, R-AZ-25). Give M2-06's InvocationGrant a field Control bool: a command grant without RunID is valid only with Control = true; every other grant keeps the M2-06 table (fail closed). Adjust the reference verifier only if it compares an absent run_id. d. A denial of command.invoked sends nothing, changes no run, writes one audit entry as M2-08 note 8 (action agent.invoke, decision deny, the exact reason, resource thread, attributes command_id, agent_id, stage = delivery, event_type = command.invoked, trigger_kind = command, run_id when present) and counts bothy.agent.invocation.denied with the mapped reason; the event counts as checked and not in flight (M2-08 note 9).

  15. PrincipalRef of the invoker. invoked_by and triggered_by are PrincipalRef (id, kind, handle, display_name), and runs may not use identity (02 4.4). Use the port through which M2-08 builds triggered_by; if it only renders authors of messages, add PrincipalRefs to the messaging port (Scope), because messaging may use identity and runs may use messaging. Ruling for docs/DECISIONS.md if you add it.

  16. command.changed (05 6, catalogue resource_version: true): scope equal to the scope target (instance: no member; space: space_id; channel: space_id and channel_id); resource {kind: command, id, version: new version}; receive_action command.read; no audience; actor the owner; payload CommandChangedPayload{command, change}. The receive check uses the visibility of note 8 b without OfferedInChannelID.

  17. Concurrency. Registrations of one owner serialize on the owner's principals row (note 4 c); updates are compare-and-swap on version; 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 gets run_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 different jti (M2-08, AG-09).

  18. Rulings for docs/DECISIONS.md (section 10, one row D-M2-16-<n> each, with reason and place): NULLS NOT DISTINCT on the unique index (note 2); definition and option rules including the static-choice cap and the size rule (notes 3 d, 3 e); description is a plain string as in CommandRegister and 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 gives 409 conflict without reason, the limit gives 400 validation_failed with limit_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 command 204 (notes 5, 6); visibility of the resource kind command (note 8 b); target rule of command.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 without thread_id: main thread for control commands, and for model commands only with auto_thread false, otherwise 400 /thread_id required (note 10 b); command invocation counts as an explicit address and the order of checks (notes 10 e, 10 f); outcome mapping to agent_invocation_denied (note 10 g); invocation_id and accepted (note 10 h); option value rules (note 10 i); AgentInvocationPayload gets command_id, command_name and options (note 12 c); delivery re-checks agent.invoke only (note 13 a); the messaging port 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 that detail never 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, including file with not_enabled, a choice with and without autocomplete, the canonical integer form, and a string with a bidi character); TestCommandInvokedPayload and TestAgentInvocationPayloadCommand (validate against CommandInvokedPayload and the extended AgentInvocationPayload, no JSON null, run_id present exactly as note 11 says, no message_id); FuzzValidateOptionValues (no panic; a value with a control or bidi character is never accepted).
  • internal/runs/domain (M2-06 issuer): TestCommandGrantWithoutRunOnlyForControl: a command grant without RunID signs with Control = true and is an internal error without it; the claims carry command_id and no message_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 without allow_mentioned_agents, a personal agent never as guest, a disabled owner never, the app Tracker with and without write, a DM channel never; a command resource without Command facts in request mode is an internal error); TestCommandRegisterTargetGuard (each case of note 8 c with its outcome and reason); TestCommandInvokeRequirement (required_level write and trigger; run_active only for a model command with ActiveRun; a control command with ActiveRun allowed; read-only and archived channels give channel_read_only; an app owner is an internal error); TestDeferredActionsGolden updated (no command.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 of stop, status, new, resume, title, close: 409 conflict with reason: command_name_reserved; no row, no command.changed, no allow audit entry; renaming an existing command to stop with updateCommand gives 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 registers escalate (instance scope, model command, required option ticket of type string, option priority of type choice with static choices): 201 with version = 1; exactly one command.changed with change registered, resource.version = 1, no scope member, receive action command.read, payload valid against CommandChangedPayload; exactly one audit entry command.register (allow) whose details hold only change, scope_kind, control, required_level, version (the strings escalate and the description appear nowhere in audit_events).
  • TestRegisterCommand_Validation: every 400 case of note 3 through HTTP with pointer and code (description too long and with a bidi character, scope_kind = host, scope_id missing and superfluous, duplicate option names, choices on boolean, a choice without choices and without autocomplete, more static choices than command.autocomplete_max_choices, a payload larger than sync.event_payload_max_bytes); no row is written.
  • TestRegisterCommand_DuplicateAndNulls: a second live escalate of Lotse with instance scope gives 409 conflict without reason (proves NULLS NOT DISTINCT); the same name with channel scope on Support/Allgemein is 201; after removeCommand the instance name can be registered again.
  • TestRegisterCommand_LimitPerPrincipal: with command.max_per_principal set low through the test configuration, the next registration gives 400 validation_failed with errors[].code = limit_reached; removed commands do not count; two parallel registrations at one below the limit give exactly one 201.
  • TestRegisterCommand_TargetRule: Lotse on Support/Allgemein (operator) 201; on Marketing/Kampagnen 404 with the body of an unknown target; on space Support (default operator) 201; on space Marketing 404; Texter with a write grant on Support/Eskalation 201 there; Texter with only a read grant on Support/Allgemein gets 403 forbidden with level_insufficient there; instance scope 201 for every agent.
  • TestRegisterCommand_HumansRefused: Alex and the Owner get 403 forbidden (subject_kind_not_allowed) and a deny audit entry.
  • TestUpdateCommand: without If-Match 428; with a stale version 412 version_conflict with current; with the current version 200, version + 1, ETag, command.changed with change updated, one audit entry; an identical body gives 200 without event and audit; Texter updating Lotse's instance-scoped command gets 403 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 without scope_kind and with a Marketing/Kampagnen scope gives 404 with the same status, body and headers as an unknown ID; Texter, after revokeGrant of its write grant on Support/Eskalation, moves its Eskalation command to instance scope with 200.
  • TestRemoveCommand: 204, removed_at set, version + 1, command.changed with change removed, one audit entry command.remove; a second removeCommand by Lotse gives 204 without event or audit; getCommand then gives 404; a run that referenced the command keeps triggering_command_id. Texter removing Lotse's Support/Allgemein command gives 404 equal to an unknown ID; Texter removing Lotse's instance-scoped command gives 403 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 gives 404 with the same status, body and headers as an unknown ID; ETag equals version.
  • TestCommandChanged_Delivery: through listEvents (and a wstest connection of Alex): Alex receives command.changed of 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 after putChannelBehavior sets allow_mentioned_agents and Texter has a write grant; Assistent with a write grant never appears as guest; after disableAgent for Texter its commands disappear; equal names of Lotse and Texter appear both, Lotse's first; Lotse's deploy with instance and channel scope appears once, the channel one; no built-in name appears; Robin gets 404 with the body of an unknown channel.
  • TestInvokeModelCommand_CreatesRun: Alex invokes Lotse's escalate in a thread T of Support/Allgemein with valid options: 202 with invocation_id, run_id, thread_id T, accepted: true; the run is queued with trigger_kind = command, triggering_command_id, triggered_by Alex, no triggering_message_id; one anchor kind = run in T; run.state_changed; one agent.invocation whose payload has trigger_kind = command, command_invocation_id equal to invocation_id, command_id, command_name, the options in definition order and no message_id; no command.invoked; no message changed its trigger_state; a runs.queued_expiry job exists. Lotse's consumer receives agent.invocation with a token whose claims carry trigger_kind = command, command_id, run_id, sub Alex, and whose jti equals runs.invocation_jti.
  • TestInvokeModelCommand_RunActive (plan: "a model command during a run yields run_active"): with an active run of Lotse in T (opened by Alex's message), the model command gives 403 agent_invocation_denied with reason: run_active; with an active run of Texter (guest) the same; nothing is written (no run, no event, no run_followups row, no anchor) and exactly one deny audit entry command.invoke with reason run_active.
  • TestInvokeModelCommand_ConcurrentOneRun: two parallel invocations in T give exactly one 202 with a run and one 403 agent_invocation_denied with run_active; one active run in T (R-DM-14).
  • TestInvokeModelCommand_ThreadResolution: without thread_id in Support/Allgemein with auto_thread set: 400 validation_failed, /thread_id, required, no run, no anchor, no thread; a control command without thread_id with auto_thread set: 202, command.invoked scoped to the main thread; after putChannelBehavior sets auto_thread = false: 202, the run lives in the main thread and no thread is created; without thread_id in Engineering/Projekt-A 400 validation_failed with pointer /thread_id and code required; a thread_id of another channel gives 404.
  • TestInvokeControlCommand_NeverToModel (plan: "control commands never go to the model"): Lotse's control command usage without a run: 202 without run_id; no run, no anchor, no agent.invocation, no run_followups row; exactly one command.invoked with audience Lotse, control: true, resource.kind = command without version, receive action agent.events.consume, no run_id. During an active run of Lotse: 202, the event carries that run_id, and the run keeps its state, version and invocation_jti. During an active run of Texter: 202, delivered without run_id. Lotse's consumer receives each command.invoked with a token: trigger_kind = command, command_id, run_id exactly as in the payload, verified by the reference verifier.
  • TestInvokeCommand_Rights: Robin gets 404 equal to an unknown command ID; Alex invoking Lotse's command in Marketing/Kampagnen 404; Sam with only a write grant on Support/Eskalation gets 403 agent_invocation_denied with level_insufficient for a trigger command and for a write command (agent commands need trigger, 05 5 point 2); after a raw SQL statement on the pgtest connection sets channels.read_only = true for Support/Allgemein (as TestInvokeCommand_I2 does; the operation setChannelReadOnly comes with M8-03, and the authz structure source reads read_only per request, M1-53 D7), 403 channel_read_only; after archiveChannel 403 channel_read_only with reason: channel_archived; Texter's command in Support/Allgemein without guests allowed gives 403 agent_invocation_denied with agent_not_reachable; a command scoped to Eskalation invoked in Allgemein, and a shadowed command ID, give 404. 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 the pgtest connection sets Lotse to personal with 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 gives 403 agent_invocation_denied with billing_mode_personal_not_holder and a deny audit entry; the Owner invoking Assistent's command in Research/Notizen gets 202.
  • TestInvokeCommand_SR2: Runner's tool class set to host through setAgentToolClass (M1-51) and a trigger grant for Alex on Engineering/Werkzeuge (createGrant): Alex invoking Runner's command there gets 403 agent_invocation_denied with tool_class_owner_only; the Owner gets 202.
  • TestInvokeCommand_SR4: Lotse invokes a command with its agent token: 403 agent_invocation_denied, no event, no run.
  • TestInvokeCommand_OptionValidation: every 400 case 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 same Idempotency-Key returns the stored 202 with Idempotent-Replay: true and writes no second run or event; the same key with another body gives 422 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: no command.invoked and no agent.invocation frame; the model run is failed with access_revoked; two deny audit entries with stage = delivery and the event types; bothy.agent.invocation.denied rose 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 of services/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-lint
  • make codegen-check, make arch, make api-lint, make api-checks, make api-tables, make register-check, make catalog-check, make config-doc-check, make docs
  • make check

Acceptance criteria

  • Migration commands exactly as 03 with NULLS NOT DISTINCT on the unique index and the FK of runs.triggering_command_id; make migrations-lint green.
  • registerCommand, updateCommand, removeCommand, getCommand, listChannelCommands and invokeCommand are enabled and registered; AZ-P1 passes; command.invoke is no longer a deferred entry.
  • AG-24: every reserved name gives 409 conflict with reason: command_name_reserved on registration and on update; nothing is stored.
  • Scope only on served or writable targets, at most command.max_per_principal live commands per owner (also under concurrency), duplicates 409 conflict.
  • Every register, update and remove writes exactly one audit entry and one command.changed; audit details carry no name, description or option.
  • listChannelCommands follows 09 10.3: operator, permitted guests and apps with write, operator first, no built-ins, invisible channel 404.
  • A model command during an active run gives 403 agent_invocation_denied with reason: run_active; otherwise a run queued with trigger_kind = command and triggering_command_id, the anchor and agent.invocation with command ID and options.
  • A control command never creates a run, an anchor, a follow-up or agent.invocation; it produces exactly one command.invoked to 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 with trigger_kind = command and command_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 check is green.

Definition of done

  • Code and tests as specified; doc comment on every public symbol (purpose, parameters, return value, errors, example where useful); comments explain why.
  • READMEs of agents, runs and authz updated (the register and its rules, the list rule, the execution paths of control and model commands, the delivery resolvers, the command visibility, the guards of command.register and command.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.md entry under 'Unreleased'.
  • An ADR only if you take an architecture decision beyond this brief (MADR 4 as in docs/adr/0000-template-madr-4.md, headings and text in English, next free number, make adr-index).
  • make check green.
  • An independent code review over the diff, then the red team gate (docs/quality/red-team-gate.md, verdict JA or NEIN); on NEIN fix with a test per finding and have the delta checked again. Record both verdicts.
  • Land the change as AGENTS.md ("How work lands on main") describes: a pull request from this branch, squash-merged only when review, red team and make check, ci-ok and the review threads are green; then git worktree remove.

Report

Write to {{REPORT_PATH}}:

  1. First line exactly Verdict: DONE or Verdict: BLOCKED <reason>.
  2. Commits (SHA and subject), changed files with line counts, test results with counts (the red run before the change and the green run after), the summary of make check.
  3. Review and red team verdicts and the findings fixed.
  4. What is not proven.
  5. Follow-ups that could not be done in this scope, each with its reason, and spec gaps found.

Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.