Skip to content

M2-24: App manifest, installation and scopes

Status: ready | Depends on: M1-44 | Milestone: M2

Goal

An owner installs an integration as an app from an uploaded manifest: the server checks the manifest against app_manifest.v1 and the rules of 11 "Validation rules" (unknown members, scopes from the catalogue, subscribable events with their scope, https addresses on hosts from webhook.allowed_hosts, never trigger or manage, commands without reserved names), stores nothing before the owner consents, and then creates the app principal, the apps row and the app_installations row with the granted scopes and event subscriptions in one transaction with step-up, audit and a new rights version. A new manifest version is installed with a recorded difference: scopes and events it adds stay inactive until the owner consents with setAppScopes, while what it removes acts at once. Suspend is an emergency stop that keeps every grant, uninstall revokes tokens and grants and disables the principal. createToken for an app no longer answers 409 conflict with reason app_not_installed when the app has an active installation: it issues a token shown exactly once whose scopes come from the installation (08 "API tokens for agents, apps and tools"). From then on authz lets an app act only with a grant on the target and a scope that both its token and its installation hold, which is AZ-M32 to AZ-M34 with a real app. This is the extension contract of ADR 0022: an app is a principal outside the server process whose reach is its token, its grants and its scopes, and the server module never loads plugin code in process.

Read first

  • 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"; Scope: ADR 0022; Out of scope; Exit criteria 3 and 10; Test strategy: fakes, no real service; Red team: "exceeding the scopes of an app", threat model "apps and webhook targets as boundaries"; Risks); block "agents and extensions" rows M2-23 (generated clients), M2-24 (this ticket), M2-25, M2-26, M2-27, M2-28 (who builds what next to this ticket), block "reference integrations" rows M2-49 and M2-50 (they install with exactly their scopes), section 12 row M6-08 (consent dialog in the console), section 7 rows M1-28, M1-29, M1-44, M1-48, M1-51, M1-56, M1-57, M1-64, M1-65; section 18.2 row B6 (maps ADR 0022 to this ticket).
  • Dependencies: the header names M1-44. Implied through it and therefore not in the header: M1-01, M1-02, M1-03, M1-05 to M1-18, M1-20, M1-22 to M1-29 and M1-32 to M1-35 (among them M1-12 migrations and pgtest, M1-13 unit of work, M1-16 event log, M1-17 operation list and generated server, M1-18 request validation and problem writer, M1-20 idempotency, M1-24 audit, M1-25 catalogue in code, M1-26 principals, M1-27 grant store, M1-28 authorize, M1-29 rights changes and the authz Admin port, M1-35 step-up). Not in the closure, used only if merged (Design note 17): M1-19 (rate limits), M1-31 (response validator in test/apitest), M1-46 (routes.SpecVersion), M1-48 (createGrant), M1-51 (identity port methods for non-human principals), M1-53, M1-55, M1-56 and M1-57 (channels, threads, createMessage, message reads), M1-64 and M1-65 (gateway identify and Revalidate).
  • docs/spec/11-extensions.md (normative for module apps): 1 "Principles" (points 1, 2 and 4); 2 "Agent or app"; 3.1 "Format" (the field table, unknown members are an error, app.manifest_max_bytes, hash); 3.2 "Validation rules" (rules 1 to 5); 3.3 "Example"; 4.1 "Installation" (sequence diagram and the three bullets); 4.2 "Update"; 4.3 "Suspension and uninstallation" (the table); 5 "Scopes and grants"; 6 "Isolation and limits" (row "Never allowed"); 9 "Commands and interactions for apps" (only to know what M2-25 builds); 12 "Versioning and compatibility" (row "App manifest"); 14 "Decisions of this chapter" (R-EX-01 to R-EX-05); 15 "Alignment with the core contract" (E-EX-01, E-EX-02, E-EX-05); 16 "Configuration values of this chapter".
  • docs/spec/09-agents.md: 10.1 "Built-in commands" (reserved names, command_name_reserved), 10.2 "Registration" (fields, command.max_per_principal); the rest of 10 only to know M2-16 and M2-25.
  • docs/spec/03-data-model.md (core contract, read only): 1 "Principles" points 6, 8, 9, 12 and 13; 2 "Modules" (row apps); 4.2 principals (kind app, handle rule, unique lower(handle)), api_tokens (scopes); 4.3 grants (checks for subject_kind IN ('agent','app'), R-DM-08, R-DM-20, the host rule R-DM-35); 4.4 apps and app_installations (every column, check, index and the retention class), commands (only to know M2-25); 6.3 "Lock order"; 8 rows R-DM-08, R-DM-17, R-DM-20, R-DM-35.
  • docs/spec/05-actions.md (core contract, read only): rules 1.1, 1.2, 1.7 and 1.10; 2.3 "Effective level" point 5; 2.4 (columns Owner, Step-up, Audit, Permissions); 2.5 "Decision"; 2.6 "Scopes for apps"; 2.7 "Effect (effect)"; 4.3 rows token.list, token.create, token.revoke; 4.11 "Apps and webhooks" (rows app.read, app.install, app.scopes.update, app.suspend, app.resume, app.uninstall); 6 "Durable event types and receive action" (receive actions, audience = self, the rows interaction.created and command.invoked). Machine-readable: docs/spec/actions.yaml (app_scope per action; events with receive_action, audience, receivers).
  • docs/spec/07-permissions.md: 2.10 "Apps"; 3 "Principal context" (token_id, token_scopes, installation_id, granted_scopes); 4.1 "Decisions and reasons" (app_not_installed, scope_missing, owner_only, step_up_not_possible, subject_kind_not_allowed, no_grant); 4.2 "Flow" (check_active with "installation", audit of denials); 4.3 "Visibility"; 7.2 "Permission state (snapshot)"; 7.3 "Validity of the cache"; 8.2 "Immediate effect on revocation"; SR5 "Administration only by humans with confirmation"; SR7 "Disabled means no permissions"; 11.1 (the world with grant G-6 for app Tracker and rows AZ-M32, AZ-M33, AZ-M34); 11.2 rows AZ-P1, AZ-P4, AZ-P13.
  • docs/spec/08-identity.md: 12 "API tokens for agents, apps and tools" (kinds, storage, the bullet "Scopes" with the open points this ticket decides, binding, expiry, rotation); 15 row ID-T23.
  • docs/spec/04-sync-protocol.md (core contract, read only): 6.3 point 6 "Token renewal" (apps keep their API token on the gateway); 6.4 "Close codes" (row "session, device, token, principal or app installation revoked or not active" gives 4012); 8.1 "Basic rule"; 8.2 "What happens on a change" (no access.changed without a difference).
  • docs/spec/06-api.md: 2 "Basic rules" (rows "No effect", "No null", "Unknown values"); 3 "Authentication per principal kind" (row App); 4 "Authorization in the API" points 2, 3 and 4; 5 "Errors" points 3, 4 and 7 (webhook_host_not_allowed, conflict, invalid_state_transition); 6 "Pagination"; 7 "Idempotency" (one-time secret, R-API-26); 13 "Versioning, deprecation, compatibility" point 1; 16 "Endpoints", table "Apps".
  • docs/spec/02-architecture.md: 4.3 "Ports, events and sources per module" (rows identity, authz, apps); 4.4 "Allowed dependencies" (row apps, the layer line); 4.5 "Cross-cutting ports (internal/kernel)" (kernel/actor, kernel/authorize, kernel/txn, kernel/auditing, kernel/textcheck); 5 "Layers within a module"; 6 "Code layout" (test helpers internal/<module>/adapters/<module>test); 7.1 and 7.2 (module register, go-arch-lint, vendor allow list); 8.1 to 8.4 (unit of work, lock order, in-transaction events, idempotency); 13 "Configuration loading" point 5 (a key exists from its first user on).
  • docs/spec/19-security.md: SEC-IN-1, SEC-IN-4, SEC-IN-6, SEC-NET-2 (webhook.allowed_hosts as an allowed target), SEC-LOG-1, SEC-AUD-5 (flat audit details, only codes, IDs and reason), SEC-T06, SEC-T07.
  • docs/spec/20-privacy.md: the row apps, app_installations of the data categories.
  • docs/api/openapi.yaml: operations listApps, installApp, validateManifest, getApp, upgradeAppManifest, setAppScopes, suspendApp, resumeApp, uninstallApp, createToken, listTokens, revokeToken; schemas AppManifest, App, AppInstallation, AppInstall, AppScopesPut, ManifestValidation, AppPage, AppScope, ReasonBody, ApiToken, ApiTokenCreate, ApiTokenCreated, CommandRegister, CommandOption, CommandLevel, TargetKind, Level, Capability, FieldError, Problem (x-problem-codes); responses BadRequest, Conflict, NotFound, UnprocessableEntity; parameters AppIdPath, IdempotencyKey. docs/api/asyncapi.yaml: close code 4012, message event.access.changed. docs/api/webhooks.md 1 "Subscribe" (event_subscriptions come from installApp and setAppScopes).
  • ADRs 0008, 0013, 0015, 0017, 0018, 0021, 0022, 0049, 0050, 0053, 0061.
  • docs/DECISIONS.md: D-B6, D-EX-01, D-W-100; section 10 for the format of new rows; the M1-28 row on token scopes (ruling D6 of the M1-28 brief) if the base recorded it.
  • docs/security/threat-model.md: TB6, E5, 5.11 "Apps and webhooks", T-PL-01, T-TK-02. No item of docs/RISKS.md or docs/OPEN-QUESTIONS.md concerns this ticket. docs/observability/signals.md: this ticket adds no metric.
  • docs/operations/configuration.md: app.manifest_max_bytes, webhook.allowed_hosts, command.max_per_principal, command.description_max_chars, principal.handle_max_chars, principal.display_name_max_chars, audit.reason_max_chars, auth.api_token_max_ttl, auth.dpop_required_for, auth.step_up_max_age, database.tx_retry_max, audit.deny_aggregate_window.
  • M1 briefs: M1-12 (migrations, numbering rule D6, grants per table, pgtest), M1-13 (unit of work), M1-17 (api/operations.yaml, the generator, api/routes), M1-18 (StageValidate, the errors[].code vocabulary, problem writer), M1-20 (idempotency middleware, MarkOneTimeSecret), M1-24 (auditing.Recorder), M1-25 (kernel/authorize generated catalogue: EventType, AllEventTypes, LookupEvent, EventSpec), M1-26 (identity.Directory, authz.PrincipalSource, PrincipalFacts, kernel/textcheck), M1-28 (actor fields, liveness rule "kind app and InstallationID zero gives app_not_installed", domain/scopes.go, ruling D6, snapshot, cache key, internal/authz/adapters/fake), M1-29 (txn.RightsChange, AddAffected, authz.Admin with CreateGrant and RevokeGrant, the "No effect" pattern), M1-35 (step-up and its test helpers), M1-44 (token format, CreateToken use case and its app_not_installed branch, AuthenticateAPIToken, Tokens.RevokeAllForPrincipal, the actor of the API token path), and if merged M1-51 (identity port additions), M1-64 and M1-65 (identify, Revalidate).
  • Code on the base branch: services/server/internal/apps (README and doc.go of M1-05), internal/identity, internal/authz, internal/kernel/{actor,authorize,txn,auditing,textcheck,errs,clock,ids}, internal/wire (config keys in configkeys_*.go, chapter constants in configchapters.go, golden files), architecture/modules.yaml (module apps: tables and actions are there, events: [], depends_on: [instance, identity, chat, agents, messaging, sync]), api/operations.yaml, migrations/, sqlc.yaml, .golangci.yml. Read merged M1 code with git show <base>:<path>; prefer its names.

Fixed requirements that apply

Owner's fixed build rules (apply verbatim):

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

Rules for every brief:

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

Product requirements implemented here:

  1. Extensions never run in the server process; an app is a principal of kind app with its own token, grants and scopes and uses only the public API (11 1 points 1 and 2, ADR 0022, D-B6).
  2. A manifest is uploaded, never fetched from an address; it follows app_manifest.v1; unknown members are an error; it is at most app.manifest_max_bytes; the server stores it and its SHA-256 (11 3.1, R-EX-01, R-EX-02, 03 apps, 06 2 "Unknown values", SEC-IN-1).
  3. Check rules: every scope is in the catalogue; every event is a durable type of 05 6 and not a personal event of another principal, and the scope of its receive action is requested; every address is https and its host is in webhook.allowed_hosts; requested_grants never carries trigger and as capability at most upload; commands follow 09 10 without reserved names and at most command.max_per_principal (11 3.2 rules 1 to 5, R-DM-08, R-DM-17, R-DM-20, R-EX-03).
  4. app.install, app.scopes.update, app.suspend, app.resume and app.uninstall are owner only, need step-up, are audited on allow and deny, raise the rights version and use rl.admin; app.read is owner only without step-up and audit and uses rl.read (05 4.11, 2.4, rule 1.7).
  5. Installation in one transaction: rights version first, then principal (kind app), apps, app_installations with granted_scopes and event_subscriptions, audit (11 4.1, 03 6.3).
  6. A new manifest with the same slug is a difference; new scopes and events need a new consent with app.scopes.update and stay inactive until then; new delivery hosts are confirmed by the upgrade itself (Design note 7 g); an upgrade never adds grants (Design note 8); removed scopes, events and commands act at once (11 4.2; the command part with M2-25).
  7. Suspend keeps every grant and blocks the tokens without revoking them; uninstall revokes tokens and grants and disables the principal while its messages keep their author (11 4.3, R-EX-05).
  8. An app acts only with both an effective level from grants (at most write) and the scope of the action; no grant gives 404, a missing scope on a visible target gives 403 scope_missing (07 2.10, 05 2.3 point 5 and 2.6, 06 4 point 4; AZ-M32 to AZ-M34).
  9. App tokens carry a subset of the granted scopes of the installation, are stored only as HMAC and shown exactly once; a replay with the same Idempotency-Key answers 409 conflict with reason one_time_secret (08 12, 06 7, ID-T23).
  10. An app whose installation is not active is unauthenticated with reason app_not_installed (07 4.1); a gateway connection of such an app ends with 4012 (04 6.4).
  11. No JSON null in answers; every answer validates against openapi.yaml (06 2, ADR 0053).

Scope

All paths under services/server/ unless noted. Names of M1 below are the expected ones; where the merged code names them differently, follow the code (Boundaries).

Create:

  • migrations/<next>_apps.sql and migrations/<next+1>_app_installations.sql (next free numbers on the base, M1-12 D6): the tables apps and app_installations exactly as 03 4.4, with every column, check, foreign key, unique index (lower(slug); partial (app_id) WHERE state <> 'uninstalled') and a trigger apps_principal_kind_check that rejects an apps row whose principal is not of kind app (03: "kind = 'app' checked by a trigger"; same pattern as identity_users_kind_check of M1-26). Grants per Design note 16. Then make migrations-lock.
  • internal/apps/apps.go (port): interface Apps with List, Get, Validate, Install, Upgrade, SetScopes, Suspend, Resume, Uninstall (each takes ctx and the caller as actor.Actor), the DTOs App, Installation, InstallCmd, UpgradeCmd, SetScopesCmd, ManifestValidation, FieldError, Page. No in-transaction event (02 4.3 row apps: "Emits: none").
  • internal/apps/domain: manifest.go (the typed manifest of Design note 2 and its parse from the raw member), rules.go (ValidateManifest(m Manifest, raw []byte, env Env) []FieldError, pure, Design note 3), semver.go (SemVer 2.0.0 parse and precedence, standard library only), consent.go (rules for granted_scopes and event_subscriptions at install, upgrade and scope change, Design notes 6 to 8), diff.go (DiffManifests(old, new Manifest) ManifestDiff), installation.go (states active, suspended, uninstalled and the transitions of Design note 9).
  • internal/apps/app: one use case per operation, the outgoing Repository port and the consumer ports for identity and authz (Design note 12).
  • internal/apps/adapters/postgres: queries/apps.sql, queries/app_installations.sql, regenerated sqlc code, repository; an entry for apps in sqlc.yaml.
  • internal/apps/adapters/httpapi: the strict-server methods of the nine operations listed under Change.
  • internal/apps/adapters/identitysource: implements identity.AppInstallations over the repository (Design note 12).
  • internal/apps/adapters/appstest: test helper that installs an app through the real use case and returns its id, installation id and an app token, for this ticket and for M2-25 to M2-28 (02 6: only _test.go files import it).
  • internal/wire/configkeys_apps.go: register app.manifest_max_bytes (chapter 11), webhook.allowed_hosts (chapter 03), command.max_per_principal and command.description_max_chars (chapter 09) unless the base registers them already, with the defaults of their chapter tables; add the chapter constants to configchapters.go if missing; run make config-reference.

Change:

  • api/operations.yaml: move listApps, installApp, validateManifest, getApp, upgradeAppManifest, setAppScopes, suspendApp, resumeApp, uninstallApp to enabled; rerun the route generator of M1-17 (make codegen-check must show no drift).
  • internal/identity (port): interface AppInstallations with ActiveInstallation(ctx context.Context, appID ids.ID) (ActiveAppInstallation, bool, error) and LockActiveInstallation(ctx context.Context, appID ids.ID) (ActiveAppInstallation, bool, error) (the second takes FOR SHARE in the ambient unit of work), DTO ActiveAppInstallation{ID ids.ID; GrantedScopes []string}. Principal port methods, only where the base lacks them (M1-51 adds the agent variants): create a principal of kind app (handle and display-name rules of M1-26, case-insensitive handle uniqueness), set principal status (active, disabled with disabled_at), update the display name.
  • internal/identity/app: the createToken branch for apps (Design note 10); the API token path of the authenticator and, if the base has it, Revalidate (Design note 11); the authz.PrincipalSource implementation sets the new fact of Design note 11 for app principals.
  • internal/kernel/actor: the principal carries TokenScopes next to TokenID, InstallationID and GrantedScopes (07 3); add it only if missing.
  • internal/authz: PrincipalFacts gets AppInstallationActive bool; the snapshot rule of Design note 11; the scope check of Design note 10 in domain/scopes.go and in Scope; a new port interface AppScopes with EventScope(t authorize.EventType) (scope string, subscribable bool) and its implementation over the scope table (Design note 4); Admin.RevokeSubjectGrants(ctx context.Context, by actor.Actor, subjectID ids.ID, reason string) (int, error) that joins the ambient unit of work (Design note 9 d).
  • internal/wire: register the handlers, bind identity.AppInstallations to apps/adapters/identitysource, bind authz.AppScopes and the authz Admin port into apps, update the golden files.
  • architecture/modules.yaml: module apps depends_on gains authz; regenerate .go-arch-lint.yml with tools/archgen; set the adapter vendors of apps the way the merged modules list them for their postgres adapters.
  • .golangci.yml: enable depguard with one rule that forbids the standard-library package plugin in the server module, message naming ADR 0022 (Design note 15).
  • docs/api/openapi.yaml: AppManifest per Design note 2; ManifestValidation example; descriptions of validateManifest, installApp, upgradeAppManifest, setAppScopes, createToken and ApiTokenCreate.scopes per Design notes 3, 6 to 8 and 10; setAppScopes gains the response 409 (Conflict); all examples of App with the new manifest form. Then make api-lint, make api-checks, regenerate the server code.
  • Client SDKs: if a client package under sdk/python, sdk/typescript or packages/api_client already has a generate-check target, regenerate it with the generator its README names so that make api-clients stays green; otherwise nothing.
  • docs/spec/11-extensions.md (English, like the chapter): 3.3 (the example's command description as a string, as CommandRegister defines it; events:subscribe added to scopes.optional next to messages:read, because 05 2.6 row "Event subscriptions" requires it for events (Design note 4); a description for the option title, as CommandOption requires), 3.2 rule 3 (a host outside webhook.allowed_hosts is refused with 400 webhook_host_not_allowed; the operator adds it to the process configuration), 4.1 (the installation creates no token and no grant; grants with grant.create per target, tokens with token.create), 4.2 (upgrade never adds grants; consent with app.scopes.update; token scopes; replace "New scopes, events, hosts or grants" with "New scopes and events"; new target hosts are confirmed by the upgrade itself (Owner, Step-up) and must be in webhook.allowed_hosts), 5 (token scopes and installation scopes both apply).
  • docs/spec/08-identity.md 12, bullet "Scopes": replace the M1 interim text with the rules of Design notes 10 and 11.
  • docs/spec/07-permissions.md 2.10: the scope must be held by the token and by the installation; an app without an active installation has no rights in its snapshot.
  • docs/spec/02-architecture.md: 4.3 (row identity declares AppInstallations; row authz port AppScopes; row apps implements identity.AppInstallations), 4.4 (row apps may use authz, reason "revoke the app's grants on uninstallation; scope table for event subscriptions").
  • docs/security/threat-model.md: tests column of T-PL-01 and T-TK-02 with the test names of this ticket.
  • READMEs of apps, identity and authz; services/server/CHANGELOG.md under "Unreleased"; docs/DECISIONS.md section 10 rows D-M2-24-<n> for every ruling (Design note 18), and "superseded by D-M2-24-" on the M1-28 token-scope row if the base has one.

This ticket creates no durable event type, no job, no metric, no new action, no new problem code and no new top-level make target.

Out of scope

  • Registering manifest commands in commands, hiding them on suspend, removing them on uninstall, disabling the app's components, app commands and interactions, requests to interactions_url and autocompletion for apps (M2-25; the table commands comes with M2-16).
  • Outgoing webhook subscriptions and deliveries, signing keys webhook_out, getWebhookKeys, pausing deliveries on suspend and disabling subscriptions on uninstall, reducing webhook_subscriptions.event_types when subscriptions shrink, webhook_out.deliver (M2-26).
  • Incoming webhooks, the built-in minimal manifest of an "incoming app" and their revocation on uninstall (M2-27).
  • The cost reconciliation app and the scope costs:reconcile in use (M2-28); the reference apps for code hosting and ticketing (M2-49, M2-50); building generated clients and SDKs (M2-23, M2-29, M2-30); regenerating an existing generated core after the spec change of this ticket is in scope.
  • The consent dialog, the display of the manifest difference and the access preview in the console (M6-08; access.preview is M1-49).
  • The HTTP operations for grants (createGrant, M1-48); this ticket only revokes grants on uninstall.
  • Gateway connection, hub and Revalidate themselves (M1-64, M1-65); this ticket extends them only if they are on the base (Design note 11).
  • Changing webhook.allowed_hosts at runtime; it is process configuration (Design note 5).
  • Changes to the core contract.

Boundaries

  • A new migration needs its line in migrations.sha256: run make migrations-lock after creating the file and before make check; never edit a merged migration (M1-01, ADR 0007).
  • Work in a dedicated git worktree on the branch feat/M2-24-apps-install; one writer per worktree.
  • Conventional Commits with the module as scope (for example feat(apps): ...) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md).
  • Never push to main, never tag and never create remote repositories; the only remote steps are the branch push and the pull request of AGENTS.md.
  • Do not modify files outside the scope above, except the CHANGELOG, README and docs/DECISIONS.md entries named in this brief.
  • Follow the host rules supplied by the launcher (ports, machine rules).
  • Tests bind only 127.0.0.1 and port 0; PostgreSQL runs only as a throwaway container on a random port through pgtest (P-09, P-10). Without a Docker-compatible runtime the integration tests fail; they never skip.
  • Stop only processes you started, by PID. Temporary directories only via mktemp -d, removed at the end.
  • Never read or print secrets. No network calls except package downloads for pinned tools and Go modules.
  • Never lower a threshold, disable a gate or delete a test.
  • If the core contract lacks something this ticket needs, stop with Verdict: BLOCKED <reason> instead of building a deviation (P-11). The known gap of Design note 11 (scope reductions and open gateway connections) is recorded and does not block this ticket.
  • 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>.
  • M1 tickets outside the dependency closure (M1-19, M1-31, M1-46, M1-48, M1-51, M1-53 to M1-57, M1-64, M1-65) are used only if they are on the base; never stub or rebuild them. Record in the report which of them were present and which test tier ran (Design note 17).
  • No real third-party service: manifests name only hosts under example.com, the test configuration puts them into webhook.allowed_hosts, and nothing in this ticket opens an outgoing connection; internal/apps does not use the egress client of M1-22 (deliveries come with M2-26).
  • Tokens, token hashes and HMAC peppers never appear in logs, audit details, problem bodies or test failure messages.
  • Time only through kernel/clock; tests move a fake clock and never sleep.
  • A mutation used to prove a red run is never committed.

Design notes

  1. Where the rules live. apps owns manifests, installations and their use cases. authz owns every rights rule: the scope table of 05 2.6 (M1-28 domain/scopes.go), which events an app may subscribe to (Design note 4), the scope check and the snapshot (Design notes 10 and 11), and grant revocation. identity owns principals and tokens and reads the installation through a port that apps implements, so identity never imports apps (02 4.4, edges point down). Ruling for docs/DECISIONS.md: the module apps may use authz (new edge in 02 4.4 and modules.yaml), because uninstall must revoke the app's grants (11 4.3) and the subscription rule needs the scope table that only authz holds (05 rule 1.1); authz is a lower layer, so the graph stays acyclic.

  2. AppManifest in openapi.yaml follows 11 3.1 field for field. The current schema has scopes as a list, event_subscriptions and webhooks and lacks min_api_version, delivery, components, requested_grants, incoming_webhooks and data_egress; 03 apps.manifest names components and webhook URLs, and the plan's acceptance needs requested_grants to reject manage. Ruling for docs/DECISIONS.md: the YAML precedence for forms (06 header, SPEC 8) decides between two complete descriptions; here the YAML lacks members that 11 3.1, 3.2 and 03 apps.manifest require, so the change that finds the contradiction aligns the YAML with 11 (SPEC 13 point 5). requested_grants uses the shared enums on purpose, so trigger, manage, export, approve_actions and host reach the rule check and validateManifest can report them (Design note 3). Update every example that shows a manifest. Rewrite the schema with inline objects (no new component names) and additionalProperties: false on every object (06 2 "Unknown values", M1-18 W7):

    • required: schema_version (integer, enum [1]), slug (pattern ^[a-z0-9][a-z0-9-]*$), name, description, publisher (strings, minLength: 1), version (string, SemVer), min_api_version (string, pattern ^[0-9]+\.[0-9]+$), scopes (object with required required and optional, both arrays of AppScope);
    • optional: homepage_url (string), events (array of string), delivery (object, required mode with enum [gateway, webhook], optional events_url and interactions_url), commands (array of CommandRegister), components (array of enum [button, select, form]), requested_grants (array of objects with required target_kind (TargetKind), level (Level), purpose (string) and optional capabilities (array of Capability)), incoming_webhooks (array of objects with required name and purpose), data_egress (string).
  3. Validation pipeline and answers. a. Request validation (M1-18 StageValidate) checks the body against the schema first: unknown members, wrong types, unknown enum values give 400 validation_failed with errors[] (unknown_member, invalid_enum, ...) for every operation, validateManifest included (SEC-IN-1). b. The handler reads the raw bytes of the manifest (the body for validateManifest, the member manifest otherwise) and calls the pure domain.ValidateManifest with an Env built by the app layer: catalogue facts (Design note 4), webhook.allowed_hosts, app.manifest_max_bytes, command.max_per_principal, command.description_max_chars, principal.handle_max_chars, principal.display_name_max_chars and the server's spec version (routes.SpecVersion). c. validateManifest answers 200 ManifestValidation with valid true and errors empty, or valid false and every rule error, pointers relative to the manifest root; it stores nothing and writes no audit entry. d. installApp and upgradeAppManifest answer rule errors with 400 validation_failed and the same errors with pointers under /manifest; when every error is a host error (Design note 5) the answer is 400 webhook_host_not_allowed with those errors. Nothing is stored and no rights version is raised. e. errors[].code uses the M1-18 vocabulary (required, invalid, too_large, too_long, too_many_items); where 06 or 09 already name a reason, the item uses it as its code: webhook_host_not_allowed for a host, command_name_reserved for a reserved command name. detail is a short English text naming the rule (for example "R-EX-03"), never the submitted value. Ruling for docs/DECISIONS.md: points a to e.

  4. Rule set of ValidateManifest (11 3.1 and 3.2), each failure one error item. Ruling for docs/DECISIONS.md: the subscribable set and the events:subscribe requirement, the conditional members of the delivery block, and the command rules for manifests.

    • Size: raw bytes above app.manifest_max_bytes give too_large on the root pointer and stop further checks.
    • slug at most principal.handle_max_chars characters (it becomes the handle, note 6); name a display name after M1-26 (not blank, at most principal.display_name_max_chars, kernel/textcheck); description, publisher, data_egress, every purpose and incoming_webhooks[].name pass kernel/textcheck (SEC-IN-4); version is SemVer 2.0.0; min_api_version is not above the major and minor of routes.SpecVersion (if the base lacks routes.SpecVersion, extend the M1-17 route generator with it exactly as M1-46 Scope item 10 describes).
    • Scopes: no duplicates; no scope in both required and optional.
    • Events: no duplicates; each event t must be subscribable: authz.AppScopes.EventScope(t) returns subscribable true only for a durable type of the catalogue that either has no audience: self and a receive action that the scope table of 05 2.6 lists (with a scope or "no scope"), or is interaction.created or command.invoked (receivers: agent_or_app, E-EX-05); every other type (access.changed, draft.*, agent.invocation, grant.changed with receive action grant.read, host.updated, ...) is refused (rule 1). The returned scope must be in required or optional (rule 2). events:subscribe must be requested when events is not empty (05 2.6 row "Event subscriptions"). If the generated EventSpec lacks receivers, keep the two personal types as a list in authz/domain with a test that compares it with docs/spec/actions.yaml.
    • Delivery: delivery is required when events, commands or components is not empty; delivery.events_url is required when mode is webhook and forbidden otherwise; delivery.interactions_url is required when commands or components is not empty and mode is not gateway; data_egress is required when events is not empty or interactions_url is set (11 3.1 column "Required").
    • Addresses: Design note 5.
    • requested_grants: target_kind not host (R-DM-35); level read or write, never trigger (R-DM-08); capabilities a subset of [upload] without duplicates, so manage, export and approve_actions are refused (R-DM-20, R-EX-03). The manifest never names a target (11 4.1).
    • Commands: at most command.max_per_principal; names unique (case-insensitive) and not reserved (stop, status, new, resume, title, close, 09 10.1); description at most command.description_max_chars and through kernel/textcheck; option names unique within a command; scope_kind absent or instance and scope_id absent, because a manifest never sets a target (11 4.1).
    • components and incoming_webhooks[].name: no duplicates.
  5. Addresses and webhook.allowed_hosts. delivery.events_url and delivery.interactions_url are absolute URLs with scheme https, a host, no user information and no fragment; their host, lowercased and without port, must equal an entry of webhook.allowed_hosts (case-insensitive, no wildcard); otherwise the item has code webhook_host_not_allowed (R-DM-17, 11 3.2 rule 3, SEC-NET-2). homepage_url must be an absolute https URL; its host is not compared, because the server never loads it (11 3.1). Parse with net/url; patterns only with regexp (SEC-IN-6). 11 3.2 rule 3 offers to add a missing host "in the same confirmed action"; webhook.allowed_hosts is process configuration that changes only at start (configuration.md 1, 03 9), so this ticket refuses the manifest instead and changes the sentence in 11 (spec gap, Ruling for docs/DECISIONS.md).

  6. installApp (app.install, resource instance, step-up, rl.admin, Idempotency-Key), in this order: a. authorize(actor, app.install, instance) before anything is loaded, so a non-owner learns nothing about existing apps: non-owners get 403 forbidden with reason owner_only, app and agent tokens the same, an owner without fresh step-up 401 step_up_required (07 4.2 order, SR5). Denials are audited by the authz layer, aggregated per audit.deny_aggregate_window (R-AZ-04). b. Validation of Design note 3; reason through kernel/textcheck, at most audit.reason_max_chars (too_long), required. c. Consent rules (domain/consent.go): granted_scopes without duplicates, every scope in scopes.required or scopes.optional and every scope of scopes.required present (11 4.1 "choose optional scopes"), else 400 validation_failed with pointer /granted_scopes or /granted_scopes/<i>; event_subscriptions absent means none; each must be in the manifest's events, with events:subscribe and the event's scope in granted_scopes, else pointer /event_subscriptions/<i>. d. Generate the app principal id (UUIDv7) before the unit of work; open it with txn.RightsChange{Action: app.install, Resource: {kind: app, id}, Principals: [id]} (permission_version first, 03 6.3). On a reinstall (step e) declare the existing principal with AddAffected before its rows are written (M1-29). e. Lock apps by lower(slug) FOR UPDATE. An app with an installation that is not uninstalled gives 409 conflict (use upgradeAppManifest). An app whose installations are all uninstalled is reinstalled: same principal, status back to active through identity, manifest columns replaced, display name updated, a new app_installations row; its revoked tokens and grants stay revoked. Otherwise create the principal (kind app, handle = slug, display name = name, status active); a handle already taken by a person or an agent gives 409 conflict. A unique violation from a concurrent install maps to 409 conflict. f. Insert or update apps (manifest, manifest_version, manifest_sha256, publisher, homepage_url, updated_at) and insert app_installations (granted_scopes and event_subscriptions sorted, state = active, installed_by = actor). g. Audit allow inside the transaction through auditing.Recorder (Design note 14). h. Commit; 201 App. No token and no grant is created here (Design note 8). A replay with the same Idempotency-Key answers the stored 201 with Idempotent-Replay: true (M1-20). Ruling for docs/DECISIONS.md: handle = slug, display name = name, reinstall reuses the principal, all required scopes at install, absent subscriptions mean none.

  7. upgradeAppManifest (app.install, resource instance, step-up, rl.admin; PUT without Idempotency-Key): a. Authorize as 6a, then load the app (unknown or not an app: 404). b. Validation of Design note 3; manifest.slug must equal the stored slug (pointer /manifest/slug, invalid). c. After the no-change check of 9g, inside the unit of work (RightsChange with app.install): lock apps then app_installations FOR UPDATE; an uninstalled installation gives 409 conflict; suspended stays suspended. d. No change: the stored manifest equals the new one (jsonb equality) and granted_scopes and event_subscriptions equal the request: answer 200 with the current App, no rights version, no event, audit with no_change: true (06 2 "No effect", the M1-29 pattern). A different manifest with the same SemVer precedence of version gives 400 validation_failed at /manifest/version, because manifest_version names the content in the console and the audit; a lower version is allowed (rollback). e. Consent: granted_scopes of the request must be a subset of the current granted_scopes and of the new manifest's scopes; a scope that is not granted now gives 400 validation_failed at /granted_scopes/<i> with detail "consent with setAppScopes" (11 4.2: new scopes need app.scopes.update). event_subscriptions absent means the current ones that the new manifest and the remaining scopes still allow; present, each must be current and still allowed. The result replaces the stored lists, so scopes and events the new manifest dropped act at once. f. Update apps and the display name; update the installation lists; audit allow with the difference of DiffManifests (Design note 14); commit; 200 App. g. Hosts: the upgrade itself, run by the owner with step-up under app.install, is the consent for new delivery hosts (delivery.events_url, delivery.interactions_url). Every new host must already be in webhook.allowed_hosts (Design note 5), and the audit names it in delivery_hosts_added. setAppScopes has no host member. An existing webhook subscription keeps its URL (M2-26). Ruling for docs/DECISIONS.md: an upgrade never adds a grant; changed content needs a new version; identical upgrade is "No effect"; the upgrade is the consent for new delivery hosts (point g).

  8. Grants and tokens are separate steps. 11 4.1 shows grants and an API token inside the installation transaction, but AppInstall has no member for the chosen targets and App has no secret. Grants for an app are created per target with grant.create (subject kind app, owner only per R-RE-08, M1-29 Admin.CreateGrant, HTTP createGrant of M1-48); tokens with createToken (Design note 10). The manifest's requested_grants only inform the consent. Change 11 4.1 accordingly. Ruling for docs/DECISIONS.md.

  9. Scope change, suspend, resume, uninstall. a. setAppScopes (app.scopes.update, resource app, step-up, rl.admin): load (unknown 404; a non-owner gets 404 because only owners see apps, M1-28 visibility of kind app); authorize; no-change check of 9g; RightsChange; lock apps and the installation; uninstalled gives 409 conflict (add 409 to the operation in openapi.yaml); granted_scopes must be a subset of the manifest's scopes (required ones may be dropped after the install; the app then fails with scope_missing); scopes not granted before are the new consent; event_subscriptions absent means the current ones that the new scopes still allow, present it replaces them under the rules of 6c; equal lists are "No effect"; audit; 200 App. b. suspendApp (app.suspend): active to suspended; on suspended "No effect" (204, audit no_change: true); on uninstalled 409 invalid_state_transition. Tokens stay unrevoked; they stop working because the installation is not active (Design note 11). 204. c. resumeApp (app.resume): suspended to active; on active "No effect"; on uninstalled 409 invalid_state_transition. 204. d. uninstallApp (app.uninstall, ReasonBody): active or suspended to uninstalled with uninstalled_at and uninstalled_by; in the same unit of work, in this order after the installation row: revoke every active token through identity.Tokens.RevokeAllForPrincipal (M1-44), revoke every active grant with subject the app through authz.Admin.RevokeSubjectGrants (no authorize per grant: it is a consequence of the authorized app.uninstall; rows in ascending id, revoked_by the actor, revoke_reason the reason; if the base writes grant.changed in RevokeGrant (M1-48), write it here too), set the principal disabled through identity; the app's messages keep their author (11 4.3). On uninstalled "No effect" (204). 204. e. Every change of a to d runs in one unit of work with txn.RightsChange{Action, Resource: {kind: app, id}, Principals: [app]}; the no-change answers are decided before it (point g); the M1-29 hook writes access.changed for the app where its visible targets differ (Design note 11). f. Later tickets extend these use cases with their parts of 11 4.3 (M2-25 commands and components, M2-26 deliveries and subscriptions, M2-27 incoming webhooks); the README lists them. g. "No effect" comes before the rights change. Phase 1 of a RightsChange unit of work always raises permission_version (02 8.1, M1-13), and R-API-27 forbids a rights version for a no-op. So upgradeAppManifest, setAppScopes, suspendApp, resumeApp and uninstallApp first, after authorize, open a unit of work without RightsChange, load apps and the installation and check the no-change condition (7d, 9a to 9d). If it holds, they write the allow audit entry with no_change: true there and answer. Otherwise that unit of work ends without a write, the RightsChange unit of work of e opens, locks as described and checks the condition again. If a concurrent request made the same change first, it finishes as no-change (the one extra version step is rare and harmless; same pattern as M1-45 step 3 and M1-48 revokeGrant). Ruling for docs/DECISIONS.md: no-change answers, decided before the RightsChange unit of work (point g), and 409 invalid_state_transition for the transitions above, 409 conflict for scope changes and upgrades of an uninstalled app.

  10. App tokens and scope check (decides the open points of 08 12). a. createToken for a principal of kind app (after the M1-44 steps up to authorize): identity.AppInstallations.LockActiveInstallation(app) inside the unit of work; no active installation (none, suspended, uninstalled) keeps 409 conflict with reason app_not_installed. Body scopes absent or empty: the token stores the installation's granted_scopes at this moment; present: every scope must be in granted_scopes, without duplicates, else 400 validation_failed at /scopes/<i>. Expiry, dpop_jkt (required only when app is in auth.dpop_required_for), one-time secret and audit as M1-44, with the stored scopes added to the audit details. The share lock serializes with an uninstall that holds the installation row FOR UPDATE, so no live token is created for an uninstalled app. b. Authentication of an app token (the API token path of M1-44): ActiveInstallation(app); not active gives an actor with zero InstallationID, so authorize answers unauthenticated with reason app_not_installed (M1-28 liveness rule; HTTP 401 unauthenticated); active gives InstallationID, GrantedScopes from the installation and TokenScopes from the token. Read on every request, no cache (AZ-P9 style). c. Scope check in authz (05 rule 1.1): an app request needs the action's scope in GrantedScopes and in TokenScopes; missing in either gives forbidden scope_missing. A check without a token (TokenID zero, for example a webhook delivery of M2-26) uses GrantedScopes alone. Scope filters lists for apps with the same rule. Agents stay unchanged (no scope check). d. Consequence: removing a scope from the installation acts on the next request of every token; a scope granted later needs a new token (rotation as 08 12: create the new token, switch, revoke the old one). Ruling for docs/DECISIONS.md: points a to d; it replaces the M1-28 ruling that token scopes are not evaluated.

  11. Installation state on long-lived connections. a. The installation is part of the credential: AuthenticateAPIToken (used by REST and by the gateway identify, M1-64) applies 10b; put the installation check into the identity function that every API token check calls, so Revalidate (M1-65) inherits it; if Revalidate exists on the base, it also fails with app_not_installed when the installation is no longer active, or when the actor's GrantedScopes hold a scope the installation no longer grants. b. The rights snapshot of an app principal whose installation is not active is empty, like the snapshot of a principal that is not active: identity's PrincipalSource sets PrincipalFacts.AppInstallationActive from AppInstallations.ActiveInstallation, and the authz snapshot builder applies it. Then app.suspend and app.uninstall remove every visible target of the app, and app.resume adds them again, so the M1-29 hook writes access.changed for the app and the gateway runs Revalidate on it (M1-65 "Revocation" step a) and closes the connection with 4012 (04 6.4). c. Known gap, not built around: a change of granted_scopes alone changes no visible target (07 2.10), so 04 8.2 writes no access.changed, and an open gateway connection of the app learns a removed scope only at its next identify, resume or Revalidate. REST and webhook deliveries (checked per attempt, M2-26) see it at once. Record it in the report as a spec gap of the core contract (04 8.2). Ruling for docs/DECISIONS.md: points a and b as the answer to "how long-lived connections recheck the state of the installation" (08 12).

  12. Ports and wiring. identity declares AppInstallations (root package, kernel types only); apps/adapters/identitysource implements it over the apps repository, reading in the ambient transaction; internal/wire binds it. authz declares AppScopes and gains Admin.RevokeSubjectGrants; apps/app uses them through consumer interfaces of its own. apps/app uses from identity: Directory (handle lookup), the principal methods of the Change list, Tokens.RevokeAllForPrincipal. The domain imports only kernel-core (02 5); the app layer turns kernel/authorize facts and configuration into domain.Env.

  13. Reads. listApps and validateManifest authorize app.read against the instance as their container (06 4 point 2): a non-owner gets 403 forbidden with reason owner_only. listApps returns apps whose latest installation is active or suspended, ordered by lower(slug) then principal_id, keyset cursor with the base's cursor helper (06 6), next_cursor absent on the last page. getApp loads by id and authorizes app.read on resource kind app: a non-owner gets 404 exactly like an unknown id (M1-28 visibility, AZ-P4); it returns any app, uninstalled included, with its latest installation. App.id is the principal id, display_name the principal's display name; absent columns are absent fields. Ruling for docs/DECISIONS.md: list contents and order, container check for list and validate.

  14. Audit details (SEC-AUD-5: flat, only IDs, codes, integers, booleans and lists of codes; never names, descriptions, URLs, data_egress or the manifest):

    • app.install: app_id, installation_id, slug, manifest_sha256 (lowercase hex), granted_scopes, event_subscriptions, delivery_hosts, reinstall, reason.
    • app.install as upgrade: app_id, installation_id, manifest_sha256, previous_manifest_sha256, manifest_scopes_added, manifest_scopes_removed, manifest_events_added, manifest_events_removed, manifest_commands_added, manifest_commands_removed, delivery_hosts_added, granted_scopes_removed, event_subscriptions_removed, reason.
    • app.scopes.update: app_id, installation_id, granted_scopes_added, granted_scopes_removed, event_subscriptions_added, event_subscriptions_removed, reason.
    • app.suspend, app.resume: app_id, installation_id. app.uninstall: app_id, installation_id, tokens_revoked, grants_revoked, reason.
    • "No effect" adds no_change: true. manifest_sha256 is the SHA-256 of the manifest bytes as received; apps.manifest keeps the parsed content (jsonb). Ruling for docs/DECISIONS.md: the hash covers the received bytes; the field lists above.
  15. ADR 0022 confirmation. The vendor allow list of architecture/modules.yaml already rejects any unlisted third-party import in make arch, which covers a WebAssembly runtime; the standard-library package plugin is not covered. Add a depguard rule to .golangci.yml that forbids plugin in the server module. Prove it red once with a throwaway file that imports plugin in a scratch copy, keep the output, never commit the file. Ruling for docs/DECISIONS.md.

  16. Migrations. bothy_app gets SELECT, INSERT, UPDATE on apps and app_installations and no DELETE, because rows are never deleted (03 1 point 7, M1-12 D4). Retention classes as 03 (account, config). Ruling for docs/DECISIONS.md.

  17. Tests that need tickets outside the closure. Everything that needs spaces and channels (AZ-M32 to AZ-M34, the access.changed assertions of suspend, resume and uninstall) runs in two tiers. Tier 1, always: a real installation through installApp, a real token through createToken, the real authenticator, the real Authorizer and the real M1-29 hook, with grants from authz.Admin.CreateGrant; when chat (M1-52, M1-53) is not on the base, the structure (space Support with channels Allgemein and Eskalation) comes from the fake structure source of internal/authz/adapters/fake, wired the way the M1-29 tests wire it. Tier 2, when M1-56 and M1-57 are on the base: the AZ cases over HTTP with createMessage and the message read of M1-57, the grant through createGrant if M1-48 is there. Gateway tests run only with M1-64 and M1-65 on the base. The report names the tiers that ran.

  18. Rulings for docs/DECISIONS.md section 10, one row each with reason: notes 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 14, 15 and 16. Facts that the spec already states (owner only, step-up, audit, rights version, never trigger or manage, unknown members are an error) are not repeated there.

Tests (write them first)

Unit:

  • internal/apps/domain: TestValidateManifest_Example (the example of 11 3.3 as changed by this ticket, with its hosts in the allowed list and min_api_version at the server's version, gives no error from domain.ValidateManifest); TestValidateManifest_Rules with one row per rule of Design notes 4 and 5: size above app.manifest_max_bytes (too_large), slug longer than principal.handle_max_chars, name with a bidi override (SEC-T07), invalid SemVer, min_api_version above the spec version, scope in both lists, event access.changed, draft.updated, agent.invocation and grant.changed, message.created without messages:read, events without events:subscribe, events without delivery, webhook mode without events_url, gateway mode with events_url, commands without interactions_url in webhook mode, events without data_egress, http address, address with user information, host not allowed (webhook_host_not_allowed), homepage_url with http, requested grant with manage, export, approve_actions, level trigger, target host, reserved command stop (command_name_reserved), duplicate command names in other case, more commands than command.max_per_principal, command with scope_kind channel; each row asserts pointer and code and that detail does not contain the submitted value.
  • internal/apps/domain: TestConsent_Install, TestConsent_Upgrade, TestConsent_SetScopes (Design notes 6c, 7e, 9a), TestDiffManifests, TestInstallationTransitions (every pair of states: allowed, no change or invalid_state_transition), TestSemVerPrecedence.
  • internal/authz: TestAppEventScope_AllCatalogueEvents (every type of AllEventTypes() is classified; message.created needs messages:read, instance.updated no scope, command.invoked and interaction.created subscribable, every other audience: self type and grant.changed not); TestAppScope_TokenAndInstallation (scope only in the installation, only in the token, in both, no token); TestSnapshot_AppWithoutActiveInstallationIsEmpty; the M1-28 matrix rows AZ-M32 to AZ-M34 stay green (if their app actor carries a token id, give it token scopes equal to the installation's).
  • internal/identity/app: TestCreateToken_AppScopes with a fake AppInstallations (no installation, suspended, default scopes copied, subset, scope not granted); TestAuthenticateAPIToken_AppInstallation (inactive gives zero InstallationID, active fills both scope lists).

Integration (PostgreSQL 18 via pgtest, real modules through internal/wire, HTTP through the real chain, owners and step-up from the identity test helpers, fake clock, test configuration with webhook.allowed_hosts set to hooks.example.com, every answer checked against openapi.yaml with the response validator of the base):

  • TestMigrations_AppsTables: columns, checks, both unique indexes, foreign keys, the kind trigger (an apps row for a person's principal fails), grants of bothy_app without DELETE.
  • TestInstallApp_HappyPath: Robin (owner, fresh step-up) installs app Tracker (slug tracker, scopes.required [messages:write], no events): one principal of kind app with handle tracker and status active, one apps row with the manifest, manifest_version and the SHA-256 of the sent manifest bytes, one installation active with granted_scopes [messages:write]; permission_version raised by one; exactly one audit allow entry with the fields of Design note 14; no row in api_tokens and grants for the app; 201 App without JSON null.
  • TestInstallApp_RejectsInvalidManifests (plan acceptance): a manifest with an unknown member gives 400 validation_failed with unknown_member at its pointer under /manifest; with requested_grants capability manage gives 400 validation_failed with invalid at /manifest/requested_grants/0/capabilities/0; with delivery.events_url on hooks.other.example.com gives 400 webhook_host_not_allowed with the pointer of the URL; after each nothing is stored and permission_version is unchanged.
  • TestValidateManifest_ReportsWithoutStoring: the valid manifest gives 200 with valid true and errors empty; the manage and the foreign-host manifests give 200 with valid false and the same pointers relative to the root; the unknown member gives 400 validation_failed; no row, no audit entry; Sam (no owner) gets 403 forbidden with reason owner_only.
  • TestInstallApp_OwnerStepUpAudit: Sam gets 403 with owner_only; Robin without fresh step-up gets 401 step_up_required; an app token of Tracker calling installApp gets 403; each denial is one aggregated deny entry (AZ-P13).
  • TestInstallApp_GrantedScopesRules: a required scope missing, a scope not in the manifest, a subscription without its scope, each 400 validation_failed with the pointer of Design note 6c.
  • TestInstallApp_SlugConflicts: the same slug again gives 409 conflict; a slug equal to Alex's handle gives 409 conflict; TestInstallApp_ConcurrentSameSlug: two parallel installs of one slug give one 201 and one 409 conflict, never a 500.
  • TestInstallApp_IdempotencyKey: a replay gives the stored 201 with Idempotent-Replay: true and no second installation.
  • TestCreateToken_AppShownOnce (plan acceptance "token shown only once" and "createToken for apps"): before the install 409 conflict with reason app_not_installed; after it 201 ApiTokenCreated with a secret of kind app whose scopes equal the granted scopes; an explicit subset is stored as sent; a scope not granted gives 400 validation_failed at /scopes/0; a replay with the same Idempotency-Key gives 409 conflict with reason one_time_secret; listTokens never returns the secret; the secret appears in no row of api_tokens, idempotency_keys or audit_events and in no captured log line; while suspended 409 conflict with reason app_not_installed.
  • TestAppToken_EffectiveScopes: a token with [messages:write] of an installation that later loses messages:write through setAppScopes gets 403 scope_missing on the next request; a token created before a scope was granted does not gain it.
  • TestUpgrade_NewScopesStayInactive (plan acceptance): Tracker 1.0.0 with scopes.required [messages:write], upgrade to 1.1.0 that also requests messages:read with granted_scopes [messages:write]: 200, installation unchanged, a message.read decision of the app is forbidden scope_missing; the same upgrade with granted_scopes [messages:write, messages:read] gives 400 validation_failed at /granted_scopes/1; after setAppScopes with both scopes and a new token the decision is allow; the audit entry of the upgrade names messages:read in manifest_scopes_added.
  • TestUpgrade_RemovedScopeActsAtOnce: an upgrade whose manifest drops messages:write with granted_scopes []: the next message.create decision of the old token is forbidden scope_missing.
  • TestUpgrade_Rules: other slug gives 400 at /manifest/slug; changed content with the same version gives 400 at /manifest/version; identical manifest and lists give 200 without a new permission_version and an audit entry with no_change: true; upgrade of an uninstalled app gives 409 conflict; an upgrade with a new interactions_url on hooks.example.com (in webhook.allowed_hosts, not named by the stored manifest) gives 200 and its audit names the host in delivery_hosts_added, the same address on hooks.other.example.com gives 400 webhook_host_not_allowed and the stored manifest is unchanged (Design note 7 g); a non-owner gets 403 forbidden with reason owner_only for an unknown and for an existing app alike.
  • TestSetAppScopes: new consent, narrowing, absent subscriptions keep only what the new scopes allow, unchanged lists are "No effect" and leave permission_version unchanged, an uninstalled app gives 409 conflict, a non-owner gets 404 like an unknown id.
  • TestSuspendResume: after suspendApp a request of Tracker's token (listTokens for its own principal) gives 401 unauthenticated with audit deny reason app_not_installed, the token row has no revoked_at, grants are unchanged; a second suspend gives 204 with no_change: true and leaves permission_version unchanged; after resumeApp the same listTokens request is authenticated again and gives 403 forbidden with reason subject_kind_not_allowed instead of 401 (05 2.6 gives apps no scope for token.list), and with M1-31 on the base getMe with the token gives 200 (self.read, no scope); resume of an uninstalled app gives 409 invalid_state_transition.
  • TestAppAccessChanged_SuspendResumeUninstall (Design note 17): with a grant write on Support/Eskalation, suspendApp writes one access.changed for the app with that channel in removed, resumeApp one with it in added, uninstallApp one with it in removed; setAppScopes that only removes a scope writes none (the gap of Design note 11c, asserted so that a later fix of the core contract shows up).
  • TestUninstall: every token revoked, every grant of the app revoked with the reason, principal disabled, installation uninstalled with uninstalled_at and uninstalled_by, audit with the counts; a second call gives 204 with no_change: true and leaves permission_version unchanged; a reinstall with the same slug reuses the principal, makes it active and creates a new installation while the old tokens stay revoked; TestUninstall_RacesCreateToken: parallel uninstallApp and createToken never leave an unrevoked token of an uninstalled app.
  • TestAZM32_AppWritesWithGrantAndScope, TestAZM33_AppReadWithoutScope, TestAZM34_AppWithoutGrant (07 11.1 with a real app, Design note 17): Tracker with G-6 (write on Support/Eskalation) and scope messages:write. Tier 1: message.create on a thread of Support/Eskalation is allow, message.read there is forbidden scope_missing, message.create on a thread of Support/Allgemein is not_found. Tier 2 (M1-56 and M1-57 on the base): createMessage there gives 201 with origin = app, the message read gives 403 scope_missing, createMessage in Allgemein gives 404 with the same status, body and headers as an unknown thread id.
  • TestGetApp_NotFoundEquality (AZ-P4): for Sam an existing and an unknown app id give the same status, body (ignoring correlation_id and instance) and headers.
  • TestListApps: owner sees active and suspended apps ordered by slug with cursor pages; uninstalled ones only through getApp.
  • TestAppAuditDetails_Shape (SEC-AUD-5, T-PL-01 "R"): every allow entry of this ticket passes the recorder's schema; none contains the app name, description, URL, data_egress or a token.
  • TestAppGateway_InstallationState (only with M1-64 and M1-65 on the base): identify with the token of a suspended app closes with 4012; an open connection closes with 4012 after suspendApp.
  • Route count AZ-P1 and the response validation pass with the nine enabled operations.

ADR 0022: the red run of the depguard rule from Design note 15 is quoted in the report.

Red runs: keep the red output of TestInstallApp_RejectsInvalidManifests, TestCreateToken_AppShownOnce (red as 409 conflict with reason app_not_installed before the change), TestUpgrade_NewScopesStayInactive and the three AZ tests. Where a case is green on the base because M1-28 already decides it with a fake installation, prove that the test can fail with a one-line local mutation of the guard it covers, keep the output, revert it and say which case needed it.

Commands that must pass, from the repository root:

  • cd services/server && go test -race -count=1 ./internal/apps/... ./internal/identity/... ./internal/authz/... ./internal/kernel/... ./internal/wire/... ./api/... ./migrations/...
  • make migrations-lint, make codegen-check, make arch, make register-check, make api-lint, make api-checks, make api-clients, make config-doc-check, make lint
  • make docs
  • make check

Acceptance criteria

  • The nine operations listApps, installApp, validateManifest, getApp, upgradeAppManifest, setAppScopes, suspendApp, resumeApp and uninstallApp are enabled and registered; AZ-P1 passes.
  • AppManifest in openapi.yaml follows 11 3.1; invalid manifests are refused: an unknown member, manage in requested_grants and a host outside webhook.allowed_hosts (webhook_host_not_allowed); validateManifest reports rule errors with valid false and stores nothing.
  • The installation runs in one transaction with step-up, audit and a new rights version, creates principal, apps and app_installations and no token; owners only.
  • createToken for an app with an active installation issues a token with scopes from the installation, shown exactly once (replay 409 conflict with reason one_time_secret); without an active installation it keeps 409 conflict with reason app_not_installed.
  • New scopes of an upgrade stay inactive until setAppScopes; removed scopes act on the next request; the upgrade's audit entry carries the difference.
  • Suspend blocks the app without revoking tokens or grants; resume restores it; uninstall revokes tokens and grants and disables the principal; suspend and uninstall write access.changed for an app with grants.
  • AZ-M32 to AZ-M34 pass with a real app (tier 1 always, tier 2 when M1-56 and M1-57 are on the base); app requests need the scope in token and installation.
  • The server module cannot import plugin (make lint fails on it).
  • 11, 08, 07, 02, the threat model, modules.yaml, the READMEs and docs/DECISIONS.md describe the behaviour; the report lists the gateway gap of Design note 11c as a spec gap.
  • make check is green.

Definition of done

  • Code and tests as specified; doc comment on every public symbol (purpose, parameters, return value, errors, example where useful); comments explain why.
  • READMEs of apps, identity and authz updated (ports Apps, AppInstallations, AppScopes, Admin.RevokeSubjectGrants, the token scope rule, the snapshot rule, the new edge, the parts of 11 4.3 that M2-25 to M2-27 add).
  • Rulings of Design note 18 recorded in docs/DECISIONS.md (one row each with reason); the changes to openapi.yaml, 11, 08, 07, 02 and the threat model of the Scope made in the same change.
  • services/server/CHANGELOG.md entry under 'Unreleased'.
  • An ADR only if you take an architecture decision beyond this brief (MADR 4 as in docs/adr/0000-template-madr-4.md, headings and text in English, next free number, make adr-index).
  • make check green.
  • An independent code review over the diff, then the red team gate (docs/quality/red-team-gate.md, verdict JA or NEIN); on NEIN fix with a test per finding and have the delta checked again. Record both verdicts. Name the milestone's red team point "exceeding the scopes of an app" and T-PL-01 in the request.
  • Land the change as AGENTS.md ("How work lands on main") describes: a pull request from this branch, squash-merged only when review, red team and make check, ci-ok and the review threads are green; then git worktree remove.

Report

Write to {{REPORT_PATH}}:

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

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