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 withapi_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 kindapp), M1-36, M1-35, M1-34, M1-33, M1-32 (signing keys, purposewebhook_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 writescommandsin 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" (rowwebhooks: "also forinteractions_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 withwriteon 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 (Auditdenials, R-AZ-04); 2.5 "Decision"; 2.6 "Scopes for apps"; 2.7 "Effect (effect)"; 3 "Rate-limit classes"; 4.7 rowmessage.create; 4.10 rowsagent.events.consume,command.read,command.register,command.remove,command.autocomplete,command.autocomplete.respond,command.invoke,component.update,interaction.create,interaction.respond; 4.11 rowsapp.install,app.scopes.update,app.suspend,app.resume,app.uninstall,webhook_out.deliver; 5 "Agent invocationagent.invoke" (point 1, point 6, the paragraph "App commands and components of apps do not checkagent.invokeand I2 ..." and the paragraph "Check without a request"); 6 "Durable event types and receive action" (rowsinteraction.created,command.invoked,command.changed,message.updated,message.created; table "Ephemeral events" rowcommand.autocomplete_requested); 7 rowcommand.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 fieldsinvocation_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.invokedhas noresource.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(statevalues),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 scopeevents:subscribeinstead ofagent.events.consume"; unique(id, seq)),webhook_subscriptions(hmac_enabled,signing_key_kid),server_signing_keys(purposewebhook_out,EdDSAonly), 6.3 "Lock order".docs/spec/07-permissions.md: 2.1 "Subjects" (apps: at mostwrite), 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", "Nonull"), 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" (rowsruns,agents,messaging,apps), 4.4 "Allowed dependencies" (rowapps: "commands of apps"; the levels), 4.6 "Actions per module" (command.invokeandcommand.autocompletebelong toruns, the register toagents), 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: operationsinvokeCommand,listChannelCommands,getCommand,registerCommand,updateCommand,removeCommand,autocompleteCommand,answerAutocomplete,createInteraction,updateComponent,respondToInteraction,createMessage,listEvents,installApp,validateManifest,upgradeAppManifest,setAppScopes,suspendApp,resumeApp,uninstallApp,getWebhookKeys,createWebhookSubscription,putChannelBehavior,createGrant; schemasAppManifest(commandsasCommandRegister),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; parametersWebhookId,WebhookTimestamp,WebhookSignature; thewebhooks:entriescommand.invokedandinteraction.created.docs/api/asyncapi.yaml: messagesevent.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" (rowsinteraction.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 formatD-<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 metricbothy.egress.blocked; this ticket adds no metric.docs/observability/privacy.md6 "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 witherrors[]andreason), 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 kindapp), M1-28 (Design notes "Public contract inkernel/authorize", "Requirement table" withAgentSelfandAppSelf, "App scopes", "Visibility", "Algorithm" withapp_not_installed,Filter(ctx), "Decision to error mapping"), M1-32 (Keys.Signer(ctx, webhook_out)), M1-44 (token kindapp), M1-56 (createMessageflow,origin), M1-63 (listEvents), M1-64 (identify withapi_token,4018,wstest), M1-65 (sync.Admitas 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 cInteractions, 6 a and b card rules withnot_enabledfor apps, 8 a to eInteractionFactsand mapping, 10createInteraction, 11 adispatched, 12 c respond transitions, 15 bDisableRunComponents, 16 lock order, 17 audit, 18 canary, 19 events), M2-16 (docs/briefs/M2-16-slash-commands.md: thecommandstable,agents.Commands,runs.CommandExec, the visibility of 09 10.3, the option validator, the problem mapping ofinvokeCommand,command.changed), M2-17 (docs/briefs/M2-17-command-autocomplete.md: Design notes 1AutocompleteRelay, 2 g "for app owners until M2-25", 3CommandFactsand the app branch, 5 relay, 6 open requests, 7AnswerAutocomplete, 9 answer validation), M2-24 (docs/briefs/M2-24-apps-install.md: install, upgrade, scopes, suspend, uninstall, the installation facts given toauthz, 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'sconsumeris ignored). - Code (merged names win, D-W-100):
services/server/architecture/modules.yaml(moduleapps:depends_on: [instance, identity, chat, agents, messaging, sync]; modulerunsownsmessage_components,interactions, the actionscommand.invoke,command.autocomplete,interaction.createand the eventscommand.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):
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
Rules for every brief:
- Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
- No en dash and no em dash in any file you write; the
text-rulespre-commit hook rejects them. Use a hyphen or rephrase. - White label: examples and test data use
example.comand the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory. - Limits, windows and thresholds only through named configuration keys listed in
docs/operations/configuration.md; code comments and docs name the key, never the number. - Specification first: a change to behaviour, API, event, right or configuration updates
docs/spec/ordocs/api/in the same change. The core contract (docs/spec/00-glossary.md,03-data-model.md,04-sync-protocol.md,05-actions.md) is not edited by this ticket.
Product requirements implemented here:
- 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.10command.invoke, 09 10.4 last paragraph, R-AZ-18, E-EX-05, E-AG-17, D-K-D17). - An app command checks the person's
required_leveland the app's grants and scope; an app component checkstrigger, the component state,required_capabilityandrestricted_toand 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). - App commands are listed for a channel only for apps with
writethere (09 10.3, 05 4.10command.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 aredisabledin both cases, while its messages stay (11 4.3, R-EX-05). - The install transaction writes the commands of the manifest (11 4.1); a manifest names no target (11 4.1).
command.invokedandinteraction.createdare durable personal events of the modulerunswith the owning app as audience and the receive actionagent.events.consume, for which an app is checked byevents:subscribe(05 6, 03event_log.receive_action, webhooks.md 7); they carry no invocation token (04 2.2) and noresource.version(04 9 point 3).- 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 synchronousPOSTto that address, signed like outgoing webhooks, answered withinapp.interaction_timeout: suggestions,200with a message the server writes in the app's name, or202; without an answer the component staysused(11 9, 11 3.1, 04 15.7 point 2, webhooks.md 2 and 3). - Only
httpsto hosts inwebhook.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). - No network call inside a database transaction; consequences after the commit come from a job inserted with
InsertTxin 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). - Messages of apps, including the reply the server writes for an app, have
origin = appand 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). component.updateandinteraction.respondneedcomponents:write,command.register,command.removeandcommand.autocomplete.respondneedcommands:write,message.createneedsmessages:write(05 2.6, 07 2.10: a missing scope with a grant gives403 scope_missing).- 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 gives404, identical to an unknown ID (R-AZ-01, AZ-P4). - No JSON
nullin 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). - 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 portAppDispatcherwithAppWay,AppPost,AppAsk,AppOutcomeand the closed list of outcome reasons (Design note 2 b).internal/runs/domain/appowner.go: the target thread of an app command, theCommandInvokedPayloadbuilder for app owners, the map fromAppOutcometo 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 ofCommandExecandInteractions(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: argsAppDispatch, kindruns.app_dispatch, queuedefault, and the handler (Design note 8).internal/apps/domain/commands.go:DiffCommandsand 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 targetFuzzDecodeAppReplynext to it.internal/apps/adapters/appdispatch/: the implementation ofruns.AppDispatcher(Design note 9).services/server/test/apps/fakeapp/: an HTTPS fake app for tests that verifiesv1awith the keys ofgetWebhookKeys, records requests in memory and answers by script (section "Tests").- Tests of the section "Tests".
Change:
architecture/modules.yaml:runsadded toapps.depends_on(Design note 2 a); regenerate the architecture configuration (make arch).internal/authz(andinternal/kernel/authorizeonly 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 forcommand.invokeandinteraction.create(Design note 8, R-AZ-29), tests.internal/runscode of M2-15, M2-16 and M2-17: the app branches; remove M2-15's internal error forOwnerKind = app(its Design note 8 b), M2-17's "empty list at once for app owners" (its Design note 2 g);InteractionsgainsDisableAuthoredBy.internal/messaging(code of M1-56 and M2-15): cards from app principals are accepted (Design note 6);Messages.Authorsif 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.deliveryonly 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 byidonly if the merged port has none (Design note 2 e).internal/wire: bindappdispatchintoruns, register the jobruns.app_dispatchfor the rolesworkerandall, pass the new ports; regenerate the golden files of jobs and bindings.internal/platform/configschema:app.interaction_timeout(chapter11-extensions.md, duration, positive) only if no earlier ticket registered it; regeneratedocs/operations/configuration.mdwithmake config-referencewhen 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.md5.11 and register row T-PL-01;docs/RISKS.mdRK-04 (Design note 16 f).- READMEs of
runs,apps,agents,authz,messagingandtest/canary;services/server/CHANGELOG.md;docs/DECISIONS.mdsection 10 rowsD-M2-25-<n>(Design note 19).
Out of scope¶
- Manifest validation, consent, installation, scopes, tokens and the operations of
appsthemselves: M2-24; this ticket adds only the command and component parts of 11 4.1 to 4.3 and, if missing,deliveryin the manifest schema. - Agent commands, control and model commands, reserved names,
run_active,command.changeditself: 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 admitscommand.invokedfor an app subscription. - Incoming webhooks and their messages: M2-27.
- The SDK side (verification of
v1aforinteractions_url, command declarations, components): M2-29, M2-30. The reference apps/issuewith 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 ofAGENTS.md. - Do not modify files outside the scope above, except the CHANGELOG, README and
docs/DECISIONS.mdentries named in this brief. - Follow the host rules supplied by the launcher (ports, machine rules).
- Tests bind only
127.0.0.1and port 0; PostgreSQL runs only as a throwaway container on a random port throughpgtest(P-09, P-10). Without a Docker-compatible runtime the integration tests fail; they never skip. - Stop only processes you started, by PID. Temporary directories only via
mktemp -d, removed at the end. - Never read or print secrets. 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 on127.0.0.1with a test certificate authority created in memory;example.comnames 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_urloutside the tables and events the specification names, not even in test output; tests compare such values in memory. - Changes to
authz,agents,messaging,syncand 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¶
-
Start check. Run
make checkon the fresh worktree; if it is red, stop withVerdict: BLOCKED base branch redand the failing gate. Confirm that the base branch has: M2-24 (internal/apps,installApp,upgradeAppManifest,setAppScopes,uninstallApp, installation facts inauthz), M2-16 (commands,agents.Commands,runs.CommandExec,invokeCommand,listChannelCommands), M2-15 (message_components,interactions,runs.Interactions,InteractionFacts), M2-17 (Autocomplete,AutocompleteRelay,answerAutocomplete, opcommand.autocomplete_result) and M2-26 (getWebhookKeys, thev1asigner, the webhook envelope withsync_epoch, the event read ofapps.webhook_deliver, the host policy ofwebhook.allowed_hosts). A missing one ends withVerdict: BLOCKED dependency <ticket> missing. If M2-24 has no use case forapp.suspendorapp.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). -
Module boundaries and ports (02 4.1, 4.3, 4.4; ADR 0050). a.
appsmay useruns: addrunstoapps.depends_oninmodules.yamland to the rowappsof 02 4.4 with the reason "implementsruns.AppDispatcher; disables the components of an app onapp.suspendandapp.uninstall". No cycle arises:runsuses neitherappsnorsync, and the levels of 02 4.4 already putappsaboveruns.make archandmake register-checkkeep register and chapter equal. Ruling fordocs/DECISIONS.md:runscannot read the manifest, the event log or the egress client ofapps, andappsmay not decide rights or writeinteractions; a source port of the lower module is the way 02 4.1 point 4 names. b. Source port ininternal/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) }Choiceis the type of M2-17. Errors are returned only for faults of the server (database, programming); every answer of the app is anAppOutcome. c.runs.Interactions(M2-15) gainsDisableAuthoredBy(ctx context.Context, authorID ids.ID) (int, error): in the ambient unit of work, find the messages withactivecomponents (runsquery onmessage_components), keep those whose author isauthorID(one batched read through themessagingport,Messages.Authors(ctx, messageIDs) (map[ids.ID]ids.ID, error); add it if the merged port has no such read; it reads onlyauthor_principal_idand checks nothing), then per message in ascendingid:Messages.LockCard, the message'sactivecomponentsORDER BY id FOR UPDATE, eachdisabledwithversion + 1,Messages.ComponentsChangedonce. It returns the number of disabled components and calls noauthorize: 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 withoutauthorizefor owner actions thatappsauthorized:UpsertOwned(ctx, ownerID ids.ID, def CommandDef) (Command, bool, error)(create, or replace the fields of the owner's command withscope_kind = instanceand the same name compared case-insensitively;version + 1andcommand.changedonly on a real change; the limitcommand.max_per_principaland the reserved names asregisterCommandenforces them),RemoveOwned(ctx, ownerID ids.ID, names []string) ([]ids.ID, error)andRemoveAllOwned(ctx, ownerID ids.ID) ([]ids.ID, error)(setremoved_at, writecommand.changedasremoveCommanddoes).CommandDefcarries the fields ofCommandRegister. They join the ambient unit of work. e. The adapter ofappsreads one stored event by itsid(03event_log: unique(id, seq)). Use the read thatapps.webhook_deliverof M2-26 uses; if it reads only byseq, add a read byidto the samesyncport with a doc comment and a test. -
The app as owner in
authz(07 4.2: all rights logic inauthz; 05 5 last paragraph; 11 9; 09 10.3). a. Facts: when a descriptor names an owner of kindapp(Command.OwnerKindof M2-16 and M2-17,Interaction.OwnerKindof M2-15 withResource.OwnerID= the card's author), the app layer ofauthzgathers the installation state and granted scopes of that app from the installation facts M2-24 givesauthz(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 mostwrite; DM channels are never reached, 05 2.2). b. GuardAppOwnerReachable(scope): true when the installation isactive,scopeis in its granted scopes and the app's effective level on the path is at leastwrite. Forcommand.readwithoutOfferedInChannelID(getCommand) the descriptor has no channel and the path of an instance-wide command reaches no place where the app holdswrite(M2-16 Design note 8 b, 07 2.2). There the guard checks only that the installation isactiveand thatcommands:writeis granted. Thewritecondition applies where a channel is resolved: list items withOfferedInChannelID,command.invokeandcommand.autocomplete. Ruling fordocs/DECISIONS.md: "the grants and the scope of the app" of 05 5 meanswriteon the place (09 10.3 "apps withwriteon the channel") and the scope of the feature:commands:writefor commands,components:writefor components (05 2.6); spec gap reported. c. Uses and results:command.readfor every app-owned command of a list or ofgetCommand(filter mode for list elements, request mode forgetCommand),command.invokeandcommand.autocomplete, scopecommands:write: a false guard givesnot_foundwith reasonno_grant. Ruling fordocs/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.createon a component whose card author is an app, scopecomponents:write: a false guard givesforbiddenwith reasoncomponent_inactive, which M2-15 answers with409 component_used. Ruling fordocs/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); thenLevel(required_level)for commands orLevel(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 givecomponent_inactive, a missingrequired_capabilitygivescapability_missing,restricted_to = ownerwithout effective owner andrestricted_to = run_starter(no run exists for an app card, so only owners pass) givecomponent_restricted. Never for app owners: theagent.invokeguard (SR4 origin, operator or mention reachability, I2billing_mode_personal_not_holder, SR2, SR3, project readiness, active-run rules). e. Where M2-16 implemented "apps withwriteon the channel" outsideauthz, replace it by this guard. Mapping to HTTP:command.invokeandcommand.autocompleteuse the helper with which M2-16 mapsinvokeCommand(06 4 point 6: commands without the right answer403 agent_invocation_deniedwithreason;404 not_found;403 channel_read_only);interaction.createuses M2-15's mapping (its Design note 8 d).
-
Receive rule for apps (05 6 rows
interaction.createdandcommand.invoked: "appevents:subscribe"; 03event_log.receive_action; 07 8.1). In filter mode (authorize.Filter(ctx)of M1-28, implied byDelivery(ctx)of M1-65) the actionagent.events.consumeadmits a principal of kindappwhen the resource'sOwnerIDis the app itself (the audience), its installation isactiveandevents:subscribeis granted; otherwiseforbiddenscope_missing, ornot_foundnot_selffor another principal's event. Agents keepAgentSelf. In request mode (REST poll, ack and the consumer take of M2-07) the requirement staysAgentSelf, so apps keep403 forbiddenwithsubject_kind_not_allowedthere. Ruling fordocs/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 asOwnerIDfor personal events; otherwise set it there. Effects: the hub deliverscommand.invoked,interaction.createdand the ephemeralcommand.autocomplete_requestedto the connections of the audience app (M1-65, M2-17),listEventsreturns them to the app (M1-63,events.catch_upwithevents:subscribe), and webhook subscriptions of M2-26 pass if its receive check callsauthorizein filter mode; if it decides the receive action elsewhere, apply the same rule there and report it. -
App commands (
runs.CommandExecof 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_foundwhen unknown or removed); its scope must cover the channel (404, M2-17 Design note 2 d); a giventhread_idmust belong to the channel (404, checked beforeauthorize). b. Target thread: the given thread; else in a text channel the main thread (Channel.main_thread_id, R-DM-11); else (forum) none. Noauto_thread: it exists to give an agent's run a thread. Ruling fordocs/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 againstcommand_options.v1(400 validation_failedwith its codes). e. Unit of work (it joins the unit of the M1-20 idempotency stage): appendcommand.invoked(Design note 13) and insert the job of Design note 8 withjobs.Inserter.InsertTx. Nothing else: norunsrow, no token, notrigger_state, norun_activerule;controlis passed through unchanged and changes nothing. Ruling fordocs/DECISIONS.md. f. Answer202withCommandInvocation{invocation_id, command_id, channel_id, thread_id (when there is a target thread), accepted: true}and norun_id;invocation_idis a new UUIDv7 and equals the payload's. A replay with the sameIdempotency-Keyreturns the stored answer and writes no second event and no second job (M1-20). g. Invokers: the specification asks only forrequired_level, so agents and apps (at mostwrite) can run app commands whose level iswrite; build it so and report it as a spec gap. -
App cards (M2-15 Design note 6 extended; 05 2.6, 4.7; SEC-OUT-3, SEC-OUT-4).
- Remove M2-15's
not_enabledfor app authors: an app sendskind = cardwithcomponentsthroughcreateMessage;message.createneedswriteandmessages:write. Every other card rule of M2-15 applies unchanged (definition checks, server-set fields, event size, request hashbothy-msg-v2). run_idfrom an app is always400 validation_failedon/run_idwith codeinvalid, because no run has the app as agent (M2-15 Design note 6 b; 11 2: apps have no runs).agents.operator_config.supports_componentsis not consulted for apps (apps have no agent record); the manifest'scomponentslist is "only for the consent" (11 3.1) and is not enforced.- Posting needs no
components:write: 05 2.6 names that scope only forcomponent.updateandinteraction.respond. Without it the app's components are inactive (Design note 3) and the app can neither change nor answer them. Spec gap reported. updateComponentandrespondToInteractionby an app: the M1-28 requirementAny(AgentSelf, AppSelf)with scopecomponents:writeandOwnerID= the card's author (M2-15 Design note 8 e); a missing scope with a grant gives403 scope_missing(07 2.10).- The header is the author,
origin = appand the component fields the server sets (M2-15 Design note 14); an app card never carries a run.
- Remove M2-15's
-
Interactions on app components (
runs.Interactions.Createof M2-15 for a card whose author is an app). a. DescriptorInteractionFacts{Stage: "accept", OwnerKind: "app", ...}withoutInvoke;authzdecides by Design note 3 instead of the internal error of M2-15 Design note 8 b. b. Replay, locks, single-use rule, value checks andmessage.updatedexactly 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.updatedwhen a component changed (M2-15) andinteraction.createdwith the app as audience (Design note 13); neveragent.invocation. e. Insert the job of Design note 8 with kindinteractionin the same unit of work. f. Answer201withInteractionwithoutrun_id,state = received. The audit entry for a component withrequired_capability = approve_actionsfollows M2-15 Design note 17. -
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. Queuedefault; rolesworkerandall; a row in 02 12.3 (moduleruns, trigger "InsertTxoncommand.invokedandinteraction.createdfor an app", queuedefault, chapter 11). Ruling fordocs/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:
AppDispatcher.Way.gateway: return; the event reaches the app through Design note 4, and an interaction staysreceiveduntilrespondToInteraction.inactive: an interaction becomesfailed(compare-and-swap fromreceived); return.- Re-check with the rights of this moment in filter mode, without audit (R-SY-02, 11 7 row "Check"):
command.invokewith the descriptor of Design note 5 c, orinteraction.createwith stagedeliver(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 withcommand.invokeandinteraction.create, and widen M2-08'sTestRecheckPrincipal_OnlyAgentInvoketo these three actions. Denied: no request; an interaction becomesfailed; log reasondenied; return. - Interaction: compare-and-swap
received -> dispatched(M2-15 Design note 11 a: the server setsdispatchedwhen it hands the event to a transport). A state that is alreadydispatched(a rerun) continues;acknowledged,failedorrejected(set byrespondToInteraction) end the job without a request. AppDispatcher.Post(AppPost{AppID, EventID, ReplyThreadID: ThreadID}), outside every transaction.- Interaction state by outcome, each a compare-and-swap from
dispatchedin its own short transaction: answered givesacknowledged; deferred keepsdispatched(the app answers withrespondToInteraction, M2-15 Design note 12 c); every other outcome givesfailed. Commands store nothing; the outcome is logged (Design note 9 c). - 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, andctx.Err()on shutdown (02 12.4 rule 2); a repeated request then carries the samewebhook-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).
- Args, IDs and codes only:
-
appsimplementsAppDispatcher(internal/apps/adapters/appdispatch; 11 9; webhooks.md 2, 3, 6). a.Way: installation notactivegivesinactive; manifest withdelivery.interactions_urlgivesinteractions_url; otherwisegateway. Ruling fordocs/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 mergedAppManifest(=app_manifest.v1) has nodelivery, adddelivery {mode, events_url, interactions_url}as 11 3.1 defines it todocs/api/openapi.yaml, and rule 3 of 11 3.2 for it (onlyhttps, host inwebhook.allowed_hosts) to the manifest validator ininternal/apps/domain, with tests (spec gap reported). The address rule of M2-24 (its Design note 5) compares the host without port; with thehost[: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 ininternal/apps/domainstill 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 fordocs/DECISIONS.md). b.Post:- Load installation and manifest; not
activeor no URL gives the reasonsinactiveorno_urlwithout a request. Then check the URL with M2-26's target rule (domain.ValidateTargetagainst the currentwebhook.allowed_hosts: schemehttps, no user info, canonical host and port in the list); a miss gives reasonblockedwithout calling the egress client. Ruling fordocs/DECISIONS.md: a matchingegress.allowed_hostsentry admits a host whateverOptions.Hostssays (M1-22 Design note 2; 19 SEC-NET-2), soOptions.Hostsalone cannot keep a host removed fromwebhook.allowed_hostsout. - 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 fieldsync_epoch(04 2.2, 7.5; ADR 0059), withoutinvocation_tokenandredelivered. - Headers:
webhook-id= the eventid,webhook-timestamp= Unix seconds of this attempt,webhook-signature=v1a,<base64>over{webhook-id}.{webhook-timestamp}.{body}with the activewebhook_outkey (M1-32Keys.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; theUser-Agentof M2-26. Nov1: HMAC belongs to a subscription (webhook_subscriptions.hmac_enabled), and an interactions URL is none. Ruling fordocs/DECISIONS.md. - One egress client per process for purpose
app_interaction(M1-22New):Options.Hosts= the host policy ofwebhook.allowed_hoststhat M2-26 uses (onlyhttps),Redirects = RedirectNever(SEC-NET-4),Timeout = app.interaction_timeout,MaxResponseBytes = http.max_json_body_bytes. Ruling fordocs/DECISIONS.md: a reply is acreateMessagebody, so it has the same bound. - Status
200: decode the body strictly asMessageCreate(unknown members, duplicate keys, invalid UTF-8, JSONnulland a missingclient_msg_idgiveinvalid_reply; 06 2), then write it through themessagingport with the app as actor (ID= app,Kind = app,InstallationIDandGrantedScopesfrom the installation, no token) intoReplyThreadID:message.createis decided byauthorize(write,messages:write), the message getsorigin = app, mentions are resolved as for every message of the app, cards follow Design note 6, andclient_msg_idmakes a repeated job idempotent (04 5.1). Written:Answered. No thread:no_thread. Refused by any rule of the create path:reply_refused. Status202:Deferred; the body is ignored. Every other status, including other2xx:http_status;3xx:redirect; a deadline:timeout; a body above the bound:too_large;ErrBlocked:blocked; any other transport error:transport. Ruling fordocs/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,deferredornot_answered,bothy.reason) andserver.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 inbothy.egress.blocked. d.Ask: asPost, but the body is the rendered envelope of the ephemeralEvent(noseq, nopermission_version, withsync_epoch), the request context ends atDeadline, and status200is decoded asAutocompleteAnswerand checked with M2-17 Design note 9 (at mostcommand.autocomplete_max_choices, non-emptynameandvalue,kernel/textcheck.DisplayText); valid givesAnsweredwithChoices, invalid givesinvalid_reply;202givesDeferred. No message is written.
- Load installation and manifest; not
-
Autocomplete for app commands (M2-17
Autocomplete, the app branch of its Design note 2 g replaced; an agent owner in a forum channel withoutthread_idkeeps 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:AskwithDeadline= the earlier of the end of the person's wait (command.autocomplete_timeout) and now plusapp.interaction_timeout; answered: the compare-and-swap of M2-17 toansweredwith the choices; deferred: keep waiting foranswerAutocompleteuntilcommand.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 withready(apps have no consumer cursor, M2-07 Design note 3).inactive: an empty list at once. Ruling fordocs/DECISIONS.md: the person never waits longer thancommand.autocomplete_timeout(R-AG-14), and202keeps the meaning of 11 9.- The app answers through the op
command.autocomplete_resultoranswerAutocomplete(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).
- After the app branch of
-
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_kindabsent orinstance, noscope_id, names not reserved (09 10.1), at mostcommand.max_per_principal(11 3.2 rule 5), descriptions withincommand.description_max_chars;validateManifestandinstallAppreject a violation with400 validation_failedand the pointer of the field below/manifest/commands/<i>. Where M2-24's validator lacks one of these rules, add it ininternal/apps/domainwith a test. Ruling fordocs/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 haswrite(09 10.3); narrower scopes come throughregisterCommandwithcommands:write. installApp: after the installation row,agents.Commands.UpsertOwnedper 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 throughUpsertOwned, removed ones throughRemoveOwned. 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 removedcommands:writeorcomponents:writetakes effect at once through the guard of Design note 3.app.suspend:runs.Interactions.DisableAuthoredBy(app); the commands are hidden through the guard (installation notactive).app.resume: the commands are visible again; disabled components staydisabled(the app may reactivate them withupdateComponentwithin M2-15's rules). Ruling fordocs/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)andDisableAuthoredBy(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) andcomponents_disabled(an integer), all allowed by SEC-AUD-5. Nocommand.registerorcommand.removeentries of their own, because the app did not act. Ruling fordocs/DECISIONS.md. - No
command.changedis 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 nextlistChannelCommands, and an invocation answers404. Spec gap reported.
- Manifest commands:
-
Ways to the app and what proves them. Gateway: the app connects with its API token (
identifywithapi_token,gateway.connectneedsevents:subscribe, otherwise4018, M1-64) and gets the events live through the hub (M1-65) by Design note 4. Catch-up:listEventswithevents:subscribe(M1-63). Webhook subscription:command.invokedandinteraction.createdinevent_typesof 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 sameid(equal towebhook-id); delivery is at least once (04 9 point 1). Document this in webhooks.md (Design note 18). -
Events (05 6; 04 2.2, 9; ADR 0052, 0053):
Type Producer scoperesourceaudienceReceive action Payload command.invokedruns{space_id, channel_id, thread_id},thread_idabsent without target thread{kind: command, id: command_id}withoutversionthe owning app agent.events.consume; apps by Design note 4CommandInvokedPayload{invocation_id, command_id, command_name, control, channel_id, thread_id, invoked_by, options}withoutrun_idinteraction.createdrunsthe card's {kind: interaction, id}withoutversionthe card's app as above InteractionCreatedPayload{interaction}withvalues, withoutresponseandrun_idmessage.updatedmessagingthroughComponentsChangedthe card's {kind: message, id, version: new}none message.readas M2-15 message.created(reply)messagingthe reply's thread {kind: message, id, version}none message.readorigin = appcommand.changedagentsas M2-16 as M2-16 none command.readas M2-16 command.autocomplete_requested(ephemeral)runsas M2-17 {kind: command, id}the app agent.events.consume; apps by Design note 4as M2-17, without invocation_tokenactoris the person (or the invoking principal),correlation_idthe request's; notrace_contextand noinvocation_token(04 2.2: only to agent adapters).invoked_byis built as M2-16 builds it for agent commands. Every payload validates against its schema and contains no JSONnull. -
Audit (05 2.4, 4.10; R-AZ-04; 07 4.2). Denials of
command.invoke(Auditdenials),command.autocomplete,interaction.create(Audit "yes forapprove_actions, otherwise denials"),component.updateandinteraction.respondare written by the authz layer and aggregated; do not add a second entry. No allow entry for app commands; the allow entry forapprove_actionscomponents as M2-15 Design note 17. The filter-mode re-check of the job writes nothing (AZ-P13). Lifecycle details as Design note 11. -
Concurrency and idempotency (03 6.3; 04 5; 06 7).
invokeCommand:Idempotency-Keythrough 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 andrespondToInteractionleavesreceivedordispatchedfirst wins; the job's final change starts only fromdispatched. - Suspend or uninstall racing an invocation: the invocation commits first (the job then finds
inactive, the hub refuses withapp_not_installed) or after (the guard answers404or409 component_used). DisableAuthoredByracing a click takes the locks of M2-15 Design note 16 (messages, then components, ascending); the click either wins or finds the componentdisabled(409 component_used). Deadlocks are retried by the unit of work (database.tx_retry_max).
-
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_hostsat the time of the request;appschecks this itself before the egress client (Design note 9 b 1), because anegress.allowed_hostsentry would otherwise admit a host removed fromwebhook.allowed_hosts; private and blocked addresses only with an operator entry inegress.allowed_hostswithallow_private; no redirect is followed. b. Authenticity: the app checksv1aagainst the keys ofgetWebhookKeys; 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 andorigin = appcome 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: noagent.*event, norunsrow, notrigger_state. e. Canary (SEC-T10; privacy.md 6; "The ticket that builds a new path for such a field extends the scenario"): extendservices/server/test/canarywith Tracker's card form submitted by Alex with a canary value, an option of/ticketwith a canary value and a canary text in the fake app's reply. Expected: the values appear only in the201and202answers 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" (v1awith purposewebhook_out), I "Form values and options leave the instance" (only to the owning app, onlyhttpstowebhook.allowed_hosts, canary test), D "Slow interaction address" (app.interaction_timeout, job without retry, queuedefault), E "App command or reply triggers an agent" (origin = app, noagent.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. -
Configuration (11 16, 09 21, 03 9, 19 16). Keys used:
app.interaction_timeout(register it if absent: chapter11-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. -
Specification and API documents (spec first; English in the chapters,
webhooks.mdandopenapi.yaml):docs/spec/11-extensions.md9: 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 stayingdisabledafterapp.resume(Design note 11).docs/spec/09-agents.md10.3: "commands of apps withwriteon the channel, an active installation and the scopecommands:write"; 10.5 last paragraph: reference "section 9" of 11 if M2-17 did not correct it.docs/spec/07-permissions.md4.1:component_inactivealso "app of the component without an active installation, withoutcomponents:writeor withoutwriteon the channel";no_grantalso "app command whose app is not reachable"; 8.1 row "Durable events": personal events to apps withevents:subscribe.docs/spec/02-architecture.md4.3 (rowruns: "declaresAppDispatcher"; rowapps: "Implements sources:runs.AppDispatcher"; rowsagentsandmessagingfor the methods of Design note 2), 4.4 (Design note 2 a), 12.3 (rowruns.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-idas idempotency key, duplicates over several ways, a command in a forum without thread needs202and an owncreateMessage.docs/api/openapi.yaml: descriptions ofCommandInvocation(for app commandsacceptedis alwaystrueandrun_idis absent),Interaction.state(who setsdispatched,acknowledgedandfailedfor app components),CommandInvokedPayload.run_id(absent for apps), andAppManifest.deliveryif Design note 9 a adds it. Thenmake api-lint,make api-checks,make api-tablesand the server code generation.
-
Rulings for
docs/DECISIONS.md(section 10, one rowD-M2-25-<n>each, with date, decision, reason and place): edgeappstorunsand the source port (note 2 a); the app side of the check, the guard without channel forgetCommand, and its results (note 3 b, c); the receive rule by mode (note 4); target thread andcontrolof app commands (note 5 b, e); posting cards withoutcomponents: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);v1awithoutv1(note 9 b 3); the reply bound (note 9 b 4); the answer forms (note 9 b 5); the autocomplete deadline and202(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_idgiven,scope_kind = channel, reserved namestop, more thancommand.max_per_principal, description abovecommand.description_max_chars: each400 validation_failedwith its pointer);TestDecodeAppReply(validMessageCreate; unknown member; duplicate key; JSONnull; missingclient_msg_id; not JSON; validAutocompleteAnswer; too many choices; emptyname; bidi character in a choice);TestStatusToOutcome(200,202,204,302,404,500, deadline, blocked, host not inwebhook.allowed_hosts(alsoblocked, 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 fromdispatched);TestAppCommandPayload(norun_id, no JSONnull, schema-valid).internal/authz:TestAppOwnerReachable(a table: installationactive,suspended,uninstalled; scope present or missing; app levelwrite, onlyread, none; DM channel);TestCommandInvoke_AppBranchSkipsInvokeGuard(Alex withtriggeron Research through the fixture ofTestAppCommandsSkipI2, Assistentpersonalwith the Owner as holder: the app command givesallow, an Assistent command givesbilling_mode_personal_not_holder);TestCommandInvoke_AppBranchOrder(hidden before level, level before read-only; archived giveschannel_archived);TestCommandRead_AppCommandHidden(getCommandby Alex of an instance-wide app command in request mode:allowwith an active installation andcommands:write, whatever the app's grants are;not_foundwithno_grantwhen suspended or without the scope);TestInteractionCreate_AppBranch(Level(trigger), state and expiry, guard false givescomponent_inactive,required_capability,restricted_to = ownerandrun_starterpass only owners; no reason of theagent.invokeguard ever appears);TestAgentEventsConsume_ReceiveRuleForApps(filter mode: audience app withevents:subscribeallows, without givesscope_missing, another app's event givesnot_found; request mode: an app givessubject_kind_not_allowed; agents unchanged);TestRecheckPrincipal_AppActions(R-AZ-29: the Owner's re-check principal, without a grant on Support/Eskalation, allowscommand.invokeof Tracker'sticketthere andinteraction.createwith stagedeliveron a Tracker card withrestricted_to = owner; a disabled person givesprincipal_inactive; any other action stays an internal error).internal/runs/appwith fakes forAppDispatcher, the authorizer and the ports:TestAppDispatch_GatewayWayDoesNothing,TestAppDispatch_InactiveFailsInteraction,TestAppDispatch_RecheckDeniedNoRequest,TestAppDispatch_OutcomesToStates,TestAppDispatch_RespondWinsOverJob,TestAppDispatch_ReturnsNilAfterRequest.- Fuzz:
FuzzDecodeAppReplynever panics and never accepts a body thatTestDecodeAppReplyrejects.
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: afterinstallAppof Linker the commandlinkexists withowner_principal_id= Linker,scope_kind = instance; onecommand.changed; the audit entry ofapp.installlists the command incommands_added;listChannelCommandsshows it in Support/Allgemein for Alex and not in Marketing/Kampagnen for Sam.TestUpgradeAppliesCommandDiff: a new manifest addsarchive, changes the description oflinkand drops nothing, then a second one dropsarchive: rows, versions,removed_atandcommand.changedas Design note 11; a command Linker registered throughregisterCommandstays.TestAppCommandHiddenWithoutScopeOrWrite:setAppScopeswithoutcommands:write:linkdisappears fromlistChannelCommands,invokeCommandandgetCommandgive404with the same status, body and headers (apart fromcorrelation_idandinstance) as an unknown command ID (AZ-P4); with the scope back,linkappears again inlistChannelCommands,getCommandgives200andinvokeCommandgives202; after the grant on Support/Allgemein is revoked,linkdisappears fromlistChannelCommandsandinvokeCommandgives404, whilegetCommandstill gives200.TestSuspendHidesCommandsAndDisablesComponentsandTestResumeKeepsComponentsDisabled: Linker posts a card;suspendAppdisables its components (onemessage.updatedper card, versions up by one) and hideslink; Linker's gateway connection is closed with4012(04 6.4; M1-65 revalidates after theaccess.changedofapp.suspend);resumeAppshowslinkagain and the components staydisabled; a click gives409 component_used.TestUninstallRemovesCommandsKeepsMessages:uninstallApp:linkhasremoved_at,command.changedis written, the card's components aredisabled, 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 eventscommand.invokedandinteraction.createdreach the app): Linker connected throughwstest; Alex runs/linkin Support/Allgemein:202withaccepted: trueand norun_id; Linker receives exactly onedispatchcommand.invokedwithaudienceself, withoutinvocation_tokenand withoutresource.version, payload as Design note 13; Linker posts a card, Alex clicks: Linker receivesinteraction.createdwith the values; Sam's connection (reader of Support/Allgemein) receives neither.TestAppPersonalEventsReachApp_CatchUp: Linker disconnected; Alex runs/link; Linker'slistEventsreturns the event; alistEventsof Linker withoutevents:subscribe(aftersetAppScopes) gives403 scope_missing.TestAppPersonalEventsReachApp_WebhookSubscription: Linker's installation listscommand.invokedinevent_subscriptionsand an owner subscribes Linker to it withcreateWebhookSubscription(M2-26, webhooks.md 1); Alex runs/link; the fake receiver gets the event withwebhook-idequal to itsid.TestAppPersonalEventsReachApp_InteractionsURL: Alex runs/ticketin Support/Eskalation with optiontitle; the fake app receives one signed request whose body is the storedCommandInvokedEventwithsync_epoch,webhook-idequal to the eventid, av1asignature that verifies withgetWebhookKeysand nov1; it answers200with aMessageCreate: one message by Tracker withorigin = appin the main thread of Support/Eskalation,message.createdfor its readers, norunsrow, no agent event. A second test answers202: 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 sendsInteractionCreatedEventwith the values; the fake answers202:interactions.state = dispatched; Tracker callsrespondToInteractionwithacknowledged:200; the job never overwrites it. Variants:200with a reply givesacknowledgedand the reply message in the card's thread.TestAppInteractionNoAnswer: the fake delays beyondapp.interaction_timeout: exactly one request,interactions.state = failed, the component staysused, 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 thepgtestconnection gives Alextriggeron Research while Assistent stayspersonalwith the Owner as holder (a fixture that simulates an I1 defect, OQ-01, as M2-15 does); Alex runs Tracker's/ticketin Research/Notizen:202, the fake app receives the request, no deny entry; Alex runs a command that Assistent registered:403 agent_invocation_deniedwithreasonbilling_mode_personal_not_holderand 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): withfree_responseset on Support/Eskalation throughputChannelBehavior, Alex runs/ticket; the fake app replies200with a text that mentions Lotse: the reply is stored withorigin = app, no run, noagent.invocation, notrigger_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_idgives400on/run_id; withoutmessages:write403 scope_missing; in Marketing/Kampagnen (no grant)404; withoutcomponents:writethe post works but a click gives409 component_used, andupdateComponentandrespondToInteractiongive403 scope_missing; with the scope both give200.TestAppAutocomplete_InteractionsURL: Alex asks forqueueof/ticket: the fake receives a signedCommandAutocompleteRequestedEventwithoutinvocation_tokenand answers choices:200with them; a delay gives an empty list no later thancommand.autocomplete_timeout;202and thenanswerAutocompleteby Tracker gives the choices; an invalid answer gives an empty list.TestAppAutocomplete_Gateway: Linker connected; Alex asks fortargetof/link: Linker receivescommand.autocomplete_requestedwithoutinvocation_token, answers with the opcommand.autocomplete_result: Alex gets the choices.TestInteractionsURLSecurity(SEC-T09 for this way, RK-04):webhook.allowed_hostswithouttracker.example.com:<port>whileegress.allowed_hostskeepstracker.example.com:<port>;allow_private: the fake app receives no request, the log line hasbothy.reason=blocked, the interaction becomesfailed; withtracker.example.com:<port>inwebhook.allowed_hosts, no entry inegress.allowed_hostsand the resolver answering a private address: refused before any connection (address_blocked),bothy.egress.blockedrises; no request carriesAuthorization, 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 onpollAgentEventsandackAgentEvents(M2-07) still gives403 forbiddenwithsubject_kind_not_allowed; in a variant where Tracker also holdsevents:subscribeand is connected, Tracker never receives Linker's personal events, and Linker never receives Tracker's.TestDeniedAppActionsAudited(AZ-P13): Sam (onlyreadthrough Leser) runs/ticketon Support/Allgemein where Tracker has no grant:404; on Support/Eskalation where Sam has no right:404; Robin runs/linkon 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:suspendAppand/linkbehind a barrier: either202and no delivery after the suspend, or404; 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_CanaryAcrossAllOutputswith the app way ends with zero findings;TestSEC_T10_ScenarioCoversEveryEnabledOperationstill 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 docsuv run --project tools --locked --quiet python tools/hooks/text_rules.pyon every changed Markdown and YAML file (D-W-29)make check
Acceptance criteria¶
-
command.invokedandinteraction.createdreach the owning app over the gateway, the REST catch-up and a webhook subscription when it holdsevents:subscribe, and as a signedPOSTtodelivery.interactions_urlwhen 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 haveorigin = app. - App commands are created at install, follow upgrades, are hidden without an active installation,
commands:writeorwriteon the channel (404like an unknown command), and are removed at uninstall; app components aredisabledat suspend and uninstall and stay so after resume. - Apps post cards with
messages:write;updateComponentandrespondToInteractionneedcomponents:write; without it their components answer409 component_used. - Requests to
interactions_urlcome from the jobruns.app_dispatchafter the commit, once, through the egress client withRedirectNever,app.interaction_timeoutandhttp.max_json_body_bytes, only to hosts inwebhook.allowed_hosts, signed withv1a; the answer forms200and202work as 11 9 says; interaction states follow Design note 8. - Autocomplete for app commands answers within
command.autocomplete_timeoutover 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 checkis green.
Definition of done¶
- Code and tests as specified; doc comment on every public symbol (purpose, parameters, return value, errors, example where useful); comments explain why.
- READMEs of
runs,apps,agents,authz,messagingandtest/canaryupdated (portAppDispatcher, the jobruns.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.mdentry under 'Unreleased'.- An ADR only if you take an architecture decision beyond this brief (MADR 4 as in
docs/adr/0000-template-madr-4.md, headings and text in English, next free number,make adr-index). make checkgreen.- An independent code review over the diff, then the red team gate (
docs/quality/red-team-gate.md, verdictJAorNEIN); onNEINfix with a test per finding and have the delta checked again. Record both verdicts. - Land the change as
AGENTS.md("How work lands onmain") describes: a pull request from this branch, squash-merged only when review, red team andmake check,ci-okand the review threads are green; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}:
- First line exactly
Verdict: DONEorVerdict: BLOCKED <reason>. - Commits (SHA and subject), changed files with line counts, test results with counts (the red run before the change and the green run after), the summary of
make check. - Review and red team verdicts and the findings fixed.
- What is not proven.
- Follow-ups that could not be done in this scope, each with its reason, and spec gaps found.
Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.