ADR 37: PDS Entitlement Proxy and Payment-Provider-Agnostic Billing
Status: Proposed
Date: 2026-06-09
Last Updated: 2026-06-11
Terms (this ADR)
| ID | Term | Meaning |
|---|---|---|
| Rn | Functional requirement | Numbered obligation (R1–R19 in this ADR). |
| NRn | Non-functional requirement | Quality attribute (NR1–NR8). |
| Cn | Constraint | Non-negotiable boundary (C1–C11). |
| CCn | Cross-cutting challenge | Risk spanning components (CC1–CC9). |
| Entitlement service | Runtime authority | Postgres-backed store + internal API answering allow/deny for a DID; not the payment provider. |
| Billing adapter | Inbound port | Maps checkout / webhook events from a payment provider into entitlement rows (Stripe is one adapter). |
| PDS repo authz proxy | Enforcement edge | HTTP proxy in front of Substratum-operated PDS repo mutation XRPC; consults entitlement service before forwarding. |
| Capability | Entitlement flag | Boolean (or enum) grant on a DID, e.g. metadata_write_allowed, safety_net_allowed. |
| Substratum lexicon | NSID prefix | cloud.substratum.* collections governed by this ADR (ADR 29 allowlist). |
| Payment provider | External PSP | Stripe, Paddle, regional acquirer, manual ops — never on the PDS or gateway hot path. |
Canonical product vocabulary: Glossary.
Context
ADR 32 defines account plans, UploadPolicy, and safety-net quota reserve/commit on the gateway. ADR 27 requires owner-repo writes of cloud.substratum.passport.receipt (and ADR 30 adds cloud.substratum.filesystem.*) via OAuth putRecord on the user's PDS.
Substratum login (Business model) is operated on Substratum-hosted PDS (pds.substratum.cloud). That storage has real COGS. Gating signup alone is insufficient:
- Direct PDS XRPC — stock ATProto PDS implementations accept custom collections optimistically; any account with repo write access can
putRecordcloud.substratum.*without passing through the gateway. - Sync workers —
ReceiptSyncWorker/ futureCatalogSyncWorkercall PDS with OAuth and do not check billing today. - Home gateway + lapsed subscription —
deployment_mode: self_hostedskips SaaS upload quotas (ADR 32) but may still sync metadata to Substratum PDS for the same DID.
Login (OAuth) proves which DID is acting. Billing decides which paid capabilities that DID may consume. Those must be decoupled: OAuth stays available for export and account management; new Substratum metadata commits and safety-net usage stop when entitlements lapse.
Payment must be provider-agnostic: families in different countries may use different checkout paths; the runtime system must depend only on normalized entitlement state, not on Stripe (or any single PSP) APIs during putRecord or upload handling.
Requirements
Functional requirements
| ID | Requirement |
|---|---|
| R1 | Substratum SHALL operate an entitlement service (Postgres + internal API) as the runtime source of truth for per-DID capabilities and plan metadata, extending ADR 32 plan / entitlement tables. |
| R2 | Capabilities SHALL include at least metadata_write_allowed (new/changed cloud.substratum.* on Substratum PDS) and safety_net_allowed (SaaS blockstore quota per ADR 32). |
| R3 | A billing adapter port SHALL translate payment-provider events (checkout completed, subscription renewed/canceled, charge failed, manual ops) into idempotent upserts of entitlement rows. No gateway, PDS proxy, or sync worker SHALL call a payment provider on the request hot path. |
| R4 | Multiple billing adapters MAY coexist (e.g. Stripe, manual admin, future regional PSP); each writes the same entitlement schema. |
| R5 | Substratum-operated PDS SHALL deploy a PDS repo authz proxy in front of repo mutation XRPC. The proxy SHALL consult the entitlement service before forwarding gated requests. |
| R6 | The proxy SHALL require entitlement approval for com.atproto.repo.createRecord and com.atproto.repo.putRecord when the collection NSID is under cloud.substratum.*. |
| R7 | The proxy SHALL allow com.atproto.repo.deleteRecord on cloud.substratum.* when the caller is authorized for the repo even if metadata_write_allowed is false (lapsed), so customers can free PDS storage and migrate toward BYO PDS without new paid commits. |
| R8 | Non-Substratum collections and all read XRPC (getRecord, describeRepo, …) SHALL pass through the proxy without entitlement checks (OAuth/session rules unchanged). |
| R9 | The gateway SHALL use the same entitlement service (shared port / API) at: (a) safety-net upload paths on SaaS (UploadPolicy / quota reserve); (b) receipt_sync_outbox / catalog_sync_outbox enqueue when the write targets Substratum PDS; (c) sync workers immediately before putRecord — not a duplicate policy store. |
| R10 | Home and cloud-connected gateways SHALL consult the entitlement service for Substratum-login DIDs at PDS sync boundaries (outbox enqueue + worker pre-write per R9), regardless of local deployment_mode. Local home copy uploads and catalog rows that do not enqueue Substratum PDS sync SHALL NOT require a live cloud entitlement call (ADR 32 self-hosted account layer). |
| R11 | GET /api/v1/me/limits (ADR 32) SHALL expose capability flags, lapse/grace state, and CTA URLs (upgrade_url, Once vs base copy) derived from entitlement rows. |
| R12 | On metadata_write_allowed: false, ingress SHALL reject new catalog intent that would enqueue PDS sync (HTTP 403 with structured body); safety-net uploads SHALL require safety_net_allowed. |
| R13 | BYO PDS (Bluesky, Tangled, self-hosted) repos are out of scope for the PDS proxy; Substratum gates cloud resources only (safety-net, support) via gateway entitlements. |
| R14 | Lapse SHALL not delete existing PDS records or revoke OAuth login by default; it blocks new metadata writes and safety-net until Substratum Once, base renewal, or export/migration completes. |
| R15 | Billing adapters SHALL record payment_provider, external_customer_id, and external_subscription_id (nullable) on entitlement or sibling audit tables for support and reconciliation — without exposing PSP secrets to PDS or browsers. |
| R16 | Checkout on the marketing site (ADR 36) SHALL eventually invoke a billing adapter; until live, manual ops implement the same adapter contract (ADR 32 CC10). |
| R17 | deleteRecord allowance on lapse SHALL apply only to cloud.substratum.* on Substratum PDS; gateway delete flows (ADR 35) SHALL remain consistent (catalog + PDS tombstones + quota). |
| R18 | Entitlement denials from the PDS proxy SHALL use stable error codes (e.g. substratum.entitlement.metadata_write_denied) suitable for clients and support runbooks. |
| R19 | The PDS proxy SHALL derive the caller DID from the upstream PDS access JWT (Authorization: Bearer …) using PDS_JWT_SECRET (same secret as Tranquil PDS). JWT verification logic SHALL live in crates/auth (verify_pds_access_jwt); the proxy MUST NOT duplicate decode logic in the app crate. |
Non-functional requirements
| ID | Requirement |
|---|---|
| NR1 | Entitlement lookups on the PDS proxy hot path SHALL be local Postgres or same-region service with p99 latency suitable for synchronous XRPC (target < 50 ms excluding network). |
| NR2 | Billing webhook processing MAY be asynchronous; entitlement rows MUST be eventually consistent within a bounded SLA (e.g. < 60 s after PSP event). |
| NR3 | Security: Payment provider webhooks MUST be verified (signature / shared secret) in the billing adapter only; entitlement API MUST NOT be public internet without mTLS or service auth. |
| NR4 | Idempotency: Billing adapters MUST dedupe by PSP event id; proxy denials MUST be safe to retry. |
| NR5 | Observability: Log DID, plan_code, capability, and denial reason — never payment card or webhook payloads. |
| NR6 | Ports and adapters: Entitlement resolution SHALL be a port in the hexagonal sense (ADR 02); gateway and proxy are adapters. |
| NR7 | Evolution: Adding a new payment provider SHALL require only a new billing adapter + config, not changes to PDS proxy or UploadPolicy. |
| NR8 | Entitlement service MUST remain deployable on DigitalOcean Managed PostgreSQL (ADR 09) alongside existing catalog tables. |
Constraints
| ID | Constraint |
|---|---|
| C1 | Payment provider SDKs and webhook secrets MUST NOT be linked into the PDS process or receipt-sync worker binaries. |
| C2 | The PDS proxy MUST NOT replace ADR 27 MST signing — it authorizes forwards; PDS still signs with the user's #atproto key. |
| C3 | deployment_mode: self_hosted MUST NOT bypass Substratum PDS metadata billing at sync outbox enqueue or worker pre-write for Substratum-login DIDs (see R10). |
| C4 | Capabilities MUST NOT be client-supplied; only billing adapters and audited admin tools may mutate entitlement rows (ADR 32 NR1). |
| C5 | deleteRecord on lapse is allowed; createRecord / putRecord on cloud.substratum.* when lapsed is denied unless metadata_write_allowed is true. |
| C6 | BYO PDS users MUST NOT be required to use the Substratum PDS proxy. |
| C7 | Entitlement enforcement MUST NOT depend on Stripe (or any single PSP) being online during repo writes (ADR 32 C8 pattern). |
| C8 | OpenAPI DTOs for limits/errors MUST live in crates/ingress/src/models per repo root AGENTS.md. |
| C9 | Substratum lexicon gating MUST use the ADR 29 allowlist (passport + filesystem collections); grantee accessRemovalRequest on grantee repos follows the same rule on Substratum PDS. |
| C10 | v1 MAY ship gateway + entitlement checks before the PDS proxy is deployed; Substratum PDS MUST NOT be opened to general signup until the proxy (R5–R7) is live. |
| C11 | PDS access JWT verification MUST NOT reuse verify_jwt (Substratum session tokens): different secret (PDS_JWT_SECRET vs gateway JWT_SECRET), different issuer, and PDS tokens typically carry sub + exp only (no iat). Use a dedicated minimal-claims decoder in crates/auth. |
Cross-cutting challenges
| ID | Challenge | Mitigation |
|---|---|---|
| CC1 | Duplicate policy in gateway vs proxy | Single entitlement service; shared port trait; one Postgres schema. |
| CC2 | Home gateway offline cannot reach cloud entitlement API when sync outbox would write to Substratum PDS | Cache last-known entitlement with TTL; when stale beyond grace, do not enqueue new receipt_sync_outbox / catalog_sync_outbox jobs and fail worker pre-write non-retryably — local home copies (catalog + blockstore without Substratum PDS sync) continue on self-hosted gateways; document in ops runbook. |
| CC3 | User migrates to BYO PDS while lapsed | Allow deleteRecord on Substratum PDS (R7); export/CAR (ADR 29); OAuth to new PDS; catalog relink is follow-up. |
| CC4 | Multiple PSPs assign conflicting plans | Last-write-wins per capability with audit log; support tooling shows provider + external ids (R15). |
| CC5 | Proxy breaks PDS OAuth UI paths | Route only /xrpc/com.atproto.repo.{create,put,delete}Record (and optional applyWrites) through proxy; other XRPC direct to PDS upstream. |
| CC6 | Receipt worker retries on entitlement denial | Fail job with non-retryable error when capability false; surface on /account sync-failures (ADR 35). |
| CC7 | Once vs base lapse semantics | Once → metadata_write_allowed true, safety_net_allowed false; base lapsed without Once → both false; base lapsed with Once → metadata true, safety-net false (Business model). |
| CC8 | International checkout | Billing adapter normalizes to plan_code + capabilities; landing/checkout UI selects PSP per region without changing proxy. |
| CC9 | Confusing Substratum session JWT with PDS access JWT | Document two token families in this ADR and crates/auth/AGENTS.md; gateway/ingress use JWT_SECRET + verify_jwt; PDS proxy uses PDS_JWT_SECRET + verify_pds_access_jwt. |
Decision
1. Three-layer billing architecture
- Billing adapters (inbound) — Verify webhooks / admin actions; upsert
entitlement+plan+ capability columns; store PSP external ids for support. - Entitlement service (runtime) — Answer
authorizeRepoMutation(did, collection, action)andeffectiveCapabilities(did)for gatewayUploadPolicy//me/limits. - Enforcement (outbound) — PDS proxy + gateway checks; same decisions, no PSP calls.
2. PDS repo authz proxy (Substratum PDS only)
Deploy on the Substratum PDS host (Garage DO stack pattern: Caddy or sidecar). Hosting topology (Postgres repo store, Spaces blobs, stateless droplet, single-shard Garage posture): ADR 41.
| XRPC | cloud.substratum.* | Entitlement check |
|---|---|---|
createRecord | yes | metadata_write_allowed |
putRecord | yes | metadata_write_allowed |
deleteRecord | yes | Allow if repo owner/session valid — even when lapsed (R7, C5) |
| Other repo ops / reads | — | Pass through |
Deny createRecord / putRecord with 403 + substratum.entitlement.metadata_write_denied when lapsed.
3. Capability and lapse policy
| Customer state | metadata_write_allowed | safety_net_allowed | Proxy putRecord | Proxy deleteRecord |
|---|---|---|---|---|
| Substratum Once (paid) | true | false | Allow | Allow |
| Base active | true | true | Allow | Allow |
| Base lapsed, no Once | false | false | Deny | Allow |
| Base lapsed, has Once | true | false | Allow | Allow |
| BYO login (any) | N/A (not Substratum PDS) | per SKU | N/A | N/A |
Product copy when lapsed: home copies on their hardware continue; renew base or buy Once for operated metadata; delete on Substratum PDS remains for cleanup and BYO migration.
4. Gateway integration
Extend ADR 32:
EntitlementPolicyPort(or extendUploadPolicyPort) backed by entitlement service.- Check
safety_net_allowed+ quota on SaaS safety-net uploads; checkmetadata_write_allowedbeforereceipt_sync_outbox/catalog_sync_outboxenqueue (when target is Substratum PDS) and before workerputRecord— not on every local home-catalog mutation that never syncs to Substratum PDS. - Wire
get_me_limitsto real capability rows (replace stub insession.rs).
Home gateway config: ENTITLEMENT_SERVICE_URL (and service credentials) required when pds_url points at Substratum PDS.
5. Billing adapter contract (payment-provider agnostic)
Normalized events (examples):
| Event | Entitlement effect |
|---|---|
checkout.completed (Once) | plan_code=substratum_once, metadata_write=true, safety_net=false |
checkout.completed (Base) | plan_code=base_membership, both true, set quota bundle |
subscription.renewed | refresh safety_net_allowed, extend period |
subscription.canceled / invoice.failed | grace → lapse per CC7 |
manual.grant | ops override with audit row |
Adapters map PSP-specific payloads → these events. Stripe is the first adapter, not the architecture.
6. Identity vs billing (unchanged product rule)
- OAuth / login — Not blocked on lapse (export, delete, account page, Discourse escalation).
- Signup — May remain invite/checkout-assisted; not the primary billing enforcement point (see Context).
- Substratum lexicon writes — Primary enforcement at PDS proxy + gateway outbox.
7. PDS access JWT verification (crates/auth)
The proxy must know which DID is calling before consulting the entitlement service. That DID comes from the PDS-issued access JWT in Authorization: Bearer …, not from the Substratum browser session JWT the gateway mints after OAuth.
| Token family | Secret | Issuer | Typical claims | Verify API | Consumers |
|---|---|---|---|---|---|
| Substratum session / operation JWT | Gateway JWT_SECRET | Substratum gateway | sub, exp, iat | verify_jwt | Ingress, swarm pin/unpin workers, mobile FFI |
| PDS access JWT | PDS_JWT_SECRET | Tranquil PDS (createSession / refresh) | sub, exp (often no iat) | verify_pds_access_jwt (same crate) | apps/pds-authz-proxy |
Implementation rules:
crates/auth/src/token.rs— Addverify_pds_access_jwt(token, pds_jwt_secret) -> Result<String, TokenError>using a minimal claims struct (subonly at decode time;expvalidated byjsonwebtoken). Reject tokens whosesubis not a DID (did:prefix).apps/pds-authz-proxy— Strip theBearerprefix from the header, callverify_pds_access_jwt, then run sharedauthorize_repo_mutation. HTTP-specific policy stays in the proxy:- Missing or malformed
Authorization→ forward to upstream PDS (upstream returns standard ATProto auth errors). - Present Bearer but invalid signature / expired token → 401 at the proxy (do not forward tokens that fail local verification).
- Missing or malformed
- Do not link
verify_jwton the PDS proxy hot path — wrong secret and wrong claims shape (C11).
OAuth proves identity; entitlements prove paid capability. The proxy reads identity from the PDS access token because repo-mutation XRPC is authenticated by PDS session semantics, independent of Substratum’s substratum_session cookie.
Rejected alternatives
| Alternative | Why rejected |
|---|---|
| Gate signup only | Direct PDS XRPC and home gateway bypass; lapsed users keep writing metadata. |
| Gateway-only enforcement | Does not stop direct putRecord to Substratum PDS. |
PDS calls Stripe per putRecord | Violates C1/C7; latency and outage coupling. |
| Fork upstream PDS with embedded billing | High maintenance; proxy achieves same policy with smaller blast radius. |
Deny deleteRecord on lapse | Blocks storage cleanup and BYO migration; rejected per product need (R7). |
| Single global PSP in core domain | Blocks regional payment approaches; rejected (R3, R4, NR7). |
PDS JWT decode only in apps/pds-authz-proxy | Duplicates auth-crate responsibility; rejected (R19). Prefer shared verify_pds_access_jwt even though the proxy is a thin service. |
Reuse verify_jwt + JWT_SECRET on the proxy | Wrong issuer/secret and fails on PDS tokens missing iat; rejected (C11). |
Consequences
Positive
- Closes the Substratum PDS billing leak (direct XRPC, workers, home gateway).
- Payment provider swap without touching PDS or mesh.
- Clear Once vs base lapse semantics aligned with Business model.
deleteRecordon lapse supports sovereignty (export, migrate to BYO PDS, free disk).
Negative
- Extra moving part on PDS host (proxy service + routing).
- Home gateways depend on reachable entitlement service (CC2).
- Entitlement schema and
/me/limitscontract grow beyond ADR 32 v1 slice.
Neutral
- BYO PDS path unchanged; cloud-only enforcement for cloud-hosted repos.
- Implementation phased: entitlement service + gateway before public Substratum PDS (C10). See Garage v1 rollout.
- Mesh may still honor pre-lapse receipts until separate revocation policy is defined.
Verification
| Scenario | Expected |
|---|---|
Active Once DID, putRecord receipt | Proxy allow; worker succeeds |
Base lapsed, no Once, putRecord | Proxy 403; outbox not enqueued from gateway |
Base lapsed, no Once, deleteRecord on cloud.substratum.* | Proxy allow |
| Home gateway, lapsed DID, upload + sync | Entitlement deny at outbox enqueue or worker pre-write; local home copy may still succeed |
Stripe webhook subscription.deleted | Adapter upserts lapse; subsequent putRecord denied within SLA |
| Manual adapter grant Once | Capabilities updated without Stripe |
| BYO handle on bsky PDS | Proxy N/A; safety-net gated on SaaS gateway only |
Gated putRecord with valid PDS access JWT, lapsed DID | Proxy 403 metadata_write_denied |
Gated putRecord with missing Authorization | Proxy forwards; upstream PDS auth error |
Gated putRecord with expired PDS access JWT | Proxy 401 (not forwarded) |
Related
- Glossary
- Business model — Once, base, BYO paths
- ADR 02: Ports and Adapters
- ADR 09: Database and ORM
- ADR 27: Zero Trust PDS-Based Provenance
- ADR 28: Receipt Sync
- ADR 29: Private PDS Namespace
- ADR 30: Catalog–PDS Dual-Write
- ADR 32: Account Entitlements and Hosting Policy
- ADR 35: Drive Node Delete
- ADR 36: Marketing Landing Page
- ADR 38: Support and Entitlement Admin Tooling
- ADR 39: AT Protocol OIDC Bridge for Discourse Support SSO
crates/auth—verify_jwtvsverify_pds_access_jwtapps/pds-authz-proxy— proxy routing and env (PDS_JWT_SECRET)- Garage v1 rollout — implementation and launch sequence