Changelog
What’s changed in this specification over time. Questions still awaiting a decision are tracked in the repo’s DECISIONS.md and marked with an “Awaiting decision” callout on the pages they affect; this page tracks what’s already changed. When a decision is made, add a new dated entry here rather than editing an old one.
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
DECISIONS.md: 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 (now in DECISIONS.md, 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.