Skip to content

M2-25 Commands and interactions for apps

No Date Ruling Reason
D-M2-25-1 2026-10-23 apps may use runs: runs is added to apps.depends_on and to the row apps of 02 4.4 ("implements runs.AppDispatcher; disables the components of an app on app.suspend and app.uninstall"). runs declares the source port AppDispatcher (Way, Post, Ask with AppWay, AppPost, AppAsk, AppOutcome and a closed list of outcome reasons), and apps implements it. Place: architecture/modules.yaml, runs/appdispatch.go, apps/adapters/appdispatch, 02 sections 4.3 and 4.4. 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, and no cycle arises because runs uses neither apps nor sync.
D-M2-25-2 2026-10-23 The app side of the check is the guard AppOwnerReachable(scope) in authz: the installation is active, scope is granted (commands:write for commands, components:write for components) and the app holds at least write on the resolved place. For command.read without OfferedInChannelID (getCommand) there is no channel, so the guard checks only the active installation and commands:write; the write condition applies to list items with OfferedInChannelID, command.invoke and command.autocomplete. The guard is skipped when the principal is the owning app itself. Order for app owners: visibility of the place, the guard (commands), Level(required_level) or Level(trigger), the read-only and archive rules, then for components state, expiry and a false guard, required_capability and restricted_to; the agent.invoke guard (SR4 origin, reachability, I2, SR2, SR3, project readiness, active-run rules) is never checked. Place: authz, 05 section 5, 07 section 4.2, 09 section 10.3, 11 section 9. "The grants and the scope of the app" of 05 5 names neither the level nor the scope; 09 10.3 says "apps with write on the channel" and 05 2.6 names the scope of each feature (spec gap reported for 05 5, the paragraph on app commands and components). An instance-wide command reaches no place where the app holds write (07 2.2), so getCommand would otherwise never show it.
D-M2-25-3 2026-10-23 Results of a false guard: command.read (filter mode for list elements, request mode for getCommand), command.invoke and command.autocomplete give not_found with reason no_grant; interaction.create on an app card gives forbidden with reason component_inactive, answered 409 component_used as M2-15 maps it. Place: authz, 07 section 4.1. 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); from the person's view a component of an unreachable app cannot be used (11 4.2).
D-M2-25-4 2026-10-23 The receive rule for apps is decided by mode: in filter mode the action agent.events.consume admits a principal of kind app when it is the audience of the event, its installation is active and events:subscribe is granted; 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. Agents keep AgentSelf in both modes. Place: authz, 05 section 6, 07 section 8.1. The catalogue has one receive action per event type (05 6, 03 event_log.receive_action: an app receiver is checked by events:subscribe); the mode separates the receive check from the agent operations, which apps do not have (M2-07).
D-M2-25-5 2026-10-23 Target thread of an app command: the given thread_id, else in a text channel the main thread (R-DM-11), else (forum) none; there is no auto_thread. The unit of work only appends command.invoked and inserts the job runs.app_dispatch: no runs row, no token, no trigger_state, no run_active rule, and control is passed through unchanged. invokeCommand answers 202 with accepted: true, thread_id only with a target thread and no run_id. Agents and apps (at most write) can run an app command whose required_level is write. Place: runs/domain/appowner.go, runs/app, 09 section 10.4, 11 section 9. auto_thread exists to give an agent's run a thread, and an app command opens no run; control only separates control from model commands, which both start nothing for an app. The specification asks the invoker only for required_level, so agents and apps are not excluded (spec gap reported).
D-M2-25-6 2026-10-23 An app posts a card through createMessage with write and messages:write; posting needs no components:write. Without that scope the app's components are inactive through the guard (D-M2-25-3), and the app can neither change nor answer them (updateComponent and respondToInteraction need components:write, otherwise 403 scope_missing). operator_config.supports_components is not consulted for apps, and the manifest's components list is not enforced. Place: messaging, 05 sections 2.6 and 4.7, 09 section 11. 05 2.6 names components:write only for component.update and interaction.respond, not for posting a card (spec gap reported); apps have no agent record, and 11 3.1 keeps components "only for the consent".
D-M2-25-7 2026-10-23 A request to an app comes from the job runs.app_dispatch (module runs, queue default, roles worker and all), inserted with InsertTx with every app command and every interaction on an app card, whatever the way of the app. The handler takes the way: on gateway it ends and an interaction stays received until respondToInteraction; on inactive an interaction becomes failed (compare-and-swap from received). On interactions_url it re-checks in filter mode without audit (a denial sets an interaction failed without a request), sets an interaction dispatched by compare-and-swap, sends once, and sets the final state from dispatched only: answered acknowledged, deferred keeps dispatched, every other outcome failed; commands store nothing. It sends one request and never repeats it. The job pauses in maintenance mode with every queue except the deliveries of ADR 0065 point 4. Place: runs/adapters/jobs/app_dispatch.go, runs/app/app_dispatch.go, 02 section 12.3, 11 section 9. 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; nothing is repeated automatically (09 1 point 5); the job may write a reply message, so it must not run in maintenance mode. The outcome reaches the person only through stored state (spec gap reported), and the display "app is not responding" has no event that carries it (client display in M4).
D-M2-25-8 2026-10-23 The way to an app follows the manifest: an installation that is not active is inactive, a manifest with delivery.interactions_url is interactions_url, otherwise gateway; the durable event is written on both ways. Manifest addresses follow the rule of M2-26: the canonical host and port of the URL must match an entry of webhook.allowed_hosts, and an entry without a port allows only the https default port. This replaces the host-only comparison of M2-24. Place: apps/adapters/appdispatch, apps/domain, 11 sections 3.2 and 9. The way is then the same on every api node (OQ-07) and needs no lookup in the hub. With the host-only rule of M2-24 and the host[:port] entries of M2-26 a URL on a port other than the default could be neither installed nor reached.
D-M2-25-9 2026-10-23 Before the egress client is called, apps checks the interaction address with M2-26's target rule against the current webhook.allowed_hosts (scheme https, no user info, canonical host and port in the list); a miss gives the reason blocked without a connection. Place: apps/adapters/appdispatch, 11 section 7, 19 SEC-NET-1 to SEC-NET-5. A matching egress.allowed_hosts entry admits a host whatever Options.Hosts says (M1-22, SEC-NET-2), so Options.Hosts alone cannot keep out a host removed from webhook.allowed_hosts (RK-04).
D-M2-25-10 2026-10-23 A request to an interaction address carries only the v1a signature with the active webhook_out key (both keys during a key change) over {webhook-id}.{webhook-timestamp}.{body}, never v1; the body is the webhook envelope of the stored event with sync_epoch, and webhook-id is the event id. Place: apps/adapters/appdispatch, docs/api/webhooks.md section 10. HMAC belongs to a subscription (webhook_subscriptions.hmac_enabled), and an interaction address is none; the app checks v1a with the keys of getWebhookKeys.
D-M2-25-11 2026-10-23 One egress client per process for purpose app_interaction: the host policy of webhook.allowed_hosts, no redirects, timeout app.interaction_timeout, response bound http.max_json_body_bytes. Place: apps/adapters/appdispatch, 11 section 6. A reply is a createMessage body, so it has the same bound; a redirect could lead to a private address (SEC-NET-4).
D-M2-25-12 2026-10-23 Answer forms: 200 is decoded strictly as MessageCreate (unknown members, duplicate keys, invalid UTF-8, JSON null and a missing client_msg_id give invalid_reply) and written through the messaging port with the app as actor into the reply thread (Answered; no thread no_thread; refused by the create path reply_refused); 202 is Deferred and its body is ignored; every other status http_status, 3xx redirect, a deadline timeout, a body above the bound too_large, a blocked address blocked, any other transport error transport. Place: apps/domain/reply.go, apps/adapters/appdispatch, 11 section 9. Exactly the answer forms of 11 9; the reply goes through every rule of message.create, and client_msg_id makes a repeated job idempotent (04 5.1).
D-M2-25-13 2026-10-23 Autocomplete for an app with an interaction address asks it with a deadline of the earlier of the end of the person's wait (command.autocomplete_timeout) and now plus app.interaction_timeout; a 200 with valid choices answers the person, 202 keeps waiting for answerAutocomplete until command.autocomplete_timeout, every other outcome answers an empty list at once. An app without one gets command.autocomplete_requested over one live gateway connection without a token; an inactive app gets an empty list at once. Place: runs/app/app_autocomplete.go, apps/adapters/appdispatch, 04 section 15.7, 09 section 10.5. The person never waits longer than command.autocomplete_timeout (R-AG-14), and 202 keeps the meaning of 11 9; the use case holds no transaction, so the request may run inside the person's request (02 8.5).
D-M2-25-14 2026-10-23 Manifest commands are instance-wide: scope_kind absent or instance, no scope_id, names not reserved, at most command.max_per_principal, descriptions within command.description_max_chars; validateManifest and installApp refuse a violation with 400 validation_failed below /manifest/commands/<i>. They appear where the app has write (09 10.3); narrower scopes come through registerCommand with commands:write. Place: apps/domain/commands.go, 11 sections 3.2 and 4.1. A manifest is independent of the instance and names no target (11 4.1).
D-M2-25-15 2026-10-23 app.suspend disables the app's active components and hides its commands through the guard; after app.resume the commands are visible again, while the components stay disabled (the app may reactivate them with updateComponent within the rules of M2-15). No command.changed is written when only the reachability changes (suspend, resume, scopes); clients see it at the next listChannelCommands, and an invocation answers 404. Place: apps/app, 11 section 4.3. 11 4.3 does not bring components back, and a stale button is worse than a new card. The command rows do not change, and a version that does not rise would be dropped by clients (04 9 point 3); 11 names no event for a change of reachability (spec gap reported).
D-M2-25-16 2026-10-23 The owner actions app.install (also for upgradeAppManifest, which M2-24 audits as app.install), app.suspend and app.uninstall keep their one audit entry of M2-24, whose details gain commands_added, commands_changed, commands_removed (lists of command UUIDs) and components_disabled (an integer); no command.register or command.remove entries are written for them. Place: apps/app, 05 section 2.4, 19 SEC-AUD-5. The app did not act, the owner did; IDs and a count carry no agent or human text (SEC-AUD-5).
D-M2-25-17 2026-10-23 The owned-command methods are a separate port agents.OwnedCommands (UpsertOwned, RemoveOwned, RemoveAllOwned) without authorize, implemented by agents/app.CommandService, not methods of agents.Commands. UpsertOwned creates or replaces the owner's instance-wide command of the same case-insensitive name (version + 1 and command.changed only on a real change, limit and reserved names as registerCommand); the remove methods set removed_at and write command.changed as removeCommand. They join the ambient unit of work. Place: agents/agents.go, agents/app, 02 section 4.3. Only apps calls them, for owner actions it authorized itself; a separate port keeps the unchecked writes out of the interface every caller of agents.Commands sees.
D-M2-25-18 2026-10-23 authz.PrincipalFacts gains AppGrantedScopes, which the identity principal source fills from the active installation; authorize.CommandFacts gains OwnerID, and command.invoke and command.autocomplete name the thread creator in Resource.OwnerID. The guard AppOwnerReachable reads the facts of the owning app, not those of the request's principal. Place: authz/sources.go, kernel/authorize/resource.go, the identity principal source. The guard decides about the owner, while the principal of the request is the person; Resource.OwnerID already has a meaning for these actions, so the command's owner needs its own field.
D-M2-25-19 2026-10-23 The receive rule for apps (D-M2-25-4) is decided in authz when the action is agent.events.consume, the principal an app and the mode filter, in the domain function AppReceive with the results not_self, app_not_installed, scope_missing and allow. sync passes the audience in the descriptor of a personal event for an app receiver as it does for the agent consumer. Request mode (the consumer operations of M2-07) is unchanged. Place: authz/domain, sync, 07 section 8.1. All rights logic stays in authz (07 4.2); the hub, listEvents and the webhook path of M2-26 reach it through Delivery(ctx) without a rule of their own.
D-M2-25-20 2026-10-23 The arguments of runs.app_dispatch are IDs only (event_id, app_id, person_id, person_installation_id, channel_id, thread_id, command_id or interaction_id; the kind follows from which of the last two is set), and the job reads the kind of the invoking principal from the directory, so an agent or app that ran an app command is re-checked as itself, and a person as the principal of the check without a request (R-AZ-29); the re-check marker of M2-08 is allowed for command.invoke and interaction.create as well. Place: runs/adapters/jobs/app_dispatch.go, runs/app/app_dispatch.go, authz. D-M2-25-5 lets agents and apps run app commands; re-checking them as a person would decide with rights they do not have. The job registry accepts only IDs and integers as arguments (02 12.1 "only IDs").
D-M2-25-21 2026-10-23 run_id on a card of an app is refused with the code of M2-19, 400 validation_failed with run_mismatch on /run_id, not with invalid. Place: messaging/app/cards.go, 09 section 11.4. No run has the app as its agent (11 2), and D-M2-15-29 gives every kind of message one answer rule for run_id.
D-M2-25-22 2026-10-23 runs.Interactions.DisableAuthoredBy reads the cards with active components without a run (run_id IS NULL), keeps those whose author is the app through the batched read messaging.Messages.Authors (it reads only author_principal_id), then per card in ascending id takes Messages.LockCard, locks its active components, disables each with version + 1 and calls Messages.ComponentsChanged once; it calls no authorize. Place: runs/app/components_authored.go, messaging/messaging.go. An app card never carries a run (D-M2-25-6), so the filter narrows the read without losing a card; the lock order of D-M2-15-19 holds, and the caller authorized its owner action.
D-M2-25-23 2026-10-23 runs.app_dispatch uses River's default attempts for faults before the request (a database fault, the keys, shutdown); after the request it returns nil. runs.AppDispatcher.Post tells the two apart: a fault after the app got the request (writing the reply failed with kind internal or unavailable) wraps runs.ErrAfterRequest and ends the job with the outcome transport; every other error is a fault before the request and is returned for a retry, which proceeds from dispatched. A rerun sends with the same webhook-id. Place: runs/appdispatch.go, runs/app/app_dispatch.go, apps/adapters/appdispatch. A request is sent at most once (09 1 point 5, 02 12.4 rule 4), while a fault before it sent nothing; the app uses webhook-id as its idempotency key (webhooks.md 3 point 4).
D-M2-25-24 2026-10-23 The gateway way of an app stays closed: an app with events:subscribe passes gateway.connect, but sync.snapshot.read has no app scope in the core contract (05, actions.yaml), so ready is refused and the app is closed with 4018 (M1-64/11, unchanged). This ticket builds the delivery rule the gateway needs (the receive rule of D-M2-25-4 in authz, the audience in the descriptor of sync, the relay of autocomplete with an empty token) and proves it on the rule, while the integration tests prove the 4018 and that an app without a live connection gets nothing over the gateway: autocomplete answers an empty list at once. Catch-up, webhook subscriptions and the interaction address work. Spec gap reported: the contract change (an app scope for sync.snapshot.read, or ready without snapshot for apps) belongs to the owner of 04 and 05. Place: test/integration/apps, 11 section 9. The core contract is not edited by this ticket (brief rule 10); building ready without a snapshot for apps would be a deviation from 04 6.3 step 4.
D-M2-25-25 2026-10-23 The level of the app in the guard is its own effective level on the place without the caps of an archived or read-only channel, so the person still learns channel_archived or channel_read_only in its own step (05 rule 1.8, 07 4.5) instead of a hidden command. Place: authz/app/appowner.go. The guard asks whether the app was granted write there; the archive and read-only rules hold for the person (brief requirement 12).
D-M2-25-26 2026-10-23 wire.Deps.AppsEgress (resolver, TLS roots and counter of refused attempts of the two egress clients of apps) and harness.Options.AppsEgress are test seams for the fake app of test/apps/fakeapp, like APNsRootCAs; no configuration key reaches them and production leaves them zero. fakeapp.App.SetKeys sets the v1a keys of getWebhookKeys after the server started. Place: wire/serve.go, test/harness, test/apps/fakeapp. The configuration and the manifest name the fake's port, which exists before the server; the keys exist only after it.