Skip to content

M2-25: Commands and interactions for apps

Status: ready | Depends on: M2-15, M2-17, M2-26 | Milestone: M2

Goal

After this ticket an installed app owns slash commands and message components exactly like an agent, and none of them ever triggers an agent. The commands of the app's manifest are created with the installation, follow manifest upgrades, are hidden while the app is suspended or lacks commands:write, and are removed at uninstall; the app posts cards with buttons, selects and forms, which are disabled when it is suspended or uninstalled. A person who runs an app command or operates an app component is checked for required_level (commands) or trigger (components), required_capability and restricted_to, and the app for an active installation, the scope of the feature and write on the channel; agent.invoke and I2 are never checked, because no agent runtime is involved (05 "Agent invocation agent.invoke", R-AZ-18). The personal durable events command.invoked and interaction.created reach the owning app on every way the specification names: through the app's gateway connection, the REST catch-up and webhook subscriptions when the app holds events:subscribe, and, for an app whose manifest declares delivery.interactions_url, as a POST signed like an outgoing webhook, sent after the commit by a job and answered within app.interaction_timeout with a message that the server writes in the app's name, with 202, or with suggestions for autocomplete. This is 11 "Commands and interactions for apps" and the app half of 05 rule 1.6: integrations get interactive controls without becoming a way to run a model, to bypass a personal subscription (I2) or to reach a private address, and the reference apps M2-49 and M2-50 can build /issue with a form on it.

Read first

  • Dependencies: the header names M2-15, M2-17 and M2-26, as the plan row does (D-W-103). Implied through them and therefore not in the header: M2-16 (through M2-17), M2-24 (through M2-26), M2-01 (through M2-26), M2-09 (end of run), M2-08 (runs.Delivery, the way it builds the triggering human for its re-check), M2-07 (agent consumers; apps have none), M2-06, M2-05, M2-04, M2-02 (channel switches, putChannelBehavior), M1-65 (hub, admission), M1-64 (gateway identify with api_token, wstest), M1-63 (listEvents, sync.Admit), M1-61, M1-60, M1-56 (createMessage, origin), M1-55, M1-54, M1-53, M1-52, M1-51, M1-49, M1-48, M1-46, M1-45, M1-44 (API tokens of kind app), M1-36, M1-35, M1-34, M1-33, M1-32 (signing keys, purpose webhook_out), M1-31, M1-29, M1-28 (authorize), M1-27, M1-26 (kernel/textcheck), M1-25, M1-24 (audit), M1-23, M1-22 (egress client), M1-21, M1-20 (idempotency), M1-19, M1-18, M1-17, M1-16 (event log), M1-15, M1-14 (jobs), M1-13 (unit of work) and the gate tickets M1-01 to M1-12. M1-66 (eventlog.EphemeralEvent) is in the closure as well (through M2-02, M1-69 and M1-68); M2-17's start check also requires it. So are M1-68 (through M2-02 and M1-69), M1-50 (through M1-68), and M1-37 and M1-62 (through M1-71). Present because M2-15 stops without them: M1-57, M1-58, M1-69 (services/server/test/canary), M1-71 (services/server/test/harness).
  • docs/PLAN.md: section 3 "Way of working per ticket"; section 8 "M2 Agent platform, extensions and terminal": Goal ("Apps and webhooks use the same public API (P-18)"), Scope, Out of scope ("user interface (M4)", "fencing across several nodes (M7)"), Exit criteria points 9 and 10, Test strategy (fake APIs with webhook fixtures, FakeServer in the SDK), Red team ("another person triggering a personal subscription through every path (message, follow-up, command, interaction, session switch, autocomplete)", "confused deputy through the server API", "imitating a card header", "webhooks to private targets", "exceeding the scopes of an app", "apps and webhook targets as boundaries"), Risks; block "agents and extensions" rows M2-08, M2-15, M2-16, M2-17, M2-24, M2-25, M2-26, M2-27, M2-29, M2-30, M2-31; block "reference integrations" (introduction: "No integration calls a real service in tests"; rows M2-49 and M2-50, which build on this ticket).
  • docs/spec/11-extensions.md: 1 "Principles" (points 1 to 4), 2 "Agent or app" (rows "Triggered by", "Triggers agents"), 3.1 "Format" (commands, components, delivery.mode, delivery.interactions_url, data_egress), 3.2 "Validation rules" (points 1, 3 and 5), 3.3 "Example", 4.1 "Installation" (the diagram writes commands in the install transaction; "A manifest can never set a target itself"), 4.2 "Update", 4.3 "Suspension and uninstallation" (rows "Commands", "Components of the app", "Messages of the app"), 5 "Scopes and grants", 6 "Isolation and limits" (rows "Timeouts", "Never allowed"), 7 "Outgoing webhooks" (rows "Signature", "Key", "SSRF protection"), 9 "Commands and interactions for apps" (normative for this ticket), 10.1 (the reference app for code hosting, row "Commands"), 11.2 "Hand-written layer" (row webhooks: "also for interactions_url"), 14 (R-EX-03, R-EX-05), 15 (E-EX-05), 16 "Configuration values of this chapter" (app.interaction_timeout).
  • docs/spec/09-agents.md: 1 "Principles" (point 5: no automatic repetition), 10.1 "Built-in commands" (reserved names), 10.2 "Registration", 10.3 "Visibility and name conflicts" ("commands of apps with write on the channel"), 10.4 "Execution" (last paragraph on app commands), 10.5 "Autocomplete" (last paragraph on apps), 11.1 "Kinds (component.v1)", 11.2 "Who may interact", 11.3 "Single-use rule per message", 11.4 "Flow of an interaction", 20 "Alignment with the core contract" (E-AG-17), 21 "Configuration values of this chapter".
  • docs/spec/05-actions.md (core contract, read only): 1 "Binding rules" (rules 1, 2, 4, 6, 8, 9); 2.1 Column "Permission" (app-self); 2.2 "Target of a resource" (component, interaction; DM channels); 2.3 "Effective level" (point 5); 2.4 (Audit denials, R-AZ-04); 2.5 "Decision"; 2.6 "Scopes for apps"; 2.7 "Effect (effect)"; 3 "Rate-limit classes"; 4.7 row message.create; 4.10 rows agent.events.consume, command.read, command.register, command.remove, command.autocomplete, command.autocomplete.respond, command.invoke, component.update, interaction.create, interaction.respond; 4.11 rows app.install, app.scopes.update, app.suspend, app.resume, app.uninstall, webhook_out.deliver; 5 "Agent invocation agent.invoke" (point 1, point 6, the paragraph "App commands and components of apps do not check agent.invoke and I2 ..." and the paragraph "Check without a request"); 6 "Durable event types and receive action" (rows interaction.created, command.invoked, command.changed, message.updated, message.created; table "Ephemeral events" row command.autocomplete_requested); 7 row command.autocomplete_result; 8 (R-AZ-01, R-AZ-03, R-AZ-04, R-AZ-11, R-AZ-12, R-AZ-18, R-AZ-29).
  • docs/spec/04-sync-protocol.md (core contract, read only): 2.1 "Two kinds", 2.2 "Envelope" (delivery fields invocation_token "for events to agents", sync_epoch "only in webhook deliveries"; payload rules 1 to 4), 5.1 "Messages: client_msg_id", 5.2, 6.3 "Lifecycle" (identify of agents and apps), 6.4 "Close codes" (4012, 4018), 7.4 "REST variant", 7.5 "Sync epoch", 8.1 "Basic rule", 9 "Delivery guarantee" (point 3: command.invoked has no resource.version), 15.7 "Autocomplete" (points 1 and 2: "For apps without a gateway, the server asks through their interaction address").
  • docs/spec/03-data-model.md (core contract, read only): apps, app_installations (granted_scopes, state), commands (owner, scope columns, required_level, control, removed_at, the unique partial index), message_components, interactions (state values), messages (origin, kind = 'card', index (author_principal_id, created_at DESC)), event_log (audience_principal_id, receive_action: "If the receiver is an app, delivery checks the scope events:subscribe instead of agent.events.consume"; unique (id, seq)), webhook_subscriptions (hmac_enabled, signing_key_kid), server_signing_keys (purpose webhook_out, EdDSA only), 6.3 "Lock order".
  • docs/spec/07-permissions.md: 2.1 "Subjects" (apps: at most write), 2.10 "Apps", 4.1 "Decisions and reasons" (no_grant, level_insufficient, scope_missing, subject_kind_not_allowed, not_self, app_not_installed, channel_read_only, channel_archived, component_inactive, component_restricted, capability_missing), 4.2 "Flow" (order; filter mode writes no denial), 4.3 "Visibility", SR4 "Only humans trigger", 8.1 "Delivery filter", 10 "Examples", 11.1 "Test matrix" (world with app Tracker and G-6; AZ-M30, AZ-M32 to AZ-M34, AZ-M53), 11.2 "Required tests" (AZ-P1, AZ-P2, AZ-P4, AZ-P13).
  • docs/spec/06-api.md: 2 "Basic rules" ("Unknown values", "No null"), 4 "Authorization in the API" (points 3, 4 and 6), 5 "Errors" (points 3, 4, 7 and the generated table: not_found, forbidden, scope_missing, channel_read_only, agent_invocation_denied, component_used, validation_failed, idempotency_key_reuse), 7 "Idempotency", 12 "Webhooks".
  • docs/spec/02-architecture.md: 4.1 "Rules" (point 4: source port and in-transaction event), 4.3 "Ports, events and sources per module" (rows runs, agents, messaging, apps), 4.4 "Allowed dependencies" (row apps: "commands of apps"; the levels), 4.6 "Actions per module" (command.invoke and command.autocomplete belong to runs, the register to agents), 4.7, 7.1 "Module register as the source", 8.4 "Idempotency in the Unit of Work", 8.5 "What never happens in a transaction", 12.1 "Usage", 12.2 "Queues" (maintenance mode), 12.3 "Job catalogue", 12.4 "Rules for jobs".
  • docs/spec/19-security.md: SEC-IN-4, SEC-OUT-3, SEC-OUT-4, SEC-NET-1 to SEC-NET-5, SEC-LOG-2, SEC-LOG-4, SEC-AUD-5, SEC-T09, SEC-T10, SEC-T14.
  • docs/api/openapi.yaml: operations invokeCommand, listChannelCommands, getCommand, registerCommand, updateCommand, removeCommand, autocompleteCommand, answerAutocomplete, createInteraction, updateComponent, respondToInteraction, createMessage, listEvents, installApp, validateManifest, upgradeAppManifest, setAppScopes, suspendApp, resumeApp, uninstallApp, getWebhookKeys, createWebhookSubscription, putChannelBehavior, createGrant; schemas AppManifest (commands as CommandRegister), AppScope, AppInstallation, CommandRegister, Command, CommandInvoke, CommandInvocation, CommandOptionValue, CommandInvokedPayload, CommandInvokedEvent, InteractionCreatedPayload, InteractionCreatedEvent, Interaction, InteractionRespond, Component, ComponentCreate, ComponentUpdate, MessageCreate (components: "Agents and apps only"), AutocompleteAnswer, CommandAutocompleteRequestedEvent, EventEnvelope, Problem, FieldError; parameters WebhookId, WebhookTimestamp, WebhookSignature; the webhooks: entries command.invoked and interaction.created.
  • docs/api/asyncapi.yaml: messages event.command.invoked, event.interaction.created, event.command.autocomplete_requested (x-receive-action: agent.events.consume, x-audience: self), op.command.autocomplete_result.
  • docs/api/webhooks.md: 2 "Delivery", 2.1 "Signing keys", 3 "Verification by the receiver", 4 "Retries and disabling", 6 "Delivery security", 7 "Event types for apps and agents" (rows interaction.created, command.invoked: "own only (app or agent is the receiver)").
  • ADRs 0013 (spec first), 0016 (Standard Webhooks), 0017, 0022 (extensions outside the server process), 0049 (action catalogue), 0050 (module register), 0052 (one envelope), 0053 (no JSON null), 0059 (sync epoch), 0065 (maintenance mode, point 4 "Deliveries").
  • docs/DECISIONS.md: D-K-D17, D-EX-01, D-EX-02, D-AG-10, D-AG-13, D-W-29, D-W-100; section 10 "Rulings of the build milestones" (row format D-<Ticket>-<n>).
  • docs/security/threat-model.md: 5.11 "Apps and webhooks", register rows T-PL-01 and T-TK-02, 9 "Maintenance rule" point 2 ("a new app feature without an entry here is a finding that blocks the merge"). docs/RISKS.md: RK-04 (SSRF "through outgoing webhooks and addresses for interactions"), RK-20. docs/OPEN-QUESTIONS.md: OQ-01 (default: strict I1 without owner exception; the I2 test needs an I1-bypassing fixture), OQ-07 (default: multi-node operation in M7; the code stays node-neutral).
  • docs/observability/signals.md: 5.2 "Spans" (job <bothy.job.kind>), 5.3 "Attributes in spans and logs" (allowlist), the metric bothy.egress.blocked; this ticket adds no metric. docs/observability/privacy.md 6 "Canary test (gate)".
  • docs/operations/configuration.md: app.interaction_timeout, webhook.allowed_hosts, egress.allowed_hosts, http.max_json_body_bytes, command.max_per_principal, command.description_max_chars, command.autocomplete_timeout, command.autocomplete_max_choices, component.default_ttl, component.max_per_message, sync.event_payload_max_bytes, message.max_content_bytes, ratelimit.rl.trigger.per_minute, ratelimit.rl.trigger.burst, ratelimit.rl.interaction.per_minute, ratelimit.rl.interaction.burst, ratelimit.rl.agent_runtime.per_minute, ratelimit.rl.agent_runtime.burst, audit.deny_aggregate_window, authz.not_found_timing_tolerance, database.tx_retry_max, jobs.queue.default.max_workers, server.maintenance_mode.
  • M1 briefs whose interfaces you use: M1-13 (txn.UnitOfWork.Do, txn.InUnit), M1-14 (jobs.Inserter.InsertTx, jobs.Register), M1-16 (eventlog.Appender, eventlog.Event), M1-18 (strict decoder, problem writer with errors[] and reason), M1-20 (idempotency stage), M1-22 (platform/egress: New, Options{Purpose, Hosts, Redirects, Timeout, MaxResponseBytes}, RedirectNever, Deps{Resolver, RootCAs}, ErrBlocked), M1-24 (auditing.Recorder, audit_details.v1), M1-25 (authorize.Registry), M1-26 (kernel/textcheck, principals of kind app), M1-28 (Design notes "Public contract in kernel/authorize", "Requirement table" with AgentSelf and AppSelf, "App scopes", "Visibility", "Algorithm" with app_not_installed, Filter(ctx), "Decision to error mapping"), M1-32 (Keys.Signer(ctx, webhook_out)), M1-44 (token kind app), M1-56 (createMessage flow, origin), M1-63 (listEvents), M1-64 (identify with api_token, 4018, wstest), M1-65 (sync.Admit as the single admission rule, authorize.Delivery(ctx), "Agents and apps in M1"), M1-69 (canary scenario), M1-71 (BuildMatrixWorld).
  • M2 briefs this ticket extends: M2-15 (docs/briefs/M2-15-components-interactions.md: Design notes 2 c Interactions, 6 a and b card rules with not_enabled for apps, 8 a to e InteractionFacts and mapping, 10 createInteraction, 11 a dispatched, 12 c respond transitions, 15 b DisableRunComponents, 16 lock order, 17 audit, 18 canary, 19 events), M2-16 (docs/briefs/M2-16-slash-commands.md: the commands table, agents.Commands, runs.CommandExec, the visibility of 09 10.3, the option validator, the problem mapping of invokeCommand, command.changed), M2-17 (docs/briefs/M2-17-command-autocomplete.md: Design notes 1 AutocompleteRelay, 2 g "for app owners until M2-25", 3 CommandFacts and the app branch, 5 relay, 6 open requests, 7 AnswerAutocomplete, 9 answer validation), M2-24 (docs/briefs/M2-24-apps-install.md: install, upgrade, scopes, suspend, uninstall, the installation facts given to authz, the manifest validator), M2-26 (docs/briefs/M2-26-webhooks-outgoing.md: signer, envelope, event read, host policy, test receiver), M2-08 (re-check principal of a delivery), M2-07 (Design note 3: consumers exist only for agents; an app's consumer is ignored).
  • Code (merged names win, D-W-100): services/server/architecture/modules.yaml (module apps: depends_on: [instance, identity, chat, agents, messaging, sync]; module runs owns message_components, interactions, the actions command.invoke, command.autocomplete, interaction.create and the events command.invoked, interaction.created), internal/runs, internal/apps, internal/agents, internal/authz, internal/messaging, internal/sync, internal/gateway, internal/platform/egress, internal/kernel/{authorize,actor,auditing,eventlog,txn,textcheck,ids,clock}, internal/platform/jobs, internal/wire, services/server/test/harness, services/server/test/canary.

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. Apps own commands and components like agents and never trigger an agent: no run, no invocation token, no agent.invoke, no I2 for app commands and app components (11 2 row "Triggers agents: never", 11 9, 05 5 last paragraph, 05 4.10 command.invoke, 09 10.4 last paragraph, R-AZ-18, E-EX-05, E-AG-17, D-K-D17).
  2. An app command checks the person's required_level and the app's grants and scope; an app component checks trigger, the component state, required_capability and restricted_to and the app's grants and scope (05 5 last paragraph, 05 4.10, 11 9, 09 11.2). The read-only and archive rules hold for every principal (05 rule 1.8, 07 4.5).
  3. App commands are listed for a channel only for apps with write there (09 10.3, 05 4.10 command.read); new commands that need a scope the installation does not grant stay inactive, removed scopes and commands take effect at once (11 4.2); a suspended app's commands are hidden, an uninstalled app's commands are removed, and its components are disabled in both cases, while its messages stay (11 4.3, R-EX-05).
  4. The install transaction writes the commands of the manifest (11 4.1); a manifest names no target (11 4.1).
  5. command.invoked and interaction.created are durable personal events of the module runs with the owning app as audience and the receive action agent.events.consume, for which an app is checked by events:subscribe (05 6, 03 event_log.receive_action, webhooks.md 7); they carry no invocation token (04 2.2) and no resource.version (04 9 point 3).
  6. An app whose manifest declares delivery.interactions_url ("if commands or components without a gateway", 11 3.1) gets commands, interactions and autocomplete requests as a synchronous POST to that address, signed like outgoing webhooks, answered within app.interaction_timeout: suggestions, 200 with a message the server writes in the app's name, or 202; without an answer the component stays used (11 9, 11 3.1, 04 15.7 point 2, webhooks.md 2 and 3).
  7. Only https to hosts in webhook.allowed_hosts, through the egress client with the address check after DNS resolution, bound to the checked address, without redirects (11 1 point 4, 11 7 row "SSRF protection", SEC-NET-1 to SEC-NET-5, RK-04).
  8. No network call inside a database transaction; consequences after the commit come from a job inserted with InsertTx in the same transaction; a job is repeatable without double effect and has a deadline (02 8.5, 12.1, 12.4); nothing is repeated automatically (09 1 point 5).
  9. Messages of apps, including the reply the server writes for an app, have origin = app and never trigger an agent (05 5 point 1, SR4, 11 6 row "Never allowed", AZ-M30, AZ-M53); app cards show a header built from server fields and the badge of their origin (SEC-OUT-3, SEC-OUT-4, T-PL-01); apps never reach DM channels (11 6, 05 2.2).
  10. component.update and interaction.respond need components:write, command.register, command.remove and command.autocomplete.respond need commands:write, message.create needs messages:write (05 2.6, 07 2.10: a missing scope with a grant gives 403 scope_missing).
  11. Every denial of a request is audited and aggregated per audit.deny_aggregate_window; filter checks write no denial (05 2.4, R-AZ-04, 07 4.2, AZ-P13). No read right gives 404, identical to an unknown ID (R-AZ-01, AZ-P4).
  12. No JSON null in answers, events and requests to apps (ADR 0053). No form value, option value, reply body, token, signature or URL path in logs, traces, metrics or audit details; the canary scenario covers the new way (SEC-LOG-2, SEC-LOG-4, SEC-AUD-5, SEC-T10).
  13. Rate-limit classes rl.trigger (command.invoke, command.autocomplete), rl.interaction (interaction.create), rl.write (component.update), rl.agent_runtime (interaction.respond, command.autocomplete.respond), rl.message (message.create) (05 3, 4.7, 4.10).

Scope

All paths under services/server/ unless they start with docs/ or tools/. Names of M2-15, M2-16, M2-17, M2-24 and M2-26 below are the expected ones; where the merged code names them differently, follow the code (Boundaries). No migration: commands comes from M2-16, message_components and interactions from M2-15, apps and app_installations from M2-24. No operation is enabled by this ticket and no new top-level Makefile target is created.

Create:

  • internal/runs/appdispatch.go (port): the source port AppDispatcher with AppWay, AppPost, AppAsk, AppOutcome and the closed list of outcome reasons (Design note 2 b).
  • internal/runs/domain/appowner.go: the target thread of an app command, the CommandInvokedPayload builder for app owners, the map from AppOutcome to the next interaction state (Design notes 5 and 8).
  • internal/runs/app/app_commands.go, internal/runs/app/app_interactions.go, internal/runs/app/app_autocomplete.go: the app branches of CommandExec and Interactions (Design notes 5, 7 and 10); internal/runs/app/app_dispatch.go: the use case of the job (Design note 8); internal/runs/app/components_authored.go: DisableAuthoredBy (Design note 2 c).
  • internal/runs/adapters/jobs/app_dispatch.go: args AppDispatch, kind runs.app_dispatch, queue default, and the handler (Design note 8).
  • internal/apps/domain/commands.go: DiffCommands and the manifest command rules (Design note 11); internal/apps/domain/reply.go: strict decoding of replies and the table from status to outcome (Design note 9); fuzz target FuzzDecodeAppReply next to it.
  • internal/apps/adapters/appdispatch/: the implementation of runs.AppDispatcher (Design note 9).
  • services/server/test/apps/fakeapp/: an HTTPS fake app for tests that verifies v1a with the keys of getWebhookKeys, records requests in memory and answers by script (section "Tests").
  • Tests of the section "Tests".

Change:

  • architecture/modules.yaml: runs added to apps.depends_on (Design note 2 a); regenerate the architecture configuration (make arch).
  • internal/authz (and internal/kernel/authorize only if a fact of Design note 3 has no field yet): the app-owner guard and its uses (Design note 3), the receive rule for apps (Design note 4), the re-check marker of M2-08 admitted for command.invoke and interaction.create (Design note 8, R-AZ-29), tests.
  • internal/runs code of M2-15, M2-16 and M2-17: the app branches; remove M2-15's internal error for OwnerKind = app (its Design note 8 b), M2-17's "empty list at once for app owners" (its Design note 2 g); Interactions gains DisableAuthoredBy.
  • internal/messaging (code of M1-56 and M2-15): cards from app principals are accepted (Design note 6); Messages.Authors if the port has no batched author read (Design note 2 c).
  • internal/agents (code of M2-16): the owned-command methods of Design note 2 d; the visibility of app-owned commands through the guard of Design note 3.
  • internal/apps (code of M2-24): the lifecycle hooks of Design note 11; AppManifest.delivery only if the merged schema lacks it, and the port rule of manifest addresses (Design note 9 a).
  • internal/sync: a read of one stored event by id only if the merged port has none (Design note 2 e).
  • internal/wire: bind appdispatch into runs, register the job runs.app_dispatch for the roles worker and all, pass the new ports; regenerate the golden files of jobs and bindings.
  • internal/platform/config schema: app.interaction_timeout (chapter 11-extensions.md, duration, positive) only if no earlier ticket registered it; regenerate docs/operations/configuration.md with make config-reference when the schema text changes.
  • services/server/test/canary: the app way of Design note 16 e.
  • docs/api/openapi.yaml, docs/api/webhooks.md, docs/spec/11-extensions.md, docs/spec/09-agents.md, docs/spec/07-permissions.md, docs/spec/02-architecture.md (Design note 18).
  • docs/security/threat-model.md 5.11 and register row T-PL-01; docs/RISKS.md RK-04 (Design note 16 f).
  • READMEs of runs, apps, agents, authz, messaging and test/canary; services/server/CHANGELOG.md; docs/DECISIONS.md section 10 rows D-M2-25-<n> (Design note 19).

Out of scope

  • Manifest validation, consent, installation, scopes, tokens and the operations of apps themselves: M2-24; this ticket adds only the command and component parts of 11 4.1 to 4.3 and, if missing, delivery in the manifest schema.
  • Agent commands, control and model commands, reserved names, run_active, command.changed itself: M2-16. Agent components, cards bound to runs, approval cards: M2-15. Autocomplete for agents and its registry: M2-17.
  • The webhook consumer, delivery attempts, retries and getWebhookKeys: M2-26; this ticket only proves that its path admits command.invoked for an app subscription.
  • Incoming webhooks and their messages: M2-27.
  • The SDK side (verification of v1a for interactions_url, command declarations, components): M2-29, M2-30. The reference apps /issue with a form: M2-49, M2-50. The conformance suite: M2-31.
  • A display of "App is not responding" for the person: no event and no field carries the outcome after the commit (spec gap reported); the client display is M4.
  • Distribution of autocomplete requests across api nodes (cluster.mode = multi): M7-17.
  • Changes to the core contract.

Boundaries

  • Work in a dedicated git worktree on the branch feat/M2-25-app-commands; one writer per worktree.
  • Conventional Commits with the module as scope (for example feat(runs): ..., feat(apps): ..., 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. No network calls except package downloads for pinned tools and Go modules.
  • Never lower a threshold, disable a gate or delete a test.
  • If the core contract lacks something this ticket needs, stop with Verdict: BLOCKED <reason> instead of building a deviation (P-11).
  • This brief was written before milestone M1 closed (D-W-100). Where the merged code of an M1 ticket names a package, type, function, file or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with Verdict: BLOCKED <reason>.
  • The same holds for the merged code of M2-07, M2-08, M2-15, M2-16, M2-17, M2-24 and M2-26: follow its names; where it lacks a function this brief expects from it, add it in the module that owns it with a test and report it.
  • No real third-party service, no agent runtime, no SDK and no model in any test: apps are harness principals with their API tokens and the fake app of test/apps/fakeapp, which listens only on 127.0.0.1 with a test certificate authority created in memory; example.com names reach it only through a test resolver of the egress client. Real model calls would need an explicit switch, and this ticket has none.
  • Never log, print or persist a form value, an option value, a reply body, a suggestion, a token, a signature or the path of an interactions_url outside the tables and events the specification names, not even in test output; tests compare such values in memory.
  • Changes to authz, agents, messaging, sync and to the code of M2-15, M2-16, M2-17 and M2-24 stay within Design notes 2 to 11, each with a test; no other behaviour of an earlier ticket changes.

Design notes

  1. Start check. Run make check on the fresh worktree; if it is red, stop with Verdict: BLOCKED base branch red and the failing gate. Confirm that the base branch has: M2-24 (internal/apps, installApp, upgradeAppManifest, setAppScopes, uninstallApp, installation facts in authz), M2-16 (commands, agents.Commands, runs.CommandExec, invokeCommand, listChannelCommands), M2-15 (message_components, interactions, runs.Interactions, InteractionFacts), M2-17 (Autocomplete, AutocompleteRelay, answerAutocomplete, op command.autocomplete_result) and M2-26 (getWebhookKeys, the v1a signer, the webhook envelope with sync_epoch, the event read of apps.webhook_deliver, the host policy of webhook.allowed_hosts). A missing one ends with Verdict: BLOCKED dependency <ticket> missing. If M2-24 has no use case for app.suspend or app.resume (suspendApp, resumeApp), build everything else, leave out the hooks of Design note 11 for the missing one and report it as a follow-up (plan gap reported).

  2. Module boundaries and ports (02 4.1, 4.3, 4.4; ADR 0050). a. apps may use runs: add runs to apps.depends_on in modules.yaml and to the row apps of 02 4.4 with the reason "implements runs.AppDispatcher; disables the components of an app on app.suspend and app.uninstall". No cycle arises: runs uses neither apps nor sync, and the levels of 02 4.4 already put apps above runs. make arch and make register-check keep register and chapter equal. Ruling for docs/DECISIONS.md: runs cannot read the manifest, the event log or the egress client of apps, and apps may not decide rights or write interactions; a source port of the lower module is the way 02 4.1 point 4 names. b. Source port in internal/runs/appdispatch.go (pseudo-Go; doc comment on every symbol):

    // AppWay is how an app receives its commands, interactions and autocomplete requests (11 9).
    type AppWay string
    const (
        AppWayGateway         AppWay = "gateway"          // no delivery.interactions_url in the manifest
        AppWayInteractionsURL AppWay = "interactions_url" // POST to delivery.interactions_url
        AppWayInactive        AppWay = "inactive"         // installation not active
    )
    // AppPost names a stored durable event (command.invoked or interaction.created) for one app.
    type AppPost struct {
        AppID, EventID ids.ID
        ReplyThreadID  ids.ID // thread for a 200 reply message; zero when there is none
    }
    // AppAsk is an autocomplete request for an app (ephemeral, never stored).
    type AppAsk struct {
        AppID    ids.ID
        Event    eventlog.EphemeralEvent
        Deadline time.Time
    }
    // AppOutcome is the result of one request to an app. Reason is one code of a closed list.
    type AppOutcome struct {
        Answered bool     // 200 in time and its reply was used
        Deferred bool     // 202 in time: the app answers later through the API
        Reason   string   // set when neither: inactive, no_url, denied, timeout, transport,
                          // blocked, redirect, http_status, too_large, invalid_reply, no_thread,
                          // reply_refused
        Choices  []Choice // Ask only
    }
    // AppDispatcher is declared by runs and implemented by apps (02 4.1 point 4).
    type AppDispatcher interface {
        Way(ctx context.Context, appID ids.ID) (AppWay, error)
        Post(ctx context.Context, p AppPost) (AppOutcome, error)
        Ask(ctx context.Context, a AppAsk) (AppOutcome, error)
    }
    

    Choice is the type of M2-17. Errors are returned only for faults of the server (database, programming); every answer of the app is an AppOutcome. c. runs.Interactions (M2-15) gains DisableAuthoredBy(ctx context.Context, authorID ids.ID) (int, error): in the ambient unit of work, find the messages with active components (runs query on message_components), keep those whose author is authorID (one batched read through the messaging port, Messages.Authors(ctx, messageIDs) (map[ids.ID]ids.ID, error); add it if the merged port has no such read; it reads only author_principal_id and checks nothing), then per message in ascending id: Messages.LockCard, the message's active components ORDER BY id FOR UPDATE, each disabled with version + 1, Messages.ComponentsChanged once. It returns the number of disabled components and calls no authorize: the caller authorized its owner action (Design note 11). The order follows M2-15 Design note 16. d. agents.Commands (M2-16) gains, where the merged port has no equivalent, three methods without authorize for owner actions that apps authorized: UpsertOwned(ctx, ownerID ids.ID, def CommandDef) (Command, bool, error) (create, or replace the fields of the owner's command with scope_kind = instance and the same name compared case-insensitively; version + 1 and command.changed only on a real change; the limit command.max_per_principal and the reserved names as registerCommand enforces them), RemoveOwned(ctx, ownerID ids.ID, names []string) ([]ids.ID, error) and RemoveAllOwned(ctx, ownerID ids.ID) ([]ids.ID, error) (set removed_at, write command.changed as removeCommand does). CommandDef carries the fields of CommandRegister. They join the ambient unit of work. e. The adapter of apps reads one stored event by its id (03 event_log: unique (id, seq)). Use the read that apps.webhook_deliver of M2-26 uses; if it reads only by seq, add a read by id to the same sync port with a doc comment and a test.

  3. The app as owner in authz (07 4.2: all rights logic in authz; 05 5 last paragraph; 11 9; 09 10.3). a. Facts: when a descriptor names an owner of kind app (Command.OwnerKind of M2-16 and M2-17, Interaction.OwnerKind of M2-15 with Resource.OwnerID = the card's author), the app layer of authz gathers the installation state and granted scopes of that app from the installation facts M2-24 gives authz (by principal ID; if the merged source serves only the authenticated actor, add a lookup by ID), and the app's effective level on the resolved path from the app's own snapshot (07 2.1 and 2.10: at most write; DM channels are never reached, 05 2.2). b. Guard AppOwnerReachable(scope): true when the installation is active, scope is in its granted scopes and the app's effective level on the path is at least write. For command.read without OfferedInChannelID (getCommand) the descriptor has no channel and the path of an instance-wide command reaches no place where the app holds write (M2-16 Design note 8 b, 07 2.2). There the guard checks only that the installation is active and that commands:write is granted. The write condition applies where a channel is resolved: list items with OfferedInChannelID, command.invoke and command.autocomplete. Ruling for docs/DECISIONS.md: "the grants and the scope of the app" of 05 5 means write on the place (09 10.3 "apps with write on the channel") and the scope of the feature: commands:write for commands, components:write for components (05 2.6); spec gap reported. c. Uses and results:

    • command.read for every app-owned command of a list or of getCommand (filter mode for list elements, request mode for getCommand), command.invoke and command.autocomplete, scope commands:write: a false guard gives not_found with reason no_grant. Ruling for docs/DECISIONS.md: an unusable app command is hidden (11 4.2 "inactive", 4.3 "hidden"), so it answers like an unknown command (R-AZ-01, AZ-P4). The guard is skipped when the principal is the owning app itself.
    • interaction.create on a component whose card author is an app, scope components:write: a false guard gives forbidden with reason component_inactive, which M2-15 answers with 409 component_used. Ruling for docs/DECISIONS.md: from the person's view the component cannot be used (11 4.2 "inactive"). d. Order for app owners (07 4.2): visibility of the place for the person; for commands then the guard (part of the visibility); then Level(required_level) for commands or Level(trigger) for components; then the read-only and archive rules of M1-28; for components then point 6 of 05 5 with the guard first: state, expiry or a false guard give component_inactive, a missing required_capability gives capability_missing, restricted_to = owner without effective owner and restricted_to = run_starter (no run exists for an app card, so only owners pass) give component_restricted. Never for app owners: the agent.invoke guard (SR4 origin, operator or mention reachability, I2 billing_mode_personal_not_holder, SR2, SR3, project readiness, active-run rules). e. Where M2-16 implemented "apps with write on the channel" outside authz, replace it by this guard. Mapping to HTTP: command.invoke and command.autocomplete use the helper with which M2-16 maps invokeCommand (06 4 point 6: commands without the right answer 403 agent_invocation_denied with reason; 404 not_found; 403 channel_read_only); interaction.create uses M2-15's mapping (its Design note 8 d).
  4. Receive rule for apps (05 6 rows interaction.created and command.invoked: "app events:subscribe"; 03 event_log.receive_action; 07 8.1). In filter mode (authorize.Filter(ctx) of M1-28, implied by Delivery(ctx) of M1-65) the action agent.events.consume admits a principal of kind app when the resource's OwnerID is the app itself (the audience), its installation is active and events:subscribe is granted; otherwise forbidden scope_missing, or not_found not_self for another principal's event. Agents keep AgentSelf. In request mode (REST poll, ack and the consumer take of M2-07) the requirement stays AgentSelf, so apps keep 403 forbidden with subject_kind_not_allowed there. Ruling for docs/DECISIONS.md: the catalogue has one receive action per type; the mode separates the receive check from the agent operations. sync.Admit (M1-65) needs no change if it already passes the audience as OwnerID for personal events; otherwise set it there. Effects: the hub delivers command.invoked, interaction.created and the ephemeral command.autocomplete_requested to the connections of the audience app (M1-65, M2-17), listEvents returns them to the app (M1-63, events.catch_up with events:subscribe), and webhook subscriptions of M2-26 pass if its receive check calls authorize in filter mode; if it decides the receive action elsewhere, apply the same rule there and report it.

  5. App commands (runs.CommandExec of M2-16, app branch chosen by owner kind as M2-17 Design note 3 does: an agent record exists for agent owners, every other owner is an app). a. Load the command (404 not_found when unknown or removed); its scope must cover the channel (404, M2-17 Design note 2 d); a given thread_id must belong to the channel (404, checked before authorize). b. Target thread: the given thread; else in a text channel the main thread (Channel.main_thread_id, R-DM-11); else (forum) none. No auto_thread: it exists to give an agent's run a thread. Ruling for docs/DECISIONS.md. c. authorize(person, command.invoke, Resource{Kind: channel, ID: ChannelID, ThreadID: target, Command: &CommandFacts{OwnerID: app, OwnerKind: "app", RequiredLevel}}) in request mode (Design note 3). d. Options: M2-16's validator against command_options.v1 (400 validation_failed with its codes). e. Unit of work (it joins the unit of the M1-20 idempotency stage): append command.invoked (Design note 13) and insert the job of Design note 8 with jobs.Inserter.InsertTx. Nothing else: no runs row, no token, no trigger_state, no run_active rule; control is passed through unchanged and changes nothing. Ruling for docs/DECISIONS.md. f. Answer 202 with CommandInvocation{invocation_id, command_id, channel_id, thread_id (when there is a target thread), accepted: true} and no run_id; invocation_id is a new UUIDv7 and equals the payload's. A replay with the same Idempotency-Key returns the stored answer and writes no second event and no second job (M1-20). g. Invokers: the specification asks only for required_level, so agents and apps (at most write) can run app commands whose level is write; build it so and report it as a spec gap.

  6. App cards (M2-15 Design note 6 extended; 05 2.6, 4.7; SEC-OUT-3, SEC-OUT-4).

    • Remove M2-15's not_enabled for app authors: an app sends kind = card with components through createMessage; message.create needs write and messages:write. Every other card rule of M2-15 applies unchanged (definition checks, server-set fields, event size, request hash bothy-msg-v2).
    • run_id from an app is always 400 validation_failed on /run_id with code invalid, because no run has the app as agent (M2-15 Design note 6 b; 11 2: apps have no runs). agents.operator_config.supports_components is not consulted for apps (apps have no agent record); the manifest's components list is "only for the consent" (11 3.1) and is not enforced.
    • Posting needs no components:write: 05 2.6 names that scope only for component.update and interaction.respond. Without it the app's components are inactive (Design note 3) and the app can neither change nor answer them. Spec gap reported.
    • updateComponent and respondToInteraction by an app: the M1-28 requirement Any(AgentSelf, AppSelf) with scope components:write and OwnerID = the card's author (M2-15 Design note 8 e); a missing scope with a grant gives 403 scope_missing (07 2.10).
    • The header is the author, origin = app and the component fields the server sets (M2-15 Design note 14); an app card never carries a run.
  7. Interactions on app components (runs.Interactions.Create of M2-15 for a card whose author is an app). a. Descriptor InteractionFacts{Stage: "accept", OwnerKind: "app", ...} without Invoke; authz decides by Design note 3 instead of the internal error of M2-15 Design note 8 b. b. Replay, locks, single-use rule, value checks and message.updated exactly as M2-15 Design notes 9, 10 b to g and 16. c. No run is opened or resumed: an app card has no run (M2-15 Design note 10 h does not apply). d. Events: message.updated when a component changed (M2-15) and interaction.created with the app as audience (Design note 13); never agent.invocation. e. Insert the job of Design note 8 with kind interaction in the same unit of work. f. Answer 201 with Interaction without run_id, state = received. The audit entry for a component with required_capability = approve_actions follows M2-15 Design note 17.

  8. Job runs.app_dispatch (02 8.5, 12.1 to 12.4).

    • Args, IDs and codes only: AppDispatch{Kind string /* command, interaction */; EventID, AppID, PersonID, ChannelID, ThreadID, CommandID, InteractionID ids.ID}. Inserted for every app command and every interaction on an app component, whatever the way of the app. Queue default; roles worker and all; a row in 02 12.3 (module runs, trigger "InsertTx on command.invoked and interaction.created for an app", queue default, chapter 11). Ruling for docs/DECISIONS.md: the job may write a reply message, so it must pause in maintenance mode with every queue except the deliveries of ADR 0065 point 4.
    • Handler, in this order:
      1. AppDispatcher.Way. gateway: return; the event reaches the app through Design note 4, and an interaction stays received until respondToInteraction. inactive: an interaction becomes failed (compare-and-swap from received); return.
      2. Re-check with the rights of this moment in filter mode, without audit (R-SY-02, 11 7 row "Check"): command.invoke with the descriptor of Design note 5 c, or interaction.create with stage deliver (M2-15 Design note 8 c, which skips the state check); the person is the principal of the check without a request (05 section 5 "Check without a request", R-AZ-29), built the way M2-08 builds the triggering human for its re-check; extend M2-08's re-check marker so it is also allowed with command.invoke and interaction.create, and widen M2-08's TestRecheckPrincipal_OnlyAgentInvoke to these three actions. Denied: no request; an interaction becomes failed; log reason denied; return.
      3. Interaction: compare-and-swap received -> dispatched (M2-15 Design note 11 a: the server sets dispatched when it hands the event to a transport). A state that is already dispatched (a rerun) continues; acknowledged, failed or rejected (set by respondToInteraction) end the job without a request.
      4. AppDispatcher.Post(AppPost{AppID, EventID, ReplyThreadID: ThreadID}), outside every transaction.
      5. Interaction state by outcome, each a compare-and-swap from dispatched in its own short transaction: answered gives acknowledged; deferred keeps dispatched (the app answers with respondToInteraction, M2-15 Design note 12 c); every other outcome gives failed. Commands store nothing; the outcome is logged (Design note 9 c).
      6. Return nil: one attempt, no repetition (09 1 point 5; 02 12.4 rule 4). Return an error only for a database fault before the request, and ctx.Err() on shutdown (02 12.4 rule 2); a repeated request then carries the same webhook-id, which the app uses as its idempotency key (webhooks.md 3 point 4).
    • Ruling for docs/DECISIONS.md: a request to an app is a consequence of a transaction, so it comes from a job (02 8.5), not from the person's request; the outcome reaches the person only through stored state (spec gap reported).
  9. apps implements AppDispatcher (internal/apps/adapters/appdispatch; 11 9; webhooks.md 2, 3, 6). a. Way: installation not active gives inactive; manifest with delivery.interactions_url gives interactions_url; otherwise gateway. Ruling for docs/DECISIONS.md: the way follows the manifest, not the presence of a connection, so it is the same on every api node (OQ-07) and needs no lookup in the hub; the durable event is written on both ways. If the merged AppManifest (= app_manifest.v1) has no delivery, add delivery {mode, events_url, interactions_url} as 11 3.1 defines it to docs/api/openapi.yaml, and rule 3 of 11 3.2 for it (only https, host in webhook.allowed_hosts) to the manifest validator in internal/apps/domain, with tests (spec gap reported). The address rule of M2-24 (its Design note 5) compares the host without port; with the host[:port] entries of M2-26 (its Design note 9) a URL on a port other than 443 can then be neither installed nor reached. If the merged validator in internal/apps/domain still compares that way, change it to M2-26's rule: the URL's canonical host and port must match an entry, and an entry without a port allows only the https default port. Add a test row for a URL with and without a port, and report it as a difference to M2-24 (Ruling for docs/DECISIONS.md). b. Post:

    1. Load installation and manifest; not active or no URL gives the reasons inactive or no_url without a request. Then check the URL with M2-26's target rule (domain.ValidateTarget against the current webhook.allowed_hosts: scheme https, no user info, canonical host and port in the list); a miss gives reason blocked without calling the egress client. Ruling for docs/DECISIONS.md: a matching egress.allowed_hosts entry admits a host whatever Options.Hosts says (M1-22 Design note 2; 19 SEC-NET-2), so Options.Hosts alone cannot keep a host removed from webhook.allowed_hosts out.
    2. Read the stored event by id (Design note 2 e) and render the body exactly as a webhook delivery of that event by M2-26: the envelope of ADR 0052 with the delivery field sync_epoch (04 2.2, 7.5; ADR 0059), without invocation_token and redelivered.
    3. Headers: webhook-id = the event id, webhook-timestamp = Unix seconds of this attempt, webhook-signature = v1a,<base64> over {webhook-id}.{webhook-timestamp}.{body} with the active webhook_out key (M1-32 Keys.Signer) through M2-26's signer, both keys during a key change as M2-26 does (webhooks.md 2.1 point 2); Content-Type: application/json; the User-Agent of M2-26. No v1: HMAC belongs to a subscription (webhook_subscriptions.hmac_enabled), and an interactions URL is none. Ruling for docs/DECISIONS.md.
    4. One egress client per process for purpose app_interaction (M1-22 New): Options.Hosts = the host policy of webhook.allowed_hosts that M2-26 uses (only https), Redirects = RedirectNever (SEC-NET-4), Timeout = app.interaction_timeout, MaxResponseBytes = http.max_json_body_bytes. Ruling for docs/DECISIONS.md: a reply is a createMessage body, so it has the same bound.
    5. Status 200: decode the body strictly as MessageCreate (unknown members, duplicate keys, invalid UTF-8, JSON null and a missing client_msg_id give invalid_reply; 06 2), then write it through the messaging port with the app as actor (ID = app, Kind = app, InstallationID and GrantedScopes from the installation, no token) into ReplyThreadID: message.create is decided by authorize (write, messages:write), the message gets origin = app, mentions are resolved as for every message of the app, cards follow Design note 6, and client_msg_id makes a repeated job idempotent (04 5.1). Written: Answered. No thread: no_thread. Refused by any rule of the create path: reply_refused. Status 202: Deferred; the body is ignored. Every other status, including other 2xx: http_status; 3xx: redirect; a deadline: timeout; a body above the bound: too_large; ErrBlocked: blocked; any other transport error: transport. Ruling for docs/DECISIONS.md: exactly the answer forms of 11 9. c. Logging: one line per request, level Info for answered and deferred, Warn otherwise, with only attributes of signals.md 5.3 (bothy.job.kind, bothy.event.type, bothy.principal.kind = app, bothy.outcome = answered, deferred or not_answered, bothy.reason) and server.address; never the URL path, a header value, a body, an option or form value, a reply text or a signature (SEC-LOG-2, SEC-LOG-4). The egress client counts refused connections in bothy.egress.blocked. d. Ask: as Post, but the body is the rendered envelope of the ephemeral Event (no seq, no permission_version, with sync_epoch), the request context ends at Deadline, and status 200 is decoded as AutocompleteAnswer and checked with M2-17 Design note 9 (at most command.autocomplete_max_choices, non-empty name and value, kernel/textcheck.DisplayText); valid gives Answered with Choices, invalid gives invalid_reply; 202 gives Deferred. No message is written.
  10. Autocomplete for app commands (M2-17 Autocomplete, the app branch of its Design note 2 g replaced; an agent owner in a forum channel without thread_id keeps the empty list of R-AZ-26; 04 15.7; 09 10.5 last paragraph).

    • After the app branch of authorize (Design note 3), build the payload and register the open request as for agents (M2-17 Design notes 4 and 6), without a token (04 2.2: tokens only for agents).
    • AppDispatcher.Way: interactions_url: Ask with Deadline = the earlier of the end of the person's wait (command.autocomplete_timeout) and now plus app.interaction_timeout; answered: the compare-and-swap of M2-17 to answered with the choices; deferred: keep waiting for answerAutocomplete until command.autocomplete_timeout; every other outcome: expire at once and answer an empty list. gateway: AutocompleteRelay.Ask(ctx, appID, ev, "") (M2-17 Design note 1, empty token); its selection of one live connection applies, and for apps the live phase starts with ready (apps have no consumer cursor, M2-07 Design note 3). inactive: an empty list at once. Ruling for docs/DECISIONS.md: the person never waits longer than command.autocomplete_timeout (R-AG-14), and 202 keeps the meaning of 11 9.
    • The app answers through the op command.autocomplete_result or answerAutocomplete (command.autocomplete.respond, app-self, commands:write; M2-17 Design note 7).
    • The use case holds no transaction (M2-17 Design note 2), so the request to the app may run inside the person's request (02 8.5 concerns transactions).
  11. Commands and components across the app's lifecycle (internal/apps/app, use cases of M2-24, in their unit of work; 11 4.1 to 4.3).

    • Manifest commands: scope_kind absent or instance, no scope_id, names not reserved (09 10.1), at most command.max_per_principal (11 3.2 rule 5), descriptions within command.description_max_chars; validateManifest and installApp reject a violation with 400 validation_failed and the pointer of the field below /manifest/commands/<i>. Where M2-24's validator lacks one of these rules, add it in internal/apps/domain with a test. Ruling for docs/DECISIONS.md: a manifest is independent of the instance and names no target (11 4.1), so its commands are instance-wide and appear where the app has write (09 10.3); narrower scopes come through registerCommand with commands:write.
    • installApp: after the installation row, agents.Commands.UpsertOwned per manifest command, in the order of the diagram of 11 4.1 (commands before grants and tokens).
    • upgradeAppManifest: apps/domain.DiffCommands(old, new) by case-insensitive name: added and changed (description, options, required_level, control) commands through UpsertOwned, removed ones through RemoveOwned. Commands an app registered through the API and that neither manifest names stay. New commands whose scope is not granted stay inactive through the guard (11 4.2).
    • setAppScopes: no row changes; a removed commands:write or components:write takes effect at once through the guard of Design note 3.
    • app.suspend: runs.Interactions.DisableAuthoredBy(app); the commands are hidden through the guard (installation not active).
    • app.resume: the commands are visible again; disabled components stay disabled (the app may reactivate them with updateComponent within M2-15's rules). Ruling for docs/DECISIONS.md: 11 4.3 does not bring them back, and a stale button is worse than a new card.
    • app.uninstall: agents.Commands.RemoveAllOwned(app) and DisableAuthoredBy(app); messages stay with their author (11 4.3).
    • Audit: the owner's action is audited by M2-24 with step-up; its details gain commands_added, commands_changed, commands_removed (lists of command UUIDs) and components_disabled (an integer), all allowed by SEC-AUD-5. No command.register or command.remove entries of their own, because the app did not act. Ruling for docs/DECISIONS.md.
    • No command.changed is written when only the reachability changes (suspend, resume, scopes): the rows do not change, and a version that does not rise would be dropped by clients (04 9 point 3). Clients see the change at their next listChannelCommands, and an invocation answers 404. Spec gap reported.
  12. Ways to the app and what proves them. Gateway: the app connects with its API token (identify with api_token, gateway.connect needs events:subscribe, otherwise 4018, M1-64) and gets the events live through the hub (M1-65) by Design note 4. Catch-up: listEvents with events:subscribe (M1-63). Webhook subscription: command.invoked and interaction.created in event_types of an app subscription (webhooks.md 7, M2-26). interactions_url: Design notes 8 and 9. The same event can arrive on several ways with the same id (equal to webhook-id); delivery is at least once (04 9 point 1). Document this in webhooks.md (Design note 18).

  13. Events (05 6; 04 2.2, 9; ADR 0052, 0053):

    Type Producer scope resource audience Receive action Payload
    command.invoked runs {space_id, channel_id, thread_id}, thread_id absent without target thread {kind: command, id: command_id} without version the owning app agent.events.consume; apps by Design note 4 CommandInvokedPayload{invocation_id, command_id, command_name, control, channel_id, thread_id, invoked_by, options} without run_id
    interaction.created runs the card's {kind: interaction, id} without version the card's app as above InteractionCreatedPayload{interaction} with values, without response and run_id
    message.updated messaging through ComponentsChanged the card's {kind: message, id, version: new} none message.read as M2-15
    message.created (reply) messaging the reply's thread {kind: message, id, version} none message.read origin = app
    command.changed agents as M2-16 as M2-16 none command.read as M2-16
    command.autocomplete_requested (ephemeral) runs as M2-17 {kind: command, id} the app agent.events.consume; apps by Design note 4 as M2-17, without invocation_token

    actor is the person (or the invoking principal), correlation_id the request's; no trace_context and no invocation_token (04 2.2: only to agent adapters). invoked_by is built as M2-16 builds it for agent commands. Every payload validates against its schema and contains no JSON null.

  14. Audit (05 2.4, 4.10; R-AZ-04; 07 4.2). Denials of command.invoke (Audit denials), command.autocomplete, interaction.create (Audit "yes for approve_actions, otherwise denials"), component.update and interaction.respond are written by the authz layer and aggregated; do not add a second entry. No allow entry for app commands; the allow entry for approve_actions components as M2-15 Design note 17. The filter-mode re-check of the job writes nothing (AZ-P13). Lifecycle details as Design note 11.

  15. Concurrency and idempotency (03 6.3; 04 5; 06 7).

    • invokeCommand: Idempotency-Key through M1-20; createInteraction: the replay of M2-15 Design note 10 d, which inserts no second job.
    • Job reruns repeat the request with the same webhook-id; a reply message is idempotent by (author, client_msg_id); interaction states change only by compare-and-swap, and whichever of the job and respondToInteraction leaves received or dispatched first wins; the job's final change starts only from dispatched.
    • Suspend or uninstall racing an invocation: the invocation commits first (the job then finds inactive, the hub refuses with app_not_installed) or after (the guard answers 404 or 409 component_used).
    • DisableAuthoredBy racing a click takes the locks of M2-15 Design note 16 (messages, then components, ascending); the click either wins or finds the component disabled (409 component_used). Deadlocks are retried by the unit of work (database.tx_retry_max).
  16. Security and privacy. a. SSRF (RK-04; SEC-NET-1 to SEC-NET-5): every request to an app goes through the egress client of Design note 9 b; the host must be in webhook.allowed_hosts at the time of the request; apps checks this itself before the egress client (Design note 9 b 1), because an egress.allowed_hosts entry would otherwise admit a host removed from webhook.allowed_hosts; private and blocked addresses only with an operator entry in egress.allowed_hosts with allow_private; no redirect is followed. b. Authenticity: the app checks v1a against the keys of getWebhookKeys; the server sends no token and no secret to the app. c. Header imitation (SEC-OUT-3, SEC-OUT-4, SEC-T14, T-PL-01): author and origin = app come from the server; text that imitates a system header stays content; an app card has no run. d. Personal subscriptions and triggers (05 rule 1.6, SR4): no app command, component, autocomplete request or reply reaches an agent: no agent.* event, no runs row, no trigger_state. e. Canary (SEC-T10; privacy.md 6; "The ticket that builds a new path for such a field extends the scenario"): extend services/server/test/canary with Tracker's card form submitted by Alex with a canary value, an option of /ticket with a canary value and a canary text in the fake app's reply. Expected: the values appear only in the 201 and 202 answers to Alex, in the request to Tracker, in the event to Tracker, and the reply text only in the reply message for its readers; never in logs at level debug, traces, metrics, audit rows, error bodies or frames of Sam and Robin. f. Threat model (threat-model.md 9 point 2): add to 5.11 the rows S "Forged request to the interaction address of an app" (v1a with purpose webhook_out), I "Form values and options leave the instance" (only to the owning app, only https to webhook.allowed_hosts, canary test), D "Slow interaction address" (app.interaction_timeout, job without retry, queue default), E "App command or reply triggers an agent" (origin = app, no agent.invoke, SR4), E "App card imitates a system header" (header from server fields, SEC-OUT-4); add the new test names to T-PL-01; add M2-25 to the column "Reference" of RK-04.

  17. Configuration (11 16, 09 21, 03 9, 19 16). Keys used: app.interaction_timeout (register it if absent: chapter 11-extensions.md, duration, positive), webhook.allowed_hosts, egress.allowed_hosts, http.max_json_body_bytes, command.max_per_principal, command.description_max_chars, command.autocomplete_timeout, command.autocomplete_max_choices, component.default_ttl, sync.event_payload_max_bytes, ratelimit.rl.* of the classes above, audit.deny_aggregate_window, database.tx_retry_max, jobs.queue.default.max_workers. No new key and no number in code or docs.

  18. Specification and API documents (spec first; English in the chapters, webhooks.md and openapi.yaml):

    • docs/spec/11-extensions.md 9: the static way by the manifest (Design note 9 a), the job after the commit and its single attempt (Design note 8), the answer forms, sizes, redirects and signature (Design note 9 b), the deadline of autocomplete (Design note 10), the interaction states (Design note 8), the app side of the check (Design note 3); 4.1 to 4.3: manifest commands instance-wide, the diff at upgrade, hidden through the check at suspend, removed at uninstall, components staying disabled after app.resume (Design note 11).
    • docs/spec/09-agents.md 10.3: "commands of apps with write on the channel, an active installation and the scope commands:write"; 10.5 last paragraph: reference "section 9" of 11 if M2-17 did not correct it.
    • docs/spec/07-permissions.md 4.1: component_inactive also "app of the component without an active installation, without components:write or without write on the channel"; no_grant also "app command whose app is not reachable"; 8.1 row "Durable events": personal events to apps with events:subscribe.
    • docs/spec/02-architecture.md 4.3 (row runs: "declares AppDispatcher"; row apps: "Implements sources: runs.AppDispatcher"; rows agents and messaging for the methods of Design note 2), 4.4 (Design note 2 a), 12.3 (row runs.app_dispatch).
    • docs/api/webhooks.md: new section "10. Interaction address of apps" after section 9: when it is used, body, headers and signature, answer forms with their schemas (MessageCreate, AutocompleteAnswer), bound, no redirect, one attempt, webhook-id as idempotency key, duplicates over several ways, a command in a forum without thread needs 202 and an own createMessage.
    • docs/api/openapi.yaml: descriptions of CommandInvocation (for app commands accepted is always true and run_id is absent), Interaction.state (who sets dispatched, acknowledged and failed for app components), CommandInvokedPayload.run_id (absent for apps), and AppManifest.delivery if Design note 9 a adds it. Then make api-lint, make api-checks, make api-tables and the server code generation.
  19. Rulings for docs/DECISIONS.md (section 10, one row D-M2-25-<n> each, with date, decision, reason and place): edge apps to runs and the source port (note 2 a); the app side of the check, the guard without channel for getCommand, and its results (note 3 b, c); the receive rule by mode (note 4); target thread and control of app commands (note 5 b, e); posting cards without components:write (note 6); the job, its queue and its single attempt (note 8); the way by the manifest and the port rule of manifest addresses (note 9 a); the host check before the egress client (note 9 b 1); v1a without v1 (note 9 b 3); the reply bound (note 9 b 4); the answer forms (note 9 b 5); the autocomplete deadline and 202 (note 10); instance-wide manifest commands (note 11); components stay disabled after resume (note 11); audit details of the owner actions (note 11).

Tests (write them first)

The test world is 07 "Examples" through BuildMatrixWorld of M1-71, as M2-15 uses it, with these additions made through the real operations: app Tracker (07 11.1) installed with installApp (M2-24) from a manifest with command ticket (options title string required, queue string with autocomplete = true; required_level = trigger) and delivery.interactions_url = https://tracker.example.com:<port>/hooks/interactions (<port> is the port of the fake app, so the manifest is built after the fake app has started), granted scopes messages:write, commands:write, components:write (variants without one), grants write on Support/Eskalation (G-6) and on Research/Notizen (test grant G-7); app Linker installed from a manifest without delivery.interactions_url, command link (option target string with autocomplete = true; required_level = write), granted scopes messages:write, commands:write, components:write, events:subscribe, grant write on Support/Allgemein. If M2-24 did not add Tracker and G-6 to the harness, add them there. The fake app of test/apps/fakeapp serves tracker.example.com over TLS on 127.0.0.1 port 0 with an in-memory test certificate authority; the egress client trusts it through Deps.RootCAs, a test resolver maps the name to 127.0.0.1, egress.allowed_hosts has tracker.example.com:<port>;allow_private and webhook.allowed_hosts has tracker.example.com:<port> (the host[:port] form of M2-26 Design note 9; follow M2-26's test wiring). It verifies v1a with the keys of getWebhookKeys, records each request in memory and answers by script: 200 with a MessageCreate or an AutocompleteAnswer, 202, a delay beyond the deadline, 500, a 302, an oversized body, invalid JSON. Gateway clients are wstest (M1-64) with the app's API token. Time comes from kernel/clock; app.interaction_timeout and command.autocomplete_timeout get short test values through the configuration loader. Go test names carry test IDs with _ instead of -.

Unit:

  • internal/apps/domain: TestDiffCommands (added, changed in each field, unchanged, removed, names compared case-insensitively); TestManifestCommandRules (scope_id given, scope_kind = channel, reserved name stop, more than command.max_per_principal, description above command.description_max_chars: each 400 validation_failed with its pointer); TestDecodeAppReply (valid MessageCreate; unknown member; duplicate key; JSON null; missing client_msg_id; not JSON; valid AutocompleteAnswer; too many choices; empty name; bidi character in a choice); TestStatusToOutcome (200, 202, 204, 302, 404, 500, deadline, blocked, host not in webhook.allowed_hosts (also blocked, Design note 9 b 1), too large: the reason of Design note 9 b 5 each).
  • internal/runs/domain: TestAppCommandTargetThread (given thread, text channel main thread, forum without thread); TestInteractionStateAfterOutcome (answered, deferred, each failure reason; only from dispatched); TestAppCommandPayload (no run_id, no JSON null, schema-valid).
  • internal/authz: TestAppOwnerReachable (a table: installation active, suspended, uninstalled; scope present or missing; app level write, only read, none; DM channel); TestCommandInvoke_AppBranchSkipsInvokeGuard (Alex with trigger on Research through the fixture of TestAppCommandsSkipI2, Assistent personal with the Owner as holder: the app command gives allow, an Assistent command gives billing_mode_personal_not_holder); TestCommandInvoke_AppBranchOrder (hidden before level, level before read-only; archived gives channel_archived); TestCommandRead_AppCommandHidden (getCommand by Alex of an instance-wide app command in request mode: allow with an active installation and commands:write, whatever the app's grants are; not_found with no_grant when suspended or without the scope); TestInteractionCreate_AppBranch (Level(trigger), state and expiry, guard false gives component_inactive, required_capability, restricted_to = owner and run_starter pass only owners; no reason of the agent.invoke guard ever appears); TestAgentEventsConsume_ReceiveRuleForApps (filter mode: audience app with events:subscribe allows, without gives scope_missing, another app's event gives not_found; request mode: an app gives subject_kind_not_allowed; agents unchanged); TestRecheckPrincipal_AppActions (R-AZ-29: the Owner's re-check principal, without a grant on Support/Eskalation, allows command.invoke of Tracker's ticket there and interaction.create with stage deliver on a Tracker card with restricted_to = owner; a disabled person gives principal_inactive; any other action stays an internal error).
  • internal/runs/app with fakes for AppDispatcher, the authorizer and the ports: TestAppDispatch_GatewayWayDoesNothing, TestAppDispatch_InactiveFailsInteraction, TestAppDispatch_RecheckDeniedNoRequest, TestAppDispatch_OutcomesToStates, TestAppDispatch_RespondWinsOverJob, TestAppDispatch_ReturnsNilAfterRequest.
  • Fuzz: FuzzDecodeAppReply never panics and never accepts a body that TestDecodeAppReply rejects.

Integration (PostgreSQL 18 via pgtest, real modules through internal/wire, HTTP and gateway through the real chain, every answer checked against openapi.yaml, every event against its schema):

  • TestInstallCreatesManifestCommands: after installApp of Linker the command link exists with owner_principal_id = Linker, scope_kind = instance; one command.changed; the audit entry of app.install lists the command in commands_added; listChannelCommands shows it in Support/Allgemein for Alex and not in Marketing/Kampagnen for Sam.
  • TestUpgradeAppliesCommandDiff: a new manifest adds archive, changes the description of link and drops nothing, then a second one drops archive: rows, versions, removed_at and command.changed as Design note 11; a command Linker registered through registerCommand stays.
  • TestAppCommandHiddenWithoutScopeOrWrite: setAppScopes without commands:write: link disappears from listChannelCommands, invokeCommand and getCommand give 404 with the same status, body and headers (apart from correlation_id and instance) as an unknown command ID (AZ-P4); with the scope back, link appears again in listChannelCommands, getCommand gives 200 and invokeCommand gives 202; after the grant on Support/Allgemein is revoked, link disappears from listChannelCommands and invokeCommand gives 404, while getCommand still gives 200.
  • TestSuspendHidesCommandsAndDisablesComponents and TestResumeKeepsComponentsDisabled: Linker posts a card; suspendApp disables its components (one message.updated per card, versions up by one) and hides link; Linker's gateway connection is closed with 4012 (04 6.4; M1-65 revalidates after the access.changed of app.suspend); resumeApp shows link again and the components stay disabled; a click gives 409 component_used.
  • TestUninstallRemovesCommandsKeepsMessages: uninstallApp: link has removed_at, command.changed is written, the card's components are disabled, Linker's messages stay with Linker as author; the audit details list the removed command and the number of disabled components.
  • TestAppPersonalEventsReachApp_Gateway (plan row: personal events command.invoked and interaction.created reach the app): Linker connected through wstest; Alex runs /link in Support/Allgemein: 202 with accepted: true and no run_id; Linker receives exactly one dispatch command.invoked with audience self, without invocation_token and without resource.version, payload as Design note 13; Linker posts a card, Alex clicks: Linker receives interaction.created with the values; Sam's connection (reader of Support/Allgemein) receives neither.
  • TestAppPersonalEventsReachApp_CatchUp: Linker disconnected; Alex runs /link; Linker's listEvents returns the event; a listEvents of Linker without events:subscribe (after setAppScopes) gives 403 scope_missing.
  • TestAppPersonalEventsReachApp_WebhookSubscription: Linker's installation lists command.invoked in event_subscriptions and an owner subscribes Linker to it with createWebhookSubscription (M2-26, webhooks.md 1); Alex runs /link; the fake receiver gets the event with webhook-id equal to its id.
  • TestAppPersonalEventsReachApp_InteractionsURL: Alex runs /ticket in Support/Eskalation with option title; the fake app receives one signed request whose body is the stored CommandInvokedEvent with sync_epoch, webhook-id equal to the event id, a v1a signature that verifies with getWebhookKeys and no v1; it answers 200 with a MessageCreate: one message by Tracker with origin = app in the main thread of Support/Eskalation, message.created for its readers, no runs row, no agent event. A second test answers 202: no message.
  • TestAppInteractionViaInteractionsURL: Tracker posts a card with a button and a form (201, origin = app); Alex submits the form: 201, state = received; the job sends InteractionCreatedEvent with the values; the fake answers 202: interactions.state = dispatched; Tracker calls respondToInteraction with acknowledged: 200; the job never overwrites it. Variants: 200 with a reply gives acknowledged and the reply message in the card's thread.
  • TestAppInteractionNoAnswer: the fake delays beyond app.interaction_timeout: exactly one request, interactions.state = failed, the component stays used, no retry after the job ends; 500, 302 (not followed; the redirect target never receives a request) and an oversized reply end the same way with their reasons in the log.
  • TestAppCommandsSkipI2 (plan row: app commands do not check I2 because they trigger no agent): a raw SQL statement on the pgtest connection gives Alex trigger on Research while Assistent stays personal with the Owner as holder (a fixture that simulates an I1 defect, OQ-01, as M2-15 does); Alex runs Tracker's /ticket in Research/Notizen: 202, the fake app receives the request, no deny entry; Alex runs a command that Assistent registered: 403 agent_invocation_denied with reason billing_mode_personal_not_holder and one deny entry; Alex clicks a card of Tracker in Research/Notizen: 201; in all cases Assistent receives nothing and no run exists.
  • TestAppNeverTriggersAgent (SR4, AZ-M30 and AZ-M53 for apps): with free_response set on Support/Eskalation through putChannelBehavior, Alex runs /ticket; the fake app replies 200 with a text that mentions Lotse: the reply is stored with origin = app, no run, no agent.invocation, no trigger_state; Lotse's consumer receives no event of the invocation.
  • TestAppCardRules (SEC-T14 and SEC-OUT-4 for apps): Tracker posts a card whose text imitates a system header: stored with author Tracker, origin = app, kind = card; run_id gives 400 on /run_id; without messages:write 403 scope_missing; in Marketing/Kampagnen (no grant) 404; without components:write the post works but a click gives 409 component_used, and updateComponent and respondToInteraction give 403 scope_missing; with the scope both give 200.
  • TestAppAutocomplete_InteractionsURL: Alex asks for queue of /ticket: the fake receives a signed CommandAutocompleteRequestedEvent without invocation_token and answers choices: 200 with them; a delay gives an empty list no later than command.autocomplete_timeout; 202 and then answerAutocomplete by Tracker gives the choices; an invalid answer gives an empty list.
  • TestAppAutocomplete_Gateway: Linker connected; Alex asks for target of /link: Linker receives command.autocomplete_requested without invocation_token, answers with the op command.autocomplete_result: Alex gets the choices.
  • TestInteractionsURLSecurity (SEC-T09 for this way, RK-04): webhook.allowed_hosts without tracker.example.com:<port> while egress.allowed_hosts keeps tracker.example.com:<port>;allow_private: the fake app receives no request, the log line has bothy.reason = blocked, the interaction becomes failed; with tracker.example.com:<port> in webhook.allowed_hosts, no entry in egress.allowed_hosts and the resolver answering a private address: refused before any connection (address_blocked), bothy.egress.blocked rises; no request carries Authorization, a token or a cookie; the log lines of the requests contain no URL path, body, option or form value.
  • TestReceiveRuleForApps: Linker's API token on pollAgentEvents and ackAgentEvents (M2-07) still gives 403 forbidden with subject_kind_not_allowed; in a variant where Tracker also holds events:subscribe and is connected, Tracker never receives Linker's personal events, and Linker never receives Tracker's.
  • TestDeniedAppActionsAudited (AZ-P13): Sam (only read through Leser) runs /ticket on Support/Allgemein where Tracker has no grant: 404; on Support/Eskalation where Sam has no right: 404; Robin runs /link on Engineering/Projekt-A (G-3), where Linker has no grant: 404; each denial is one aggregated audit entry; the filter-mode re-check of the job writes none.
  • TestLifecycleRacesInvoke: suspendApp and /link behind a barrier: either 202 and no delivery after the suspend, or 404; never a delivery to a suspended app.
  • internal/wire: golden files of jobs, bindings and source ports updated; AZ-P1 and AZ-P2 green without new routes, ops or event types.

Contract: the contract tests of M1-71 (part of make check) run unchanged; the changed descriptions in openapi.yaml pass make api-lint.

End-to-end (canary, services/server/test/canary, Design note 16 e):

  • SEC-T10 (canary values through all ways: none appears in logs, traces, metrics, audit details, error responses or foreign events): TestSEC_T10_CanaryAcrossAllOutputs with the app way ends with zero findings; TestSEC_T10_ScenarioCoversEveryEnabledOperation still passes.

Commands that must pass, from the repository root:

  • cd services/server && go test -race -count=1 ./internal/runs/... ./internal/apps/... ./internal/agents/... ./internal/authz/... ./internal/messaging/... ./internal/sync/... ./internal/gateway/... ./internal/kernel/... ./internal/wire/... ./test/...
  • cd services/server && go test -run '^$' -fuzz FuzzDecodeAppReply -fuzztime 30s ./internal/apps/domain/
  • make codegen-check, make arch, make api-lint, make api-checks, make api-tables, make register-check, make config-doc-check, make docs
  • uv run --project tools --locked --quiet python tools/hooks/text_rules.py on every changed Markdown and YAML file (D-W-29)
  • make check

Acceptance criteria

  • command.invoked and interaction.created reach the owning app over the gateway, the REST catch-up and a webhook subscription when it holds events:subscribe, and as a signed POST to delivery.interactions_url when its manifest declares one; no other principal receives them.
  • App commands and app components check no I2 and no agent.invoke: a person who is not the holder of a personal subscription runs an app command and clicks an app card in that agent's channel, and no agent receives anything.
  • No app command, component, autocomplete request or reply creates a run, an agent event or a trigger_state; replies have origin = app.
  • App commands are created at install, follow upgrades, are hidden without an active installation, commands:write or write on the channel (404 like an unknown command), and are removed at uninstall; app components are disabled at suspend and uninstall and stay so after resume.
  • Apps post cards with messages:write; updateComponent and respondToInteraction need components:write; without it their components answer 409 component_used.
  • Requests to interactions_url come from the job runs.app_dispatch after the commit, once, through the egress client with RedirectNever, app.interaction_timeout and http.max_json_body_bytes, only to hosts in webhook.allowed_hosts, signed with v1a; the answer forms 200 and 202 work as 11 9 says; interaction states follow Design note 8.
  • Autocomplete for app commands answers within command.autocomplete_timeout over both ways.
  • Denials are audited once and aggregated; the canary scenario covers the app way and finds no leak.
  • 11, 09, 07, 02, webhooks.md, openapi.yaml, the threat model and RK-04 describe what was built.
  • 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 runs, apps, agents, authz, messaging and test/canary updated (port AppDispatcher, the job runs.app_dispatch, DisableAuthoredBy, the owned-command methods, the app-owner guard and the receive rule, the ways to an app, the lifecycle hooks, the canary fields).
  • Rulings of Design note 19 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>.