Changelog

What’s changed in this specification over time. Every decision is written into the page it governs, and recorded here as a dated entry. Add a new entry rather than editing an old one. Numbers like “#39” in older entries refer to the decision log the repository kept until 2026-10-18.


2026-10-22 — Browser CORS, a role filter on users, and sign out everywhere

Three gaps the management console needs (#38, #54, #51). openapi.yaml is now at 1.0.0-draft.9, with 124 operations.

  • CORS matches what’s built. NFR → CORS said only some GETs allowed the first-party origin and that an admin console would call through its own backend. In fact, allow-listed origins may call any endpoint with the signed-in person’s own User token, which is sound because a Bearer token isn’t sent automatically. The page now says so.
    • Browsers can now read Retry-After and Audit-Hot-Window-Start.
    • Preflights are cached for 2 hours instead of 10 minutes.
    • An API Key sent with an Origin header is refused with the new 400 api_key_in_browser, so a key pasted into a web page can’t be used from it. The Interactive Reference’s local “Try it out” takes a user token for the same reason.
  • GET /users?role_id= lists the Users holding any of the given Roles by explicit assignment. Repeatable, up to 20. An app-confined key may only name its own Application’s Roles (403 role_scope_mismatch). See Users → filters.
  • POST /users/{id}/sessions/revoke-all signs a User out everywhere, by the User or a users.manage caller, and returns {"revoked": n}. keep_current keeps the calling session, self only. New revoked_reason revoked_all and audit action user.sessions_revoked (migration 0012_sessions_revoked). Destructive. See Auth → Sessions.

2026-10-22 — Audit Event exports

The audit log can be exported to a file. See API Reference → Audit → Exporting. openapi.yaml is now at 1.0.0-draft.8, with 123 operations.

  • POST /audit-exports takes the same filters as GET /audit-events, as body fields, plus format (ndjson or csv). It confines the caller to the same scope as the list, requires Idempotency-Key, and returns 202. A job writes the file, usually within a minute. Limited to 20 per caller per day.
  • GET /audit-exports/{id} shows the status, and once it’s ready, a download link signed for 15 minutes. Only the requester and audit.view can see an export. Files and records are deleted after 7 days.
  • New audit action audit.exported (target type audit_export), written when the file is ready, with the requester as actor.
  • Retention needed no new code. It’s already enforced: the one-year hot window, and the nightly archival of older events, shape-only, to indefinite cold storage (Audit log lifecycle).
  • Migration 0011_audit_exports. Infrastructure: a private exports bucket with a 7-day lifecycle rule, and an audit-exports schedule every minute.

2026-10-22 — Self-service Application registration

POST /applications is open to developers, not just staff. See API Reference → Applications and The review lifecycle. openapi.yaml is now at 1.0.0-draft.7.

  • Any User with a verified email can submit an Application. It’s owned by the caller, or by an Organization they’re an org_admin of. It starts in pending_review, public, and standard, and the moderation fields are staff-only (403 moderation_field_forbidden). An unverified email is the new 403 email_not_verified. Each User can submit 10 a day. Staff creation is unchanged.
  • Applications gain self_registered (read-only, migration 0010_self_registered_apps). A self-registered Application is third-party, even once approved:
    • POST /auth/app-tokens refuses it with the new 403 embedded_login_not_allowed, as Auth → Getting an app token already required once self-service shipped. It must use the hosted flow.
    • Only applications.manage can turn on its receives_platform_notifications, so platform security alerts never go out under an unvetted app.
  • Platform-managed Entitlements wait for approval. free and subscription rows are granted only to an Application that has been approved, and approving one grants them. Before this, a standard submission would have been granted to every Standard subscriber the moment it was created, firing access.granted to an unreviewed developer.

Self-registered Applications still can’t go live until the hosted sign-in page exists.

2026-10-22 — Test-mode User expiry implemented

Test-mode Users → Lifetime is built, as specified on 2026-10-20. openapi.yaml is now at 1.0.0-draft.6:

  • User.expires_at, and expires_at on POST /auth/signup, POST /users, and PATCH /users/{id}.
  • user.expired in the webhook event and audit action enums, test_user_expired and test_user_extended in the access-event reasons, and test_user_expired in the device inactive reasons.
  • POST /auth/token/refresh and POST /auth/mfa/verify now declare 403, which they return for test_user_expired (and, for mfa/verify, account_suspended, which it already returned).

A few behaviors the spec left implicit are now stated:

  • An out-of-range or live-User expires_at is reported like any other field error: 422 validation_failed with the code in details.fields, not a separate details.reason.
  • A Platform access token whose test-mode User has expired gets 401 session_revoked, the same as a suspended User’s. Sign-in, refresh, MFA verification, and app-token issuance return 403 test_user_expired.
  • Extending a User the expiry sweep hasn’t processed yet fires no access.granted, since no access.revoked went out for them either.
  • Migration 0009_test_user_expiry sets every existing test-mode User’s expires_at to created_at + 30 days, when the purge would have removed them anyway.

2026-10-22 — Version header declared in openapi.yaml; 405 for a wrong method

  • openapi.yaml declares Substratal-Version. Every versioned operation now carries the shared ApiVersion header parameter (Substratal-Version, enum 1) and documents its 400 (api_version_required, unsupported_api_version). Generated clients learn the header from the spec instead of needing it configured by hand. GET /health and POST /auth/oauth/token don’t declare it, because they’re exempt. Nothing changes on the wire: the API already required the header (Conventions → Versioning).
  • 405 method_not_allowed. Calling a path that exists with a method it doesn’t support (for example PUT /health) now returns 405 with an Allow header and details.allowed, instead of the catch-all 404 not_found. See Conventions → Errors.

2026-10-21 — SCIM provisioning

An Organization’s identity provider can invite and remove its members with SCIM 2.0. See API Reference → SCIM. openapi.yaml is now at 1.0.0-draft.5, with 121 operations.

  • Setting it up: POST, GET, and DELETE /organizations/{id}/scim-token (org admin or organizations.manage). There’s one token per Organization, and its secret is shown once. New audit actions organization.scim_token_created and organization.scim_token_revoked. Migration 0008_scim_tokens.
  • /scim/v2 serves ServiceProviderConfig, ResourceTypes, and Users (list with a userName eq filter, create, get, put, patch, delete), in SCIM’s own response and error shapes. These endpoints follow RFC 7644, not openapi.yaml, like /.well-known, and are exempt from Substratal-Version: identity providers can’t add the header, and SCIM versions itself in the path.
  • The membership rules don’t change. Provisioning invites, so the person accepts before they join, whether or not they already had an account. Deprovisioning removes the membership, with access.revoked, and never touches the person’s own account. The last-org_admin guard applies. Writes are audited with actor type system and the token’s id.
  • Users only: no Groups, bulk, sorting, or ETags. Roles stay with org admins.

2026-10-21 — Versioning rules spelled out

Clarifications to the header-based versioning below; nothing about the API’s behavior changed.

  • What counts as breaking is now listed in Non-Functional Requirements → Versioning: removing or renaming a field, endpoint, or parameter; changing a type or format; a new or newly required request field; a different status or error code for an existing condition; or a changed default or behavior.
  • Retirement. Deprecation (RFC 9745) and Sunset (RFC 8594) appear on responses to requests that name a deprecated version. After its Sunset date, requests naming it get 400 unsupported_api_version.
  • Two separate versions. The Substratal-Version request header and a webhook subscription’s payload api_version are independent, now stated on Conventions, Webhooks, and in the Glossary.
  • OpenAPI Specification no longer describes versions as URL prefixes, and Auth → POST /auth/oauth/token notes it needs no version header.

2026-10-21 — The API version moves from the URL to a header

Breaking. The /v1 path prefix is gone. Every endpoint is now at the host root, and every request names its API version in a header instead:

POST https://api.substratalapps.com/users
Substratal-Version: 1
  • Paths drop /v1. POST /v1/users is now POST /users, GET /v1/health is GET /health, and so on for every operation. /v1/… paths now return 404 not_found; there’s no alias or redirect. In openapi.yaml, the server URL is https://api.substratalapps.com, and the paths are unchanged. openid-configuration’s token_endpoint is /auth/oauth/token.
  • Substratal-Version is required. Versions are whole numbers, and 1 is the only one. A missing header is the new 400 api_version_required, and an unknown value is the new 400 unsupported_api_version. Both list the served versions in details.supported, and the check runs before authentication and every other validation. Every response echoes the version that served it, and the header is allowed and exposed for CORS.
  • Exempt: GET /health (for uptime monitors), POST /auth/oauth/token (standard OAuth libraries can’t add the header), and /.well-known/*. MCP clients don’t send it: the MCP server calls the API with the version its tools describe.
  • Unchanged: the deprecation policy (a breaking change is the next version, and the previous one keeps working for at least 6 months, with Deprecation and Sunset headers) and webhook payload versions (api_version dates, versioned independently). /.well-known, /mcp, and /internal are reserved and will never be resource names.

See Conventions → Versioning. openapi.yaml is 1.0.0-draft.4. Older entries below keep the /v1/… paths that were current when they were written.

2026-10-20 — Test-mode Users are time-limited

Test mode already never counted toward billing (decision #36, confirmed on 2026-10-18). Now every test-mode User expires, so a test account can’t stand in for a free production account. See Auth → Test-mode Users → Lifetime.

  • Users gain expires_at. It’s set on every test-mode User and is null on every live one. The default is 24 hours after creation. POST /v1/users with a satk_test_ key, and signup with "test_mode": true, accept expires_at for a shorter or longer run: at least 15 minutes away, and at most 30 days after creation, the existing test-data purge horizon. users.manage can move it with PATCH /v1/users/{id}, which also brings back an expired User that hasn’t been purged yet.
  • At expiry sign-in, refresh, and app-token issuance return the new 403 test_user_expired, and every token already issued stops working. No token’s exp is ever later than the User’s expires_at. The 5-minute entitlement-expiry sweep then revokes sessions and devices (reason test_user_expired), fires access.revoked (reason test_user_expired) and the new user.expired webhook, and writes the user.expired Audit Event. status, Entitlements, Roles, and memberships are unchanged. An expired test User isn’t a covered member of a test-mode subscription.
  • Test-mode API Keys, Organizations, and Applications don’t expire. The nightly 30-day purge is unchanged.

Built on 2026-10-21; see the entry above.

2026-10-18 — Push notifications implemented

Push Notifications is built: all 17 operations work, and none returns 501 any more. Nothing about the API’s shape changed. A few behaviors the spec left implicit are now stated:

  • A User’s first session (at signup) never raises security.new_sign_in; there’s nothing yet to compare it with.
  • A device an app key registers or refreshes has session_id: null.
  • An unknown {provider} in a push-credential path is 404 push_credential_not_found.
  • A push-credential POST validates its fields before checking whether the provider is already configured, so an invalid body is 422 even when a credential exists.
  • Database Schema records each notification’s push counts on the notification itself, since deliveries are deleted after 30 days.

Delivery shares the webhook queue, worker, and 1-minute sweep (see the repository’s infra/README.md → Push delivery), and a daily push-maintenance job runs the device and inbox retention rules. Locally, PUSH_BACKEND=log logs pushes instead of sending them.

2026-10-18 — Billing options settled; decision log retired

  • Billing options (decision #31, the last open question): Standard is billed monthly in USD only, with no annual plan and no trial. Free is the way to try Substratal’s apps, and never needs a card. See Pricing → Stripe catalog. Either option can be added later as Stripe configuration, without an API change.
  • Push alerts and platform-managed access. The account.access_granted platform alert is raised only for access a developer or admin grants, never for platform-managed free or subscription Entitlements. Under subscriber billing, subscribing to Standard or publishing a free Application would otherwise have pushed one alert per app to every affected User.
  • The repository’s DECISIONS.md is retired. No decisions are open, every answer lives on the page it governs, and this Changelog records when each was made. The “Awaiting decision” callout style is removed from the site.

2026-10-18 — Subscriber billing applied; decisions folded into the spec

Subscriber billing (decision #39, now resolved). The Free/Standard pricing from 2026-10-17 is now the API:

  • Billing is rewritten. The four /v1/tenants/{id}/… billing endpoints are gone. In their place: GET /v1/users/{id}/subscription, POST /v1/users/me/billing/checkout-sessions and …/portal-sessions, and GET/PATCH /v1/organizations/{id}/subscription with the Organization’s own Checkout and Portal sessions. A new Subscription object (sbs_) belongs to a User or an Organization; Organizations choose which members it covers with member_scope.
  • Access stays Entitlement-based. Entitlement.source gains free and subscription, both platform-managed: every User gets free Applications at signup, and a covering subscription grants every standard Application. Admins can disable and re-enable them; anything else returns 409 entitlement_platform_managed. Entitlements gain subscription_id.
  • Applications gain access_tier (free standard, default standard), a moderation field with an access_tier list filter. Users gain plan and plan_sources.
  • Removed: Tenant plan, subscription_status, and restricted (and the plan/restricted list filters); the per-plan caps on Applications, AppRoles, webhooks, and seats; 402 subscription_required; member_decision: seat_limit; plan_does_not_allow_tier; GET /v1/tenants/{id}/usage. plan_limit_reached is reserved for future business and enterprise limits.
  • Audit: subscription.created, subscription.status_changed, and subscription.members_changed (target type subscription) replace tenant.plan_changed and tenant.subscription_status_changed. The audit hot window is one year for every Tenant.
  • New workflows: A User subscribes to Standard and An Organization subscribes to Standard for its members.

Decisions folded into the spec. #32–#34, #36, #37, and #40 are confirmed as proposed and written into the pages they govern: Organization and Tenant scoping in the data-access layer (NFR → Multi-tenancy), inline Stripe webhook processing, JWKS caching without CloudFront at Tier 0 (NFR → Service-level objectives), test mode and subscriptions (Pricing), audit immutability by trigger (NFR → Audit), and the push notification defaults (Push Notifications). Only #31, annual plans and a Standard trial, is still open.

2026-10-17 — Push notifications and the notification inbox

New: Push Notifications, so an Application can send a User alerts and other updates without building its own push stack, and the platform can tell a User about their account.

  • Devices. A client registers its APNs, FCM, or Web Push token per Application (POST /v1/users/{id}/devices), with its user token or through its backend’s app key. A Device is tied to the session that registered it, so signing out stops its pushes. A token a provider reports as gone deactivates the device automatically.
  • Push credentials. Each Application’s owner configures its own APNs key, FCM service account, or Web Push (VAPID) key pair under /v1/applications/{id}/push-credentials. Key material is write-only and KMS-encrypted, and only a person can manage it, the same rule as API Keys.
  • Sending. POST /v1/users/{id}/notifications, with a new app-confined permission, notifications.send. Every send lands in the User’s inbox, whether or not a push goes out, and push.status says what happened. The recipient must have active access to the Application, and a User who sets notifications.push: false in Settings (globally, or for one app) gets the inbox item without the push.
  • The inbox. List, count unread, mark read, unread, or archived, and mark many read at once. Kept 90 days.
  • Platform alerts. A fixed catalog of security.* and account.* notifications (new sign-in, password or email changed, MFA disabled, token reuse, organization invitation, access granted), pushed to the devices of Applications that opt in with the new receives_platform_notifications field.
  • Everywhere else it touches: 17 operations under a new Notifications tag in openapi.yaml (info.version 1.0.0-draft.3), new id prefixes (dvc_, pcr_, ntf_, ndl_), webhook events (device.registered, device.deactivated, device.deleted, notification.push_failed, push_credential.failed), audit actions (push_credential.created/updated/deleted, device.deleted), Database Schema, a new Domain Model → Notifications page, rate limits and an SLO in Non-Functional Requirements, the export and erasure cascade, Apple, Google, and browser push services as sub-processors, two new Workflows, and the Roadmap.

The defaults this rests on (opt-in platform pushes, no broadcast endpoint yet, the per-recipient limit, 90-day retention, no quiet hours yet) are listed for confirmation in decision #40. None blocks building. Until the reference implementation builds the feature, every Notifications operation returns 501 not_implemented.

2026-10-17 — Free and Standard pricing; OpenAPI Specification section

Pricing (decision #38). Pricing now has two tiers, paid by the people who use Substratal’s apps rather than by the Application owner:

  • Free gives every User the Applications, features, and services Substratal designates as free. No card is required.
  • Standard is $6 per user per month for access to every Application and service. A User subscribes for themselves, or an Organization subscribes for the members it covers.
  • Business and Enterprise pricing, tailored to an Organization’s needs, tenancy, and data location, comes later. Until then, isolated and dedicated_region tenancy are reserved for it.

The previous Starter / Team / Enterprise plans, their per-Tenant limits, and Tenant restricted mode are retired from Pricing. Billing system of record, the Glossary, the Roadmap, and NFR → Audit log lifecycle are updated to match. decision #31 now covers only annual plans and a Standard trial, and #35 is withdrawn.

The API changes that implement the new model aren’t applied yet. They’re proposed in decision #39, and every affected page (Billing, Tenancy, Entitlements, Applications, Organizations, Webhooks, Conventions, Access Control, Database Schema, Workflows) carries an “Awaiting decision” callout until then. openapi.yaml is unchanged.

OpenAPI Specification. A new top-level section for developers building against the API: OpenAPI Specification explains how to read, download, validate, and generate clients from openapi.yaml. The Interactive Reference renders the document with Swagger UI, including “Try it out” against a local devserver.

2026-10-06 — Implementation-driven openapi.yaml fixes

The first implementation’s contract tests validate every response against openapi.yaml, and found places where the YAML disagreed with the prose (which is authoritative). Each is now fixed in the YAML; no behavior described on any page changed:

  • ApplicationWrite.slug: pattern used a negative lookahead (?!platform$) that RE2-based JSON Schema validators (Go, Rust) can’t compile; now ^[a-z][a-z0-9-]{1,63}$, with platform documented as reserved in the description (the server still rejects it).
  • RoleCreate/Role.description: now nullable, matching Domain Model → Role (“string, nullable”); a Role created without one has none.
  • ApplicationWrite/ApplicationSummary description and icon_url: now nullable, matching the Applications page’s own POST response example ("icon_url": null); both are optional on create.
  • GET /organizations/{id}/entitlements: declared the filters its prose already promised (“same filters as GET /v1/entitlements”: application_id, status, order_id, ends_before, include_inactive).
  • GET /tenants: declared owner_user_id and owner_organization_id, which Tenancy → GET /v1/tenants already listed for tenants.manage.

Questions the build surfaced where the spec was silent or self-contradictory are recorded as decision #32–#37.

2026-10-06 — Domain Model and API Reference review

A review of every Domain Model and API Reference page against each other, the database schema, and openapi.yaml. Every fix is stated on the page it affects.

Security and correctness

  • Signup no longer completes an invitation. Signing up with an invited User’s email used to activate that account with no invitation token, handing whatever was pre-attached to it to anyone who knew the address. It now returns 409 email_taken (details.reason: "invitation_pending") and re-sends the invitation. See Auth → Signup.
  • Organization invitations are now pending until accepted. Since anyone can create an Organization, adding members by email used to reveal whether an address had an account, and that account’s name. Adds by email now create a pending membership whose response looks the same either way. Adds by user_id are restricted to organizations.manage. A new POST /v1/organizations/{id}/members/me/accept endpoint and audit action organization.member_invited support this.
  • Test-mode Users can sign in. Uniqueness is per mode, so the email-keyed auth endpoints now take test_mode (Auth → Test-mode Users). include_test is gone: test and live rows never cross, under any parameter.
  • Role ids can’t collide. The slug now appears verbatim in Role ids (it never contains _), and platform is a reserved slug. Application ids are documented as app_<slug>, the second derived-id exception after Role.
  • Typed-settings view and index names are now built from hashes, so they always fit Postgres’s 63-byte limit.
  • Support can read for investigations. Reading AppProfile/AppSettings/global Settings past the access gate now needs users.list (which support holds), not users.manage (which it doesn’t).
  • Application owners can grant Entitlements with their own User token, personal and org-wide alike, confined to their Application exactly like its app key.

Contradictions resolved

  • One rule for soft-deleted vs. terminal records (Conventions → Filtering): terminal records stay fetchable; soft-deleted Users 404 except to users.manage. Flags unified on include_inactive.
  • Webhook DELETE drops queued deliveries (Conventions said otherwise).
  • Audit Events: the erasure cascade’s redaction is now documented as one of two scheduled jobs allowed to touch an event; actor.via_api_key_id (never defined) is removed; user.updated and role_assignment targets are defined precisely.
  • Erasure: a soft-deleted User can be restored (deleted → active) or have erasure requested by users.manage; the cascade also clears memberships and member_overrides. Export and the cascade are described accurately (export includes global Settings; erasure leaves it).
  • application_not_available is 409 everywhere. The implicit default_app_role is listed only while access is active.
  • Profile.locale is a read-only mirror of Settings.locale. Settings resolution is described as the two disjoint chains it actually is.
  • Credential storage (webhook secrets are KMS-encrypted, recovery codes Argon2id), order_id examples, and the supporting-entity count/ER diagram corrected. Every example id is now a valid ULID.

Specified (previously missing)

  • Entitlements: disabled → expired, renewal conflicts, a scheduled resolved status for future starts_at and an access.granted reason entitlement_started, and which excluded org rows a member sees.
  • 403 tenant_suspended; 409 idempotency_key_in_flight in the global table; the plan_limit_reached details shape, with usage keys renamed to match (app_roles, webhooks).
  • The hosted flow’s authorization endpoint (authorization_endpoint in OIDC discovery); MFA disable with a recovery code; what a session’s expires_at means.
  • SSO: a domain is claimable by one active connection platform-wide, and SSOConnection has no API until the broker ships. Webhooks: request scope, per-attempt signing, webhook.test delivery rules, and a payload versions table.
  • Missing error codes added to each resource’s table; missing fields added to the Domain Model (User, AppProfile, Application, Role, Tenant) and the database schema (test_mode, created_at, version, current_period_start).

New examples: org-grant resolution worked through all three member_scope values, a settings schema change producing a stale override, a test-mode walkthrough, Application owner onboarding end to end, webhook signature verification in Node.js and Python, field-validation errors, a plan-limit error, and full responses where pages only said “the full object”.

2026-10-05 — decisions moved into the spec

  • Removed the Decisions page. Every resolved decision (#1–#15) now lives on the page an implementer would actually read, with its rationale, instead of on a separate log. For example, identity is in Auth → Native auth and per-Organization SSO, and billing ownership is in Orders & Audit → Billing system of record. New sections were added where a page didn’t yet carry the reasoning, among them Trust Model → Applications are separately hosted, Tenancy → Tenant vs. Organization, Settings → Schema validation, and Pricing → How the subscription is charged. Every link to the old page was repointed.
  • Confirmed decisions #16–#30, which the implementation-readiness pass below had written into the spec as proposals. The spec already implemented each one, so the change here is that their reasoning is now stated as settled on the page each one governs: app-token issuance (Auth → Getting an app token), app-confined API Key permissions, concierge onboarding, derived access.* webhooks, order_id as a reference, typed settings via views and expression indexes, self-service Organization creation, seats/lapse/Stripe object model, the 7-day erasure grace period, the region list, Amazon SES, and test-mode isolation.
  • Applied #29: renamed GET /v1/users/{id}/applications/{appId}/effective-permissions to GET /v1/users/{id}/apps/{appId}/effective-permissions (Roles & Permissions), matching the per-user Profile and Settings paths. Free to rename now, since no endpoint is implemented yet. The operationId (permissions.getEffective) and MCP tool name are unchanged.
  • Opened #31 in the decision log: confirming the Pricing dollar amounts, plus annual plans, a Team trial, and whether Starter requires a card on file. None blocks the build.

2026-10-05 — implementation-readiness pass

A full review aimed at “can development start and finish against this spec without guessing”. Every fix below is stated inline on the page it affects. The product calls this pass had to make are logged as Decisions #16–#30, all 🟡 Proposed (then in the repository’s decision log, each marked with an “Awaiting decision” callout on the page it affects). Each has a complete default written into the spec and a named alternative, so none blocks implementation, but each needs confirmation.

Contradictions fixed

  • Developers had no safe credential to reflect their billing. The billing-system-of-record rule requires developers to call Entitlements, but app-scoped keys could never hold entitlements.manage. Resolved with app-confined permissions (#17).
  • Org self-grant hole: once Organizations are self-service (#22), an org admin creating their own org-wide grant would mean free access to any app. Org grants can now only be created by whoever controls the Application. Org admins scope and toggle, never create.
  • Stripe lapse vs. database: “cancellation reverts plan to starter” violated check (plan != 'starter' or owner_type = 'user') for Organization-owned Tenants. That constraint also referenced a nonexistent owner_type column, now added. Replaced by restricted mode (#24).
  • Order was modeled as an entity (ord_ prefix, billing.manage = “Manage Orders”, a nonexistent /v1/webhooks/incoming/billing in Workflows), even though orders are the developer’s. order_id is now an opaque reference, and billing.* permissions cover platform billing (#20).
  • Revocation gaps: the required webhooks missed org-member removal, member_scope exclusions, org-grant changes, and user suspension/deletion. The access algorithm didn’t check user status at all. Added derived access.granted/access.revoked webhooks and user.status to allow() (#19).
  • Platform permissions leaked into app JWTs: effective_permissions unioned platform Roles. It’s now app keys only.
  • Typed settings via generated columns would collide across apps, hit Postgres’s 1,600-column ceiling, and break inserts on schema change. Replaced by per-app views and partial expression indexes (#21).
  • openapi.yaml’s Page schema put next_cursor/has_more at the top level, while every prose example nests them under page. Fixed (PageEnvelope).
  • Workflows emitted entitlement.revoked for a disable. Webhook retries said “up to 5 attempts” with 5 retry intervals, which is 6. Fixed.
  • tenants.manage was missing from the permissions/roles examples. The Applications settings_schema example had no default, yet every resolved example used one. review_notes_required was 400 in violation of the 400/422 mapping (now 422). The AppRoles plan limit is now defined consistently. The webhook plan limit is now per Tenant.

Completed (previously missing or unspecified)

  • Auth, rewritten: signup, MFA login challenge, recovery codes, refresh-token rotation with reuse detection, sessions, password change/reset, email verification and change, invitations, account lockout, password policy, JWKS/key rotation, how an Application gets its per-app JWT (embedded app-tokens now; hosted authorization code + PKCE later, #16), and the app-token claim set.
  • New Billing API (subscription, usage, Checkout, Portal), Pricing seat definition, caps, enforcement, lapse rules, and the full Stripe catalog (#23, #25). Root DEPLOYMENT.md → Stripe Billing is rewritten (event handling, re-fetch-on-event, seat sync), with new sections for SES email (#28) and the webhook outbox.
  • Users: invitations, pending email change, search filters, the erasure-request endpoint (#26). Roles: get/update/delete, derived ids, escalation guard, default_app_role, implicit assignments. Organizations: self-service create, member add/role-change/leave, last-admin guard. Tenancy: tier-change request lifecycle (scheduled added), owner visibility, region list (#27). Webhooks: get/update, secret rotation with overlap, test, delivery log, redeliver, api_version pinning, auto-disable, SSRF rules. API Keys: get/update, owner self-service for app keys (#18), secret_hint, expires_at.
  • Applications: permissions, redirect_uris, default_app_role, support_url/email_from_name, visibility rules, review-transition table. Settings: exact resolution rules, settings_schema rules, x-pii, stale overrides. Entitlements: full transition matrix, uniqueness, PATCH-able fields, expiry sweep, a 24-hour limit on delete-in-error.
  • Conventions: PATCH merge semantics, ETag/If-Match, size limits, ordering, cross-cutting error codes, response headers, credential prefixes. NFR: password hashing, brute-force limits, per-IP auth rate limits, security logging, SLOs, webhook payload versioning, and a revised hard-delete cascade.
  • Audit Events: an actor/target model (API keys and the system can be actors; many actions have no target user), request_id, and an action catalog expanded to every state change, mirrored exactly in openapi.yaml.
  • Database Schema: sessions, tokens, recovery codes, webhook outbox/deliveries, tier-change and erasure requests, Stripe events, version columns for ETags, test-mode isolation (#30), and corrected entitlement uniqueness.
  • openapi.yaml rewritten: 97 operations (from 56; every existing operationId unchanged), typed error responses on every operation, and x-substratal-destructive / x-substratal-phase extensions. The MCP Server page now holds the single canonical destructive-operation list.

Not changed, needs a decision: #29, the mixed /apps/{appId} vs. /applications/{appId} path spelling. (Since applied: see the entry above.)

2026-10-05

  • Docs are now versioned automatically. Every publish of this site gets a new version number, starting at v1.0.0. The current version shows in the top right of every page’s header and links to the new Versions page. Each version is also kept as a frozen, read-only copy under /versions/<version>/, so a link to an old version keeps showing exactly what that version said. This is the version of the documentation, separate from the API’s own /v1 path version.

A full consistency/UX review surfaced drift between the prose docs, openapi.yaml, and the root planning files — this entry is every fix that came out of it:

  • Fixed a self-contradiction in Pricing: Starter’s own “For” cell claimed “3+ Applications” while its own Applications row capped at 1. Reworded; “3+ Applications” is Team’s case, not Starter’s.
  • Added Pricing → Enforcement: the Applications/custom-AppRole/webhook-subscription caps now have a real mechanism — checked server-side at creation time against the owning Tenant’s plan, independent of the caller’s own permission standing (the same “two independent gates” pattern restrict_destructive already uses), returning 409/code: "plan_limit_reached". New database constraint: check (plan != 'starter' or owner_type = 'user') on tenants.
  • Resolved the audit-retention contradiction between Pricing’s plan-tiered “retention” numbers and Non-Functional Requirements/Compliance’s “retained indefinitely”: those numbers were never a deletion period, they’re a hot-storage window — added Non-Functional Requirements → Audit log lifecycle specifying the archive-to-cold-storage mechanism (shape-only, before/after dropped at archive time, same redaction the hard-delete cascade already does) that makes “indefinite” literally true without every plan paying for the same hot-storage size. Root DEPLOYMENT.md → Audit log archival specifies the AWS mechanics (EventBridge Scheduler → Lambda → S3 Glacier Deep Archive).
  • Fixed Orders & Audit → Action catalog: added tenant.tier_change_requested and tenant.tier_changed, both already live in openapi.yaml’s AuditAction enum but missing from this page’s own “authoritative source” table.
  • Added the MFA enrollment endpoint to openapi.yaml (POST /v1/users/{id}/mfa/totp) — fully specified in Auth since its own addition, never added to the machine-readable spec.
  • Added mode (live/test) to ApiKeyWrite/ApiKey in openapi.yaml — present in the database schema and every prose example since API Keys was written, absent from the YAML.
  • Added plan to the Tenant schema in openapi.yaml (new TenantPlan enum) — present in Tenancy, Database Schema, and this page’s own earlier Pricing/Stripe entries, absent from the YAML. Also added it to API Reference → Tenancy’s own example, which had drifted from the matching Domain Model example.
  • Added Idempotency-Key to organizations.grantEntitlement in openapi.yaml — the near-identical entitlements.grant already required it; this org-wide variant had been missed.
  • Added entitlement.member_scope_changed to the WebhookEvent enum in openapi.yaml and to Webhooks → Event types — Workflows already implied an app could subscribe to it; the event didn’t actually exist yet.
  • Closed a real gap in Entitlements: DELETE now rejects (409/code: "entitlement_order_linked") whenever order_id is set, for every caller — not just a restrict_destructive agent key. Hard-deleting a real, order-linked Entitlement was previously stopped only by prose discipline, inconsistent with the platform’s own stance that this exact class of operation is destructive enough to hard-gate.
  • Tightened the Application ownership constraint: owner_user_id/owner_organization_id is now exactly one, never neither — closing a real bug where a theoretical ownerless “system app” had no owner for tenant_id (not null) to resolve from. Every current example already set an owner; this just makes the constraint match what was already practiced. Added the matching check to applications in Database Schema.
  • Added a “most Users never touch Tenant at all” note to Tenancy — only an Application owner gets one; the common case (someone who signs up, often through an app itself, purely to use it) never does.
  • Added Conventions → Delete semantics: hard vs. soft: a resource-by-resource catalog of what DELETE actually does — hard, soft, soft-but-irreversible, or no endpoint at all — and what a soft delete generically means (status flips, record retained, excluded from default lists, nothing scrubbed or moved). Building this surfaced that Organizations had no way to actually reach status: suspended — added GET/PATCH /v1/organizations/{id}.
  • Elaborated Conventions → Errors: fixed the illustrative example (entitlement_not_active, which no page actually defines) to a real code (entitlement_required); added an explicit HTTP-status-to-error-class mapping table and the 409-vs-422 distinction, for implementation; clarified there’s no single global error-code catalog — each resource’s own table is authoritative, the same pattern Orders & Audit already uses for action values.
  • Fixed MCP Server’s hardcoded operation count (“all 55 operations”) — already stale (56+ since this was written), and guaranteed to drift again. Reworded to not state a number at all.
  • Fixed Domain Model’s “Ten entities” framing — added a Supporting entities table listing UserIdentity, SSOConnection, OrganizationMembership, and UserRoleAssignment, which the original count silently omitted.
  • Moved the original single-document draft from docs/specs/Substratal-Hub-Access-API.md to root ORIGINAL-SPEC-DRAFT.md, with a superseded banner — it contradicts several current decisions structurally (no Tenant concept, single-method auth) and sitting inside docs/ risked it being mistaken for current spec despite being excluded from the build.
  • Updated root README.md and PLAN.md, both of which had gone stale against decisions resolved in the prior round: README’s Status section still named Decision #1 as the one open item (all 15 are resolved); PLAN’s Phase 3 section still described the Application review process as unspecified (fully specified by Decision #9).

2026-10-04

  • Added API Keys as its own resource — service-to-service credentials were referenced throughout (Conventions, NFR, Trust Model) but never actually specified.
  • Added a global GET /v1/entitlements (admin/support, filterable) alongside the existing per-user list — there was no way to answer “who currently has app X enabled” without it.
  • Added a canonical Platform permission catalog and Platform role grants table, and added an explicit Requires: <permission> line to every endpoint that was missing one.
  • Added a canonical Audit Event action catalog.
  • Filled in previously-deferred numbers: rate limits, the idempotency-key TTL (24h), and the version-deprecation window (6 months) — see Non-Functional Requirements.
  • Added the me self-addressing convention and a credential-vs-resource-ID distinction to Conventions.
  • Added Quickstart (a runnable walkthrough) and this Changelog.
  • Fixed: the Audit endpoint’s documented filter name (user_id → target_user_id) didn’t match its own example.
  • Added Deployment Architecture: first-deployment and scale-out plans on AWS, replacing an earlier Vercel-based plan, designed around predictable, capped cost rather than deploy-and-see.
  • Reframed the product. Substratal Apps is the user/organization/tenancy/settings/storage API layer an app developer builds on — not a storefront a customer browses. See Home and What Substratal Apps actually is. Every page that described the platform as “the hub a customer lands on to reach apps they purchased” was describing the eventual onboarding UI, not the API’s own purpose.
  • Added owner_user_id/owner_organization_id and review_status to Application — a real gap once outside developers can register their own apps: there was no concept of who owns an Application’s catalog entry, only platform-admin control. API Reference → Applications now distinguishes an owner’s self-service rights from platform moderation.
  • Added Trust Model → Trust runs the other direction too: the existing API Key scoping and webhook signing already made third-party apps safe to onboard later — now stated explicitly instead of left implicit.
  • Resolved Decisions #2 (Organizations) and #4 (Billing system of record) based on product clarification: both individual and team end users are expected (Organizations pulled forward to Phase 2), and each Application’s developer owns their own billing relationship — Substratal Apps was never going to be a payment processor. Added three new open decisions: #8 Storage primitive scope (now Database Schema → Typed fields), #9 App developer/publisher model (now Applications → The review lifecycle), #10 Compliance scope (now Compliance).
  • Added Compliance & Data Protection: a calibrated recommendation on SOC 2 (design the posture now, defer the formal audit), GDPR/CCPA (not optional, build for it now), and HIPAA (don’t build for it speculatively — it’s a function of which apps join the platform, not the core product).
  • Added Database Schema: Postgres types, constraints, and indexes for every entity — the implementer-facing counterpart to the API-caller-facing field tables.
  • Added implementer-readiness gaps across Non-Functional Requirements (concurrency control via ETag/If-Match, transaction boundaries, the concrete Postgres Row-Level Security multi-tenancy mechanism, enum forward-compatibility policy, request tracing, testing strategy) and Conventions (test-vs-live API keys, multi-field validation error shape, default exclusion of soft-deleted/terminal records from lists, idempotency-key storage detail).
  • Added Webhooks → Verifying the signature: the concrete Substratal-Signature: t=…,v1=… header format and replay-window guidance — “HMAC-SHA256 over the raw body” wasn’t enough to actually implement against.
  • Added Deployment Architecture → Local development (the RDS Data API choice has no clean local emulator — addressed via a repository-pattern seam, not by changing the AWS choice) and → Growth trajectory (the stated dozens → 10x → 100x plan mapped against Tier 0 and the Scale-out triggers).
  • Added Tenant: the infrastructure-placement and data-isolation entity “tenancy” named in the product pitch but previously unspecified. Tied to the subscription — the owner of an Application’s catalog entry (owner_user_id/owner_organization_id), not to Organization, which stays a pure grouping/domain object with no infrastructure meaning. Three tiers (shared default, isolated, dedicated_region), support-gated tier changes via tenants.manage and POST /v1/tenants/{id}/tier-change-requests, a scheduled-downtime migration process, and deliberately no reach into a downstream Application’s own infrastructure. See Decisions #11 and #12, API Reference → Tenancy, Deployment Architecture → Tenancy tiers, and the new tenants/organization_memberships tables in Database Schema. Directly resolves Compliance’s previously-open data-residency gap.
  • Replaced User.organization_id with OrganizationMembership, a many-to-many join — a User can belong to more than one Organization at once (even across different Tenants), which a single column couldn’t express. Carries role: org_admin | member, which also resolves Organizations → Delegated admin’s previously-unresolved question: an org admin is this field, not a third Role scope.
  • Added member_scope/member_overrides to org-wide Entitlements and resolved Decision #13: an Organization’s own grant is authoritative over its own members (an allowlist/denylist an org admin controls), but never reaches into a member’s separate, personally-sourced Entitlement to the same app. Access Control → Organization vs. User precedence has the formal resolution rule and a worked multi-org example; Entitlements → Attribution shows the granted_via shape that surfaces which Organization is responsible for a given member’s resolved access.
  • Resolved Decision #8: storage stays Postgres jsonb as the source of truth, with a Postgres generated column backing every field an Application has declared in its settings_schema — typed and indexed, without giving up the blob’s flexibility for undeclared fields.
  • Added Deployment Architecture → Database engine: AWS options compared: RDS vs. Aurora Provisioned vs. Aurora Serverless v2, evaluated for lowest starting price. Aurora Serverless v2 stays the pick — not the cheapest floor in isolation (~$30/month more than bare RDS), but the only option that keeps one connection mechanism across every Tenancy tier and avoids a VPC/NAT Gateway entirely; the comparison and the reasoning are now explicit rather than asserted.
  • Added MCP Server: a Model Context Protocol server letting an AI agent call the Platform API as typed tools instead of hand-rolled HTTP calls. Introduces no new authorization model — every tool call forwards the caller’s own Bearer credential (user token or API Key) into the same REST call and the same Access Control check. The tool list is generated from openapi.yaml’s operationIds rather than hand-authored, so it tracks the REST API automatically — see Roadmap → The MCP server tracks the REST API automatically. Added operationId to all 55 operations in openapi.yaml (none had one before) specifically so this generation scheme is buildable today, not aspirational. Deployment Architecture → MCP server specifies the AWS build-out: a second, dedicated Lambda behind the existing API Gateway at /mcp (no new domain, no VPC, no database — fully stateless at Tier 0), with named scale-out triggers (Provisioned Concurrency, a Lambda Function URL with streaming responses for server-push/resumability, a small DynamoDB table if session state ever outgrows a signed token). Added Decision #14, still open: whether agent callers should get a recommended (or eventually required) narrower API Key scoping convention, given an LLM’s tool-call decision is a different risk shape from deterministic service code.

  • Added Pricing: three subscription tiers for what Substratal itself charges the Application-owner developer (Starter free, Team per-seat, Enterprise with a choice of Tenant tier as a priced option — shared included, isolated +$750/month, dedicated_region +$1,500/month) — distinct from what that developer charges their own end users, per Decision #4. Added plan (starter|team|enterprise) to Tenant, independent of tier, with a database constraint tying isolated/dedicated_region to enterprise only. Added Decision #15: how Substratal bills this subscription is still open (leans Stripe Billing), with no corresponding API surface built yet. Cites the AWS Pricing, Billing and Cost Management, and Postgres MCP servers as the tooling to keep these numbers grounded in real infrastructure cost over time.

2026-10-03

  • Initial publication: domain model, access control, full API reference, workflows, trust model, non-functional requirements, roadmap, open decisions, and glossary — elaborated from the original single-document draft at docs/specs/Substratal-Hub-Access-API.md into this site.

Back to top

Substratal Apps Platform API — living specification. This site is the system of record; see git history for how it has changed over time.

This site uses Just the Docs, a documentation theme for Jekyll.