Skip to content

M2-24 App manifest, installation and scopes

No Date Ruling Reason
D-M2-24-0 2026-10-19 Port onto main: code and tests are taken from the integration squash of int/m1-wave19 with the integration fixes that belong to this ticket: INT-W19/4 (CheckAppScope takes the token scopes, Scope and AdminScope pass them, TestScope_AppTokenScopes), INT-W19/5 (NewApps uses the structure source of chat and the host source of terminal, both principal writers stay in wire.Identity), INT-W19/7 (AppsDeps.Grants is the Admin service of the authz step, the handlers of apps join through (*Apps).fillHandlers), the apps part of INT-W19/8 (test/appsworld with the identity keys, the instance settings, the full Admin service and the rows of its space and channels; TestAuthenticateAPIToken_AppInstallation sets DevicePendingTTL) and INT-W19/9 (RevokeSubjectGrants appends one grant.changed with change: revoked per revoked grant). The messaging world of INT-W19/8 belongs to M1-56, and the local accounts of the integration base (M1-39) came to main on their own, so nothing of them is ported. The migrations follow 00029_identity_local_credentials.sql of M1-39 without a gap as 00030_apps.sql and 00031_app_installations.sql, and TestMigrations_AppsDown goes down to version 29. TestDecidePrincipalRules, which main gained after the ticket was built, calls CheckAppScope with the new arguments and no token. The spec additions are written into the English chapters (02, 07, 08, 11, configuration.md, threat-model.md) at the same places; code comments, SQL comments and the module READMEs name the English chapter files and headings, so the checksums of both migrations are recorded anew. Generated files (routes, the Dart client, the contract copy of the fake server) are regenerated, not merged. main is English (D-W-94); goose refuses a migration set with a missing version; no behaviour changes beyond the ticket and its integration fixes.
D-M2-24-1 2026-10-13 The module apps may use authz (new edge in 02 4.4, architecture/modules.yaml and the C4 component diagram): uninstall revokes the grants of the app through authz.Admin.RevokeSubjectGrants, and the subscription rule asks authz.AppScopes. identity declares AppInstallations, apps implements it in adapters/identitysource, internal/wire binds it. 11 4.3 asks uninstall to revoke grants, and the scope table of 05 2.6 lives only in authz (05 rule 1.1); authz is a lower layer and identity never imports apps, so the graph stays acyclic.
D-M2-24-2 2026-10-13 AppManifest in openapi.yaml follows 11 3.1 field for field (required schema_version, slug, name, description, publisher, version, min_api_version, scopes.required and scopes.optional; optional homepage_url, events, delivery, commands, components, requested_grants, incoming_webhooks, data_egress), with inline objects and additionalProperties: false everywhere; requested_grants uses the shared enums TargetKind, Level and Capability so trigger, manage, export, approve_actions and host reach the rule check. Every example that shows a manifest uses the new form. The YAML lacked members that 11 3.1, 3.2 and 03 apps.manifest require; the change that finds the contradiction aligns the YAML with the chapter (SPEC 13 point 5), and the rules can only name a refused value they receive.
D-M2-24-3 2026-10-13 Validation pipeline: the request validation checks the schema first (unknown_member, invalid_enum, ... as 400 validation_failed, also for validateManifest); then the handler hands the manifest bytes as received to the pure domain.ValidateManifest with an Env of catalogue facts and configuration. validateManifest answers 200 with valid and every rule error relative to the manifest root, stores nothing and writes no audit entry; installApp and upgradeAppManifest answer 400 validation_failed with the pointers under /manifest, or 400 webhook_host_not_allowed when every error is a host error. Codes come from the M1-18 vocabulary (required, invalid, too_large, too_long, too_many_items), plus webhook_host_not_allowed and command_name_reserved where 06 and 09 name the reason; the detail names the rule, never the value. SEC-IN-1 checks the shape before any rule; one rule function serves the dry run and the install, so both report the same errors.
D-M2-24-4 2026-10-13 Subscribable events: a durable type of the catalogue without audience self whose one receive action is in the scope table (with a scope or none), or interaction.created and command.invoked (receivers agent_or_app, scope events:subscribe); every other type is refused (access.changed, draft.*, agent.invocation, grant.changed, host.updated, ...). The scope of the receive action must be requested, and events:subscribe whenever events is not empty. delivery is required with events, commands or components; events_url required for webhook and forbidden otherwise; interactions_url required for commands or components without the gateway; data_egress required with events or interactions_url. Manifest commands: at most command.max_per_principal, names unique without regard to case and not reserved, description at most command.description_max_chars, option names unique, no target (scope_kind absent or instance, no scope_id). 11 3.1 and 3.2 rules 1, 2 and 5; 05 2.6 row "Event subscriptions"; 11 4.1: a manifest never names a target.
D-M2-24-5 2026-10-13 Delivery addresses are absolute https URLs with a host, without user information and fragment, whose host (lower case, without port) equals an entry of webhook.allowed_hosts without regard to case and without wildcards; otherwise the manifest is refused with webhook_host_not_allowed. homepage_url must be absolute https, its host is not compared because the server never loads it. 11 3.2 rule 3 now says that the operator adds a missing host to the process configuration. webhook.allowed_hosts is process configuration that changes only at start (configuration.md 1, 03 9); the consent cannot add a host "in the same confirmed action" (spec gap, sentence changed in 11).
D-M2-24-6 2026-10-13 installApp: authorize app.install on the instance before anything is loaded; handle = slug, display name = name; every required scope must be granted, any optional scope may be; absent event_subscriptions means none; one unit of work declared as a rights change (permission_version first) creates principal, apps and the active installation and writes the allow entry. An app whose installations are all uninstalled is reinstalled: same principal, status back to active, display name and manifest columns replaced, a new installation; its revoked tokens and grants stay revoked. A handle taken by another principal and a concurrent install of the same slug answer 409 conflict. 11 4.1 and 03 6.3; reusing the principal keeps the authorship of the app's messages (11 4.3) and the unique lower(handle).
D-M2-24-7 2026-10-13 upgradeAppManifest (app.install): same slug; changed content needs another version (a lower one is allowed, rollback); an identical manifest with identical lists is without effect (200, no rights version, audit no_change); granted scopes and subscriptions are subsets of the current ones (new ones need setAppScopes), so what the new manifest drops acts at once; the upgrade never adds a grant; it is itself the consent for new delivery hosts (owner, step-up), which must be in webhook.allowed_hosts and are named in delivery_hosts_added. 11 4.2 (changed: "New scopes and events need a new consent"); manifest_version names the content in the console and the audit; a host change is consented by the step-up action that uploads it.
D-M2-24-8 2026-10-13 Installation, grants and tokens are separate steps: app.install creates no token and no grant; grants come per target with grant.create (M1-29 Admin.CreateGrant, HTTP with M1-48), tokens with createToken. requested_grants only inform the consent. 11 4.1 says so below the diagram. AppInstall has no member for targets and App no secret; the sequence diagram of 11 4.1 shows the whole consent flow, not one transaction.
D-M2-24-9 2026-10-13 Calls without effect are decided before the rights change: upgradeAppManifest, setAppScopes, suspendApp, resumeApp and uninstallApp first load in a unit without rights change and, when nothing changes, write the allow entry with no_change: true there; otherwise the rights change locks apps and the installation and checks again (a concurrent equal change ends as no change). suspendApp and resumeApp of an uninstalled app answer 409 invalid_state_transition; setAppScopes and upgradeAppManifest of an uninstalled app 409 conflict. 02 8.1: phase 1 of a rights change always raises permission_version, and R-API-27 forbids a rights version for a no-op (the M1-29 pattern).
D-M2-24-10 2026-10-13 App tokens and the scope check: createToken for an app locks the active installation FOR SHARE (none, suspended or uninstalled: 409 conflict app_not_installed); scopes absent or empty take the granted scopes at this moment, present ones must be granted (400 at /scopes/<i>). The authenticator reads the installation of an app token on every request; an app request needs the action's scope in GrantedScopes and in the token scopes (actor.Scopes); without a token (webhook delivery) the installation alone decides. A removed scope acts on the next request; a scope granted later needs a new token. Decides the open points of 08 12; replaces the part of M1-28 D6 that token scopes are not evaluated. The share lock serializes with an uninstall that holds the installation FOR UPDATE, so no live token is created for an uninstalled app.
D-M2-24-11 2026-10-13 The installation is part of the credential: the API token path (REST and the gateway identify) applies the installation check, Revalidate fails with app_not_installed when the installation is no longer active or no longer grants a scope the actor carries, and the rights snapshot of an app without an active installation is empty (PrincipalFacts.AppInstallationActive), so suspend and uninstall write access.changed for the app's targets and resume adds them back. Known gap of the core contract (04 8.2): a change of the granted scopes alone changes no visible target, writes no access.changed, and an open gateway connection learns it only at its next identify, resume or Revalidate; REST and webhook deliveries see it at once. TestAppAccessChanged_SuspendResumeUninstall asserts the gap so a later fix shows up. Answers "how long-lived connections recheck the state of the installation" (08 12); the gateway (M1-64, M1-65) is not on the base, so its test waits for those tickets.
D-M2-24-12 2026-10-13 Ports and wiring: apps/app uses consumer interfaces of its own (Principals with IdentityPrincipals over identity.Directory and the new identity.PrincipalWrites, Tokens, Grants, Rights, Scopes, Cursors); internal/wire builds the authz.AdminService for the grant revocation over the pool and binds the PrincipalSource of identity with the installations of apps everywhere (Authorizer, rights hook, expiry, admin). The new start step apps (6e) runs after identity. The domain imports only the kernel core (02 5); the app layer turns facts and configuration into domain.Env.
D-M2-24-13 2026-10-13 listApps lists apps whose latest installation is active or suspended, ordered by lower(slug) then principal id, keyset cursor bound to the caller; getApp returns any app, uninstalled included, with its latest installation. listApps and validateManifest authorize app.read on the collection of apps (resource kind app without id): authz treats that collection as visible like the instance, so a non-owner gets 403 forbidden with the reason owner_only; one app stays invisible to a non-owner (404, AZ-P4). 06 4 point 2 (container check); the catalogue gives app.read only the kind app, so the collection is that kind without an id (TestAppCollection_OwnerOnly).
D-M2-24-14 2026-10-13 Audit details (flat, ids, codes, integers, booleans and lists of codes; never names, descriptions, addresses, data_egress or the manifest): install app_id, installation_id, slug, manifest_sha256 (lower-case hex), granted_scopes, event_subscriptions, delivery_hosts, reinstall, reason; upgrade adds previous_manifest_sha256 and the added and removed manifest scopes, events and commands, delivery_hosts_added, granted_scopes_removed, event_subscriptions_removed; scope change the added and removed scopes and subscriptions; suspend and resume app_id, installation_id; uninstall also tokens_revoked, grants_revoked, reason; no_change: true for a call without effect. manifest_sha256 is the SHA-256 of the manifest bytes as received, apps.manifest keeps the parsed content as jsonb. A delivery host that is no audit code (an address literal, longer than a code or outside its characters) stands in delivery_hosts and delivery_hosts_added as sha256: and the first 32 hex digits of its SHA-256. SEC-AUD-5; the hash names exactly what the owner uploaded; an allowed host must never make the audit entry and with it the install fail, and an address never reaches the audit (20 4.5).
D-M2-24-15 2026-10-13 ADR 0022 confirmation: a depguard rule no-plugin-in-server in services/server/.golangci.yml forbids the standard-library package plugin in every file of the server module; the red run on a throwaway file in a scratch worktree is quoted in the ticket report and the file was never committed. The vendor allow list of modules.yaml already refuses a WebAssembly or other runtime in make arch; plugin is standard library and not covered there.
D-M2-24-16 2026-10-13 bothy_app gets SELECT, INSERT, UPDATE on apps and app_installations and no DELETE; retention classes account and config (03). The trigger apps_principal_kind_check (SQLSTATE 23514) refuses an apps row whose principal is not of kind app. Rows are never deleted (03 1 point 7, M1-12 D4); same pattern as identity_users_kind_check.
D-M2-24-17 2026-10-13 platform/httpserver keeps the JSON body that StageBody checked in the request context (httpserver.RawJSONBody); the app handlers hash and store the manifest member exactly as received from it. The generated strict server hands a handler only the decoded value, and a re-encoding would change the bytes the hash must cover (11 3.1).
D-M2-24-18 2026-10-13 The base lacks routes.SpecVersion; this branch carries the M1-46 generator commits that add it (feat(api): spec version, gateway protocol versions and problem catalogue from the contract, style(api): gofmt of the generator meta test) unchanged, so min_api_version is compared with the real spec version and the later merge with M1-46 brings identical content. The brief asks to extend the generator "exactly as M1-46 Scope item 10 describes"; taking M1-46's own commits is the exact form and avoids a second, diverging implementation.
D-M2-24-19 2026-10-13 The ticket is built on feat/M2-24-apps-install from int/m1-wave12 (da08f58) in its own worktree and is not merged locally; the PR lane ports it. Rulings stand in this section in the table format of the base. M1 tickets outside the closure on the base: M1-31 (yes); M1-19 (yes); M1-46, M1-48, M1-51, M1-53 to M1-57, M1-64, M1-65 (no). The AZ cases and the access.changed assertions run in tier 1 (real installation, token, authenticator, Authorizer and rights hook, grants from authz.Admin.CreateGrant, structure from the fake source); tier 2 and the gateway test wait for M1-56, M1-57, M1-64 and M1-65. The launcher's task takes precedence over the brief's merge step; the brief allows only merged tickets (design note 17).
D-M2-24-20 2026-10-13 Names that differ from the brief: the token scopes of the actor are the existing field actor.Actor.Scopes (no new TokenScopes), read through the new authorize.Principal.AppTokenScopes; the principal port methods are identity.PrincipalWrites (CreatePrincipal, SetPrincipalStatus, UpdateDisplayName); audit.reason_max_chars is registered in configkeys_audit.go (its area), the other keys in configkeys_apps.go; the scope table port also lists the scopes (AppScopes.Scopes). The brief says to add TokenScopes only if missing and to follow the merged names; M1-51 may reuse PrincipalWrites for agents.
D-M2-24-21 2026-10-13 A manifest whose JSON holds the escape of NUL (\u0000) or of a surrogate that is not part of a pair is invalid at the root pointer (invalid); values taken from a manifest (delivery hosts, command names, the slug) enter the audit through one helper that writes a value that is no audit code as sha256: and 32 hex digits. PostgreSQL refuses both escapes in jsonb, and the audit refuses codes longer than 64 bytes or address literals; without the rule and the helper a manifest that passes every check made install or upgrade answer 500 (red team M2-24, SEC-IN-4, SEC-T06). One rule and one helper fix the class instead of each value.
D-M2-24-22 2026-10-13 A keyed call (Idempotency-Key) of suspendApp, resumeApp, uninstallApp, setAppScopes or upgradeAppManifest that loses a race decides the call without effect inside its rights change: the plan runs again under the locks and writes the allow entry with no_change, and the rights version rises once. The idempotency stage runs the handler in one unit, so the rights change is a late one whose error fails the whole unit even when swallowed; the losing caller got 500. Design note 9 g accepts the one extra version step for this rare case; unkeyed calls still roll back and plan again without it.
D-M2-24-23 2026-10-13 The integration world of the module apps lives in services/server/test/appsworld; its tests are split over internal/apps/app, internal/apps/adapters/httpapi and internal/apps/adapters/postgres. make coverage measures each package only by its own tests, so the use cases and the repository counted as uncovered while the HTTP tests ran through them, and the server total fell below the threshold. Code under test/ is test code (02 section 6, Code layout) and outside the measurement.