Skip to content

ADR 37: PDS Entitlement Proxy and Payment-Provider-Agnostic Billing

Status: Proposed
Date: 2026-06-09
Last Updated: 2026-06-11

Terms (this ADR)

IDTermMeaning
RnFunctional requirementNumbered obligation (R1–R19 in this ADR).
NRnNon-functional requirementQuality attribute (NR1–NR8).
CnConstraintNon-negotiable boundary (C1–C11).
CCnCross-cutting challengeRisk spanning components (CC1–CC9).
Entitlement serviceRuntime authorityPostgres-backed store + internal API answering allow/deny for a DID; not the payment provider.
Billing adapterInbound portMaps checkout / webhook events from a payment provider into entitlement rows (Stripe is one adapter).
PDS repo authz proxyEnforcement edgeHTTP proxy in front of Substratum-operated PDS repo mutation XRPC; consults entitlement service before forwarding.
CapabilityEntitlement flagBoolean (or enum) grant on a DID, e.g. metadata_write_allowed, safety_net_allowed.
Substratum lexiconNSID prefixcloud.substratum.* collections governed by this ADR (ADR 29 allowlist).
Payment providerExternal PSPStripe, 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:

  1. Direct PDS XRPC — stock ATProto PDS implementations accept custom collections optimistically; any account with repo write access can putRecord cloud.substratum.* without passing through the gateway.
  2. Sync workersReceiptSyncWorker / future CatalogSyncWorker call PDS with OAuth and do not check billing today.
  3. Home gateway + lapsed subscriptiondeployment_mode: self_hosted skips 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

IDRequirement
R1Substratum 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.
R2Capabilities SHALL include at least metadata_write_allowed (new/changed cloud.substratum.* on Substratum PDS) and safety_net_allowed (SaaS blockstore quota per ADR 32).
R3A 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.
R4Multiple billing adapters MAY coexist (e.g. Stripe, manual admin, future regional PSP); each writes the same entitlement schema.
R5Substratum-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.
R6The proxy SHALL require entitlement approval for com.atproto.repo.createRecord and com.atproto.repo.putRecord when the collection NSID is under cloud.substratum.*.
R7The 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.
R8Non-Substratum collections and all read XRPC (getRecord, describeRepo, …) SHALL pass through the proxy without entitlement checks (OAuth/session rules unchanged).
R9The 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.
R10Home 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).
R11GET /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.
R12On 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.
R13BYO 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.
R14Lapse 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.
R15Billing 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.
R16Checkout on the marketing site (ADR 36) SHALL eventually invoke a billing adapter; until live, manual ops implement the same adapter contract (ADR 32 CC10).
R17deleteRecord allowance on lapse SHALL apply only to cloud.substratum.* on Substratum PDS; gateway delete flows (ADR 35) SHALL remain consistent (catalog + PDS tombstones + quota).
R18Entitlement denials from the PDS proxy SHALL use stable error codes (e.g. substratum.entitlement.metadata_write_denied) suitable for clients and support runbooks.
R19The 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

IDRequirement
NR1Entitlement 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).
NR2Billing webhook processing MAY be asynchronous; entitlement rows MUST be eventually consistent within a bounded SLA (e.g. < 60 s after PSP event).
NR3Security: 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.
NR4Idempotency: Billing adapters MUST dedupe by PSP event id; proxy denials MUST be safe to retry.
NR5Observability: Log DID, plan_code, capability, and denial reason — never payment card or webhook payloads.
NR6Ports and adapters: Entitlement resolution SHALL be a port in the hexagonal sense (ADR 02); gateway and proxy are adapters.
NR7Evolution: Adding a new payment provider SHALL require only a new billing adapter + config, not changes to PDS proxy or UploadPolicy.
NR8Entitlement service MUST remain deployable on DigitalOcean Managed PostgreSQL (ADR 09) alongside existing catalog tables.

Constraints

IDConstraint
C1Payment provider SDKs and webhook secrets MUST NOT be linked into the PDS process or receipt-sync worker binaries.
C2The PDS proxy MUST NOT replace ADR 27 MST signing — it authorizes forwards; PDS still signs with the user's #atproto key.
C3deployment_mode: self_hosted MUST NOT bypass Substratum PDS metadata billing at sync outbox enqueue or worker pre-write for Substratum-login DIDs (see R10).
C4Capabilities MUST NOT be client-supplied; only billing adapters and audited admin tools may mutate entitlement rows (ADR 32 NR1).
C5deleteRecord on lapse is allowed; createRecord / putRecord on cloud.substratum.* when lapsed is denied unless metadata_write_allowed is true.
C6BYO PDS users MUST NOT be required to use the Substratum PDS proxy.
C7Entitlement enforcement MUST NOT depend on Stripe (or any single PSP) being online during repo writes (ADR 32 C8 pattern).
C8OpenAPI DTOs for limits/errors MUST live in crates/ingress/src/models per repo root AGENTS.md.
C9Substratum lexicon gating MUST use the ADR 29 allowlist (passport + filesystem collections); grantee accessRemovalRequest on grantee repos follows the same rule on Substratum PDS.
C10v1 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.
C11PDS 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

IDChallengeMitigation
CC1Duplicate policy in gateway vs proxySingle entitlement service; shared port trait; one Postgres schema.
CC2Home gateway offline cannot reach cloud entitlement API when sync outbox would write to Substratum PDSCache 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.
CC3User migrates to BYO PDS while lapsedAllow deleteRecord on Substratum PDS (R7); export/CAR (ADR 29); OAuth to new PDS; catalog relink is follow-up.
CC4Multiple PSPs assign conflicting plansLast-write-wins per capability with audit log; support tooling shows provider + external ids (R15).
CC5Proxy breaks PDS OAuth UI pathsRoute only /xrpc/com.atproto.repo.{create,put,delete}Record (and optional applyWrites) through proxy; other XRPC direct to PDS upstream.
CC6Receipt worker retries on entitlement denialFail job with non-retryable error when capability false; surface on /account sync-failures (ADR 35).
CC7Once vs base lapse semanticsOncemetadata_write_allowed true, safety_net_allowed false; base lapsed without Once → both false; base lapsed with Once → metadata true, safety-net false (Business model).
CC8International checkoutBilling adapter normalizes to plan_code + capabilities; landing/checkout UI selects PSP per region without changing proxy.
CC9Confusing Substratum session JWT with PDS access JWTDocument 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

  1. Billing adapters (inbound) — Verify webhooks / admin actions; upsert entitlement + plan + capability columns; store PSP external ids for support.
  2. Entitlement service (runtime) — Answer authorizeRepoMutation(did, collection, action) and effectiveCapabilities(did) for gateway UploadPolicy / /me/limits.
  3. 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.

XRPCcloud.substratum.*Entitlement check
createRecordyesmetadata_write_allowed
putRecordyesmetadata_write_allowed
deleteRecordyesAllow if repo owner/session valideven when lapsed (R7, C5)
Other repo ops / readsPass through

Deny createRecord / putRecord with 403 + substratum.entitlement.metadata_write_denied when lapsed.

3. Capability and lapse policy

Customer statemetadata_write_allowedsafety_net_allowedProxy putRecordProxy deleteRecord
Substratum Once (paid)truefalseAllowAllow
Base activetruetrueAllowAllow
Base lapsed, no OncefalsefalseDenyAllow
Base lapsed, has OncetruefalseAllowAllow
BYO login (any)N/A (not Substratum PDS)per SKUN/AN/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 extend UploadPolicyPort) backed by entitlement service.
  • Check safety_net_allowed + quota on SaaS safety-net uploads; check metadata_write_allowed before receipt_sync_outbox / catalog_sync_outbox enqueue (when target is Substratum PDS) and before worker putRecord — not on every local home-catalog mutation that never syncs to Substratum PDS.
  • Wire get_me_limits to real capability rows (replace stub in session.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):

EventEntitlement 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.renewedrefresh safety_net_allowed, extend period
subscription.canceled / invoice.failedgrace → lapse per CC7
manual.grantops 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 familySecretIssuerTypical claimsVerify APIConsumers
Substratum session / operation JWTGateway JWT_SECRETSubstratum gatewaysub, exp, iatverify_jwtIngress, swarm pin/unpin workers, mobile FFI
PDS access JWTPDS_JWT_SECRETTranquil PDS (createSession / refresh)sub, exp (often no iat)verify_pds_access_jwt (same crate)apps/pds-authz-proxy

Implementation rules:

  1. crates/auth/src/token.rs — Add verify_pds_access_jwt(token, pds_jwt_secret) -> Result<String, TokenError> using a minimal claims struct (sub only at decode time; exp validated by jsonwebtoken). Reject tokens whose sub is not a DID (did: prefix).
  2. apps/pds-authz-proxy — Strip the Bearer prefix from the header, call verify_pds_access_jwt, then run shared authorize_repo_mutation. HTTP-specific policy stays in the proxy:
    • Missing or malformed Authorizationforward to upstream PDS (upstream returns standard ATProto auth errors).
    • Present Bearer but invalid signature / expired token401 at the proxy (do not forward tokens that fail local verification).
  3. Do not link verify_jwt on 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

AlternativeWhy rejected
Gate signup onlyDirect PDS XRPC and home gateway bypass; lapsed users keep writing metadata.
Gateway-only enforcementDoes not stop direct putRecord to Substratum PDS.
PDS calls Stripe per putRecordViolates C1/C7; latency and outage coupling.
Fork upstream PDS with embedded billingHigh maintenance; proxy achieves same policy with smaller blast radius.
Deny deleteRecord on lapseBlocks storage cleanup and BYO migration; rejected per product need (R7).
Single global PSP in core domainBlocks regional payment approaches; rejected (R3, R4, NR7).
PDS JWT decode only in apps/pds-authz-proxyDuplicates 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 proxyWrong 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.
  • deleteRecord on 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/limits contract 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

ScenarioExpected
Active Once DID, putRecord receiptProxy allow; worker succeeds
Base lapsed, no Once, putRecordProxy 403; outbox not enqueued from gateway
Base lapsed, no Once, deleteRecord on cloud.substratum.*Proxy allow
Home gateway, lapsed DID, upload + syncEntitlement deny at outbox enqueue or worker pre-write; local home copy may still succeed
Stripe webhook subscription.deletedAdapter upserts lapse; subsequent putRecord denied within SLA
Manual adapter grant OnceCapabilities updated without Stripe
BYO handle on bsky PDSProxy N/A; safety-net gated on SaaS gateway only
Gated putRecord with valid PDS access JWT, lapsed DIDProxy 403 metadata_write_denied
Gated putRecord with missing AuthorizationProxy forwards; upstream PDS auth error
Gated putRecord with expired PDS access JWTProxy 401 (not forwarded)