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-28authorize, M1-29 rights changes and theauthzAdmin port, M1-35 step-up). Not in the closure, used only if merged (Design note 17): M1-19 (rate limits), M1-31 (response validator intest/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 (gatewayidentifyandRevalidate). docs/spec/11-extensions.md(normative for moduleapps): 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" (rowapps); 4.2principals(kindapp, handle rule, uniquelower(handle)),api_tokens(scopes); 4.3grants(checks forsubject_kind IN ('agent','app'), R-DM-08, R-DM-20, the host rule R-DM-35); 4.4appsandapp_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 rowstoken.list,token.create,token.revoke; 4.11 "Apps and webhooks" (rowsapp.read,app.install,app.scopes.update,app.suspend,app.resume,app.uninstall); 6 "Durable event types and receive action" (receive actions,audience = self, the rowsinteraction.createdandcommand.invoked). Machine-readable:docs/spec/actions.yaml(app_scopeper action;eventswithreceive_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_activewith "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" gives4012); 8.1 "Basic rule"; 8.2 "What happens on a change" (noaccess.changedwithout a difference).docs/spec/06-api.md: 2 "Basic rules" (rows "No effect", "Nonull", "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" (rowsidentity,authz,apps); 4.4 "Allowed dependencies" (rowapps, 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 helpersinternal/<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_hostsas an allowed target), SEC-LOG-1, SEC-AUD-5 (flat audit details, only codes, IDs andreason), SEC-T06, SEC-T07.docs/spec/20-privacy.md: the rowapps,app_installationsof the data categories.docs/api/openapi.yaml: operationslistApps,installApp,validateManifest,getApp,upgradeAppManifest,setAppScopes,suspendApp,resumeApp,uninstallApp,createToken,listTokens,revokeToken; schemasAppManifest,App,AppInstallation,AppInstall,AppScopesPut,ManifestValidation,AppPage,AppScope,ReasonBody,ApiToken,ApiTokenCreate,ApiTokenCreated,CommandRegister,CommandOption,CommandLevel,TargetKind,Level,Capability,FieldError,Problem(x-problem-codes); responsesBadRequest,Conflict,NotFound,UnprocessableEntity; parametersAppIdPath,IdempotencyKey.docs/api/asyncapi.yaml: close code4012, messageevent.access.changed.docs/api/webhooks.md1 "Subscribe" (event_subscriptionscome frominstallAppandsetAppScopes).- 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 ofdocs/RISKS.mdordocs/OPEN-QUESTIONS.mdconcerns 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, theerrors[].codevocabulary, problem writer), M1-20 (idempotency middleware,MarkOneTimeSecret), M1-24 (auditing.Recorder), M1-25 (kernel/authorizegenerated 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 givesapp_not_installed",domain/scopes.go, ruling D6, snapshot, cache key,internal/authz/adapters/fake), M1-29 (txn.RightsChange,AddAffected,authz.AdminwithCreateGrantandRevokeGrant, the "No effect" pattern), M1-35 (step-up and its test helpers), M1-44 (token format,CreateTokenuse case and itsapp_not_installedbranch,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 anddoc.goof M1-05),internal/identity,internal/authz,internal/kernel/{actor,authorize,txn,auditing,textcheck,errs,clock,ids},internal/wire(config keys inconfigkeys_*.go, chapter constants inconfigchapters.go, golden files),architecture/modules.yaml(moduleapps: 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 withgit show <base>:<path>; prefer its names.
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:
- Extensions never run in the server process; an app is a principal of kind
appwith its own token, grants and scopes and uses only the public API (11 1 points 1 and 2, ADR 0022, D-B6). - A manifest is uploaded, never fetched from an address; it follows
app_manifest.v1; unknown members are an error; it is at mostapp.manifest_max_bytes; the server stores it and its SHA-256 (11 3.1, R-EX-01, R-EX-02, 03apps, 06 2 "Unknown values", SEC-IN-1). - 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
httpsand its host is inwebhook.allowed_hosts;requested_grantsnever carriestriggerand as capability at mostupload; commands follow 09 10 without reserved names and at mostcommand.max_per_principal(11 3.2 rules 1 to 5, R-DM-08, R-DM-17, R-DM-20, R-EX-03). app.install,app.scopes.update,app.suspend,app.resumeandapp.uninstallare owner only, need step-up, are audited on allow and deny, raise the rights version and userl.admin;app.readis owner only without step-up and audit and usesrl.read(05 4.11, 2.4, rule 1.7).- Installation in one transaction: rights version first, then principal (kind
app),apps,app_installationswithgranted_scopesandevent_subscriptions, audit (11 4.1, 03 6.3). - A new manifest with the same
slugis a difference; new scopes and events need a new consent withapp.scopes.updateand 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). - 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).
- An app acts only with both an effective level from grants (at most
write) and the scope of the action; no grant gives404, a missing scope on a visible target gives403 scope_missing(07 2.10, 05 2.3 point 5 and 2.6, 06 4 point 4; AZ-M32 to AZ-M34). - 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-Keyanswers409 conflictwithreasonone_time_secret(08 12, 06 7, ID-T23). - An app whose installation is not
activeis unauthenticated with reasonapp_not_installed(07 4.1); a gateway connection of such an app ends with4012(04 6.4). - No JSON
nullin answers; every answer validates againstopenapi.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.sqlandmigrations/<next+1>_app_installations.sql(next free numbers on the base, M1-12 D6): the tablesappsandapp_installationsexactly as 03 4.4, with every column, check, foreign key, unique index (lower(slug); partial(app_id) WHERE state <> 'uninstalled') and a triggerapps_principal_kind_checkthat rejects anappsrow whose principal is not of kindapp(03: "kind = 'app'checked by a trigger"; same pattern asidentity_users_kind_checkof M1-26). Grants per Design note 16. Thenmake migrations-lock.internal/apps/apps.go(port): interfaceAppswithList,Get,Validate,Install,Upgrade,SetScopes,Suspend,Resume,Uninstall(each takesctxand the caller asactor.Actor), the DTOsApp,Installation,InstallCmd,UpgradeCmd,SetScopesCmd,ManifestValidation,FieldError,Page. No in-transaction event (02 4.3 rowapps: "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 forgranted_scopesandevent_subscriptionsat install, upgrade and scope change, Design notes 6 to 8),diff.go(DiffManifests(old, new Manifest) ManifestDiff),installation.go(statesactive,suspended,uninstalledand the transitions of Design note 9).internal/apps/app: one use case per operation, the outgoingRepositoryport 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 forappsinsqlc.yaml.internal/apps/adapters/httpapi: the strict-server methods of the nine operations listed under Change.internal/apps/adapters/identitysource: implementsidentity.AppInstallationsover 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.gofiles import it).internal/wire/configkeys_apps.go: registerapp.manifest_max_bytes(chapter 11),webhook.allowed_hosts(chapter 03),command.max_per_principalandcommand.description_max_chars(chapter 09) unless the base registers them already, with the defaults of their chapter tables; add the chapter constants toconfigchapters.goif missing; runmake config-reference.
Change:
api/operations.yaml: movelistApps,installApp,validateManifest,getApp,upgradeAppManifest,setAppScopes,suspendApp,resumeApp,uninstallApptoenabled; rerun the route generator of M1-17 (make codegen-checkmust show no drift).internal/identity(port): interfaceAppInstallationswithActiveInstallation(ctx context.Context, appID ids.ID) (ActiveAppInstallation, bool, error)andLockActiveInstallation(ctx context.Context, appID ids.ID) (ActiveAppInstallation, bool, error)(the second takesFOR SHAREin the ambient unit of work), DTOActiveAppInstallation{ID ids.ID; GrantedScopes []string}. Principal port methods, only where the base lacks them (M1-51 adds the agent variants): create a principal of kindapp(handle and display-name rules of M1-26, case-insensitive handle uniqueness), set principal status (active,disabledwithdisabled_at), update the display name.internal/identity/app: thecreateTokenbranch for apps (Design note 10); the API token path of the authenticator and, if the base has it,Revalidate(Design note 11); theauthz.PrincipalSourceimplementation sets the new fact of Design note 11 for app principals.internal/kernel/actor: the principal carriesTokenScopesnext toTokenID,InstallationIDandGrantedScopes(07 3); add it only if missing.internal/authz:PrincipalFactsgetsAppInstallationActive bool; the snapshot rule of Design note 11; the scope check of Design note 10 indomain/scopes.goand inScope; a new port interfaceAppScopeswithEventScope(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, bindidentity.AppInstallationstoapps/adapters/identitysource, bindauthz.AppScopesand the authz Admin port intoapps, update the golden files.architecture/modules.yaml: moduleappsdepends_ongainsauthz; regenerate.go-arch-lint.ymlwithtools/archgen; set the adapter vendors ofappsthe way the merged modules list them for their postgres adapters..golangci.yml: enabledepguardwith one rule that forbids the standard-library packagepluginin the server module, message naming ADR 0022 (Design note 15).docs/api/openapi.yaml:AppManifestper Design note 2;ManifestValidationexample; descriptions ofvalidateManifest,installApp,upgradeAppManifest,setAppScopes,createTokenandApiTokenCreate.scopesper Design notes 3, 6 to 8 and 10;setAppScopesgains the response409(Conflict); all examples ofAppwith the new manifest form. Thenmake api-lint,make api-checks, regenerate the server code.- Client SDKs: if a client package under
sdk/python,sdk/typescriptorpackages/api_clientalready has agenerate-checktarget, regenerate it with the generator its README names so thatmake api-clientsstays green; otherwise nothing. docs/spec/11-extensions.md(English, like the chapter): 3.3 (the example's commanddescriptionas a string, asCommandRegisterdefines it;events:subscribeadded toscopes.optionalnext tomessages:read, because 05 2.6 row "Event subscriptions" requires it forevents(Design note 4); adescriptionfor the optiontitle, asCommandOptionrequires), 3.2 rule 3 (a host outsidewebhook.allowed_hostsis refused with400 webhook_host_not_allowed; the operator adds it to the process configuration), 4.1 (the installation creates no token and no grant; grants withgrant.createper target, tokens withtoken.create), 4.2 (upgrade never adds grants; consent withapp.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 inwebhook.allowed_hosts), 5 (token scopes and installation scopes both apply).docs/spec/08-identity.md12, bullet "Scopes": replace the M1 interim text with the rules of Design notes 10 and 11.docs/spec/07-permissions.md2.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 (rowidentitydeclaresAppInstallations; rowauthzportAppScopes; rowappsimplementsidentity.AppInstallations), 4.4 (rowappsmay useauthz, 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,identityandauthz;services/server/CHANGELOG.mdunder "Unreleased";docs/DECISIONS.mdsection 10 rowsD-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 tointeractions_urland autocompletion for apps (M2-25; the tablecommandscomes with M2-16). - Outgoing webhook subscriptions and deliveries, signing keys
webhook_out,getWebhookKeys, pausing deliveries on suspend and disabling subscriptions on uninstall, reducingwebhook_subscriptions.event_typeswhen 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:reconcilein 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.previewis M1-49). - The HTTP operations for grants (
createGrant, M1-48); this ticket only revokes grants on uninstall. - Gateway connection, hub and
Revalidatethemselves (M1-64, M1-65); this ticket extends them only if they are on the base (Design note 11). - Changing
webhook.allowed_hostsat runtime; it is process configuration (Design note 5). - Changes to the core contract.
Boundaries¶
- A new migration needs its line in
migrations.sha256: runmake migrations-lockafter creating the file and beforemake 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 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). 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 intowebhook.allowed_hosts, and nothing in this ticket opens an outgoing connection;internal/appsdoes 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¶
-
Where the rules live.
appsowns manifests, installations and their use cases.authzowns every rights rule: the scope table of 05 2.6 (M1-28domain/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.identityowns principals and tokens and reads the installation through a port thatappsimplements, soidentitynever importsapps(02 4.4, edges point down). Ruling for docs/DECISIONS.md: the moduleappsmay useauthz(new edge in 02 4.4 andmodules.yaml), because uninstall must revoke the app's grants (11 4.3) and the subscription rule needs the scope table that onlyauthzholds (05 rule 1.1);authzis a lower layer, so the graph stays acyclic. -
AppManifestinopenapi.yamlfollows 11 3.1 field for field. The current schema hasscopesas a list,event_subscriptionsandwebhooksand lacksmin_api_version,delivery,components,requested_grants,incoming_webhooksanddata_egress; 03apps.manifestnames components and webhook URLs, and the plan's acceptance needsrequested_grantsto rejectmanage. 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 03apps.manifestrequire, so the change that finds the contradiction aligns the YAML with 11 (SPEC 13 point 5).requested_grantsuses the shared enums on purpose, sotrigger,manage,export,approve_actionsandhostreach the rule check andvalidateManifestcan report them (Design note 3). Update every example that shows a manifest. Rewrite the schema with inline objects (no new component names) andadditionalProperties: falseon 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 requiredrequiredandoptional, both arrays ofAppScope); - optional:
homepage_url(string),events(array of string),delivery(object, requiredmodewith enum[gateway, webhook], optionalevents_urlandinteractions_url),commands(array ofCommandRegister),components(array of enum[button, select, form]),requested_grants(array of objects with requiredtarget_kind(TargetKind),level(Level),purpose(string) and optionalcapabilities(array ofCapability)),incoming_webhooks(array of objects with requirednameandpurpose),data_egress(string).
- required:
-
Validation pipeline and answers. a. Request validation (M1-18
StageValidate) checks the body against the schema first: unknown members, wrong types, unknown enum values give400 validation_failedwitherrors[](unknown_member,invalid_enum, ...) for every operation,validateManifestincluded (SEC-IN-1). b. The handler reads the raw bytes of the manifest (the body forvalidateManifest, the membermanifestotherwise) and calls the puredomain.ValidateManifestwith anEnvbuilt 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_charsand the server's spec version (routes.SpecVersion). c.validateManifestanswers200 ManifestValidationwithvalidtrue anderrorsempty, orvalidfalse and every rule error, pointers relative to the manifest root; it stores nothing and writes no audit entry. d.installAppandupgradeAppManifestanswer rule errors with400 validation_failedand the same errors with pointers under/manifest; when every error is a host error (Design note 5) the answer is400 webhook_host_not_allowedwith those errors. Nothing is stored and no rights version is raised. e.errors[].codeuses 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_allowedfor a host,command_name_reservedfor a reserved command name.detailis 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. -
Rule set of
ValidateManifest(11 3.1 and 3.2), each failure one error item. Ruling for docs/DECISIONS.md: the subscribable set and theevents:subscriberequirement, the conditional members of the delivery block, and the command rules for manifests.- Size: raw bytes above
app.manifest_max_bytesgivetoo_largeon the root pointer and stop further checks. slugat mostprincipal.handle_max_charscharacters (it becomes the handle, note 6);namea display name after M1-26 (not blank, at mostprincipal.display_name_max_chars,kernel/textcheck);description,publisher,data_egress, everypurposeandincoming_webhooks[].namepasskernel/textcheck(SEC-IN-4);versionis SemVer 2.0.0;min_api_versionis not above the major and minor ofroutes.SpecVersion(if the base lacksroutes.SpecVersion, extend the M1-17 route generator with it exactly as M1-46 Scope item 10 describes).- Scopes: no duplicates; no scope in both
requiredandoptional. - Events: no duplicates; each event
tmust be subscribable:authz.AppScopes.EventScope(t)returnssubscribabletrue only for a durable type of the catalogue that either has noaudience: selfand a receive action that the scope table of 05 2.6 lists (with a scope or "no scope"), or isinteraction.createdorcommand.invoked(receivers: agent_or_app, E-EX-05); every other type (access.changed,draft.*,agent.invocation,grant.changedwith receive actiongrant.read,host.updated, ...) is refused (rule 1). The returned scope must be inrequiredoroptional(rule 2).events:subscribemust be requested wheneventsis not empty (05 2.6 row "Event subscriptions"). If the generatedEventSpeclacksreceivers, keep the two personal types as a list inauthz/domainwith a test that compares it withdocs/spec/actions.yaml. - Delivery:
deliveryis required whenevents,commandsorcomponentsis not empty;delivery.events_urlis required whenmodeiswebhookand forbidden otherwise;delivery.interactions_urlis required whencommandsorcomponentsis not empty andmodeis notgateway;data_egressis required wheneventsis not empty orinteractions_urlis set (11 3.1 column "Required"). - Addresses: Design note 5.
requested_grants:target_kindnothost(R-DM-35);levelreadorwrite, nevertrigger(R-DM-08);capabilitiesa subset of[upload]without duplicates, somanage,exportandapprove_actionsare 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);descriptionat mostcommand.description_max_charsand throughkernel/textcheck; option names unique within a command;scope_kindabsent orinstanceandscope_idabsent, because a manifest never sets a target (11 4.1). componentsandincoming_webhooks[].name: no duplicates.
- Size: raw bytes above
-
Addresses and
webhook.allowed_hosts.delivery.events_urlanddelivery.interactions_urlare absolute URLs with schemehttps, a host, no user information and no fragment; their host, lowercased and without port, must equal an entry ofwebhook.allowed_hosts(case-insensitive, no wildcard); otherwise the item has codewebhook_host_not_allowed(R-DM-17, 11 3.2 rule 3, SEC-NET-2).homepage_urlmust be an absolutehttpsURL; its host is not compared, because the server never loads it (11 3.1). Parse withnet/url; patterns only withregexp(SEC-IN-6). 11 3.2 rule 3 offers to add a missing host "in the same confirmed action";webhook.allowed_hostsis 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). -
installApp(app.install, resourceinstance, 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 get403 forbiddenwithreasonowner_only, app and agent tokens the same, an owner without fresh step-up401 step_up_required(07 4.2 order, SR5). Denials are audited by the authz layer, aggregated peraudit.deny_aggregate_window(R-AZ-04). b. Validation of Design note 3;reasonthroughkernel/textcheck, at mostaudit.reason_max_chars(too_long), required. c. Consent rules (domain/consent.go):granted_scopeswithout duplicates, every scope inscopes.requiredorscopes.optionaland every scope ofscopes.requiredpresent (11 4.1 "choose optional scopes"), else400 validation_failedwith pointer/granted_scopesor/granted_scopes/<i>;event_subscriptionsabsent means none; each must be in the manifest'sevents, withevents:subscribeand the event's scope ingranted_scopes, else pointer/event_subscriptions/<i>. d. Generate the app principal id (UUIDv7) before the unit of work; open it withtxn.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 withAddAffectedbefore its rows are written (M1-29). e. Lockappsbylower(slug)FOR UPDATE. An app with an installation that is notuninstalledgives409 conflict(useupgradeAppManifest). An app whose installations are alluninstalledis reinstalled: same principal, status back toactivethrough identity, manifest columns replaced, display name updated, a newapp_installationsrow; its revoked tokens and grants stay revoked. Otherwise create the principal (kindapp, handle =slug, display name =name, statusactive); a handle already taken by a person or an agent gives409 conflict. A unique violation from a concurrent install maps to409 conflict. f. Insert or updateapps(manifest,manifest_version,manifest_sha256,publisher,homepage_url,updated_at) and insertapp_installations(granted_scopesandevent_subscriptionssorted,state = active,installed_by= actor). g. Audit allow inside the transaction throughauditing.Recorder(Design note 14). h. Commit;201 App. No token and no grant is created here (Design note 8). A replay with the sameIdempotency-Keyanswers the stored201withIdempotent-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. -
upgradeAppManifest(app.install, resourceinstance, step-up,rl.admin; PUT withoutIdempotency-Key): a. Authorize as 6a, then load the app (unknown or not an app:404). b. Validation of Design note 3;manifest.slugmust equal the stored slug (pointer/manifest/slug,invalid). c. After the no-change check of 9g, inside the unit of work (RightsChangewithapp.install): lockappsthenapp_installationsFOR UPDATE; anuninstalledinstallation gives409 conflict;suspendedstayssuspended. d. No change: the stored manifest equals the new one (jsonb equality) andgranted_scopesandevent_subscriptionsequal the request: answer200with the currentApp, no rights version, no event, audit withno_change: true(06 2 "No effect", the M1-29 pattern). A different manifest with the same SemVer precedence ofversiongives400 validation_failedat/manifest/version, becausemanifest_versionnames the content in the console and the audit; a lower version is allowed (rollback). e. Consent:granted_scopesof the request must be a subset of the currentgranted_scopesand of the new manifest's scopes; a scope that is not granted now gives400 validation_failedat/granted_scopes/<i>with detail "consent with setAppScopes" (11 4.2: new scopes needapp.scopes.update).event_subscriptionsabsent 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. Updateappsand the display name; update the installation lists; audit allow with the difference ofDiffManifests(Design note 14); commit;200 App. g. Hosts: the upgrade itself, run by the owner with step-up underapp.install, is the consent for new delivery hosts (delivery.events_url,delivery.interactions_url). Every new host must already be inwebhook.allowed_hosts(Design note 5), and the audit names it indelivery_hosts_added.setAppScopeshas 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). -
Grants and tokens are separate steps. 11 4.1 shows grants and an API token inside the installation transaction, but
AppInstallhas no member for the chosen targets andApphas no secret. Grants for an app are created per target withgrant.create(subject kindapp, owner only per R-RE-08, M1-29Admin.CreateGrant, HTTPcreateGrantof M1-48); tokens withcreateToken(Design note 10). The manifest'srequested_grantsonly inform the consent. Change 11 4.1 accordingly. Ruling for docs/DECISIONS.md. -
Scope change, suspend, resume, uninstall. a.
setAppScopes(app.scopes.update, resourceapp, step-up,rl.admin): load (unknown404; a non-owner gets404because only owners see apps, M1-28 visibility of kindapp);authorize; no-change check of 9g;RightsChange; lockappsand the installation;uninstalledgives409 conflict(add409to the operation inopenapi.yaml);granted_scopesmust be a subset of the manifest's scopes (required ones may be dropped after the install; the app then fails withscope_missing); scopes not granted before are the new consent;event_subscriptionsabsent 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):activetosuspended; onsuspended"No effect" (204, auditno_change: true); onuninstalled409 invalid_state_transition. Tokens stay unrevoked; they stop working because the installation is notactive(Design note 11).204. c.resumeApp(app.resume):suspendedtoactive; onactive"No effect"; onuninstalled409 invalid_state_transition.204. d.uninstallApp(app.uninstall,ReasonBody):activeorsuspendedtouninstalledwithuninstalled_atanduninstalled_by; in the same unit of work, in this order after the installation row: revoke every active token throughidentity.Tokens.RevokeAllForPrincipal(M1-44), revoke every active grant with subject the app throughauthz.Admin.RevokeSubjectGrants(noauthorizeper grant: it is a consequence of the authorizedapp.uninstall; rows in ascendingid,revoked_bythe actor,revoke_reasonthe reason; if the base writesgrant.changedinRevokeGrant(M1-48), write it here too), set the principaldisabledthrough identity; the app's messages keep their author (11 4.3). Onuninstalled"No effect" (204).204. e. Every change of a to d runs in one unit of work withtxn.RightsChange{Action, Resource: {kind: app, id}, Principals: [app]}; the no-change answers are decided before it (point g); the M1-29 hook writesaccess.changedfor 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 aRightsChangeunit of work always raisespermission_version(02 8.1, M1-13), and R-API-27 forbids a rights version for a no-op. SoupgradeAppManifest,setAppScopes,suspendApp,resumeAppanduninstallAppfirst, afterauthorize, open a unit of work withoutRightsChange, loadappsand the installation and check the no-change condition (7d, 9a to 9d). If it holds, they write the allow audit entry withno_change: truethere and answer. Otherwise that unit of work ends without a write, theRightsChangeunit 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-48revokeGrant). Ruling for docs/DECISIONS.md: no-change answers, decided before theRightsChangeunit of work (point g), and409 invalid_state_transitionfor the transitions above,409 conflictfor scope changes and upgrades of an uninstalled app. -
App tokens and scope check (decides the open points of 08 12). a.
createTokenfor a principal of kindapp(after the M1-44 steps up toauthorize):identity.AppInstallations.LockActiveInstallation(app)inside the unit of work; no active installation (none,suspended,uninstalled) keeps409 conflictwithreasonapp_not_installed. Bodyscopesabsent or empty: the token stores the installation'sgranted_scopesat this moment; present: every scope must be ingranted_scopes, without duplicates, else400 validation_failedat/scopes/<i>. Expiry,dpop_jkt(required only whenappis inauth.dpop_required_for), one-time secret and audit as M1-44, with the storedscopesadded to the audit details. The share lock serializes with an uninstall that holds the installation rowFOR 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 zeroInstallationID, soauthorizeanswersunauthenticatedwith reasonapp_not_installed(M1-28 liveness rule; HTTP401 unauthenticated); active givesInstallationID,GrantedScopesfrom the installation andTokenScopesfrom the token. Read on every request, no cache (AZ-P9 style). c. Scope check inauthz(05 rule 1.1): an app request needs the action's scope inGrantedScopesand inTokenScopes; missing in either givesforbiddenscope_missing. A check without a token (TokenIDzero, for example a webhook delivery of M2-26) usesGrantedScopesalone.Scopefilters 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. -
Installation state on long-lived connections. a. The installation is part of the credential:
AuthenticateAPIToken(used by REST and by the gatewayidentify, M1-64) applies 10b; put the installation check into the identity function that every API token check calls, soRevalidate(M1-65) inherits it; ifRevalidateexists on the base, it also fails withapp_not_installedwhen the installation is no longer active, or when the actor'sGrantedScopeshold a scope the installation no longer grants. b. The rights snapshot of an app principal whose installation is notactiveis empty, like the snapshot of a principal that is not active: identity'sPrincipalSourcesetsPrincipalFacts.AppInstallationActivefromAppInstallations.ActiveInstallation, and the authz snapshot builder applies it. Thenapp.suspendandapp.uninstallremove every visible target of the app, andapp.resumeadds them again, so the M1-29 hook writesaccess.changedfor the app and the gateway runsRevalidateon it (M1-65 "Revocation" step a) and closes the connection with4012(04 6.4). c. Known gap, not built around: a change ofgranted_scopesalone changes no visible target (07 2.10), so 04 8.2 writes noaccess.changed, and an open gateway connection of the app learns a removed scope only at its nextidentify,resumeorRevalidate. 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). -
Ports and wiring.
identitydeclaresAppInstallations(root package, kernel types only);apps/adapters/identitysourceimplements it over theappsrepository, reading in the ambient transaction;internal/wirebinds it.authzdeclaresAppScopesand gainsAdmin.RevokeSubjectGrants;apps/appuses them through consumer interfaces of its own.apps/appuses fromidentity:Directory(handle lookup), the principal methods of the Change list,Tokens.RevokeAllForPrincipal. The domain imports onlykernel-core(02 5); the app layer turnskernel/authorizefacts and configuration intodomain.Env. -
Reads.
listAppsandvalidateManifestauthorizeapp.readagainst the instance as their container (06 4 point 2): a non-owner gets403 forbiddenwithreasonowner_only.listAppsreturns apps whose latest installation isactiveorsuspended, ordered bylower(slug)thenprincipal_id, keyset cursor with the base's cursor helper (06 6),next_cursorabsent on the last page.getApploads by id and authorizesapp.readon resource kindapp: a non-owner gets404exactly like an unknown id (M1-28 visibility, AZ-P4); it returns any app,uninstalledincluded, with its latest installation.App.idis the principal id,display_namethe principal's display name; absent columns are absent fields. Ruling for docs/DECISIONS.md: list contents and order, container check for list and validate. -
Audit details (SEC-AUD-5: flat, only IDs, codes, integers, booleans and lists of codes; never names, descriptions, URLs,
data_egressor the manifest):app.install:app_id,installation_id,slug,manifest_sha256(lowercase hex),granted_scopes,event_subscriptions,delivery_hosts,reinstall,reason.app.installas 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_sha256is the SHA-256 of the manifest bytes as received;apps.manifestkeeps the parsed content (jsonb). Ruling for docs/DECISIONS.md: the hash covers the received bytes; the field lists above.
-
ADR 0022 confirmation. The vendor allow list of
architecture/modules.yamlalready rejects any unlisted third-party import inmake arch, which covers a WebAssembly runtime; the standard-library packagepluginis not covered. Add adepguardrule to.golangci.ymlthat forbidspluginin the server module. Prove it red once with a throwaway file that importspluginin a scratch copy, keep the output, never commit the file. Ruling for docs/DECISIONS.md. -
Migrations.
bothy_appgetsSELECT, INSERT, UPDATEonappsandapp_installationsand noDELETE, because rows are never deleted (03 1 point 7, M1-12 D4). Retention classes as 03 (account,config). Ruling for docs/DECISIONS.md. -
Tests that need tickets outside the closure. Everything that needs spaces and channels (AZ-M32 to AZ-M34, the
access.changedassertions of suspend, resume and uninstall) runs in two tiers. Tier 1, always: a real installation throughinstallApp, a real token throughcreateToken, the real authenticator, the realAuthorizerand the real M1-29 hook, with grants fromauthz.Admin.CreateGrant; whenchat(M1-52, M1-53) is not on the base, the structure (space Support with channels Allgemein and Eskalation) comes from the fake structure source ofinternal/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 withcreateMessageand the message read of M1-57, the grant throughcreateGrantif M1-48 is there. Gateway tests run only with M1-64 and M1-65 on the base. The report names the tiers that ran. -
Rulings for
docs/DECISIONS.mdsection 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, nevertriggerormanage, 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 andmin_api_versionat the server's version, gives no error fromdomain.ValidateManifest);TestValidateManifest_Ruleswith one row per rule of Design notes 4 and 5: size aboveapp.manifest_max_bytes(too_large), slug longer thanprincipal.handle_max_chars,namewith a bidi override (SEC-T07), invalid SemVer,min_api_versionabove the spec version, scope in both lists, eventaccess.changed,draft.updated,agent.invocationandgrant.changed,message.createdwithoutmessages:read, events withoutevents:subscribe, events withoutdelivery,webhookmode withoutevents_url,gatewaymode withevents_url, commands withoutinteractions_urlinwebhookmode, events withoutdata_egress,httpaddress, address with user information, host not allowed (webhook_host_not_allowed),homepage_urlwithhttp, requested grant withmanage,export,approve_actions, leveltrigger, targethost, reserved commandstop(command_name_reserved), duplicate command names in other case, more commands thancommand.max_per_principal, command withscope_kindchannel; each row asserts pointer and code and thatdetaildoes 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 orinvalid_state_transition),TestSemVerPrecedence.internal/authz:TestAppEventScope_AllCatalogueEvents(every type ofAllEventTypes()is classified;message.createdneedsmessages:read,instance.updatedno scope,command.invokedandinteraction.createdsubscribable, every otheraudience: selftype andgrant.changednot);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_AppScopeswith a fakeAppInstallations(no installation, suspended, default scopes copied, subset, scope not granted);TestAuthenticateAPIToken_AppInstallation(inactive gives zeroInstallationID, 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 (anappsrow for a person's principal fails), grants ofbothy_appwithoutDELETE.TestInstallApp_HappyPath: Robin (owner, fresh step-up) installs app Tracker (slugtracker,scopes.required[messages:write], no events): one principal of kindappwith handletrackerand statusactive, oneappsrow with the manifest,manifest_versionand the SHA-256 of the sent manifest bytes, one installationactivewithgranted_scopes[messages:write];permission_versionraised by one; exactly one audit allow entry with the fields of Design note 14; no row inapi_tokensandgrantsfor the app;201 Appwithout JSONnull.TestInstallApp_RejectsInvalidManifests(plan acceptance): a manifest with an unknown member gives400 validation_failedwithunknown_memberat its pointer under/manifest; withrequested_grantscapabilitymanagegives400 validation_failedwithinvalidat/manifest/requested_grants/0/capabilities/0; withdelivery.events_urlonhooks.other.example.comgives400 webhook_host_not_allowedwith the pointer of the URL; after each nothing is stored andpermission_versionis unchanged.TestValidateManifest_ReportsWithoutStoring: the valid manifest gives200withvalidtrue anderrorsempty; themanageand the foreign-host manifests give200withvalidfalse and the same pointers relative to the root; the unknown member gives400 validation_failed; no row, no audit entry; Sam (no owner) gets403 forbiddenwithreasonowner_only.TestInstallApp_OwnerStepUpAudit: Sam gets403withowner_only; Robin without fresh step-up gets401 step_up_required; an app token of Tracker callinginstallAppgets403; 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, each400 validation_failedwith the pointer of Design note 6c.TestInstallApp_SlugConflicts: the same slug again gives409 conflict; a slug equal to Alex's handle gives409 conflict;TestInstallApp_ConcurrentSameSlug: two parallel installs of one slug give one201and one409 conflict, never a500.TestInstallApp_IdempotencyKey: a replay gives the stored201withIdempotent-Replay: trueand no second installation.TestCreateToken_AppShownOnce(plan acceptance "token shown only once" and "createTokenfor apps"): before the install409 conflictwithreasonapp_not_installed; after it201 ApiTokenCreatedwith a secret of kindappwhosescopesequal the granted scopes; an explicit subset is stored as sent; a scope not granted gives400 validation_failedat/scopes/0; a replay with the sameIdempotency-Keygives409 conflictwithreasonone_time_secret;listTokensnever returns the secret; the secret appears in no row ofapi_tokens,idempotency_keysoraudit_eventsand in no captured log line; while suspended409 conflictwithreasonapp_not_installed.TestAppToken_EffectiveScopes: a token with[messages:write]of an installation that later losesmessages:writethroughsetAppScopesgets403 scope_missingon the next request; a token created before a scope was granted does not gain it.TestUpgrade_NewScopesStayInactive(plan acceptance): Tracker 1.0.0 withscopes.required[messages:write], upgrade to 1.1.0 that also requestsmessages:readwithgranted_scopes[messages:write]:200, installation unchanged, amessage.readdecision of the app isforbiddenscope_missing; the same upgrade withgranted_scopes[messages:write, messages:read]gives400 validation_failedat/granted_scopes/1; aftersetAppScopeswith both scopes and a new token the decision isallow; the audit entry of the upgrade namesmessages:readinmanifest_scopes_added.TestUpgrade_RemovedScopeActsAtOnce: an upgrade whose manifest dropsmessages:writewithgranted_scopes[]: the nextmessage.createdecision of the old token isforbiddenscope_missing.TestUpgrade_Rules: other slug gives400at/manifest/slug; changed content with the same version gives400at/manifest/version; identical manifest and lists give200without a newpermission_versionand an audit entry withno_change: true; upgrade of an uninstalled app gives409 conflict; an upgrade with a newinteractions_urlonhooks.example.com(inwebhook.allowed_hosts, not named by the stored manifest) gives200and its audit names the host indelivery_hosts_added, the same address onhooks.other.example.comgives400 webhook_host_not_allowedand the stored manifest is unchanged (Design note 7 g); a non-owner gets403 forbiddenwithreasonowner_onlyfor 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 leavepermission_versionunchanged, an uninstalled app gives409 conflict, a non-owner gets404like an unknown id.TestSuspendResume: aftersuspendAppa request of Tracker's token (listTokensfor its own principal) gives401 unauthenticatedwith audit deny reasonapp_not_installed, the token row has norevoked_at, grants are unchanged; a second suspend gives204withno_change: trueand leavespermission_versionunchanged; afterresumeAppthe samelistTokensrequest is authenticated again and gives403 forbiddenwithreasonsubject_kind_not_allowedinstead of401(05 2.6 gives apps no scope fortoken.list), and with M1-31 on the basegetMewith the token gives200(self.read, no scope); resume of an uninstalled app gives409 invalid_state_transition.TestAppAccessChanged_SuspendResumeUninstall(Design note 17): with a grantwriteon Support/Eskalation,suspendAppwrites oneaccess.changedfor the app with that channel inremoved,resumeAppone with it inadded,uninstallAppone with it inremoved;setAppScopesthat 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, principaldisabled, installationuninstalledwithuninstalled_atanduninstalled_by, audit with the counts; a second call gives204withno_change: trueand leavespermission_versionunchanged; a reinstall with the same slug reuses the principal, makes itactiveand creates a new installation while the old tokens stay revoked;TestUninstall_RacesCreateToken: paralleluninstallAppandcreateTokennever 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 (writeon Support/Eskalation) and scopemessages:write. Tier 1:message.createon a thread of Support/Eskalation isallow,message.readthere isforbiddenscope_missing,message.createon a thread of Support/Allgemein isnot_found. Tier 2 (M1-56 and M1-57 on the base):createMessagethere gives201withorigin = app, the message read gives403 scope_missing,createMessagein Allgemein gives404with 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 (ignoringcorrelation_idandinstance) and headers.TestListApps: owner sees active and suspended apps ordered by slug with cursor pages; uninstalled ones only throughgetApp.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_egressor a token.TestAppGateway_InstallationState(only with M1-64 and M1-65 on the base):identifywith the token of a suspended app closes with4012; an open connection closes with4012aftersuspendApp.- 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 lintmake docsmake check
Acceptance criteria¶
- The nine operations
listApps,installApp,validateManifest,getApp,upgradeAppManifest,setAppScopes,suspendApp,resumeAppanduninstallAppare enabled and registered; AZ-P1 passes. -
AppManifestinopenapi.yamlfollows 11 3.1; invalid manifests are refused: an unknown member,manageinrequested_grantsand a host outsidewebhook.allowed_hosts(webhook_host_not_allowed);validateManifestreports rule errors withvalidfalse and stores nothing. - The installation runs in one transaction with step-up, audit and a new rights version, creates principal,
appsandapp_installationsand no token; owners only. -
createTokenfor an app with an active installation issues a token with scopes from the installation, shown exactly once (replay409 conflictwithreasonone_time_secret); without an active installation it keeps409 conflictwithreasonapp_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.changedfor 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 lintfails on it). - 11, 08, 07, 02, the threat model,
modules.yaml, the READMEs anddocs/DECISIONS.mddescribe the behaviour; the report lists the gateway gap of Design note 11c as a spec gap. -
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
apps,identityandauthzupdated (portsApps,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 toopenapi.yaml, 11, 08, 07, 02 and the threat model of the Scope made in the same change. 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. 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 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>.