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-27 — Third-party Applications set their own access and pricing

A developer who adds their own Application charges however they want, with any processor or none, or makes it free, and uses Substratal Apps for distribution, accounts, and settings. Substratal’s Free/Standard subscription covers Substratal’s own Applications only. openapi.yaml is now at 1.0.0-draft.29, with 143 operations.

  • first_party marks Substratal’s own Applications. Only applications.manage sets it. It isn’t inferred from the owner, because staff also create customers’ Applications (concierge onboarding), and those aren’t first-party.
  • access_tier gains restricted: only the people the developer grants Entitlements to get in, and the platform keeps no rows for it. free is still everyone. standard is now only for first-party Applications: anything else is 422 access_tier_reserved, for staff too, and a database check enforces it.
  • The developer chooses. An Application that isn’t first-party, self-registered or staff-onboarded, starts restricted, and its owner sets free or restricted. They can change it only while the Application is approved or pending_review (409 application_not_available otherwise, so a suspension can’t be bypassed), and at most 10 times a day. First-party Applications default to standard, and only staff change their tier.
  • Migration 0024_access_tier_restricted adds first_party, marks every existing staff-created Application first-party (until now staff created only Substratal’s own), and moves existing self-registered standard Applications to restricted. Standard subscribers lose the access Standard gave them to those Applications at the next hourly reconcile; the developers’ own grants are untouched.
  • Rewritten to match: Home, Pricing (new Third-party Applications section, and Standard’s description), Orders & Audit → Billing system of record, Applications, Entitlements, the Trust Model, and the developer and user Terms. They no longer say Substratal builds every Application.

2026-10-27 — Confirming a Checkout on return

Clients can finish a subscription without waiting for Stripe’s webhook. openapi.yaml is now at 1.0.0-draft.28, with 143 operations.

  • POST /users/me/billing/checkout-sessions/{session_id}/confirm and the Organization form read the Checkout Session from Stripe and, if it’s complete, apply the subscription at once (the same sync the webhook runs, access included). They return the matching GET …/subscription body. An open or expired session changes nothing; someone else’s is 404 checkout_session_not_found. See Billing → Confirming a Checkout.
  • success_url gets session_id. The API adds session_id={CHECKOUT_SESSION_ID} for Stripe to fill in, unless the URL already has the template. The Billing page used to say Stripe added it by itself; it doesn’t.
  • Unknown subscriptions are ignored. A Stripe event for a subscription this deployment doesn’t know is now marked processed instead of retried forever. Local development and production share the Stripe test account, so each sees the other’s events.
  • make stripe-listen passes the event list that Stripe CLI 1.55 requires.

    2026-10-27 — Hosted avatars and Application icons

The platform now hosts avatars and Application icons at https://assets.substratalapps.com, so no client has to host images itself (#87). Until now avatar_url and icon_url only stored a URL someone else hosted, and the Hub’s Content Security Policy only allows images from that origin. openapi.yaml is now at 1.0.0-draft.27, with six new operations. Migration 0021_image_audit_actions.

  • Upload, then confirm. POST /users/{id}/avatar/uploads (self or users.manage) and POST /applications/{id}/icon/uploads (the owner or applications.manage) take { "content_type", "content_length" } and return an ImageUpload: a presigned POST, valid 5 minutes, that the client sends the file to directly. The API never handles the bytes on the way in. POST …/uploads/{uploadId}/complete checks the file and sets avatar_url or icon_url to a content-addressed URL, returning the updated Profile or Application. See Profiles → Avatar uploads and Applications → Icon uploads.
  • Checks. PNG, JPEG, or WebP; avatars up to 2 MB, icons up to 1 MB; both at most 2048 × 2048, and icons square. The magic bytes must match the declared type, and dimensions are read from the header without decoding the image. A failure is 422 invalid_image with details.reason, and the file is deleted. A missing or already-used upload is 404 upload_not_found. 10 upload starts per caller per hour. See NFR → Image uploads.
  • Removing. DELETE /users/{id}/avatar and DELETE /applications/{id}/icon clear the URL and delete the hosted image (204, destructive). Replacing an image, or a PATCH that moves avatar_url or icon_url off a hosted image, deletes the old one too. PATCH still accepts any https URL, for compatibility; an external URL is never touched.
  • Lifecycle. Erasure deletes the avatar with the Profile, and the export already carries avatar_url. Test-mode images live under a separate test/ prefix; the 30-day test-data purge deletes test Users’ avatars, and deleting a test-mode Application deletes its icon.
  • Audit. profile.avatar_changed when someone other than the User changes their avatar, matching profile.updated; application.icon_changed on every icon change, matching application.updated. Migration 0021_image_audit_actions adds both to the action check. Expand-only.
  • Infrastructure. A private, versioned assets bucket behind CloudFront with Origin Access Control, which can read only the checked-image prefixes, never uploads/; an ACM certificate and A/AAAA records for assets.substratalapps.com. See infra/README.md → Hosted images.

2026-10-27 — Usage and stats

Two new reads, so a console’s home screen loads its counts in one call and an Application’s owner can chart their own requests and error rate (#44). openapi.yaml is now at 1.0.0-draft.26, with 130 operations and a new Usage & stats tag. Migration 0020_usage_daily. See Usage & stats.

  • GET /usage returns requests, 4xx and 5xx errors, and mean and maximum latency per day, week (ISO, from Monday) or month, in UTC. Rows can be split by group_by=application|api_key|operation (repeatable) and filtered by application_id, api_key_id and operation. since and until are dates (until exclusive). They default to the last 30 days including today, and the range can be at most 13 months.
  • Who can read usage:
    • Platform operations.view sees everything.
    • An app-confined API Key sees only its own Application, with no particular permission needed.
    • An Application’s owner sees their Application by passing application_id.
    • Anyone else gets 403.
  • GET /stats returns Users, Applications (by review_status and access_tier), Entitlements, covering subscriptions (by subscriber type), Organizations, webhook subscriptions and push credentials, each by status, plus open tier-change requests.
    • Every known status is present, 0 included.
    • Counts are computed at most once a minute per server (generated_at).
    • Platform-wide needs operations.view or users.list.
    • With tenant_id, the counts are one Tenant’s, and that Tenant’s owner may call it too. users, subscriptions and organizations are then null.
  • Where usage comes from. A new hourly job, usage-rollup, rolls the request log up into usage_daily with one Logs Insights query over the UTC day so far. It replaces that day’s rows on every run, so a re-run never double-counts. Usage is kept 13 months, and test-mode usage 30 days. See infra/README.md → Usage rollup.
  • Migration 0020_usage_daily adds the table, unique NULLS NOT DISTINCT on (day, test_mode, tenant_id, application_id, api_key_id, operation). Expand-only. See Database Schema → usage_daily.

2026-10-27 — Per-key rate limits

An API Key can carry its own per-minute limit, so a high-volume service integration (billing) gets more room from the console instead of a code change, as NFR → Rate limiting always recommended (#49). openapi.yaml is now at 1.0.0-draft.25, with no new operations. Migration 0019_api_key_rate_limit.

  • rate_limit_per_minute on the API Key object: 1–10,000, or null (the default) for the default 100/minute. Set on POST /api-keys or PATCH /api-keys/{id}; null on a PATCH restores the default. Out of range is 422 validation_failed (out_of_range). See API Keys → Per-key rate limits.
  • Only platform api_keys.manage may send it. An Application’s owner still manages their app-scoped keys, but sending this field, even null, is 403 forbidden, so they can’t raise their own key’s limit. A change writes api_key.updated with before and after.
  • It replaces the default bucket only. An operation with its own per-minute limit (POST /users at 20, POST /users/{id}/notifications at 600, …) keeps it for that key, and per-hour and per-day limits still apply. NFR → Rate limiting now lists the order in which a request’s bucket is chosen: the operation’s own limit, then the key’s override, then the staff session limit (never for keys), then the default.
  • Migration 0019_api_key_rate_limit adds the nullable api_keys.rate_limit_per_minute with a 1–10,000 check. Expand-only.

2026-10-27 — Staff MFA, and step-up for sensitive operations

Staff must use two-step verification, and the most sensitive operations need a recent second factor (#50). Before this, a stolen staff password without MFA meant full platform control. openapi.yaml is now at 1.0.0-draft.24, with 131 operations. Migration 0022_session_mfa_verified_at.

  • Staff MFA. Staff are Users holding any platform Role that grants a permission (as for the staff rate limit). A staff User without TOTP can still sign in, so nobody is locked out, including the protected superadmin. Their session, though, can only read itself (GET /users/{id}, GET /users/{id}/sessions), enroll and confirm TOTP, change its password, log out, and read GET /platform/settings. Everything else is 403 mfa_enrollment_required. Assigning a staff Role to a User without TOTP is allowed, and the Role takes effect once they enroll. See Auth → Staff MFA.
  • mfa_enrollment_required (boolean) is new on the User object, and on the POST /users/{id}/roles/{roleId} response.
  • Grace period: enforcement starts 2026-11-05T00:00:00Z in production (STAFF_MFA_ENFORCE_AFTER, a Terraform variable). Until then, staff without TOTP keep working and aren’t asked to step up, but mfa_enrollment_required already says true. Every staff member, the protected superadmin included, should enroll before that date. Unset (local development, tests), enforcement is on from the start.
  • Step-up. Creating a platform-scoped API Key, assigning or removing a platform Role, PATCH /platform/settings, and deleting, suspending, or erasing a User (DELETE /users/{id}, POST /users/{id}/suspend, PATCH /users/{id} to suspended, POST /users/{id}/erasure-requests) need a second factor proved by the session in the last 10 minutes. Otherwise they return 403 reauthentication_required with details.max_age_seconds: 600. It applies to User sessions whose User has TOTP. API Keys aren’t affected. openapi.yaml marks these operations with the new x-substratal-step-up extension (always or conditional, with x-substratal-step-up-when). See Auth → Step-up.
  • POST /auth/reauthenticate (User token) takes exactly one of code (TOTP) or recovery_code, with the same checks as POST /auth/mfa/verify. It sets the session’s mfa_verified_at and returns a new access token. Wrong codes lock it after 5 in 15 minutes (429 too_many_attempts). See Auth → POST /auth/reauthenticate.
  • mfa_verified_at is recorded on sessions at MFA sign-in, at TOTP enrollment confirm, and at re-authentication. It’s carried through refresh, and access tokens carry it as a claim (Unix seconds) next to amr, so a console can prompt before calling rather than after.
  • Read-only maintenance now also leaves open POST /auth/reauthenticate and TOTP enrollment, so an operator can still lift a freeze.
  • Migration 0022_session_mfa_verified_at adds the nullable sessions.mfa_verified_at. Expand-only.

2026-10-27 — Platform settings: closing signup, read-only maintenance, client flags

Operators can now close signup or freeze writes from the console without a deploy, and Hub clients read their feature flags from the API (#48). New page: Platform settings. openapi.yaml is now at 1.0.0-draft.23, with 130 operations. Migration 0018_platform_settings.

  • GET /platform/settings, authentication optional. Anyone gets signup_open and announcement, live by default or test mode with ?test_mode=true. An authenticated caller gets its own mode’s settings plus client_flags. operations.view also gets read_only and updated_at, read fresh. Everyone else may get a copy up to 30 seconds old.
  • PATCH /platform/settings needs operations.manage and If-Match (400 if_match_required without it). client_flags is merged one level deep, and a flag sent as null is removed. announcement is replaced, and null clears it. A field the object doesn’t have is 422 (undeclared_key). Every change is audited as platform.settings_updated, a new action (target type platform_settings), with before and after. Changes reach every API instance within 30 seconds.
  • signup_open: false: POST /auth/signup returns 403 signup_closed. Invitations still work. See Auth → POST /auth/signup.
  • read_only: true: every request other than a GET returns 503 service_unavailable with Retry-After: 300 and details.reason: "read_only", the answer a Tenant mid-migration already gives. A User holding superadmin bypasses it; API Keys never do. Sign-in, MFA, refresh, logout, revoking sessions, app tokens, OAuth codes, and token exchange stay open, so signed-in Users keep using their Applications. PATCH /platform/settings also stays open, so an operator can lift the freeze. SCIM writes are refused too. See Read-only maintenance.
  • Settings are per mode: a test-mode credential changes only test mode’s switches.
  • Migration 0018_platform_settings adds the table with one seeded row per mode, and adds platform.settings_updated to the audit action check (not valid, like the others). Expand-only.

2026-10-27 — Read a User’s pending erasure request

GET /users/{id}/erasure-requests/current returns the scheduled erasure request, so a console can show when the cascade will run and offer Cancel erasure only inside the 7-day window (#88). openapi.yaml is now at 1.0.0-draft.22, with 130 operations. No migration.

  • Response: the existing ErasureRequest (user_id, status: scheduled, requested_at, scheduled_for). When nothing is pending (never requested, cancelled, or completed) it’s 404 erasure_not_scheduled, the same code cancel returns. See Users → GET /users/{id}/erasure-requests/current.
  • Permission: self or users.manage. A soft-deleted User is 404 user_not_found for anyone without users.manage, as on cancel. Read-only: not destructive, and no Audit Event.
  • No erasure_scheduled_for field on User; one call from the user overview is enough for now.

2026-10-26 — Filter audit events by request ID

GET /audit-events?request_id= returns the events one request wrote, so an operator can go from a request ID in a log or a support ticket to the changes it made (#97). openapi.yaml is now at 1.0.0-draft.21, still with 128 operations. Migration 0017_audit_events_request_id.

  • request_id matches an event’s request_id exactly: the X-Request-Id the request sent, or the req_… the API gave it. It combines with the other filters. Over 128 characters is 422 validation_failed (too_long), because the API replaces a longer X-Request-Id with its own. GET /audit-events now declares 422, which it already returned for a malformed since or until. See Audit → GET /audit-events.
  • POST /audit-exports takes request_id as a body field too, since its filters are the list’s.
  • Migration 0017_audit_events_request_id adds an index on audit_events (request_id, timestamp desc), like every other audit filter. Expand-only.

2026-10-26 — What an Application’s owner may do with its Roles

Written down, not changed (#93). An Application’s owner (its owner_user_id, or any active org_admin of its owner_organization_id) may GET /roles?scope=<application_id> and PATCH /roles/{id} for that Application’s Roles without any platform permission, as Roles & Permissions already said. openapi.yaml now states the rule on both operations, and PATCH /roles/{id} declares its 404.

  • An owner never sees or changes another Application’s Roles (an empty page, or 404 role_not_found), nor platform Roles: those are invisible without a platform permission (404), and need platform roles.manage to change (403 forbidden).
  • An app Role still holds only its own Application’s declared keys: a platform key, another Application’s key, or an undeclared key is 422 unknown_permission, so an owner can’t grant more than their Application defines.

2026-10-26 — Sorting the Applications list

GET /applications documents its order and takes a sort parameter, so the review queue can ask for oldest first instead of assuming it (#90). openapi.yaml is now at 1.0.0-draft.15, with 128 operations. No migration.

  • Order: by created_at, ties broken by id, newest first. The review queue (?review_status=pending_review with applications.manage) stays oldest first by default.
  • sort is created_at (oldest first) or -created_at (newest first), and overrides either default. Any other value is 422 validation_failed (enum_mismatch). A cursor is bound to the sort it was issued under. See Applications → GET /applications.

2026-10-26 — Owner and holder count on the Applications list

GET /applications rows now carry what a console list needs, so it doesn’t have to make two extra calls per row (#89). openapi.yaml is now at 1.0.0-draft.18, with 128 operations. No migration.

  • ApplicationSummary adds owner_user_id and owner_organization_id, as on Application, and holder_count: the Application’s active Entitlements, where a User grant and an Organization grant count once each. See Applications → GET /applications.
  • Every ApplicationSummary field is now listed as required, since every row always has all of them.

2026-10-26 — When an Application entered review

Applications now record when they entered review, so the console can tell how long a submission has waited (#94). openapi.yaml is now at 1.0.0-draft.17. Migration 0016_application_review_requested_at.

  • review_requested_at (read-only, nullable date-time) is on the Application and on each GET /applications item. It’s set whenever review_status becomes pending_review: when a submission is created, and again each time the owner’s edit resubmits a rejected Application. Every other change keeps it, and it’s null for an Application that has never been in review. Sending it is 422 read_only_field. See Applications → The Application object.
  • The review queue (GET /applications?review_status=pending_review with applications.manage) was oldest first by created_at. It now sorts by review_requested_at, so a resubmitted Application waits behind submissions that arrived before its resubmission. No new sort parameter.
  • The migration adds the column and backfills pending_review Applications from their latest application.review_status_changed audit event into pending_review, or else created_at. Expand-only.

2026-10-26 — Filter webhooks by scope

GET /webhooks takes scope=platform, which returns only platform-scoped subscriptions, paged on the server (#91). A console listing platform webhooks no longer pages through every Application’s subscriptions and drops them in the browser. openapi.yaml is now at 1.0.0-draft.16, with 128 operations.

  • It combines with status, so ?scope=platform&status=unhealthy works.
  • Callers without webhooks.manage can’t see platform subscriptions, so they get an empty page, not 403.
  • It can’t be combined with application_id: 400 invalid_request.

See Webhooks → GET /webhooks.

2026-10-26 — Fetch one webhook delivery

A delivery can be read by its id instead of paging the log to find it (#92). openapi.yaml is now at 1.0.0-draft.15, with 129 operations.

  • GET /webhooks/{id}/deliveries/{deliveryId} returns the same delivery object as GET /webhooks/{id}/deliveries, to the same callers: webhooks.manage, or the owning Application’s owner and its app keys. A delivery that isn’t on that subscription, or is past the 30-day log, is 404 delivery_not_found. See Webhooks → GET /webhooks/{id}/deliveries/{deliveryId}.

2026-10-26 — Owners list their push credentials in one call

GET /push-credentials now serves Application owners, not just applications.manage (#95). openapi.yaml is now at 1.0.0-draft.14, still with 128 operations.

  • A User without applications.manage gets the credentials of the Applications they own (their own, or their Organization’s when they’re an active org_admin), so ?status=failed finds every failed credential across their apps in one call instead of one call per Application. A User who owns none gets an empty list. The status and provider filters, pagination and ?include=total work the same, and key material is still never returned. See Push Notifications → GET /push-credentials.
  • applications.manage is unchanged: it still sees every Application’s credentials.
  • API Keys without applications.manage, including app keys, still get 403 and use GET /applications/{id}/push-credentials.

2026-10-26 — Trusted client IP from first-party backends

First-party web apps call the API server-side, so every person signing in or refreshing through one shared that backend’s per-IP limits, and their Sessions list and the security log showed the backend’s IP. A first-party backend can now forward the person’s IP and user agent in signed headers (X-Substratal-Client-IP, X-Substratal-Client-UA, X-Substratal-BFF-Timestamp, X-Substratal-BFF-Signature). A valid signature means they’re used for per-IP rate limits, session records and the security log; anything else is ignored. See NFR → Authentication.

No public API change: the headers aren’t usable without the first-party secret, and aren’t in openapi.yaml.

2026-10-24 — Retiring Applications, and deleting test-mode ones

An Application’s life can now end (#52). openapi.yaml is now at 1.0.0-draft.13, with 128 operations. Migration 0015_retired_applications.

  • review_status: retired is terminal. applications.manage moves an approved or suspended Application there with PATCH /applications/{id}, and review_notes is required. Nothing leaves it (409 invalid_review_transition). A live Application needs a live-mode credential to retire it (403 live_mode_required). Destructive. See Applications → Retiring an Application.
  • Retiring cascades, in one transaction and in both modes. Platform-managed Entitlements end, every other live one is revoked with disabled_reason: application_retired, API Keys are revoked, webhook subscriptions are disabled, push credentials become disabled (a new status, also accepted by GET /push-credentials?status=), and devices become inactive (new inactive_reason application_retired). access.revoked fires with the new reason application_retired. New audit action application.retired, with counts in after.cut_off. Roles, settings, audit history, and the slug stay. Unlike suspension, which still doesn’t cascade.
  • A retired Application takes no new credentials or access: a new API Key, webhook, or push credential for it, re-enabling one of its webhooks, or re-enabling or renewing one of its Entitlements, is 409 application_retired. App tokens and new grants were already refused for any Application that isn’t approved.
  • GET /applications leaves retired Applications out unless include_inactive=true or review_status=retired.
  • DELETE /applications/{id} deletes a test-mode Application that nothing references in either mode, for applications.manage. A live one is 409 application_not_deletable; a referenced one is 409 application_in_use with details.references. Its Roles go with it, its audit history stays, and it writes the new audit action application.deleted. Destructive.

2026-10-24 — One log line per request

Every API operation now writes one structured request log line with the operation, status, latency, caller, Application, Tenant and error code. This lets operators answer which keys call most, what an Application’s error rate is, and which operations are slow, even though every REST call reaches API Gateway on one catch-all route. The line holds identifiers only: no bodies, emails, tokens, query strings or IPs. See NFR → Observability and infra/README.md → Request log. No API change.

2026-10-24 — Delivery health across subscriptions

Delivery failures and push credential health can be read in one call each, instead of one call per subscription or per Application (#45). openapi.yaml is now at 1.0.0-draft.12, with 127 operations.

  • GET /webhooks?status= filters by healthy, unhealthy, or disabled. Repeatable. See Webhooks → GET /webhooks.
  • GET /webhook-deliveries is the 30-day delivery log across every subscription the caller can see, newest first. Filters: status, since, until, application_id, subscription_id, and event_type. It’s scoped like GET /webhooks: webhooks.manage sees everything, and an Application’s owner and its app keys see only that Application’s. Each item is a delivery plus subscription_id and scope. See Webhooks → GET /webhook-deliveries.
  • GET /push-credentials lists every Application’s push credentials for applications.manage, with status and provider filters. Owners keep the per-Application list. See Push Notifications → GET /push-credentials.
  • Both new lists support ?include=total.
  • Migration 0014_webhook_deliveries_status adds an index on webhook_deliveries (status, created_at desc), so a platform-wide status=failed query doesn’t scan the whole log. Expand-only.

2026-10-24 — operations.view, operations.manage, and the operator role

Operational access is now its own pair of permissions, so a support person can see operations data without being superadmin (#47). No new operations, and openapi.yaml is unchanged: GET /permissions just lists two more keys.

  • operations.view reads operational data: GET /usage and GET /stats when they ship (#44), and the management console’s operations views. operations.manage changes platform settings (PATCH /platform/settings, #48). Both are platform-only: an app-scoped key asking for either gets 422 permission_not_grantable_to_scope. See Platform permission catalog.
  • superadmin now holds both. A new seed Role, operator (role_platform_operator), holds operations.view and audit.view. See Platform role grants.
  • Migration 0013_operations_permissions updates the seed rows in place (expand-only).

2026-10-24 — Staff sessions get 600 reads a minute

A staff session (a User holding a platform Role with at least one permission) now has two buckets: GET requests at 600/minute, and everything else at 100/minute. Opening one user in the console takes 6–10 calls, so ordinary support work was hitting 429 at the old 100/minute combined limit. Non-staff sessions and API Keys keep one combined bucket at 100/minute. Endpoints with their own limits (POST /users, POST /entitlements, …) keep them for everyone. RateLimit-* headers report the bucket the request counted against. See NFR → Rate limiting. No API or schema change.

2026-10-24 — Scheduled job run records

Every scheduled job run now logs one structured job_run line with the job name, start time, duration, outcome, error, and counts of what it did (#46). The line replaces the old “job finished” line and the separate per-job count lines. A failed run raises a CloudWatch alarm that names the job. The jobs, their schedules, and the fields are in infra/README.md → Scheduled jobs. No API, schema, or migration change.

2026-10-24 — The web Hub is at hub.substratalapps.com

The first-party web app, Substratal Hub, is served at https://hub.substratalapps.com instead of the planned dashboard.substratalapps.com, which was never served.

  • Links in verification, password-reset and invitation emails now point to https://hub.substratalapps.com (DASHBOARD_URL).
  • The CORS allow-list’s first-party origin is now https://hub.substratalapps.com. See NFR → CORS.
  • Billing examples and the legal documents name the new host.

No API change: paths, requests and responses are the same.

A new Legal section publishes the agreements at stable URLs, for app stores, sign-in providers, and payment providers that ask for them. Substratal Apps is operated by Composite Code LLC (Washington, USA).

2026-10-23 — Checkout saves the billing address

Checkout now sets customer_update[address]: auto, so the billing address entered in Checkout is saved to the subscriber’s Stripe Customer. Stripe refuses a Checkout with automatic tax when the Customer has no address, and ours are created with only an email, so every Checkout would have failed against real Stripe (the test suite uses a fake). Found while setting up Stripe test mode. No API change. See infra/README.md → Objects and mapping.

2026-10-23 — Abandoned Checkouts expire

A first Checkout that’s never completed no longer leaves its subscription incomplete forever. When Stripe expires the Checkout session (checkout.session.expired, about 24 hours later), the row becomes incomplete_expired, Stripe’s own status for a first payment that never completed, and subscription.status_changed is written. A row Stripe has already linked to a subscription is left alone, and so is one whose subscriber has a newer Checkout still open. The subscriber can check out again as before. Add checkout.session.expired by hand to the Stripe webhook endpoint’s events, in test and live mode; see infra/README.md → Webhook handling. No API or schema change.

2026-10-23 — Optional counts on list responses

Console tables can show “1,204 users” for their current filters without paging everything (#53). openapi.yaml is now at 1.0.0-draft.11. No new operations, and no migration.

  • ?include=total adds page.total and page.total_capped to a list response. The count uses the list’s own filters and visibility, ignores cursor (every page reports the same total), and stops at 10,000: above that, total is 10000 and total_capped is true. Without the parameter, both fields are absent. Any other include value is 422 validation_failed. See Conventions → Pagination.
  • Supported on GET /users, /entitlements, /users/{id}/entitlements, /applications, /organizations, /organizations/{id}/members, /audit-events, /webhooks, /webhooks/{id}/deliveries, /api-keys, /roles, and /subscriptions, each declaring the shared Include parameter. PageInfo gains the two optional fields.
  • Cursors ignore include, so a cursor from a counted page works on an uncounted one and the other way round.

2026-10-23 — A cross-subscriber subscription list

Billing admins can work the past_due list without going to Stripe (#57). openapi.yaml is now at 1.0.0-draft.10, with 125 operations.

  • GET /subscriptions lists every User’s and Organization’s subscription, for billing.manage only. Filters: status (repeatable), subscriber_type, cancel_at_period_end, and current_period_end_before. Cursor-paginated, most recently updated first. Each item is the Subscription object plus user_email or organization_name for display. Revenue and invoices stay in Stripe. No migration: the table holds one row per subscriber and already has a (status) index; an updated_at index can follow if query plans call for it. See Billing → GET /subscriptions.

2026-10-22 — Browsers can read Deprecation and Sunset

Access-Control-Expose-Headers adds Deprecation and Sunset, so a web client can read them on a response to a deprecated version and tell its user to update, as Versioning already promised. No other change. See NFR → CORS.

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.