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 — 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’s404 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 is404 user_not_foundfor anyone withoutusers.manage, as on cancel. Read-only: not destructive, and no Audit Event. - No
erasure_scheduled_forfield onUser; 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_idmatches an event’srequest_idexactly: theX-Request-Idthe request sent, or thereq_…the API gave it. It combines with the other filters. Over 128 characters is422 validation_failed(too_long), because the API replaces a longerX-Request-Idwith its own.GET /audit-eventsnow declares422, which it already returned for a malformedsinceoruntil. See Audit →GET /audit-events.POST /audit-exportstakesrequest_idas a body field too, since its filters are the list’s.- Migration
0017_audit_events_request_idadds an index onaudit_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 platformroles.manageto 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 byid, newest first. The review queue (?review_status=pending_reviewwithapplications.manage) stays oldest first by default. sortiscreated_at(oldest first) or-created_at(newest first), and overrides either default. Any other value is422 validation_failed(enum_mismatch). A cursor is bound to thesortit 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.
ApplicationSummaryaddsowner_user_idandowner_organization_id, as onApplication, andholder_count: the Application’sactiveEntitlements, where a User grant and an Organization grant count once each. See Applications →GET /applications.- Every
ApplicationSummaryfield is now listed asrequired, 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 eachGET /applicationsitem. It’s set wheneverreview_statusbecomespending_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’snullfor an Application that has never been in review. Sending it is422 read_only_field. See Applications → The Application object.- The review queue (
GET /applications?review_status=pending_reviewwithapplications.manage) was oldest first bycreated_at. It now sorts byreview_requested_at, so a resubmitted Application waits behind submissions that arrived before its resubmission. No newsortparameter. - The migration adds the column and backfills
pending_reviewApplications from their latestapplication.review_status_changedaudit event intopending_review, or elsecreated_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=unhealthyworks. - Callers without
webhooks.managecan’t see platform subscriptions, so they get an empty page, not403. - It can’t be combined with
application_id:400 invalid_request.
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 asGET /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, is404 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.managegets the credentials of the Applications they own (their own, or their Organization’s when they’re an activeorg_admin), so?status=failedfinds every failed credential across their apps in one call instead of one call per Application. A User who owns none gets an empty list. Thestatusandproviderfilters, pagination and?include=totalwork the same, and key material is still never returned. See Push Notifications →GET /push-credentials. applications.manageis unchanged: it still sees every Application’s credentials.- API Keys without
applications.manage, including app keys, still get403and useGET /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: retiredis terminal.applications.managemoves anapprovedorsuspendedApplication there withPATCH /applications/{id}, andreview_notesis 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 becomedisabled(a new status, also accepted byGET /push-credentials?status=), and devices becomeinactive(newinactive_reasonapplication_retired).access.revokedfires with the new reasonapplication_retired. New audit actionapplication.retired, with counts inafter.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’tapproved. GET /applicationsleaves retired Applications out unlessinclude_inactive=trueorreview_status=retired.DELETE /applications/{id}deletes a test-mode Application that nothing references in either mode, forapplications.manage. A live one is409 application_not_deletable; a referenced one is409 application_in_usewithdetails.references. Its Roles go with it, its audit history stays, and it writes the new audit actionapplication.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 byhealthy,unhealthy, ordisabled. Repeatable. See Webhooks →GET /webhooks.GET /webhook-deliveriesis the 30-day delivery log across every subscription the caller can see, newest first. Filters:status,since,until,application_id,subscription_id, andevent_type. It’s scoped likeGET /webhooks:webhooks.managesees everything, and an Application’s owner and its app keys see only that Application’s. Each item is a delivery plussubscription_idandscope. See Webhooks →GET /webhook-deliveries.GET /push-credentialslists every Application’s push credentials forapplications.manage, withstatusandproviderfilters. Owners keep the per-Application list. See Push Notifications →GET /push-credentials.- Both new lists support
?include=total. - Migration
0014_webhook_deliveries_statusadds an index onwebhook_deliveries (status, created_at desc), so a platform-widestatus=failedquery 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.viewreads operational data:GET /usageandGET /statswhen they ship (#44), and the management console’s operations views.operations.managechanges platform settings (PATCH /platform/settings, #48). Both are platform-only: an app-scoped key asking for either gets422 permission_not_grantable_to_scope. See Platform permission catalog.superadminnow holds both. A new seed Role,operator(role_platform_operator), holdsoperations.viewandaudit.view. See Platform role grants.- Migration
0013_operations_permissionsupdates 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.
2026-10-23 — Legal documents and contact addresses
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).
- Terms of Service, Privacy Policy, End User License Agreement for the six Hub apps, Developer and API Terms, Acceptable Use Policy, Data Processing Addendum (with the EU SCCs and UK Addendum by reference), Sub-processors, Cookie Notice, Account and Data Deletion, Security and Vulnerability Disclosure, and Copyright, Licenses and Trademarks.
- Contact addresses
privacy@,legal@andsecurity@substratalapps.comnow receive mail, as doesdmarc@for DMARC reports. SES receives them on the apex domain and forwards them to the operator’s inbox (newmail-forwardfunction; stored copies are deleted after 30 days). - Compliance now points to these documents: lawful basis and consent are covered by the Developer Terms, and the sub-processor agreements are in place.
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=totaladdspage.totalandpage.total_cappedto a list response. The count uses the list’s own filters and visibility, ignorescursor(every page reports the same total), and stops at 10,000: above that,totalis10000andtotal_cappedistrue. Without the parameter, both fields are absent. Any otherincludevalue is422 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 sharedIncludeparameter.PageInfogains 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 /subscriptionslists every User’s and Organization’s subscription, forbilling.manageonly. Filters:status(repeatable),subscriber_type,cancel_at_period_end, andcurrent_period_end_before. Cursor-paginated, most recently updated first. Each item is the Subscription object plususer_emailororganization_namefor display. Revenue and invoices stay in Stripe. No migration: the table holds one row per subscriber and already has a(status)index; anupdated_atindex 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-AfterandAudit-Hot-Window-Start. - Preflights are cached for 2 hours instead of 10 minutes.
- An API Key sent with an
Originheader is refused with the new400 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.
- Browsers can now read
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-allsigns a User out everywhere, by the User or ausers.managecaller, and returns{"revoked": n}.keep_currentkeeps the calling session, self only. Newrevoked_reasonrevoked_alland audit actionuser.sessions_revoked(migration0012_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-exportstakes the same filters asGET /audit-events, as body fields, plusformat(ndjsonorcsv). It confines the caller to the same scope as the list, requiresIdempotency-Key, and returns202. 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’sready, a download link signed for 15 minutes. Only the requester andaudit.viewcan see an export. Files and records are deleted after 7 days.- New audit action
audit.exported(target typeaudit_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 anaudit-exportsschedule 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_adminof. It starts inpending_review,public, andstandard, and the moderation fields are staff-only (403 moderation_field_forbidden). An unverified email is the new403 email_not_verified. Each User can submit 10 a day. Staff creation is unchanged. - Applications gain
self_registered(read-only, migration0010_self_registered_apps). A self-registered Application is third-party, even once approved:POST /auth/app-tokensrefuses it with the new403 embedded_login_not_allowed, as Auth → Getting an app token already required once self-service shipped. It must use the hosted flow.- Only
applications.managecan turn on itsreceives_platform_notifications, so platform security alerts never go out under an unvetted app.
- Platform-managed Entitlements wait for approval.
freeandsubscriptionrows are granted only to an Application that has been approved, and approving one grants them. Before this, astandardsubmission would have been granted to every Standard subscriber the moment it was created, firingaccess.grantedto 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, andexpires_atonPOST /auth/signup,POST /users, andPATCH /users/{id}.user.expiredin the webhook event and audit action enums,test_user_expiredandtest_user_extendedin the access-event reasons, andtest_user_expiredin the device inactive reasons.POST /auth/token/refreshandPOST /auth/mfa/verifynow declare403, which they return fortest_user_expired(and, formfa/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_atis reported like any other field error:422 validation_failedwith the code indetails.fields, not a separatedetails.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 return403 test_user_expired. - Extending a User the expiry sweep hasn’t processed yet fires no
access.granted, since noaccess.revokedwent out for them either. - Migration
0009_test_user_expirysets every existing test-mode User’sexpires_attocreated_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.yamldeclaresSubstratal-Version. Every versioned operation now carries the sharedApiVersionheader parameter (Substratal-Version, enum1) and documents its400(api_version_required,unsupported_api_version). Generated clients learn the header from the spec instead of needing it configured by hand.GET /healthandPOST /auth/oauth/tokendon’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 examplePUT /health) now returns405with anAllowheader anddetails.allowed, instead of the catch-all404 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, andDELETE /organizations/{id}/scim-token(org admin ororganizations.manage). There’s one token per Organization, and its secret is shown once. New audit actionsorganization.scim_token_createdandorganization.scim_token_revoked. Migration0008_scim_tokens. /scim/v2servesServiceProviderConfig,ResourceTypes, andUsers(list with auserName eqfilter, create, get, put, patch, delete), in SCIM’s own response and error shapes. These endpoints follow RFC 7644, notopenapi.yaml, like/.well-known, and are exempt fromSubstratal-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_adminguard applies. Writes are audited with actor typesystemand 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) andSunset(RFC 8594) appear on responses to requests that name a deprecated version. After itsSunsetdate, requests naming it get400 unsupported_api_version. - Two separate versions. The
Substratal-Versionrequest header and a webhook subscription’s payloadapi_versionare independent, now stated on Conventions, Webhooks, and in the Glossary. - OpenAPI Specification no longer describes versions as URL prefixes, and Auth →
POST /auth/oauth/tokennotes 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/usersis nowPOST /users,GET /v1/healthisGET /health, and so on for every operation./v1/…paths now return404 not_found; there’s no alias or redirect. Inopenapi.yaml, the server URL ishttps://api.substratalapps.com, and thepathsare unchanged.openid-configuration’stoken_endpointis/auth/oauth/token. Substratal-Versionis required. Versions are whole numbers, and1is the only one. A missing header is the new400 api_version_required, and an unknown value is the new400 unsupported_api_version. Both list the served versions indetails.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
DeprecationandSunsetheaders) and webhook payload versions (api_versiondates, versioned independently)./.well-known,/mcp, and/internalare 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 isnullon every live one. The default is 24 hours after creation.POST /v1/userswith asatk_test_key, and signup with"test_mode": true, acceptexpires_atfor a shorter or longer run: at least 15 minutes away, and at most 30 days after creation, the existing test-data purge horizon.users.managecan move it withPATCH /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’sexpis ever later than the User’sexpires_at. The 5-minute entitlement-expiry sweep then revokes sessions and devices (reasontest_user_expired), firesaccess.revoked(reasontest_user_expired) and the newuser.expiredwebhook, and writes theuser.expiredAudit 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 is404 push_credential_not_found. - A push-credential
POSTvalidates its fields before checking whether the provider is already configured, so an invalid body is422even 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_grantedplatform alert is raised only for access a developer or admin grants, never for platform-managedfreeorsubscriptionEntitlements. 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.mdis 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-sessionsand…/portal-sessions, andGET/PATCH /v1/organizations/{id}/subscriptionwith 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 withmember_scope. - Access stays Entitlement-based.
Entitlement.sourcegainsfreeandsubscription, 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 returns409 entitlement_platform_managed. Entitlements gainsubscription_id. -
Applications gain access_tier(freestandard, defaultstandard), a moderation field with anaccess_tierlist filter. Users gainplanandplan_sources. - Removed: Tenant
plan,subscription_status, andrestricted(and theplan/restrictedlist 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_reachedis reserved for future business and enterprise limits. - Audit:
subscription.created,subscription.status_changed, andsubscription.members_changed(target typesubscription) replacetenant.plan_changedandtenant.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, andpush.statussays what happened. The recipient must have active access to the Application, and a User who setsnotifications.push: falsein 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.*andaccount.*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 newreceives_platform_notificationsfield. - Everywhere else it touches: 17 operations under a new Notifications tag in
openapi.yaml(info.version1.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,
isolatedanddedicated_regiontenancy 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}$, withplatformdocumented 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
invitedUser’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 returns409 email_taken(details.reason: "invitation_pending") and re-sends the invitation. See Auth → Signup. - Organization invitations are now
pendinguntil 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 apendingmembership whose response looks the same either way. Adds byuser_idare restricted toorganizations.manage. A newPOST /v1/organizations/{id}/members/me/acceptendpoint and audit actionorganization.member_invitedsupport 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_testis 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
_), andplatformis a reserved slug. Application ids are documented asapp_<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(whichsupportholds), notusers.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 oninclude_inactive. - Webhook
DELETEdrops 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.updatedandrole_assignmenttargets are defined precisely. - Erasure: a soft-deleted User can be restored (
deleted → active) or have erasure requested byusers.manage; the cascade also clears memberships andmember_overrides. Export and the cascade are described accurately (export includes global Settings; erasure leaves it). application_not_availableis409everywhere. The implicitdefault_app_roleis listed only while access is active.Profile.localeis a read-only mirror ofSettings.locale. Settings resolution is described as the two disjoint chains it actually is.- Credential storage (webhook secrets are KMS-encrypted, recovery codes Argon2id),
order_idexamples, and the supporting-entity count/ER diagram corrected. Every example id is now a valid ULID.
Specified (previously missing)
- Entitlements:
disabled → expired, renewal conflicts, ascheduledresolved status for futurestarts_atand anaccess.grantedreasonentitlement_started, and which excluded org rows a member sees. 403 tenant_suspended;409 idempotency_key_in_flightin the global table; theplan_limit_reacheddetailsshape, with usage keys renamed to match (app_roles,webhooks).- The hosted flow’s authorization endpoint (
authorization_endpointin OIDC discovery); MFA disable with a recovery code; what a session’sexpires_atmeans. - 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.testdelivery 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_idas 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-permissionstoGET /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. TheoperationId(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
plantostarter” violatedcheck (plan != 'starter' or owner_type = 'user')for Organization-owned Tenants. That constraint also referenced a nonexistentowner_typecolumn, now added. Replaced by restricted mode (#24). - Order was modeled as an entity (
ord_prefix,billing.manage= “Manage Orders”, a nonexistent/v1/webhooks/incoming/billingin Workflows), even though orders are the developer’s.order_idis now an opaque reference, andbilling.*permissions cover platform billing (#20). - Revocation gaps: the required webhooks missed org-member removal,
member_scopeexclusions, org-grant changes, and user suspension/deletion. The access algorithm didn’t check user status at all. Added derivedaccess.granted/access.revokedwebhooks anduser.statustoallow()(#19). - Platform permissions leaked into app JWTs:
effective_permissionsunioned 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’sPageschema putnext_cursor/has_moreat the top level, while every prose example nests them underpage. Fixed (PageEnvelope).- Workflows emitted
entitlement.revokedfor a disable. Webhook retries said “up to 5 attempts” with 5 retry intervals, which is 6. Fixed. tenants.managewas missing from the permissions/roles examples. The Applicationssettings_schemaexample had nodefault, yet every resolved example used one.review_notes_requiredwas400in violation of the 400/422 mapping (now422). 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-tokensnow; 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,implicitassignments. Organizations: self-service create, member add/role-change/leave, last-admin guard. Tenancy: tier-change request lifecycle (scheduledadded), owner visibility, region list (#27). Webhooks: get/update, secret rotation with overlap, test, delivery log, redeliver,api_versionpinning, 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_schemarules,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 inopenapi.yaml. - Database Schema: sessions, tokens, recovery codes, webhook outbox/deliveries, tier-change and erasure requests, Stripe events,
versioncolumns for ETags, test-mode isolation (#30), and corrected entitlement uniqueness. openapi.yamlrewritten: 97 operations (from 56; every existingoperationIdunchanged), typed error responses on every operation, andx-substratal-destructive/x-substratal-phaseextensions. 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/v1path 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_destructivealready uses), returning409/code: "plan_limit_reached". New database constraint:check (plan != 'starter' or owner_type = 'user')ontenants. - 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/afterdropped 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. RootDEPLOYMENT.md→ Audit log archival specifies the AWS mechanics (EventBridge Scheduler → Lambda → S3 Glacier Deep Archive). - Fixed Orders & Audit → Action catalog: added
tenant.tier_change_requestedandtenant.tier_changed, both already live inopenapi.yaml’sAuditActionenum 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) toApiKeyWrite/ApiKeyinopenapi.yaml— present in the database schema and every prose example since API Keys was written, absent from the YAML. - Added
planto theTenantschema inopenapi.yaml(newTenantPlanenum) — 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-Keytoorganizations.grantEntitlementinopenapi.yaml— the near-identicalentitlements.grantalready required it; this org-wide variant had been missed. - Added
entitlement.member_scope_changedto theWebhookEventenum inopenapi.yamland 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:
DELETEnow rejects (409/code: "entitlement_order_linked") wheneverorder_idis set, for every caller — not just arestrict_destructiveagent 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_idis now exactly one, never neither — closing a real bug where a theoretical ownerless “system app” had no owner fortenant_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 matchingchecktoapplicationsin 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
DELETEactually 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 reachstatus: suspended— addedGET/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 the409-vs-422distinction, 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 foractionvalues. - 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, andUserRoleAssignment, which the original count silently omitted. - Moved the original single-document draft from
docs/specs/Substratal-Hub-Access-API.mdto rootORIGINAL-SPEC-DRAFT.md, with a superseded banner — it contradicts several current decisions structurally (no Tenant concept, single-method auth) and sitting insidedocs/risked it being mistaken for current spec despite being excluded from the build. - Updated root
README.mdandPLAN.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
meself-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_idandreview_statusto 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 (shareddefault,isolated,dedicated_region), support-gated tier changes viatenants.manageandPOST /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 newtenants/organization_membershipstables in Database Schema. Directly resolves Compliance’s previously-open data-residency gap. - Replaced
User.organization_idwith 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. Carriesrole: 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_overridesto org-wide Entitlements and resolved Decision #13: an Organization’s own grant is authoritative over its own members (anallowlist/denylistan 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 thegranted_viashape that surfaces which Organization is responsible for a given member’s resolved access. - Resolved Decision #8: storage stays Postgres
jsonbas the source of truth, with a Postgres generated column backing every field an Application has declared in itssettings_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’soperationIds rather than hand-authored, so it tracks the REST API automatically — see Roadmap → The MCP server tracks the REST API automatically. AddedoperationIdto 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
tieras a priced option —sharedincluded,isolated+$750/month,dedicated_region+$1,500/month) — distinct from what that developer charges their own end users, per Decision #4. Addedplan(starter|team|enterprise) to Tenant, independent oftier, with a database constraint tyingisolated/dedicated_regiontoenterpriseonly. 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.mdinto this site.