🛒 Descent Marketplace — System Architecture & Technical Whitepaper
🛒 Descent Marketplace — System Architecture & Technical Whitepaper
This file is the single source of truth for the Marketplace specification. Consolidated 2026-08-03: the revision-1 file and the
zh_TWtranslation were both deleted, and the_2suffix was dropped. There is one copy, in English, on purpose. Two copies of a specification diverge — the VTT corpus records that as recurring failure mode #4, where an intermediate document claimed for three months that a change had not been applied after it had; thezh_TWfile had independently acquired duplicated appendices and a broken code fence before it was removed, which is the same failure arriving by translation. Record changes as in-document amendments with their reasoning, not as new files.
Core Architecture Manifesto:
One Purchase, Every Surface — and Exactly One Shared Row
Descent Marketplace is a physically independent commerce system that shares with Descent VTT precisely one artefact: a
Global-scoped entitlement, written by the Marketplace and read — never written — by the VTT. Every other coupling between commerce and simulation is deliberately absent, and each absence is a decision with a named cost.
Document Status & Conventions
This document is not part of the Descent VTT specification. It describes a separate system that integrates with it across one contract. It follows the same house rules as the VTT corpus, which are load-bearing rather than stylistic:
| Convention | Application here |
|---|---|
| Separate identifier namespaces | Marketplace decisions are ADR-M-nnn, quantities are Q-M-nnn, findings are M-F-nn. The VTT corpus already recorded namespace collision as a recurring defect (three audit rounds reused F-nn); reusing ADR-nnn here would repeat it deliberately. A bare ADR-nnn in this document always means the VTT corpus. |
Every MUST names its enforcement point | CI check, runtime metric, or a named release-checklist item. A rule that cannot name one is rewritten as description (the VTT’s ADR-045 rule, adopted verbatim). |
| Quantities carry status | ⊙ means derived from published list prices or vendor documentation and not independently verified or measured. Such a figure is not normative. |
| Honesty over polish | Where a stated premise is in tension with the VTT corpus, the tension is recorded as a finding and disposed of, not smoothed over. Two such findings appear below (M-F-01, M-F-02). |
External constants warning. Payment-processor fees, cloud list prices, and platform timeouts appear throughout Chapters 7 and 8. Every one of them is a third-party constant that changes without notifying us. They are recorded with a Q-M id and a capture date of 2026-08-03, they are marked ⊙, and the release checklist item RC-M-11 re-captures them before each pricing change. No architectural decision in this document rests on a fee figure being exact; several rest on a fee figure’s shape (a fixed per-transaction component alongside a percentage), which is stable.
On legal matters. Chapter 7 describes tax, withholding, and money-movement architecture — what the system must be able to represent and when. It is not tax or legal advice, and three specific items are flagged as requiring counsel before launch rather than being answered here.
Table of Contents
- Executive Summary & Business Strategy
- System Architecture Overview
- Technical Stack & Native AOT Implications
- Authentication & the Ticket Exchange Pattern
- Transaction Consistency & Webhook Idempotency
- Lightweight Real-Time Push
- Payments, Taxation & Creator Revenue Split
- Security & FinOps Cost Optimisation Boundaries
- Architectural Contrast with Descent VTT
- Appendix A — Core Relational Schema
- Appendix B — ADR-M Index
- Appendix C — Quantity Registry (Q-M)
- Appendix D — Open Items
- Appendix E — Delivery Roadmap
1. Executive Summary & Business Strategy
1.1 The thesis in one paragraph
DriveThruRPG’s 30–35% take is not a margin choice; it is the residue of a 2004 cost structure — a monolithic storefront, human-mediated onboarding, manual payout runs, and a catalogue whose only product is a file. Descent Marketplace takes 20% on exclusive content and 25% on non-exclusive (ADR-M-043, Q-M-077/Q-M-078) because its cost structure is different in kind: scale-out container compute with an always-on floor of roughly one and a quarter vCPUs, serverless PostgreSQL, a payment processor that performs KYB and payout mechanics as a line item, and a fulfilment path that is a state machine rather than a person. Both rates are claims about unit economics, and §7.3 works the arithmetic out to the cent, including the case where it fails.
The spread is an exclusivity incentive and is not a cost-recovery charge, and the distinction matters because the corpus’s own model refuses the other reading: ADR-M-020 already removed per-GB delivery pricing, which is why a 20% rate was viable in the first place. Attributing the extra five points to CDN cost would double-count a cost the platform stopped paying per download. What the spread buys is a reason to be exclusive here.
1.2 What the 20% actually buys, and where it breaks
The number that matters is not the take rate; it is contribution per order. With the platform acting as merchant of record and absorbing processing fees out of its own share (§7.2), contribution on a gross order value $G$ at a commission rate $s$ is approximately:
$$\text{contribution} = sG - (0.029G + $0.30) - 0.005G = (s - 0.034)G - $0.30$$
$$s = 0.20 \text{ (exclusive)} \Rightarrow 0.166G - $0.30 \qquad s = 0.25 \text{ (non-exclusive)} \Rightarrow 0.216G - $0.30$$
That is a straight line with a fixed negative intercept, and the intercept is the entire business-model story — the rate moves the slope and never the intercept, which is why the fixed processing fee, not the take rate, is what makes small orders unprofitable:
| Gross order value | Creator receives (80% / 75%) | Stripe + Tax cost | Contribution @20% | Contribution @25% |
|---|---|---|---|---|
| $0.99 | $0.79 / $0.74 | $0.33 | −$0.14 | −$0.09 |
| $1.39 | $1.11 / $1.04 | $0.34 | −$0.07 | $0.00 |
| $1.81 | $1.45 / $1.36 | $0.36 | $0.00 | $0.09 |
| $4.99 | $3.99 / $3.74 | $0.47 | $0.53 | $0.78 |
| $19.99 | $15.99 / $14.99 | $0.98 | $3.02 | $4.02 |
| $59.99 (Tier 3) | $47.99 / $44.99 | $2.34 | $9.66 | $12.66 |
| $89.97 (3-item cart) | $71.98 / $67.48 | $3.36 | $14.63 | $19.13 |
(Fee inputs Q-M-001…Q-M-004, all ⊙. Physical-goods cost of sale is excluded here and handled in §7.6 — Tier 3’s figures are contribution before the printer’s invoice.)
Break-even moves from $1.81 to $1.39 as the rate rises, which is worth stating because it is the opposite of the intuition that a higher take rate is what protects small orders. It does not: Q-M-005’s floor is what protects them, at either rate.
Three architectural requirements fall directly out of that table, and they are requirements, not marketing preferences:
- A single
PaymentIntentper cart, never per item (ADR-M-019). The $0.30 is per charge, not per line. A three-item cart charged three times destroys $0.60 of contribution for nothing. Enforcement:Descent.Marketplace.ArchitectureTestsasserts no code path constructs aPaymentIntentfrom an order line.Cart aggregation is therefore already a server-side property, and the storefront’s job is only to make batching attractive. Basket suggestions, “add one more to reach the bundle price”, and a cart that survives navigation are UX affordances and explicitly not controls —
ADR-M-005denies the BFF the credentials to decide anything, and §8.9.1 records the general form of this rule (“blocking… in the BFF or the frontend is a UX affordance, not a control”). No ADR is minted for the affordance, onADR-045’s criterion: it contradicts no decision and rejects no re-proposable alternative, and a record that restatesADR-M-019would teach the next reader that the criterion is decorative. - A price floor — flat and size-banded (
Q-M-005,ADR-M-044) — or bundle-only placement below it. Sub-floor items are not banned: they are ineligible for standalone checkout and must be sold inside a bundle, or priced at exactly $0.00 so the asset falls underADR-M-031’s free-egress breaker. Enforcement: catalogue publication validator, release-checklist itemRC-M-03. - Free UGC downloads carry no revenue and real egress cost, so their bandwidth is a budgeted quantity (
Q-M-030a…Q-M-030f) rather than an unpriced externality — with allowance credited by whatever downstream sales the free asset can be shown to drive, so promotional content is not punished for working (§8.9.1).
1.3 Catalogue taxonomy
Four product classes, distinguished by what they cost the platform to deliver and what they oblige it to enforce — not by how they are merchandised:
| Class | Artefact | Delivery cost driver | Platform obligation |
|---|---|---|---|
| PDF / rulebook | Watermarked PDF, 5–200 MB | Egress; per-purchaser stamping CPU | Attribution stamping, takedown path, licence provenance keys for the VTT’s RAG layer (VTT §7.2) |
| Visual asset pack | Textures, maps, KTX2 bundles, 20 MB–2 GB | Egress, dominant | Manifest validation, 2D-bake presence for VTT Profile C (VTT ADR-005) |
| Community mod (UGC, free or paid) | Script + declarative UI descriptors | Egress, small; review throughput | Provenance and signing only. Execution safety is the VTT’s P1/P2 sandbox tiers (VTT §6.4). The Marketplace never claims a mod is safe. |
| 3D interactive Cartridge | Signed .NET assembly + asset bundle | Egress; review cost is the real cost | Signature issuance, SDK-major compatibility metadata (VTT ADR-055), revocation reaching RulesetReadOnly rather than failed activation (VTT §12.2.6) |
The last row is the one with teeth. A Cartridge is Tier P0 privileged code inside the VTT host (VTT §6.4): signature verification at load is the only thing standing between a catalogue listing and full host privileges. The Marketplace is therefore the issuing authority for a code-signing decision, which makes catalogue review a security function and not a merchandising function (ADR-M-024).
A fifth artefact class cuts across the four rather than joining them: the opt-in DRM-free raw archive (ADR-M-046, §8.2.1) — an unencrypted ZIP of source-form assets a creator may attach to a SKU. It is not a product class of its own because it changes neither what the platform enforces nor what it costs to deliver; it changes only what the buyer receives alongside the VTT-consumable form. It is still ingested and scanned like every other artefact — that is not negotiable, and §8.2.1 records why.
1.4 Tiered SKUs and the Ultimate Bundle
A Title is the editorial unit; a SKU is the purchasable unit; an entitlement is the delivered unit. One Title fans out to three SKUs and a variable number of entitlements:
| Tier | SKU contents | Entitlements granted | Fulfilment class |
|---|---|---|---|
| Tier 1 — Digital Reading | urn:descent:pdf:{title}:{edition} | Synchronous. Entitlement is live before the buyer’s browser finishes the redirect. | |
| Tier 2 — Interactive | PDF + 3D VTT Interactive Pack | Tier 1 + urn:descent:cartridge:{id}:{major} and/or urn:descent:assetpack:{id} | Synchronous. Same transaction. |
| Tier 3 — Ultimate Bundle | PoD hardcover + PDF + Interactive Pack | Tier 2 entitlements, granted immediately, plus a pod_job | Split. Digital is synchronous; physical is an asynchronous saga measured in weeks. |
Tier 3 is the architecturally expensive tier and the reason half this document exists. It is the only SKU class where an order can be simultaneously fulfilled and unfulfilled, where a downstream partner can reject work the platform has already been paid for, and where fulfilment status changes days after the buyer has closed the tab. Those three properties produce, respectively: a per-line rather than per-order fulfilment state (§5.1), a compensation saga (§5.8), and the entire real-time push subsystem (Chapter 6).
The ARPU claim, stated precisely. Tier 3 does not raise ARPU because customers like bundles. It raises ARPU because the alternative — buying the PDF here, the VTT pack here, and the hardcover elsewhere — charges the customer three fixed processing intercepts and three shipping decisions, and because the marginal digital content in Tier 3 has zero marginal cost of goods. The bundle’s economics are the printer’s invoice plus egress. What must not be claimed is that Tier 3 is high-margin in percentage terms: §7.6 shows physical goods compressing percentage margin even as they raise absolute contribution, and the FinOps model in §8.8 is built on absolute contribution per order for exactly that reason.
1.5 Deliberate non-goals
Stated because each was considered and rejected, and a reader who assumes otherwise will build the wrong thing:
- No subscriptions in v1. A recurring-billing surface adds dunning, proration, entitlement expiry, and mid-period tier changes — four state machines — to a system whose hardest existing problem is already a split-fulfilment saga. Entitlements are modelled with a nullable
expires_atso subscriptions remain possible without being present (ADR-M-027). - No marketplace-side DRM. Watermarking is attribution, not protection (§8.2). Any claim to the contrary would be false and would set creator expectations the platform cannot meet.
ADR-M-046’s opt-in DRM-free archive is consistent with this rather than an exception to it — it makes explicit for one artefact what §8.1 already prices in for every paid PDF, and the only thing it removes is an encryption-at-rest step that was never a protection claim. - No event sourcing. The VTT is event-sourced because a game session is a timeline that must be replayable and forkable. An order is not a timeline; it is a record with a state machine and a legal obligation to be auditable, which an append-only ledger (§7.5) plus row-level history satisfies at a fraction of the operational cost. This is
ADR-M-002and it is the single decision most likely to be questioned by someone who has read the VTT corpus first. - No shared code with the VTT beyond generated contract types. Not a shared “common” library, not shared DTOs, not a shared auth middleware. The two systems share a database view and a JWT verification key, both versioned (§2.5).
1.6 The Creator Economy — what creators actually get
Finding
M-F-03— this section previously contradicted §1.5, §8.1 and §8.2 (found 2026-08-08 by the Epic C disclosure pass; disposition ruled under VTT ADR-114 clause 4). It promised “the exact same Zero-Trust IP protection and DRM as official publishers” and that “buyers cannot simply copy the source files” — claims §1.5 names as false (“No marketplace-side DRM. … Any claim to the contrary would be false”) and §8.1 prices in (“every paid PDF is public within days”). It was rewritten to state what the platform provides, in the same register the rest of this document uses. The prior wording is preserved in this blockquote’s own description rather than restated, per the corpus rule that marketing language must never overwrite the threat model (VTT §6.1).
The Marketplace operates on a Dual-Track ecosystem, accommodating both licensed B2B partners and independent C2C creators without compromising the platform’s security boundaries:
- First-Party / Official Licensed Content: major publisher Cartridges. These undergo strict review, are officially endorsed, and reach Tier P0 privileges only through signature verification — which is why catalogue review is a security function (§1.3).
- Third-Party Indie Creators (UGC): individual creators can freely develop and sell sandbox scripts, map packs, tokens, and scenarios directly on the Marketplace.
Both tracks receive the same actual protections, stated exactly:
- Provenance: every published asset is cryptographically signed. Signing establishes who published, never that content is safe (VTT ADR-098) and never that it cannot be copied.
- Entitlement-gated delivery: the VTT loads paid content only for principals the
vtt_entitlement_v1view entitles, and revocation has a real deletion path (§7.2). This gates platform-delivered execution; it does not prevent a buyer redistributing the files they received. - Attribution, not DRM: paid PDFs are watermarked per purchaser (§8.2) — which identifies a leak’s source and deters casual sharing. §8.1 states the residual plainly: a determined redistributor succeeds, and the platform does not claim otherwise.
- A takedown path for infringing redistribution, which is the enforcement mechanism that actually exists.
What makes indie creators’ work sellable here is the funnel and the split (§1.1, §1.2), the provenance that lets buyers trust what they install, and the entitlement system that makes honest access the convenient path — not a technical guarantee against copying, which neither this platform nor any other can honestly offer (§1.5).
2. System Architecture Overview
2.1 The isolation decision, and an honest audit of its stated reason
The Marketplace is a physically separate deployment: separate container apps, separate repository, separate release cadence, separate origin. Four arguments were offered for this. They are not equally strong, and the strongest-sounding one is the weakest.
Argument 1 — Cross-Origin Isolation blocks embedded payment UI. The VTT frontend sets Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp to unlock SharedArrayBuffer. Under require-corp, every cross-origin subresource must opt in with Cross-Origin-Resource-Policy; Stripe’s Checkout and Elements iframes do not. COOP: same-origin additionally severs window.opener, which breaks popup-based OAuth. Both effects are real.
Finding
M-F-01— the COEP argument is in tension with the VTT corpus, and is not sufficient on its own. The VTT’s ADR-052 specifies theDescent.GeometryWASM build with private linear memory, no+atomics, and no shared-memory toolchain — the corpus explicitly records this as making the geometry work cheaper. A build with private linear memory does not requireSharedArrayBuffer, and WebGPU does not require it either. So the premise “the VTT requires cross-origin isolation for WebGPUSharedArrayBuffer” is, as literally stated, not established by the VTT specification. Disposition (ADR-M-001): the isolation decision stands, but its justification is rebased.SharedArrayBufferremains plausibly required by other VTT subsystems that ADR-052 does not govern — a multithreaded Havok physics build, audio-worklet ring buffers,WebCodecspipelines — and whether the VTT ships cross-origin isolated is a VTT decision that is not yet recorded in its corpus. The Marketplace must not depend on either outcome. It is therefore isolated for reasons 2–4, which hold regardless, and reason 1 is downgraded to a constraint the Marketplace is immune to rather than the reason the Marketplace exists. The action item is on the VTT side: record the cross-origin-isolation posture as an ADR there, because right now two documents imply different answers. Tracked asOI-M-01. Why this matters practically: if reason 1 were load-bearing, redirect-mode Stripe Checkout would refute it — a full top-level navigation is unaffected by COEP. An architecture whose central decision is refuted by a Stripe configuration flag is an architecture resting on the wrong argument.
Argument 2 — Blast radius, and it is asymmetric. The VTT runs a fixed 20 Hz simulation tick whose budget is measured in single-digit milliseconds and whose degradation is immediately visible to every player in a room. The Marketplace serves bursty, cache-friendly, latency-tolerant HTTP. Co-hosting means a catalogue-search crawl, a launch-day traffic spike, or a runaway PDF-stamping job competes for CPU with a tick loop that cannot yield. The asymmetry matters: the Marketplace failing costs revenue for minutes; the VTT tick slipping costs every live session simultaneously. Isolation is bought by the cheap side to protect the expensive one.
Argument 3 — PCI scope containment. Keeping card data entirely inside Stripe-hosted surfaces holds the platform at SAQ-A. That boundary is enforced by which origin loads Stripe.js, and an origin shared with a WebGPU engine, a WASM sandbox, and third-party community mods is an origin whose SAQ-A eligibility is a continuous argument rather than a fact. A separate origin with no third-party script execution makes it a fact (§8.7).
Argument 4 — Release cadence. A pricing change should not require regression-testing a physics engine. This is the least glamorous argument and the one that will be felt most often.
Cost of the decision, stated plainly. Two deployments, two CI pipelines, two on-call surfaces, a cross-origin auth protocol that would otherwise be a cookie read, and a shared database contract that must be versioned like a public API. The cross-origin auth protocol is Chapter 4 and the database contract is §2.5; both exist only because of this decision.
2.2 Topology
graph TB subgraph Client["Browser"] SHOP["Storefront<br/>shop.descentvtt.com<br/>SolidStart SSR"] VTTFE["VTT Client<br/>app.descentvtt.com<br/>WebGPU / Babylon.js<br/>possibly COOP+COEP isolated"] end
subgraph Edge["Azure Front Door Standard"] FD["Path routing on one origin<br/>/* to BFF, /api/* to API<br/>WAF, TLS, no response buffering on /api/events"] end
subgraph ACA["Azure Container Apps Environment — Marketplace"] BFF["BFF: SolidStart<br/>Node container<br/>minReplicas 1<br/>NO database credentials"] API[".NET 10 Minimal API<br/>Native AOT<br/>minReplicas 1<br/>checkout, webhooks, SSE, LISTEN"] WRK["Fulfilment Worker<br/>.NET 10 JIT<br/>always-on<br/>outbox drain, PoD, reconciliation"] JOB["ACA Job — event driven<br/>PDF stamping, bundle packaging<br/>TRUE scale-to-zero"] end
subgraph Data["Neon Serverless PostgreSQL 18"] PRIM[("Primary compute<br/>orders, ledger, outbox,<br/>entitlements, notifications")] REPL[("Read replica compute<br/>separate compute, shared storage")] end
subgraph Ext["External"] STR["Stripe<br/>Checkout, Connect, Tax, Radar"] POD["PoD Partner<br/>print API"] CDN["Asset CDN<br/>signed URLs, zero-egress-fee tier"] end
subgraph VTTBE["Descent VTT Backend — separate ACA environment"] ORL["Orleans Silos<br/>20 Hz tick<br/>READ-ONLY entitlement queries"] end
SHOP --> FD FD --> BFF FD --> API BFF -->|"server-side HTTP, forwards cookie"| API API --> PRIM WRK --> PRIM JOB --> PRIM API -->|"idempotent typed client"| STR STR -->|"webhook, HMAC signed"| API WRK -->|"Idempotency-Key"| POD API -->|"issues 5-min signed URL"| CDN SHOP -->|"downloads"| CDN PRIM -.->|"logical replication"| REPL ORL -->|"SELECT on vtt_entitlement_v1"| REPL VTTFE -->|"POST /api/auth/ticket with parent-domain cookie"| API VTTFE -->|"15-second ticket"| ORLFour properties of this diagram are decisions rather than drawing conventions:
/api/*and/are the same origin. Front Door path-routes them to different container apps. This is what makesEventSourcework with theHttpOnlycookie and no CORS at all (§6.4), and it is why there is noapi.descentvtt.comhostname in the diagram. The cost is Front Door’s base fee (Q-M-021) and an origin-response timeout that caps SSE stream lifetime (§6.5).- The BFF has no database credentials. This is the enforcement mechanism for “the BFF may compose and shape; it may not decide” (§3.5). It is checked in CI by asserting the BFF container app’s secret set is empty of connection strings —
RC-M-06. - The VTT reads a replica, not the primary. Same Neon storage, separate compute. This is the mitigation for re-coupling blast radius at the data layer after decoupling it at the compute layer (§2.5).
- Only the Job scales to zero. Chapter 8 argues that this is the honest reading of “Scale-to-Zero” for this workload, and that the always-on floor is roughly 1.25 vCPU.
2.3 Bounded contexts
| Context | Owns | Never touches |
|---|---|---|
| Catalogue | Titles, SKUs, prices, media, publication state, review queue | Money, entitlements |
| Checkout | Carts, PaymentIntent creation, tax quote, order creation | Fulfilment, payouts |
| Payments | Stripe webhook ingestion, order state transitions, refunds, disputes | Product semantics — it moves state machines, it does not know what a Cartridge is |
| Fulfilment | Entitlement grants, outbox drain, PoD saga, notification emission | Pricing, tax |
| Ledger | Double-entry allocation, withholding, payouts, reconciliation | Anything a user can call directly |
| Identity | FIDO2 credentials, JWT issuance, JWKS, ticket exchange, step-up | Orders |
Contexts are assembly boundaries with InternalsVisibleTo denied, verified by an architecture test that asserts no type in Payments references a type in Catalogue. They are not separate deployments: this is a modular monolith with one process, for the same reason the VTT is one (VTT §2) — the operational cost of six services buys nothing at this scale.
2.4 The purchase flow, end to end
sequenceDiagram autonumber participant B as Browser participant BFF as SolidStart BFF participant API as .NET AOT API participant PG as PostgreSQL participant S as Stripe participant W as Worker participant P as PoD Partner
B->>BFF: POST /cart/checkout (server action) BFF->>API: POST /api/checkout (cookie forwarded) API->>PG: BEGIN; INSERT order (status=awaiting_payment); COMMIT API->>S: Create Checkout Session (Idempotency-Key = order_id) S-->>API: session url API-->>BFF: 303 -> Stripe BFF-->>B: redirect (full navigation, no iframe) B->>S: Pays on Stripe-hosted page S-->>B: redirect to /orders/{id}/thanks
Note over B,API: The success page is NOT the fulfilment trigger.<br/>It opens an SSE stream and waits.
S->>API: POST /api/webhooks/stripe (HMAC signed) API->>PG: BEGIN API->>PG: INSERT processed_stripe_events ON CONFLICT DO NOTHING alt already processed API->>PG: COMMIT API-->>S: 200 OK (no work) else first delivery API->>PG: SELECT order FOR UPDATE API->>PG: UPDATE order -> paid API->>PG: INSERT entitlements (digital, immediate) API->>PG: INSERT outbox_messages (pod_submit, receipt_email) API->>PG: INSERT user_notifications + pg_notify API->>PG: COMMIT API-->>S: 200 OK end PG-->>API: NOTIFY user_events API-->>B: SSE event: entitlements_granted
W->>PG: claim outbox FOR UPDATE SKIP LOCKED W->>P: POST print job (Idempotency-Key = outbox:{id}) P-->>W: accepted W->>PG: mark done; INSERT notification; pg_notify PG-->>API: NOTIFY API-->>B: SSE event: pod_submittedThe single most important property of this sequence: there is no path from the buyer’s browser to a fulfilment side effect. The success page is a subscriber. If the buyer closes the tab at the redirect, the order still completes; if the buyer refreshes the success page forty times, nothing happens forty times. This is enforced by an architecture test asserting that no handler reachable from a GET route writes to entitlements or outbox_messages — ARCH-M-04.
2.5 The shared entitlement contract
This is the only surface where the two systems meet, and a shared database is a coupling that is normally regretted. It is acceptable here under four conditions, all of which are mechanically enforced:
- One direction. The Marketplace writes; the VTT reads. The VTT’s database role holds
SELECTon one view and nothing else. Enforced by a CI assertion overinformation_schema.role_table_grants—RC-M-08. - A view, not a table. The VTT never sees
entitlements. It seesvtt_entitlement_v1, which filters revoked rows and exposes exactly three columns. The view is the contract; the table underneath may be reshaped freely. - Expand-only migration. A new version is a new view (
_v2) served alongside_v1for a published window. Dropping a version is a release-checklist item with a named consumer sign-off —RC-M-09. A migration that alters_v1’s shape fails CI. - A contract test in both repositories. The Marketplace’s CI asserts the view’s projected shape; the VTT’s CI asserts the same shape against a fixture. Two independent assertions, because one repository’s green build must not be able to break the other’s runtime.
The replica-lag consequence, which is not cosmetic. Reading from a replica means a just-purchased Cartridge is invisible to the Orleans silo for the replication lag window (Q-M-010, ⊙, target p99 < 500 ms). For a buyer who purchases mid-session and immediately tries to load the ruleset, that window is a support ticket. The resolution is a fresh-read escape hatch: the VTT’s entitlement lookup accepts an after_grant_id hint carried in the ticket (Chapter 4); when present and not yet visible on the replica, the silo retries once against the primary. This is deliberately the only primary read the VTT performs, it is bounded to one attempt, and its rate is a metric (vtt_entitlement_primary_fallback_total) whose growth means the replica is unhealthy.
The Global scope obligation. The VTT’s ADR-042 classifies platform-level data as Global scope and ADR-082 records that adding to Global is a review-gated act. The entitlements table is Global-scoped by that taxonomy, so this addition requires a VTT-side ADR, not merely a Marketplace one. Tracked as OI-M-02. Recording it here rather than assuming it is exactly the failure mode the VTT corpus names as “ADR A asserts an outcome; the mechanism lives under ADR B; nobody owns the join”.
2.6 Deployment profiles
| Component | Runtime | Replicas | Resources | Scaling trigger | Can it scale to zero? |
|---|---|---|---|---|---|
| BFF (SolidStart) | Node 22 container | 1 → 10 | 0.5 vCPU / 1.0 GiB | HTTP concurrency | No — first-visit SSR cold start is a conversion and SEO cost |
| API (.NET 10) | Native AOT | 1 → 20 | 0.5 vCPU / 1.0 GiB | HTTP concurrency + SSE connection count | No — see §3.1 and §6.5 |
| Worker | .NET 10 JIT | 0 → 3 | 0.25 vCPU / 0.5 GiB | Outbox depth (KEDA, PostgreSQL scaler) | Yes — amended by ADR-M-041 (§9.2) |
| Stamping Job | .NET 10 JIT + native tooling | 0 → 30 | 1.0 vCPU / 2.0 GiB | Queue depth (KEDA) | Yes |
Admin API (ADR-M-034) | .NET 10 AOT | 0 → 2 | 0.25 vCPU / 0.5 GiB | HTTP; IP-allow-listed at Front Door | Yes — human-scale, cold start acceptable |
3. Technical Stack & Native AOT Implications
3.1 What Native AOT actually buys, and what it does not
Finding
M-F-02— “sub-50 ms cold start aligned with Scale-to-Zero” conflates two different cold starts, and the one AOT fixes is not the one Scale-to-Zero exposes.
A request arriving at a container app with zero replicas waits for: control-plane scheduling → node placement → image pull (or cache hit) → container runtime start → process start → first-request JIT/warmup. Native AOT compresses only the last two. Published and community measurements put an AOT Minimal API’s process-start-to-first-response in the tens of milliseconds against low hundreds for JIT (Q-M-011, Q-M-012, both ⊙) — a real 100–200 ms saving. The preceding platform stages are measured in seconds (Q-M-013, ✓, measured at ~9s by BENCH-M-01). Saving 150 ms inside a 9-second wall does not make scale-from-zero acceptable for a checkout page or a payment webhook.
Disposition (ADR-M-003): Native AOT is adopted, the stated rationale is corrected, and the API does not scale to zero. AOT is retained because its other three effects are the ones that pay:
- Resident memory. An AOT Minimal API’s steady-state working set is a fraction of the JIT equivalent, which is what permits the 0.5 vCPU / 1.0 GiB replica profile in §2.6 instead of the next size up. On an always-on floor, replica size is the bill (§8.8).
- Image size. A self-contained AOT binary on a chiselled base image is tens of megabytes rather than hundreds. That shortens the image-pull stage — the dominant term above — for every scale-out event, which is the elasticity this system actually uses.
- Scale-out p99 during a launch spike. A publisher’s release day is a step function. The interval between “KEDA decides to add a replica” and “that replica serves its first checkout” is where conversions are lost, and AOT removes the JIT warm-up tail from it.
The honest one-line summary, which should replace the original claim wherever it appears: AOT buys cheap always-on replicas and fast scale-out; it does not rescue scale-from-zero, and this system does not ask it to.
Enforcement. BENCH-M-01 measures process-start-to-first-200 in CI on every merge and fails the build on a regression beyond Q-M-011’s budget. BENCH-M-02 records published image size with a hard ceiling (Q-M-014). Both publish to the run summary so a regression is attributable to a commit rather than discovered in a bill.
3.2 The reflection ban and its consequences, library by library
Native AOT does not merely discourage runtime reflection; the trimmer removes the metadata that would make it work, and the failure mode is a NotSupportedException in production on a code path that CI did not exercise. The mitigation is not discipline — it is making the failure a build error:
<PublishAot>true</PublishAot><InvariantGlobalization>true</InvariantGlobalization><TrimmerSingleWarn>false</TrimmerSingleWarn><IlcTreatWarningsAsErrors>true</IlcTreatWarningsAsErrors><EnableTrimAnalyzer>true</EnableTrimAnalyzer><EnableAotAnalyzer>true</EnableAotAnalyzer><EnableSingleFileAnalyzer>true</EnableSingleFileAnalyzer><TreatWarningsAsErrors>true</TreatWarningsAsErrors>IL2xxx/IL3xxx warnings are errors, and there is no suppression allow-list. A suppression is a decision to ship an unexercised failure path; if one is genuinely required, it moves the code to the Worker (§3.3). Enforcement: the publish step runs in CI on every PR, not only on release — RC-M-01. A build that only AOT-compiles at release time discovers the problem at the worst moment.
| Concern | Reflection-free mechanism | Note |
|---|---|---|
| JSON | System.Text.Json source-generated JsonSerializerContext, one partial context per bounded context | JsonSerializerIsReflectionEnabledByDefault=false so a missing type registration is a compile error, not a runtime one |
| Configuration binding | Source-generated binder (EnableConfigurationBindingGenerator) | |
| Options validation | Source-generated validators ([OptionsValidator]) | Replaces DataAnnotations reflection |
| Logging | [LoggerMessage] source generators exclusively | Also removes boxing on the hot path; interpolated-string logging is banned by analyser |
| Data access | Raw NpgsqlCommand | §3.4 |
| Validation | Hand-written static validators returning Results<...> | FluentValidation’s expression-tree model is not AOT-viable |
| Object mapping | Mapperly (source generator) or hand-written | No AutoMapper |
| HTTP clients | HttpClient + source-gen JSON | No refit-style dynamic proxies |
| Auth | Microsoft.IdentityModel.JsonWebTokens 8.22.0 + Microsoft.AspNetCore.Authentication.JwtBearer 10.0.10 are AOT-clean — measured, not assumed (spike OI-M-03, 2026-08-03: publish exit 0, zero IL2xxx/IL3xxx, sign/validate/JWKS/middleware all working in the native binary with IsDynamicCodeSupported = False). The ~200-line hand-rolled ES256 fallback is not required and must not be built. | Two mandatory configuration consequences in §4.3.1 |
3.3 The Stripe SDK problem, and the AOT/JIT split
Stripe.net is a large, reflection-oriented client library. Forcing it through the AOT pipeline is a fight with an uncertain end and a maintenance burden on every SDK bump. Two observations resolve it:
- The API needs only a narrow slice of Stripe: create a Checkout Session, create a
PaymentIntent, verify a webhook signature, and deserialise a handful of event envelopes. Webhook verification is HMAC-SHA256 over bytes — roughly twenty lines with no dependency. Session creation is one form-encodedPOST. Both are trivially AOT-safe when written directly. - The Worker needs the wide slice: refunds, transfers, payouts, account onboarding links, dispute handling, tax reporting. It is always-on, so AOT’s cold-start and memory benefits are worth approximately nothing to it.
ADR-M-004: the API is Native AOT and uses a hand-written typed Stripe client over HttpClient + source-generated JSON; the Worker runs on the JIT runtime and uses Stripe.net freely. The split is along the axis where the benefit lives, not uniformly for tidiness. Enforcement: an architecture test asserts Stripe.net is not referenced, transitively, by any project in the API’s dependency closure — ARCH-M-01.
The hand-written webhook verifier is small enough to state, because getting it wrong is a payment-authenticity failure:
// Verifies the Stripe-Signature header against the RAW body. Never re-serialise.static bool Verify(ReadOnlySpan<byte> rawBody, string sigHeader, ReadOnlySpan<byte> secret, DateTimeOffset now, TimeSpan tolerance){ if (!TryParse(sigHeader, out long t, out var v1List)) return false; if (Math.Abs(now.ToUnixTimeSeconds() - t) > tolerance.TotalSeconds) return false; // replay window
Span<byte> signed = stackalloc byte[32]; using var hmac = IncrementalHash.CreateHMAC(HashAlgorithmName.SHA256, secret); Span<byte> tsAscii = stackalloc byte[20]; Utf8Formatter.TryFormat(t, tsAscii, out int tsLen); hmac.AppendData(tsAscii[..tsLen]); hmac.AppendData("."u8); hmac.AppendData(rawBody); // the RAW bytes, exactly as received hmac.GetHashAndReset(signed);
foreach (var candidate in v1List) // multiple v1 during secret rotation if (CryptographicOperations.FixedTimeEquals(signed, candidate)) // constant time, always return true; return false;}Three properties are normative and each names its enforcement point:
- The raw request body MUST be read as a stream and hashed as raw bytes before any parsing. Model binding deserialises, and any re-serialisation produces a different byte sequence and therefore a different HMAC. Enforced by
ARCH-M-02, which asserts the webhook endpoint’s parameter list contains no bound model type. - Comparison MUST be constant-time. Enforced by a Roslyn analyser rule banning
SequenceEqual/==on values flowing from a signature parse —ARCH-M-03. - Multiple
v1signatures MUST be accepted. During endpoint-secret rotation Stripe sends two. An implementation that reads only the first breaks silently at rotation and the failure looks like an outage. Enforced by a unit test in the webhook fixture corpus (§5.11).
3.4 Data Access: The Dapper.AOT reflection collision (M-F-03)
ADR-M-039: Data access uses raw NpgsqlCommand instead of Dapper.AOT interceptors to preserve IlcTreatWarningsAsErrors=true.
Finding M-F-03: While Dapper.AOT intercepts calls at compile time, it still requires referencing the base Dapper package. Dapper’s reflection core (SqlMapper.DefaultTypeMap, etc.) remains statically reachable, triggering IL2070/IL2075 and IL3050 warnings during Native AOT publish. Because our strict rule forbids suppressing IL2xxx/IL3xxx warnings, Dapper cannot be used in the API.
Instead, we use raw NpgsqlCommand (or standalone, hand-written CommandFactory/RowFactory). The constraints we demanded still apply to the raw ADO.NET commands:
dynamicis forbidden. Every query projects explicitly to arecordor a tuple via ordinal column reads.- Command text is a constant. String-built SQL silently defeats parameterization analysis and creates SQL injection risks. Enforced by
ARCH-M-05. Dynamic filters are expressed as parameterised predicates (WHERE (@status IS NULL OR status = @status)), which is also the shape PostgreSQL plans best under generic plans. - No ORM change-tracking, no lazy loading, no navigation properties. Reads project directly to the shape the endpoint returns; writes are explicit
INSERT/UPDATEstatements. This is not asceticism — it is what makes the pessimistic-locking andSKIP LOCKEDpatterns of Chapter 5 expressible at all, since both depend on the exact SQL being under the author’s control.
3.5 SolidStart as BFF — a hard boundary on what it may do
SolidStart provides SSR for the catalogue (which is SEO-load-bearing for a storefront) and server functions for form actions. The failure mode of every BFF is that it accretes business logic until there are two systems that both believe they own authorisation.
ADR-M-005: the BFF may compose and shape; it may not decide. Concretely — it may call several API endpoints and merge their results, render, cache, and redirect. It may not evaluate an entitlement, compute a price, decide a refund, or touch a database. Enforcement is structural rather than cultural: the BFF container app has no database credentials and no Stripe keys in its secret set (RC-M-06), so the prohibited code cannot be written even by someone who wants to. A "use server" function that needs data calls /api/* with the caller’s cookie forwarded, and therefore inherits the API’s authorisation decision rather than making its own.
The one thing the BFF does own is the silent refresh: on an SSR navigation it may exchange a near-expiry refresh cookie for a new identity JWT and re-issue the cookie on the response (§4.4). This is placed in the BFF because it is the only component that sits on every full-page navigation.
3.6 Neon PostgreSQL 18 — pooling, and the three features transaction pooling takes away
Neon presents two endpoints: a direct endpoint and a -pooler endpoint backed by PgBouncer in transaction mode. Transaction pooling is what makes a scale-out container fleet viable against a serverless database — but it removes session-scoped behaviour, and this system uses session-scoped behaviour in exactly one place.
| Feature | Under transaction pooling | This system’s use |
|---|---|---|
LISTEN / NOTIFY (listen side) | Broken. A LISTEN registers on a backend the pooler will hand to someone else. | §6.3 — the SSE listener MUST use the direct endpoint (ARCH-M-06 asserts the listener resolves its connection string from the Direct configuration key, and a startup self-check fails fast if that endpoint is the pooler host) |
NOTIFY (notify side) | Fine — it is transactional and fires at commit | Used freely from pooled connections |
| Session advisory locks | Broken | Not used. All locking is FOR UPDATE inside a transaction (§5.4) or transaction-scoped advisory locks |
| Server-side prepared statements | Broken | Max Auto Prepare=0 on pooled connection strings, set once in the shared data-source factory (RC-M-07) |
SET outside a transaction | Broken | statement_timeout and idle_in_transaction_session_timeout are set per-role via ALTER ROLE, not per-session |
Two connection budgets, not one. Pooled connections are cheap and bounded by PgBouncer; direct connections are scarce and are consumed one per API replica by the SSE listener. At 20 replicas that is 20 permanently-open direct connections, plus the Worker’s, plus operational access. This is a hard scaling ceiling on the API’s replica count (Q-M-015, derived by the formula in §6.7 and enforced as a CI-checked maxReplicas cap) and it is the reason §6.6 and §6.7 exist.
Neon autosuspend interacts badly with a persistent LISTEN. Neon suspends idle compute; an open LISTEN connection is not idle. The database’s scale-to-zero is therefore pinned awake by the notification subsystem, which is a direct, quantified FinOps cost of choosing LISTEN/NOTIFY over a broker (§8.8). It is recorded here rather than in Chapter 8 alone because a reader evaluating §6.3 needs to see the bill attached to the mechanism.
Neon branching is used for CI. Each pull request provisions a database branch from a sanitised snapshot, runs migrations forward, and is destroyed on merge. This is the one place the serverless database’s economics are unambiguously better than a managed instance, and it is what makes the migration-safety checks of §2.5 and §3.7 cheap enough to run on every PR.
3.7 Schema evolution under rolling revisions (OI-M-12)
ADR-M-033: every schema change is expand-contract, forward-only, executed by a pre-deploy ACA Job on the direct endpoint under a session advisory lock, and split across at least two releases. A migration linter enforces the forbidden-operation list in CI, and pending contract steps are tracked in a debt ledger that can only shrink.
The premise, stated precisely
Azure Container Apps replaces a revision by rolling: the new revision is provisioned, passes health probes, receives traffic, and only then is the old one deactivated. The overlap window is not bounded by us — it depends on probe timing, drain, and how long an in-flight request takes. So the operating assumption is not “briefly two versions” but:
Code version N and version N+1 both execute against schema version S+1, for an unbounded period, on every single deploy.
Every rule below follows from that one sentence. It also means the familiar shortcut — “take a short maintenance window” — is unavailable, because there is no window; there is a continuous overlap.
The cycle, and why one release can never hold a whole change
| Step | Runs | Compatible with | Reversible |
|---|---|---|---|
| Expand | Before new code — new code needs the new shape | Code N and N+1 | Yes, trivially — nothing was removed |
| Deploy | ACA rolling revision | — | Yes — roll back to N, S+1 still works |
| Backfill | After deploy, as a separate resumable job | Both | Yes — idempotent by construction |
| Contract | A later release, after revision N is fully deactivated | Code N+1 only | No. This is the irreversible step |
Expand must precede the new code and contract must follow the old code’s removal, so a single pipeline run cannot contain both. This is the rule that people try hardest to avoid, and every workaround for it is a way of being briefly broken. Concretely: Q-M-057 — a contract step MUST NOT ship earlier than the release after the one that made it safe, and not until the prior revision is confirmed deactivated.
Forward-only. There are no down migrations. A down migration is a fiction under load: it assumes no writes happened in the new shape, which is false the moment the new code served one request. Rollback is a code operation — revert to N, which still works because expand was backward-compatible. Enforcement: the runner has no down path, and RC-M-29’s linter rejects a file named or annotated as a reverse migration.
Three interactions specific to this system
Generic expand-contract advice does not cover these, and each one bites hard.
1. ledger_entries cannot be backfilled. At all. §7.5 installs a BEFORE UPDATE OR DELETE trigger that raises unconditionally. That trigger does not distinguish a migration from application code, and it must not be dropped to run a backfill — a window in which the ledger is mutable is a window in which the one invariant the money rests on does not hold, and it will be discovered by whoever is on call. Consequences: a new ledger column must be nullable forever, or populated only for rows written after it existed; a change requiring historical values needs a new table joined at read time, never a rewrite. The ledger’s schema is therefore append-only in the same sense its rows are, which is a real constraint on the chart of accounts and on ADR-M-032’s subject_ref — both had to be right in the first migration, and §8.11 says so for that reason.
2. vtt_entitlement_v1 has a consumer whose release cadence we do not control. For internal tables, the contract window is one of our releases. For the shared view it is one of the VTT’s releases (§2.5), which is a different and much longer clock, negotiated rather than scheduled. A migration touching the view’s base tables must keep the _v1 projection byte-stable; retiring _v1 is RC-M-09’s named sign-off, not a contract step we may take on our own cadence.
3. Migrations spike the entitlement replica lag, and buyers feel it. Heavy DDL and large backfills generate WAL that the read replica must apply, so Q-M-010 (entitlement visibility lag) degrades exactly while a deploy is running — and the buyer who notices is the one who just purchased a Cartridge mid-session. Two consequences: the vtt_entitlement_primary_fallback_total metric is expected to rise during a migration and must not page, and backfills over large tables run off-peak and are gated on the entitlement-lag SLO rather than only on their own progress.
The forbidden-operation list
This is the artefact that does the work. Each row is rejected by the linter (RC-M-29) unless the migration carries an explicit, reviewed annotation:
| Forbidden | Why it breaks a rolling deploy | Do this instead |
|---|---|---|
DROP COLUMN / DROP TABLE | Code N still selects it | Contract phase only |
RENAME COLUMN / RENAME TABLE | It is a drop and an add at the same instant — there is no version of the code that works | Add new → dual-write → backfill → contract. Never rename. |
ALTER COLUMN TYPE | Table rewrite under ACCESS EXCLUSIVE; code N may not parse the new type | New column, dual-write, contract |
ADD COLUMN NOT NULL without default | Code N’s INSERT omits it | Nullable → backfill → NOT NULL via NOT VALID + VALIDATE |
ADD CONSTRAINT validated immediately | Full scan under a strong lock | ADD CONSTRAINT ... NOT VALID, then VALIDATE CONSTRAINT (weaker lock) |
CREATE INDEX without CONCURRENTLY | Blocks writes for the build | CREATE INDEX CONCURRENTLY, outside a transaction, then verify the index is valid |
Changing or dropping a DEFAULT | Code N relies on the old default | New column |
Native enum types | A value can never be removed, and code N reading an unknown value is undefined | text + CHECK. Appendix A already does this throughout; ADR-M-033 makes it deliberate rather than lucky |
| Backfill in one transaction | Bloat, WAL spike, replica lag, and a long-open transaction on a serverless database | Batched at Q-M-058, resumable, idempotent |
DDL without lock_timeout | The single most common way a “safe” migration causes an outage: the DDL queues behind one long transaction, and every subsequent query queues behind the DDL. A metadata-only change takes the site down. | SET lock_timeout = Q-M-056 and retry with backoff. Non-negotiable. |
Execution
Migrations MUST NOT run at application startup. Three reasons, and the third is the one that costs a night: N replicas race; a scale-to-zero app would run DDL on a cold request path; and a failed startup migration is a crash-loop that ACA reports as an unhealthy revision, so the operator sees an opaque deployment failure instead of the migration error that caused it. Enforcement: ARCH-M-18 asserts no migration invocation is reachable from the API or BFF composition root.
The runner is a separate ACA Job, triggered by the pipeline, and:
- Is not Native AOT. Same reasoning as the Worker (
ADR-M-004) — it is a short-lived batch process, so AOT buys it nothing, and it may therefore use a conventional migration library freely. This is also why EF Core migrations being unavailable to the AOT API costs us nothing: the API was never going to run migrations. - Uses the direct endpoint, because DDL and session-scoped advisory locks are both unavailable through transaction pooling (§3.6). Its connection is one of
Q-M-036’s reserved six. - Holds a session advisory lock for the whole run, so two pipeline invocations cannot interleave. Session rather than transaction scope because some required statements —
CREATE INDEX CONCURRENTLY, notably — cannot run inside a transaction, and a transaction-scoped lock would be released the moment those need to execute. - Runs with a
migratorrole that has DDL; the application’sapp_rwrole has none. Defence in depth that costs oneGRANTscript: an application-level SQL injection cannot then alter the schema. Enforcement:ARCH-M-19assertsapp_rwholds noCREATE/ALTER/DROPprivilege, alongside §2.5’s existing check onvtt_reader. - Records a checksum per applied migration and refuses to start if a previously-applied file has changed — an edited migration means two environments have divergent schemas that both believe they are current.
Pipeline shape:
PR : provision Neon branch -> migrate forward -> lint (RC-M-29) -> tests -> PREVIOUS-RELEASE compatibility test (RC-M-30)main / release : expand Job -> deploy revision (rolling) -> confirm old revision deactivated -> backfill Job (resumable, off-peak if large)release N+1 : contract Job (only for debts marked safe) -> deployThe test that actually proves backward compatibility
Every rule above is a claim about code N running against schema S+1, and almost nobody tests it. RC-M-30: CI checks out the previous release tag’s integration test suite and runs it, unmodified, against the newly-migrated schema branch. A green run is the assertion; a red run means the migration was not an expand. Neon branching makes this affordable, which is the concrete payoff of §3.6’s choice.
The contract-debt ledger
Expand-contract’s failure mode in practice is not breakage — it is that contract never happens, and the schema accretes nullable columns, shadow tables and dual-write paths that nobody dares remove. So pending contract steps are a tracked debt, deliberately modelled on the VTT corpus’s enforcement_baseline.txt:
- One line per pending contract step: what is to be dropped, the release that made it safe, the owner.
- The file can only shrink. A line that is no longer needed must be removed, and CI fails on a debt entry whose target no longer exists — which forces the file to be pruned when something is fixed rather than accumulating.
- A debt older than
Q-M-059fails the build. A permanent nullable column added “temporarily” is a design decision, and this is what makes someone say so out loud.
Enforcement: RC-M-31.
One erasure interaction, because it is easy to miss
A backfill that computes or repairs subject_ref on a Class R table (§8.11) MUST consult the suppression register first. A well-meaning repair job that recomputes linkages from accounts would resurrect exactly the associations an erasure destroyed — and it would look like a bug fix in the pull request. RC-M-28’s erase-then-reconcile test is extended to run after a backfill, not only after an erasure.
4. Authentication & the Ticket Exchange Pattern
4.1 Why server-side sessions are forbidden here
A session store is a mutable, replicated, latency-critical dependency on the request path of every page in a system that is otherwise stateless and horizontally scaled to zero-ish. It would be the only component forcing sticky routing, the only one requiring a cross-region replication story, and the only one whose loss logs out every user simultaneously. The stated prohibition is therefore adopted — with one correction that matters:
A stateless token is revocable only by reintroducing state. This design reintroduces exactly one bit, and bounds its lifetime by the token’s. §4.7 describes the revocation cache. It is not a session: it holds no user data, it is authoritative for nothing, its absence fails open into a bounded window rather than failing closed into an outage, and it can be dropped entirely at the cost of that window. Calling it a session would be inaccurate; pretending it does not exist would be worse.
4.2 FIDO2 / passkeys, and the one setting that determines whether SSO works at all
The Relying Party ID MUST be descentvtt.com — the registrable parent domain — not shop.descentvtt.com. WebAuthn scopes a credential to its RP ID and permits use on that domain and its subdomains, but not on a sibling. A credential registered against shop.descentvtt.com is unusable at app.descentvtt.com, and the error surfaces at the worst possible moment: a returning customer at a VTT session start. Enforcement: a unit test asserts the configured RP ID equals the registrable parent, and an integration test performs a registration at the storefront origin followed by an assertion at the VTT origin — RC-M-04.
| Parameter | Value | Reason |
|---|---|---|
| RP ID | descentvtt.com | Above |
| Allowed origins | https://shop.descentvtt.com, https://app.descentvtt.com | Explicit allow-list; wildcard origins are refused at config validation |
| Attestation | none | Attestation statements are enterprise-device-fleet machinery. Requesting them adds a privacy prompt and a verification burden that buys a consumer marketplace nothing. |
| Discoverable credentials | preferred | Enables username-less login, which is the actual UX win |
| User verification | preferred for login; required for step-up (§4.9) | A single policy cannot serve both a browse session and a payout-account change |
| Algorithms | ES256, then RS256 | ES256 first; RS256 for authenticators that offer nothing else |
M-F-09— §4.2 was unimplementable against Appendix A, because no table holds a credential. Raised and closed 2026-08-10. This section specifies an RP ID, an algorithm preference and a user-verification policy; §4.5’s own sequence diagram has the API “verify credential, bump sign counter”. Appendix A’s Identity block declaredauth_tickets,account_recovery_requestsandaccount_recovery_codesand nothing that can hold a credential id, a public key or a counter. Closed by0003_identity.sql’swebauthn_credentials, and Appendix A is amended.The sibling gaps found in the same pass:
M-F-10, §4.3 specifies refresh tokens as “single-use with rotation-on-use and reuse detection” and §4.7 requires killing a family, with no table for either — closed byrefresh_tokens.M-F-11, §4.7 and §4.10 both incrementaccounts.session_version, which Appendix A’sALTER TABLE accountsdoes not add — closed by0003. All three are the same kind of gap: a mechanism specified in prose whose state had nowhere to live, invisible until somebody tried to write the DDL against it.
ADR-M-048: FIDO2 ceremonies are verified in-house against BCL primitives, not through a WebAuthn library
Status: Accepted · Date: 2026-08-10 · Depends-on ADR-M-003 (the AOT constraint is what forces it) · Applies the reasoning of ADR-M-008 (§4.3) and ADR-M-042 (§4.11) a third time
attestation: none is what makes this a small decision rather than a reckless one. The large, reflective half of every WebAuthn library is the part this section has already declined: the attestation statement formats (packed, tpm, android-key, apple, fido-u2f), X.509 chain building, and the FIDO Metadata Service. Asking for none removes all of it. What remains is a CBOR decode of two fixed shapes — the attestation object and the COSE key — two SHA-256 comparisons, and one signature verification against ECDsa or RSA.
The argument against the dependency is not new here. §4.3 rejected EdDSA because “adding a third-party crypto dependency to a Native AOT binary on the auth path is avoidable risk for a marginal gain”, and §4.11 rejected Argon2id on the same ground. A WebAuthn library on the login path of a binary built with IlcTreatWarningsAsErrors and no suppression allow-list is the same trade at larger scale: a single IL2xxx from a transitive dependency fails the publish, and the response would be a suppression that ships an unexercised failure path.
Evidence, not preference. The API’s Native AOT publish was run with the whole stack in it on 2026-08-10: exit 0, zero IL2xxx/IL3xxx, 25 MB win-x64. System.Formats.Cbor needed no package reference at all — adding one fails restore with NU1510 because the assembly is in the .NET 10 shared framework — so the verifier’s only dependency is the framework.
What is given up, stated rather than implied. No verifiable authenticator make or model, therefore no hardware policy and a self-asserted aaguid. That is this section’s own trade and is inherited, not introduced.
Exit cost, recorded at adoption. The seam is WebAuthnCeremony’s two static methods, and stored credentials are raw COSE keys — the format every library accepts. Replacing the verifier is one file and no migration; the reason it would be replaced is attestation, which is also the reason it would stop being cheap.
Enforcement: RC-M-04’s cross-origin registration-then-assertion case, plus a refusal corpus that perturbs one field of an otherwise valid ceremony per test — a tampered signature, a signature over the wrong bytes, an algorithm the credential was not enrolled with, a prefix-matching origin, a subdomain-scoped RP ID hash, a regressed counter. Each is mutation-verified: the corresponding check was disabled and the corresponding test observed to fail.
Two consequences that are usually discovered late. First, synced passkeys are shared credentials by design — an iCloud or Google-account passkey travels to every device in a family sharing group. For a content marketplace this is a licence-sharing vector, and the correct response is to price and licence for it rather than to fight it; the alternative (device-bound keys only) excludes most consumers. Second, a passkey-only account has no recovery path, and a lost authenticator is a lost purchase library. §4.10 specifies the two mechanisms that answer it — time-locked email recovery (ADR-M-030) and offline recovery codes (ADR-M-042) — the places where each is deliberately weaker than a normal login, and the failure that neither one can reach.
4.3 Token design
| Property | Choice | Reason |
|---|---|---|
| Algorithm | ES256 (ECDSA P-256) | Fully supported in the BCL, AOT-safe, 64-byte signatures. EdDSA was rejected — not on cryptographic grounds but because Ed25519 is not in the .NET base class library, and adding a third-party crypto dependency to a Native AOT binary on the auth path is avoidable risk for a marginal gain. |
| Key distribution | JWKS at https://shop.descentvtt.com/.well-known/jwks.json, kid in every header — but see M-F-12 below | The VTT verifies against this endpoint and caches with the published TTL. Two keys are always published so rotation never has a gap. |
| Rotation | 90 days, with a 7-day overlap where the retiring key is still published | RC-M-05 |
| Identity token lifetime | 60 minutes (Q-M-016) | Bounds the revocation window (§4.7) |
| Refresh token lifetime | 30 days, sliding, single-use with rotation-on-use and reuse detection | Reuse of a rotated refresh token invalidates the whole family and is alerted on — it is the signature of a stolen token |
Claims are deliberately thin. The JWT carries sub, sid, iss, aud, iat, exp, nbf, jti, amr (which authentication method, and whether it was step-up-verified) and auth_time. It carries no entitlements, no roles, and no display name. An entitlement in a token is a stale entitlement sixty minutes after a refund, and the whole point of §2.5 is that entitlements are looked up.
M-F-13—auth_timewas missing from this list and §4.9 requires reading it. Raised and closed 2026-08-10 while implementing Chapter 4. This sentence previously enumerated eight claims, ending atamr. §4.9 gates every sensitive operation on “anamrclaim containingstepupwith anauth_timeinside the window” — a claim the token was not specified to carry.
iatis not a substitute and the difference is the whole control. A refresh mints a new token with a newiatwhile performing no authentication at all, so a filter readingiatwould find every refreshed session permanently five minutes old —Q-M-018’s window would renew itself hourly, for free, without anyone touching an authenticator.auth_timeis the moment of the assertion and is carried unchanged across rotation, which is what makes the window mean what §4.9 says it means. Added to the claim set; the refresh family storesauth_timealongsideamrfor exactly this reason.
nbfis listed because the token carries it and the list claimed to be complete. It is set toiatand is not load-bearing.
session_version is deliberately not a claim, and §4.7’s stated latency is why. §4.7 prices “log out everywhere” at “up to 60 minutes on already-issued identity tokens” — which is only true if nothing compares the version per request. Carrying it in the token would invite exactly that comparison, and a per-request read of accounts is the session store §4.1 refuses. The version is captured on the refresh family at issue and compared once per refresh, inside the same guarded UPDATE that consumes the token.
M-F-12— the published JWKS URL is not on a path §2.2 routes to the API. Raised 2026-08-10. §2.2 path-routes/api/*to the API and/*to the SolidStart BFF, sohttps://shop.descentvtt.com/.well-known/jwks.jsonresolves to the BFF — which byADR-M-005decides nothing, holds no key material, and would answer 404. Every VTT token verification would fail, and the failure would appear at the first cross-context session rather than at deploy.Dispositioned by mapping both paths and naming which is correct when.
/api/.well-known/jwks.jsonis reachable through the existing route rule and is what the VTT should be pointed at today./.well-known/jwks.jsonis this section’s literal URL and becomes reachable only once Front Door carries an explicit rule sending that exact path to the API. The duplication is the honest state of an edge configuration this repository does not own; it is recorded rather than resolved because resolving it means editing infrastructure, and a document claiming a URL works when the routing says otherwise is the defect.
4.3.1 Two mandatory JwtBearer settings, both found by measurement
Spike OI-M-03 established that the stack is AOT-clean. It also surfaced two behaviours of ASP.NET Core’s defaults that silently break this claim design. Both are recorded here rather than in the spike alone, because a reader implementing §4.9 will not go looking in spikes/.
1. MapInboundClaims MUST be false. Under the default inbound mapping, sub is renamed to http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier and amr to http://schemas.microsoft.com/claims/authnmethodsreferences. The request still authenticates and still returns 200 — only FindFirst("sub") returns null. The failure is invisible in the response and would be found in production. Enforcement: a unit test asserting the configured MapInboundClaims == false, plus an integration test asserting sub survives a round trip — RC-M-27.
2. amr arrives as multiple claims, not one claim holding a list. ClaimsIdentity expands a JSON array claim value into one Claim per element, which the spike observed directly (two authnmethodsreferences claims for ["webauthn","stepup"]). §4.9’s step-up filter MUST therefore read FindAll("amr") and test for membership. FindFirst("amr")?.Value == "stepup" fails for exactly the token a stepped-up session presents — one carrying both values — and it fails closed, which is worse than failing open here: it looks like an ordinary permissions bug, and the natural “fix” is to weaken the check that protects payout details. ARCH-M-07 is extended to assert the filter reads the claim set rather than a single claim, and the fixture corpus includes a multi-value amr token.
4.4 Cookies, and the __Host- prefix we cannot have
Set-Cookie: dv_id=<jwt>; Domain=.descentvtt.com; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=3600Set-Cookie: dv_rt=<opaque>; Domain=.descentvtt.com; Path=/api/auth/refresh; HttpOnly; Secure; SameSite=Lax; Max-Age=2592000__Host- prefixed cookies are impossible here and that is a real, permanent cost. The prefix requires the absence of a Domain attribute, which is precisely the attribute that makes cross-subdomain SSO work. We give up the browser-enforced guarantee that the cookie was set by this exact host, and we accept its consequence:
Any subdomain of
descentvtt.comthat can execute JavaScript, or that an attacker can claim, can obtain the identity cookie for every user who visits it. This is the price of the SSO design, it is not mitigable by cookie flags, and the mitigations are therefore operational: (a) no wildcard DNS record, ever —RC-M-10; (b) a reviewed subdomain inventory with dangling-CNAME detection run daily; (c) CAA records restricting issuance; (d) no user-controlled content served from anydescentvtt.comsubdomain — community mods and creator uploads are served exclusively from a separate registrable domain (descent-usercontent.com) with no cookie scope over it. Item (d) is the one that actually removes the class of attack, and it constrains the asset-delivery design in §8.2.
SameSite=Lax is chosen over Strict because the Stripe redirect back from checkout is a cross-site top-level GET; under Strict the buyer arrives at the success page logged out, which reads as a payment failure. Lax permits it. None is not required because no cross-site subresource needs the cookie — a direct consequence of the same-origin /api/* routing in §2.2.
4.5 The ticket exchange
sequenceDiagram autonumber participant U as User participant SHOP as shop.descentvtt.com<br/>SolidStart participant API as Marketplace API participant PG as PostgreSQL participant VFE as app.descentvtt.com<br/>VTT Client participant SIG as Orleans / SignalR
U->>SHOP: FIDO2 assertion SHOP->>API: POST /api/auth/login (assertion) API->>PG: verify credential, bump sign counter API-->>SHOP: Set-Cookie dv_id (Domain=.descentvtt.com), dv_rt U->>VFE: navigates to app.descentvtt.com Note over VFE: Zero frontend auth state.<br/>No token in JS, no localStorage. VFE->>API: POST /api/auth/ticket {audience:"vtt"}<br/>(cookie sent: parent-domain scope) API->>API: verify JWT, check revocation cache API->>PG: INSERT auth_tickets (sha256(ticket), sub, aud, exp=now()+15s) API-->>VFE: {ticket, expiresIn:15} VFE->>SIG: WebSocket connect ?access_token=<ticket> SIG->>API: POST /api/auth/ticket/redeem {ticket} API->>PG: UPDATE auth_tickets SET consumed_at=now()<br/>WHERE hash=$1 AND consumed_at IS NULL<br/>AND expires_at > now() RETURNING sub alt zero rows API-->>SIG: 401 — replayed, expired, or forged SIG-->>VFE: close 4401 else one row API-->>SIG: {sub, entitlementHint} SIG-->>VFE: connected endWhy 15 seconds, and why stateful. The ticket’s lifetime is short because of where it travels: a browser WebSocket cannot set request headers, so SignalR carries its token in the query string, and query strings land in access logs, proxy logs, and browser history. The ticket is a bearer credential in a medium that leaks, so its value window is compressed to roughly one network round trip. The ticket is a database row rather than a signed value because single-use is the property that matters, and a signed 15-second token is replayable for 15 seconds. The atomic UPDATE ... WHERE consumed_at IS NULL ... RETURNING is the entire enforcement: PostgreSQL’s row lock makes concurrent redemption of the same ticket resolve to exactly one winner, with no application-level coordination.
The alternative — a signed stateless ticket — was rejected on that single point. The cost of the choice is one write and one update on the VTT connection path, both single-row primary-key operations, against a database the API already holds a pooled connection to. If measurement (BENCH-M-03) shows that cost is material, the fallback is the Garnet instance the VTT already runs; it is not to make the ticket stateless.
Table hygiene is part of the design, not an afterthought. auth_tickets receives a row per VTT connection and its rows are dead within 15 seconds. It is declared with a low fillfactor and an aggressive autovacuum threshold, and a scheduled job deletes rows older than five minutes. Built 2026-08-10 as ExpiredStateSweeper, and hosted in the API rather than in the Worker — §2.6 gives sweeps to the always-on Worker, but that project is a class library with no entry point, so a sweeper there would never run. One replica sweeps, chosen by a PostgreSQL session advisory lock. The five minutes are not slack: a ticket that expired one second ago may be the subject of a redemption in flight, and deleting it turns “replayed” into “forged”. An unbounded high-churn table on a serverless database is a bloat and cost problem that appears three months in.
entitlementHint. The redemption response carries the id of the most recent entitlement grant for that subject. This is the after_grant_id of §2.5 — it is what lets the Orleans silo know that a replica read returning nothing might be stale rather than authoritative, and bound its one primary-read fallback.
4.6 CSRF
State-changing endpoints are POST/PUT/DELETE only, and SameSite=Lax already blocks cross-site sends for those methods. That is the primary defence. Two additions, because SameSite is a browser behaviour and not a server-enforced invariant:
Originheader validation on every state-changing request, against the same explicit allow-list as the WebAuthn origins. A request with a missing or unlistedOriginis refused. This is server-enforced and does not depend on the browser honouring anything.- No state-changing
GET. Already required byARCH-M-04for a different reason (§2.4); it does double duty here.
Token-based CSRF defences are deliberately absent: they add a synchroniser-token store to a system whose defining constraint is that it has no session store, in exchange for defending a case the two mechanisms above already cover.
4.7 Revocation, and the honest size of the window
| Action | Effect | Latency to effect |
|---|---|---|
| Logout (this device) | dv_rt family invalidated; jti added to the revocation cache with TTL = remaining token lifetime | Immediate |
| Logout everywhere | session_version on the account is incremented | Up to 60 minutes on already-issued identity tokens; immediate on refresh |
| Credential compromise / admin lock | session_version bumped and every live jti for that sub pushed to the revocation cache | Immediate — this is what the cache is for |
| Refund revoking an entitlement | No token effect at all | Immediate — entitlements are never in the token (§4.3) |
The revocation cache is a small key-value store (Garnet in the ACA environment, or a PostgreSQL table with a TTL sweep — Q-M-017 decides on measured lookup latency). Both are built as of 2026-08-10 and both are exercised, selected by Identity:RevocationStore; the PostgreSQL one is the default because it needs no new infrastructure. ADR-M-029’s standard is the reason both exist rather than one: “an implementation that is never executed is a plan, not an exit”, and a choice between one built thing and one described thing is not a choice. Q-M-017 still has no measurement — what changed is what it will be choosing between. Its failure mode is deliberately fail-open into the 60-minute window rather than fail-closed into a site-wide outage, because a cache whose unavailability logs out every user is a worse security outcome than a bounded stale-token window: the first is a guaranteed incident, the second is a bounded exposure that only matters when a revocation has actually occurred. This trade is stated explicitly because it is the kind of decision that gets silently reversed by someone hardening the system without seeing the availability side.
4.8 The VTT’s side of the contract
The VTT verifies nothing about entitlements and everything about identity:
- Redeem the ticket at the Marketplace API. The ticket, not the JWT, is the VTT’s input — the VTT never parses the identity JWT and never receives it. This keeps the JWT’s blast radius inside the Marketplace and means a VTT compromise cannot mint Marketplace credentials.
- Establish its own connection-scoped principal from the redemption response.
- Query
vtt_entitlement_v1on the replica for authorisation decisions, with the one bounded primary fallback of §2.5.
Consequence: a Marketplace outage does not disconnect live VTT sessions. It prevents new connections from authenticating, which is a degradation, not an outage. That asymmetry is intentional and is the payoff for the ticket indirection.
4.9 Step-up authentication
The parent-domain cookie means an XSS anywhere in the domain is an account takeover (§4.4). The mitigation that actually reduces harm is to ensure the cookie alone is not sufficient for the operations worth stealing an account for:
A fresh FIDO2 assertion with userVerification: required, no older than five minutes (Q-M-018), MUST accompany: changing payout bank details, changing the account email, adding or removing a passkey, initiating a payout to a new destination, and downloading tax documents. Enforcement: these endpoints require an amr claim containing stepup with an auth_time inside the window, checked by a shared endpoint filter, and an architecture test asserts every route in the Identity.Sensitive and Ledger.Payout route groups carries that filter — ARCH-M-07. A route added to those groups without the filter fails the build; this is the mechanism, because a policy that relies on remembering to add an attribute is not a mechanism.
4.10 Account recovery — zero-touch, deliberately weaker than a login, and bounded by what it cannot reach
Identity resilience here rests on three factors and no fourth. Each one substitutes for a different thing, which is why they compose rather than overlap:
| Factor | What it is | What it substitutes for | Record |
|---|---|---|---|
| FIDO2 passkey | The primary credential — phishing-resistant, non-exportable in its device-bound form | Nothing. It is the thing the other two exist to replace | §4.2 |
| Time-locked email recovery | A 48-hour lock (Q-M-038) on a request the true owner can cancel with a passkey | Possession, by substituting time — the attacker who owns the inbox does not get 48 unobserved hours | ADR-M-030, below |
| Offline recovery codes | 8–12 single-use codes, generated on request, displayed once, held by the user off-platform | Possession, by restoring it — the code is a possessed factor, which is why it needs no time lock | ADR-M-042, §4.11 |
Lose all three and the account is unrecoverable. That is a designed property, not a gap — the closing subsection of §4.11 states it, and ADR-M-042 removes the manual-review escape hatch this section previously left open.
ADR-M-030: recovery is an emailed request that starts a 48-hour time lock, cancellable by a passkey assertion, granting a quarantined session on completion. (Amended by ADR-M-042, which withdraws the manual-review residual below and adds the third factor.)
The problem is a genuine trilemma and it is worth stating before the mechanism, because each corner is a real failure:
- No recovery — a lost authenticator is a permanently lost purchase library. For a marketplace whose product is a durable digital library, this is not a security posture, it is a data-loss guarantee.
- Instant email recovery — the account’s security collapses to the security of an email inbox. Everything Chapter 4 builds is then decoration; the passkey is a convenience feature over a magic link.
- Human-verified recovery — defensible, and the reason it is rejected here is economic and honest: §1.2 shows contribution per order in single dollars, and a support queue performing identity verification is a cost structure that belongs to a 30% platform. A 20% platform that adds manual recovery has chosen a different business.
Time-locking resolves the trilemma by substituting a factor in time for a factor in possession. The attacker who owns the inbox gets the request; they do not get the 48 hours unobserved.
The mechanism
sequenceDiagram autonumber participant R as Requester (unknown) participant API as Marketplace API participant PG as PostgreSQL participant W as Worker participant O as True owner (still holds a passkey)
R->>API: POST /api/auth/recovery {email} Note over API: Always returns 202. Never reveals<br/>whether the address has an account. API->>PG: INSERT account_recovery_requests<br/>(unlocks_at = now() + 48h, status = pending) Note over PG: Partial unique index: ONE pending request per account.<br/>A re-request neither creates a second nor resets the clock. API->>PG: INSERT outbox (recovery.notify_all_channels)
W->>O: EMAIL to the address on file (informational) W->>O: PUSH to every registered device W->>PG: set a blocking interstitial flag on the account Note over O: Any authenticated session — web or VTT —<br/>renders an undismissable banner:<br/>"Recovery requested. Cancel?"
alt Attacker-initiated: the owner still has a passkey O->>API: POST /api/auth/recovery/cancel (FIDO2 assertion, UV required) API->>PG: status = cancelled; cooldown until now() + Q-M-039 Note over API: Cancel requires a PASSKEY, never an email link. else Genuine: nobody cancels W->>O: reminder notifications at T+24h and T+44h R->>API: GET /api/auth/recovery/redeem?t=... (after T+48h) API->>PG: UPDATE ... SET status='consumed', consumed_at=now()<br/>WHERE token_sha256=$1 AND status='pending'<br/>AND unlocks_at <= now() AND expires_at > now()<br/>RETURNING account_id Note over API,PG: The unlock is evaluated HERE, atomically.<br/>The Worker never grants anything. API-->>R: WebAuthn registration ceremony for a new passkey API->>PG: revoke ALL prior credentials, bump session_version,<br/>kill all refresh families, set quarantine_until = now() + Q-M-040 end
M-F-14— the diagram above redeems with aGET, and §4.6 forbids a state-changingGET. Raised and closed 2026-08-10 while implementing this section.Step 8 is
GET /api/auth/recovery/redeem?t=.... That request consumes a single-use token, revokes every credential on the account, bumpssession_version, kills every refresh family and opens aQ-M-040quarantine — it is the most state-changing endpoint in the system. §4.6 says “No state-changingGET. Already required byARCH-M-04for a different reason (§2.4); it does double duty here.”There is a second, independent objection. A token in a query string lands in access logs, proxy logs and browser history — which is the entire argument §4.5 makes for compressing the ticket’s life to fifteen seconds. This token lives for
Q-M-041, seven days.Dispositioned by splitting the link from the endpoint. The emailed link stays a
GET, because a link is one; it lands on the storefront, which renders a page thatPOSTs the token toPOST /api/auth/recovery/redeem. The token never enters the API’s log, andARCH-M-04is satisfied without weakening it. Nothing else in the mechanism changes, and the diagram above is left as drawn with this note beside it rather than redrawn — the sequence it describes is right, and only the verb was wrong.
Five corrections to the mechanism as proposed
Each of these is a case where the proposal’s design, implemented literally, would not defend against the attacker it names.
1. Email cannot be the load-bearing notification channel — it is the channel the attacker owns. “Notifications are sent to the original account” collapses to nothing if those notifications land in the compromised inbox, where they are deleted before the owner sees them. The defence survives only because the two scenarios are distinguishable: in the attack case the true owner still holds their authenticator and their sessions; in the genuine case they do not. So the notification that matters is the one the passkey-holder sees — push to registered devices and an undismissable interstitial on every authenticated session, web and VTT alike. Email is sent, and is informational only. Enforcement: ARCH-M-12 asserts the recovery-notification fan-out includes at least one non-email channel, and an integration test asserts a pending request renders the interstitial on a session established before the request.
2. Cancellation requires a passkey assertion, never a link. A cancel-link in the same inbox is a control the attacker simply declines to use. Cancelling is a normal FIDO2 assertion with userVerification: required — which the true owner can always perform, and the attacker by construction cannot.
3. The Worker does not grant recovery. The proposal has the Worker poll and unlock. That places a security decision on a background timer, where clock skew or a stuck job becomes “granted early” or “never granted”, and it creates an armed grant that nobody has claimed. The unlock is evaluated at redemption time in one atomic UPDATE ... WHERE ... RETURNING — deliberately the same shape as the ticket redemption in §4.5, for the same reason: single-use and time-validity are properties of one statement, not of a scheduler. The Worker’s job is reduced to what it is good at — sending notifications and sweeping expiries — so its worst failure is notifications stop, which is detectable, rather than recovery grants early, which is not.
4. A recovered session is quarantined and never satisfies step-up. Recovery is a weaker credential path than a passkey login, so it must not immediately unlock the operations that step-up exists to protect (§4.9). For Q-M-040 (provisional 7 days) after a recovery, the account may log in, browse, and download its entire library — the thing the user actually came for — but may not change payout bank details, initiate a payout to a new destination, change the account email, or delete the account. This matters most for creator accounts: without it, recovery is a clean path around every control in §7.8, and an attacker who waits 48 hours redirects a payout. A change to payout details after a recovery additionally notifies the previous destination. Enforcement: the step-up filter of ARCH-M-07 treats a quarantined session as not step-up-capable regardless of assertion freshness, and an architecture test asserts the quarantine check cannot be satisfied by a fresh assertion alone.
5. Rate limiting is a security control here, not hygiene. Repeated recovery initiations are a notification-fatigue attack: spam the owner until they stop reading the banner, then let one through. Therefore — one pending request per account, enforced by a partial unique index; a re-request returns the existing unlocks_at and does not extend or reset it; a cancelled request imposes a cooldown (Q-M-039, provisional 7 days) before another may be initiated; and initiation is rate-limited per email and per source. A request that unlocks and is not redeemed expires after Q-M-041 (provisional 7 days) rather than remaining armed indefinitely.
What this costs, stated rather than hidden
A legitimate user who has genuinely lost their only authenticator waits two days to open a book they paid for. That is a real cost and it will generate contacts; the correct response is not to shorten the lock but to reduce how often it is reached. Two prevention mechanisms carry more weight than the recovery path itself: prompting for a second passkey immediately after the first purchase above a value threshold, and surfacing “you have one authenticator” as a persistent account-health warning. The recovery path is the floor, not the plan. Its usage rate is a monitored metric (recovery_requests_total), and a rising rate is a signal that second-authenticator enrolment is failing, not that the lock is too long.
Two residual risks are accepted and named: an attacker who compromises the inbox of a user who has already lost their authenticator faces no cancelling owner and succeeds after 48 hours — recovery cannot distinguish that case from the genuine one, and no zero-touch mechanism can. And a user who loses their authenticator and their email access has no path through this mechanism.
Amended by
ADR-M-042(2026-08-08). That second residual previously ended “that is the point at which manual review is the only option, and it is a deliberately rare, out-of-band process rather than a supported product flow”. That sentence is withdrawn. It left a human-verification path standing as an unwritten last resort — the same corner this section’s own trilemma rejects on economics, readmitted informally at the bottom of the section that rejected it. An escape hatch nobody specified is one whose cost, PII footprint and abuse surface nobody bounded: manual review means collecting identity documents, which is a new class of retained personal data underADR-M-032with no retention class, no erasure story and no threat model. §4.11 supplies the third factor instead, and states plainly what happens when all three are gone.
4.11 Offline recovery codes — the third factor, and the floor beneath it
ADR-M-042: an account may hold a set of single-use offline recovery codes, stored only as a peppered HMAC, each redeemable exactly once for an immediate quarantined session that must complete a passkey registration; codes restore a possession factor and therefore carry no time lock, and when every factor is lost the account is unrecoverable by construction.
Status: Accepted · Date: 2026-08-08 · Amends ADR-M-030 (withdraws its manual-review residual; adds the third factor) · interacts with ADR-M-032 (retention class), ADR-M-005 (the BFF decides nothing)
Why a code needs no time lock, which is the whole reason this belongs in the design
ADR-M-030 resolved its trilemma by substituting a factor in time for a factor in possession. A recovery code is a possessed factor. So the substitution has nothing left to do, and imposing 48 hours on top of it would buy no security while making the dual-loss user — the only user who reaches this path — wait two days for a library they paid for. Redemption is therefore immediate.
What does not change is observability: a redemption fires the same fan-out ARCH-M-12 already asserts for a recovery request — push to every registered device, the undismissable interstitial on every authenticated session, and email as informational only. Email is assumed lost on this path; that is why it is not the load-bearing channel here either.
Generation
- 8–12 codes per set (
Q-M-072), minted in one batch, displayed exactly once at generation and never retrievable afterwards. Regeneration replaces the whole batch and invalidates every unused code in the previous one. - Crockford Base32, formatted
XXXX-XXXX-XXXX— 12 symbols at 5 bits each, soQ-M-073is 60 bits of entropy per code. Crockford excludesI,L,OandU, which removes the transcription collisions (I/1,O/0) that turn a recovery path into a support contact, and the dashes are display-only: input is normalised by stripping non-alphanumerics and upcasing before verification. - Generation requires step-up (§4.9). Minting a fresh recovery factor is exactly as sensitive as adding a passkey, which
ARCH-M-07already gates.
Zero-knowledge storage, and the pepper this rests on
The database never holds a code. Each is stored as HMAC-SHA-256(pepper, normalised_code); the plaintext exists only in the response body of the generation call and in whatever the user writes down.
The pepper is a Key Vault secret and is never a column, never a row, and never in a backup of the database. That is the load-bearing property: a database exfiltration alone yields no offline attack, because the attacker lacks the key the HMAC is computed under. Q-M-073’s 60 bits is safe because the pepper is out of band — so raising entropy is the required compensation if the pepper is ever removed, and this dependency is recorded here so that removal cannot look like a simplification. Rotation re-pepper requires the plaintexts, which by construction we do not have; a pepper rotation therefore invalidates all outstanding code sets and notifies affected accounts, which is the accepted cost of not storing what we do not need.
Argon2id and every other memory-hard KDF is rejected, on three independent grounds:
- It would be a third-party crypto dependency on the auth path of a Native AOT binary — precisely what §4.3 rejected for EdDSA, and the argument does not weaken for being applied a second time. HMAC-SHA-256 is in the BCL.
- Memory-hardness answers low entropy, which is a property of human-chosen passwords and not of a 60-bit system-generated code. There are no passwords anywhere in this design; there is no “our standard password hash” to be consistent with.
- It would build a denial-of-service amplifier on an unauthenticated endpoint. A per-hash salt forces verification to try the candidate against every stored hash — one request becomes 8–12 memory-hard evaluations. HMAC is deterministic, so verification is one indexed lookup on
code_hmac, and the amplifier does not exist to be rate-limited. The cost of determinism is that identical plaintexts hash identically; at 60 bits that is not a collision anyone reaches, and an attacker holding the pepper has already won by other routes.
Single use, as one statement
Redemption is one atomic guarded update, deliberately the same shape as the ticket redemption in §4.5 and the recovery redeem in §4.10, for the reason already recorded there — single-use and validity are properties of one statement rather than of a scheduler:
UPDATE account_recovery_codes SET used_at = now() WHERE code_hmac = $1 AND used_at IS NULL RETURNING account_id;No row returned means no grant. There is no read-then-write, so two concurrent submissions of the same code cannot both succeed.
What a redemption grants, and what it does not
A successful redemption grants the identical quarantined session ADR-M-030 grants — Q-M-040, provisional 7 days — and this is a reuse rather than a parallel mechanism: the ARCH-M-07 step-up filter already treats a quarantined session as not step-up-capable regardless of assertion freshness, so payout details, the account email, new payout destinations and account deletion stay out of reach while the library does not. Nothing in this ADR adds a new authority state, and that is intentional; a second quarantine notion would be a second thing to keep in step with §4.9.
The redemption ceremony must complete a WebAuthn registration for a new passkey before the session is usable for anything else, and all prior credentials are revoked with session_version bumped and every refresh family killed — again the ADR-M-030 path unchanged. A recovery that leaves the account still passkey-less has restored access to the same fragile state that produced the incident.
Brute-force bound
The redemption endpoint is bounded per account and per source: Q-M-074 failed attempts (provisional 5) locks redemption for Q-M-075 (provisional 1 hour), and a lockout is a notified event rather than a silent counter. Because verification is a single indexed lookup, the bound exists to stop online guessing, not to compensate for a slow hash. Enumeration is refused the same way §4.10 refuses it: the endpoint’s response does not distinguish “no such code” from “wrong account”, and it never reveals how many codes remain.
Remaining-code exhaustion is a product signal, not a security one: at Q-M-076 codes left the account surfaces the same persistent health warning §4.10 uses for “you have one authenticator”, because a user with zero unused codes and one passkey is back to the state this ADR exists to prevent.
The floor: what happens when everything is lost
A user who has lost their passkey, their email access and their recovery codes cannot recover the account, and no process exists by which we can recover it for them. Not a slow path, not an escalation, not a support queue — there is no mechanism, and one cannot be added without a new ADR that answers what
ADR-M-032would classify the identity documents as.
This is a deliberate exchange and both sides are stated. What is bought: no identity-document collection, therefore no new class of retained personal data, no verification queue in a 20%-take business (§1.2), and no social-engineering surface aimed at support staff — the single most reliable route into consumer accounts industry-wide. What is paid: a small number of users will permanently lose a paid library, and they will be angry, and they will be right that it is our design that did it.
The honest mitigation is not a hatch, it is making the floor unreachable: prompting for a second passkey after the first purchase above a value threshold, surfacing single-authenticator and zero-remaining-code states as account-health warnings, and offering code generation during onboarding. recovery_codes_exhausted_total and the single-authenticator population are monitored for the same reason recovery_requests_total is — a rising floor-adjacent population means prevention is failing, not that the floor should be softened.
5. Transaction Consistency & Webhook Idempotency
5.1 The order state machine
Fulfilment state is per line, not per order, because Tier 3 makes “fulfilled” ambiguous at the order level: the digital half is delivered in milliseconds and the physical half in weeks. An order-level status that must simultaneously mean both is a status that will be misread by every consumer of it.
ADR-M-040: Checkout session creation MUST be synchronous and MUST NOT be outboxed. An outbox pattern cannot be used for the initial checkout request because the API must return the Stripe Checkout redirect URL immediately to the user’s browser. Async outboxing is strictly reserved for post-payment fulfilment.
Amended 2026-08-09 — finding
M-F-08, disposition in Appendix D. This decision read “MUST be a synchronous API-to-Worker call”. The synchronous-and-never-outboxed requirement is unchanged and is the half that carries the correctness; the mechanism clause is withdrawn, and the API calls Stripe directly through the hand-written clientADR-M-004specifies.Two reasons, and the second is the one that made it unimplementable rather than merely inconsistent. First, four other places in the corpus already specified the direct call — §2.4’s sequence diagram, §3.3’s
ADR-M-004,Directory.Packages.props, and the handover’s key-scoping paragraph — so the Worker clause was the outlier rather than the rule. Second, §2.6 gives the WorkerminReplicas: 0scaled by outbox depth: a synchronous call to it has no scaler to wake it, and the one trigger it does have is empty precisely because this decision forbids enqueuing the work.BENCH-M-01puts ACA’s scheduling wall at 8 969 ms even once a replica is asked for.
ARCH-M-01is unaffected and in fact strengthened: no Stripe SDK is reachable from the API’s dependency closure, and session creation is one form-encodedPOSTunder the restricted key that cannot create transfers, refunds or payouts.
stateDiagram-v2 [*] --> Draft Draft --> AwaitingPayment: checkout session created AwaitingPayment --> Paid: checkout.session.completed<br/>AND payment_status = paid AwaitingPayment --> PaymentProcessing: async method<br/>(bank debit) PaymentProcessing --> Paid: async_payment_succeeded PaymentProcessing --> Failed: async_payment_failed AwaitingPayment --> Expired: session expired (24h) AwaitingPayment --> Failed: payment_intent.payment_failed
Paid --> Fulfilling: digital granted in the same tx;<br/>physical outbox enqueued Fulfilling --> Fulfilled: every line terminal-success Fulfilling --> PartiallyFulfilled: digital OK, physical dead-lettered
Paid --> Refunded: charge.refunded (full) Fulfilling --> Refunded: full refund Fulfilling --> PartiallyRefunded: charge.refunded (partial) Fulfilled --> PartiallyRefunded: post-delivery refund Fulfilled --> Disputed: charge.dispute.created PartiallyFulfilled --> Refunded: compensation saga Disputed --> Refunded: dispute lost Disputed --> Fulfilled: dispute won
Refunded --> [*] Expired --> [*] Failed --> [*]Every state carries an integer rank, and a transition to a state of lower or equal rank is rejected by a CHECK-backed guard rather than by application logic. This is the defence against out-of-order webhook delivery (§5.5), and it is placed in the database because that is the one place every writer must pass through. Enforcement: a database trigger on orders refuses a rank regression, and an integration test replays the fixture corpus in reverse order and asserts the terminal state is identical — RC-M-12.
5.1.1 Inbound checkout idempotency (OI-M-14)
ADR-M-035: the cart is a versioned resource and checkout is a guarded state transition on it. A replay returns the existing order; a changed cart returns a priced diff. No client-supplied idempotency key is required.
§5.3 makes webhook handling absolutely idempotent and said nothing about the inbound path, where a double-click, a network retry, or a back-button resubmit each produce a second order with its own PaymentIntent. The Stripe idempotency key cannot help, because §2.4 derives it from an order id generated fresh per request.
The rejected option is the obvious one. A client-supplied Idempotency-Key header pushes correctness onto client discipline, and both failure modes are silent: a client that generates a fresh key per retry defeats the mechanism entirely, and one that reuses a key across genuinely distinct purchases blocks the second sale. The cart already exists as a server-side resource with a natural identity, so the guard belongs there.
POST /api/carts/{cartId}/checkout If-Match: "<cart_version>"| Case | Behaviour |
|---|---|
| Double-click, or a retry where the client never saw the response | Cart is already checked_out → return the existing order’s live session URL. Idempotent-return, not an error — a 409 to someone who double-clicked is a support ticket |
| Two devices submitting concurrently | SELECT … FOR UPDATE on the cart row (§5.4’s pattern); one wins, the other takes the branch above |
| Back button after abandoning Stripe, session expired | Order still awaiting_payment → mint a fresh session for the same order, incrementing session_attempt |
| Cart contents or prices changed underneath | If-Match fails → 409 with a priced diff, not a silent re-price. Covers price changes, delistings, and a bundle gaining a contributor between cart build and checkout |
| Genuine second purchase of identical items | A new cart, new id. Nothing to disambiguate |
The Stripe idempotency key MUST include the session attempt, not just the order id. Stripe honours an idempotency key for 24 hours and a Checkout Session expires after 24 hours — the two windows coincide exactly, so a legitimate second session for an expired first would be swallowed and the buyer handed a dead URL. The key is {order_id}:{session_attempt}. Enforcement: a unit test asserting the key varies with attempt, plus an integration test that expires a session and asserts a new one is issued — RC-M-33.
One consequence of §8.11 that fixes the FK direction. orders is Class R and carries no account_id; carts is Class E and does. An FK from orders to carts would point from a retained table into an erasable one, which ARCH-M-17 forbids. So the reference runs the other way — carts.order_id is unique and nullable, which is also the direction the idempotency lookup needs. The retention classes constrained the association’s direction, and they happened to constrain it correctly.
Per-line fulfilment states are narrower: pending → granted for digital; pending → submitted → in_production → shipped → delivered, with rejected and cancelled, for PoD. submitted is the point of no return: after it, “cancel” is no longer available and the only remedy is a refund with the printed unit written off (§5.8).
5.2 The webhook endpoint contract
app.MapPost("/api/webhooks/stripe", async (HttpRequest req, WebhookPipeline pipe, CancellationToken ct) =>{ if (req.ContentLength is null or > MaxWebhookBytes) return Results.StatusCode(413);
// 1. RAW bytes. No model binding, no re-serialisation, no logging of the body before verification. using var buffer = MemoryPool<byte>.Shared.Rent((int)req.ContentLength); var raw = await req.Body.ReadExactlyAsync(buffer.Memory, ct);
// 2. HMAC over the raw bytes, constant-time, tolerance-bounded (§3.3). if (!StripeSignature.Verify(raw.Span, req.Headers["Stripe-Signature"], pipe.Secret, pipe.Clock.UtcNow, Tolerance)) return Results.Unauthorized();
// 3. Envelope only: id, type, created, api_version, data.object.id. Source-generated. Nothing else parsed here. var env = JsonSerializer.Deserialize(raw.Span, WebhookJsonContext.Default.StripeEventEnvelope)!;
// 4. One transaction. Claim and work are inseparable. See §5.3. var outcome = await pipe.HandleAsync(env, raw, ct);
// 5. 200 unconditionally on a handled or duplicate event. 500 only on a genuine inability to persist. return outcome is Outcome.Persisted or Outcome.Duplicate ? Results.Ok() : Results.StatusCode(500);});The response budget is self-imposed and much tighter than the processor’s. Stripe’s documented response window is a third-party constant that has changed before (Q-M-019, ⊙); building against it is building against a number we do not control. The internal budget is p99 < 250 ms (Q-M-020), enforced by an SLO alert on webhook_handler_duration_seconds. At that budget the external timeout never becomes relevant, which is the point: the correct way to handle an external limit you cannot pin down is to be an order of magnitude inside it.
Nothing that can block for an unbounded time may occur inside the handler. No HTTP call, no email send, no PoD submission, no PDF generation, no blob write. Enforced by ARCH-M-08, which asserts the transitive call graph of the webhook handler contains no HttpClient, BlobClient, or SMTP type. This is the constraint that makes the Outbox pattern necessary rather than merely tidy (§5.7).
5.3 Absolute idempotency, and the ordering that makes it absolute
CREATE TABLE processed_stripe_events ( event_id text PRIMARY KEY, -- Stripe's evt_... event_type text NOT NULL, api_version text NOT NULL, payload_sha256 bytea NOT NULL, received_at timestamptz NOT NULL DEFAULT now());BEGIN; -- ONE transaction. This is the whole design.
INSERT INTO processed_stripe_events (event_id, event_type, api_version, payload_sha256)VALUES ($1, $2, $3, $4)ON CONFLICT (event_id) DO NOTHING;-- 0 rows affected -> already processed -> COMMIT, return 200, do nothing else.-- 1 row affected -> we own this event -> continue below.
SELECT ... FROM orders WHERE id = $5 FOR UPDATE; -- §5.4UPDATE orders SET status = ..., status_rank = ... WHERE ...; -- rank guard appliesINSERT INTO entitlements ...;INSERT INTO outbox_messages ...;INSERT INTO user_notifications ...;SELECT pg_notify('user_events', $6); -- fires at COMMIT, not now
COMMIT;The claim and the work must be in the same transaction, and the common variant that puts them in two is broken. Consider “insert the event row, commit, then do the work”. A crash between the two commits leaves an event permanently marked processed with none of its effects applied, and Stripe’s retry — the only thing that would have fixed it — is now suppressed by the very row that records the failure. The order is silently stuck in AwaitingPayment with a paid charge behind it, and nothing in the system will ever notice. Because the insert and the work commit atomically, a crash rolls back both, and the retry finds no row and re-runs cleanly. The idempotency is a property of the transaction boundary, not of the ON CONFLICT clause; the ON CONFLICT clause only turns the second delivery into a no-op instead of an error.
payload_sha256 is not decoration. Two deliveries of the same event_id with different payload hashes means either a replay attack that passed signature verification (which should be impossible) or a processor-side anomaly. The mismatch is recorded and alerted rather than being silently ignored, and the second delivery is still treated as a duplicate — detection without changing behaviour, because changing behaviour on an anomaly is how an anomaly becomes an outage.
api_version is recorded because Stripe events carry the API version of the endpoint that produced them, and a version pin change alters payload shapes. Storing it means a schema surprise is diagnosable from the database rather than requiring log archaeology.
5.4 Pessimistic locking, and why not optimistic
SELECT ... FOR UPDATE on the order row, taken before any state read that a later write depends on.
Optimistic concurrency was rejected on a specific ground. Under optimistic control, a lost update surfaces as a version conflict that the handler must resolve by re-reading and retrying. On a webhook path, a retry is either an application-level loop (which extends the handler’s duration under exactly the contention that caused the conflict) or a 500 (which hands the retry to Stripe, and Stripe’s retry schedule is minutes, not milliseconds). Neither is acceptable for a path whose budget is 250 ms. The contended object is a single order row held for the duration of a short transaction with no external I/O in it; that is the textbook case for pessimistic locking, and the lock is held for hundreds of microseconds.
Lock ordering is normative because deadlock here is a payment failure. Where a transaction must lock more than one row — a multi-creator order touching several ledger accounts, a bundle spanning several products — rows MUST be locked in ascending primary-key order within a single SELECT ... FOR UPDATE ... ORDER BY id. Two transactions that acquire the same set in different orders will deadlock, and PostgreSQL will resolve it by aborting one, which becomes a 500 and a retry storm. Enforcement: ARCH-M-09 asserts every FOR UPDATE statement in the codebase carries an ORDER BY on the primary key; a Roslyn analyser reads the constant command text (which §3.4 guarantees exists) and fails the build otherwise.
NOWAIT is not used: a webhook that fails because another webhook held the lock is a retry we did not need. statement_timeout on the role provides the outer bound.
5.5 Out-of-order and duplicate delivery
Stripe does not guarantee event ordering. Three concrete cases and their handling:
| Case | Naive failure | Handling |
|---|---|---|
payment_intent.succeeded arrives before checkout.session.completed | Order marked paid from an event that does not identify the SKUs; entitlements never granted | Entitlement grants are triggered only by checkout.session.completed with payment_status = paid. payment_intent.succeeded updates the payment record and nothing else. The two events write to different tables. |
charge.refunded arrives before checkout.session.completed (fast fraud reversal) | Refund applied to an order in AwaitingPayment, then the completion event advances it to Paid and grants entitlements for a refunded charge | The rank guard (§5.1) refuses the regression and refuses the later advance: Refunded outranks Paid, so the completion event finds a terminal order and takes the no-op branch. The rank ordering is what makes this work without a special case. |
Checkout session completes but payment_status is unpaid/processing (bank debit, some wallets) | Entitlements granted for money not yet received | Order moves to PaymentProcessing, no entitlement is granted, and the grant waits for checkout.session.async_payment_succeeded. The success page’s SSE stream shows a “payment confirming” state rather than a download button. |
The subscribed event set is explicit and version-pinned; an unrecognised event type is recorded in processed_stripe_events and acknowledged with 200 without dispatch, so an accidentally-enabled event never becomes an error budget burn:
checkout.session.completed · checkout.session.async_payment_succeeded · checkout.session.async_payment_failed · checkout.session.expired · payment_intent.succeeded · payment_intent.payment_failed · charge.refunded · charge.dispute.created · charge.dispute.closed · transfer.reversed · payout.paid · payout.failed · account.updated
Extended 2026-08-09 by ADR-M-047 with customer.subscription.created · customer.subscription.updated · customer.subscription.deleted · invoice.paid · invoice.payment_failed. The five are subscribed for their arrival and not for their contents — §5.12 makes each one a trigger to re-read the subscription, so which of them arrives, and in what order, does not change what gets written. That is why they can be added to this list without a corresponding row in the out-of-order table above: they have no ordering hazard to describe.
5.5.1 Stripe API version pinning (OI-M-15)
ADR-M-036: the API version is pinned in two independent places, the pin is a compiled-in constant rather than configuration, drift is detected by comparing against what already arrives, and a bump is an expand-contract-shaped migration.
§5.3 records processed_stripe_events.api_version — we store what arrived and never compare it to what we expected. That is a detector with no comparison, which is not a detector.
Two pins, because there are two directions and they are configured separately:
- Outbound: every request carries an explicit
Stripe-Versionheader. The hand-written typed client (ADR-M-004) makes this a one-line concern; this is a small dividend of not using the SDK. - Inbound: a Stripe webhook endpoint renders payloads at its own configured version, independent of the account default and of our outbound header. Pinning only the outbound direction leaves event shapes free to change under a running fleet.
The pin is a compiled-in constant, not a configuration value. A version in configuration can drift per environment, and the failure — staging on one shape, production on another — presents as an unreproducible bug. Startup asserts the constant is non-empty and matches the configured webhook endpoint’s version where the API exposes it.
Pinned 2026-08-09:
2026-07-29.dahlia(Q-M-065). Captured rather than chosen — read from the account by an authenticatedGET /v1/balanceand taking theStripe-Versionresponse header, which is what Stripe resolves a request to when the caller sends none. Until this dateStripeApiVersion.PinnedheldUNPINNEDandMatches()returnedtruefor every input, so theapi_version§5.3 records had nothing to compare against — this section’s own “a detector with no comparison, which is not a detector”, in the code that implements it. It now compares, and it treats an absent arriving version as drift rather than as a pass.The inbound half is pinned by the constant and NOT by a provisioned endpoint, and that gap is real. Two pins are required because a webhook endpoint renders payloads at its own configured version. The account has no webhook endpoint at all —
GET /v1/webhook_endpointsreturned an empty list on 2026-08-09 — so local delivery goes throughstripe listen, which renders at the account default and therefore matches by coincidence of provisioning rather than by configuration. Creating the production endpoint at exactly this version is a release-checklist item that no code can discharge, because nothing in the API’s process can observe how a Stripe-side endpoint is configured.
Drift detection is free because we already store the answer. A recorded api_version differing from the pinned constant means the endpoint’s version moved without us. It is alerted, and the event is still processed as a duplicate-safe delivery — detection without changing behaviour, the same discipline §5.3 applies to a payload-hash mismatch.
A bump is a migration with the same shape as §3.7’s expand-contract. Events created before the flip can still arrive after it, so the handler must tolerate both shapes across the window — which means the §5.11 fixture corpus carries fixtures at both versions during a bump, and the corpus replay runs against both. Flipping the version without refreshing the corpus is the exact scenario that makes the highest-value test in the system silently test the wrong thing. Enforcement: ARCH-M-23 asserts every outbound Stripe call sets the header; RC-M-34 asserts the corpus contains fixtures for the pinned version.
5.6 The Outbox
CREATE TABLE outbox_messages ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, ordering_key text NOT NULL, -- e.g. 'pod:{order_line_id}' (see §5.7) topic text NOT NULL, -- 'pod.submit' | 'email.receipt' | 'ledger.allocate' payload jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), available_at timestamptz NOT NULL DEFAULT now(), attempts int NOT NULL DEFAULT 0, status text NOT NULL DEFAULT 'pending', -- pending | inflight | done | dead lease_until timestamptz, last_error text);
CREATE INDEX outbox_ready ON outbox_messages (available_at, id) WHERE status = 'pending';CREATE INDEX outbox_inflight ON outbox_messages (lease_until) WHERE status = 'inflight';The claim, which is one statement and must stay one statement:
WITH candidate AS ( SELECT o.id FROM outbox_messages o WHERE o.status = 'pending' AND o.available_at <= now() -- ordering guard: never claim a message whose predecessor on the same key is unfinished AND NOT EXISTS ( SELECT 1 FROM outbox_messages p WHERE p.ordering_key = o.ordering_key AND p.id < o.id AND p.status IN ('pending','inflight') AND p.available_at <= now()) ORDER BY o.available_at, o.id FOR UPDATE SKIP LOCKED LIMIT 32)UPDATE outbox_messages m SET status = 'inflight', attempts = m.attempts + 1, lease_until = now() + interval '2 minutes' FROM candidate c WHERE m.id = c.idRETURNING m.*;5.7 Three properties of the Outbox that are usually got wrong
1. SKIP LOCKED destroys ordering, and for PoD that is a correctness bug. The plain pattern lets worker A claim pod.submit and worker B claim pod.cancel for the same line concurrently, and the cancel can win the race. The NOT EXISTS guard above makes messages sharing an ordering_key strictly sequential while leaving different keys fully parallel — per-key FIFO, cross-key concurrency. It costs an index probe per candidate. Enforcement: an integration test enqueues an interleaved sequence across ten keys against three concurrent workers and asserts per-key delivery order — RC-M-13. This is stated at length because “we used FOR UPDATE SKIP LOCKED” is widely treated as the end of the design when it is the beginning of it.
2. The row lock is not the lease. FOR UPDATE’s lock is released at COMMIT, which happens immediately after the claim — long before the HTTP call completes. lease_until is the actual crash-recovery mechanism: a reaper resets inflight rows past their lease back to pending. This means delivery is at-least-once and a worker that dies mid-request will re-send, so every outbound call MUST carry a stable idempotency key derived from the message id (Idempotency-Key: outbox:{id}), never a freshly generated one. Enforcement: ARCH-M-10 asserts every outbound HttpRequestMessage constructed in the Fulfilment context carries the header, and the header value is derived from the message id.
3. Backoff needs jitter, and a ceiling, and a grave.
UPDATE outbox_messages SET status = CASE WHEN attempts >= 12 THEN 'dead' ELSE 'pending' END, available_at = now() + (least(power(2, attempts), 3600) * (0.5 + random() * 0.5)) * interval '1 second', last_error = $2 WHERE id = $1;Full jitter, capped at one hour, dead-lettered after twelve attempts (roughly nine hours of retrying — Q-M-022). Without jitter, a downstream outage that fails a thousand messages simultaneously produces a synchronised thundering herd at every retry boundary, and the recovering partner is knocked over by the recovery. A dead-lettered message raises an alert and is visible in an operator queue; it is never silently discarded, and a message dead-lettering on a paid order transitions the order line to rejected, which triggers the compensation saga of §5.8. A dead letter that only exists in a table nobody reads is a lost order.
5.8 When the money is captured and the printer says no
This is the failure that a digital-only marketplace never has, and it is the reason Tier 3 is architecturally expensive. The payment has settled, the digital entitlements are live and have been downloaded, and the PoD partner rejects the job — bad source PDF, discontinued trim size, unserviceable destination, or the partner is simply down past the retry budget.
The compensation is a partial refund, computed from a per-line allocation that must exist before the order is placed rather than being reconstructed afterwards. Every order line carries its own amount_minor, and bundle discounts are allocated across lines at checkout time by the same largest-remainder method used for revenue splits (§7.4), so that the sum of line amounts equals the order total to the cent. Without this, “refund the physical portion of a $59.99 bundle” has no defensible answer and every such refund becomes a manual decision.
sequenceDiagram autonumber participant W as Worker participant P as PoD Partner participant PG as PostgreSQL participant S as Stripe participant U as Buyer (SSE)
W->>P: submit print job (Idempotency-Key: outbox:{id}) P-->>W: 4xx permanent rejection W->>PG: BEGIN W->>PG: outbox row -> dead; order_line -> rejected W->>PG: INSERT outbox (topic=refund.partial, amount=line.amount_minor) W->>PG: INSERT outbox (topic=ledger.reverse, scope=line) W->>PG: INSERT user_notifications + pg_notify W->>PG: COMMIT Note over W,PG: The compensation is itself an outbox message.<br/>The refund call is no more trusted than the print call was. W->>S: POST /v1/refunds (Idempotency-Key: outbox:{id2}) S-->>W: refunded W->>PG: order -> PartiallyRefunded; ledger reversal posted PG-->>U: SSE: physical_unavailable_refundedThree policy decisions embedded in that diagram, each stated because the alternative is defensible and we are not choosing it:
- The digital entitlement is not revoked. The buyer keeps the PDF and the Interactive Pack; only the physical line is refunded. Revoking delivered digital goods because an unrelated line failed is a customer-hostile outcome for a failure that is entirely the platform’s.
- The refund is automatic, not queued for review. A human-gated refund on a platform failure is a support backlog that converts into disputes, and a dispute costs the fee plus the amount (
Q-M-023). - The creator’s revenue on the physical line is reversed, and on the digital lines it is not. Handled in the ledger as a scoped reversal (§7.5), never by editing the original allocation.
The reverse failure — the printer accepted a job for an order that later charges back — is handled in §7.8, and it is worse, because the unit has been printed and shipped.
5.9 Why not event sourcing (ADR-M-002, restated where it bites)
The VTT is event-sourced; a reader arriving from that corpus will expect the same here. The reason it is not:
- An order’s history requirement is audit, not replay-to-arbitrary-point. Nobody forks an order into a parallel timeline. The append-only ledger (§7.5) plus an
order_eventsaudit table gives the audit property at a fraction of the cost. - Event sourcing’s payoff is a rich temporal query surface; its cost is projection lag, rebuild machinery, upcasting contracts, and the discipline that read models are never authoritative. The VTT pays that cost because a game session genuinely needs it. An order needs a strongly-consistent read of “is this paid” on the request path, and building that on top of an eventually-consistent projection means either reading the stream directly (event sourcing without the benefit) or accepting a window in which a paid order looks unpaid to the download endpoint.
- The one place append-only semantics are mandatory — money — gets them explicitly and locally, in the ledger, where the invariant can be a database constraint rather than an architectural style.
5.10 Reconciliation: the Outbox is not enough
Every mechanism above is a push mechanism, and every push mechanism has a failure mode where the push simply never happens: a webhook Stripe gave up retrying after its retry window, an event type nobody subscribed to, a network partition during the one delivery, a bug that acknowledged an event and dropped it.
A nightly reconciliation job compares Stripe’s records against ours and is the only component permitted to discover work nobody told us about. It pages the Stripe API for all charges, refunds, disputes, and transfers in the last 72 hours (a deliberately overlapping window) and asserts, for each:
- A
paymentsrow exists with a matching amount and status. - The owning order’s state rank is consistent with the charge’s state.
- The ledger’s entry group for that charge balances to zero and its allocations sum to the charge amount.
- Every
paidorder has either a granted digital entitlement or an explanation.
Discrepancies are written to reconciliation_findings and alerted; the job does not self-heal, because a reconciliation job that silently repairs data destroys the evidence of the bug that caused the discrepancy. It proposes; an operator, or an explicitly-invoked repair routine, disposes. The count of open findings is a dashboard metric whose non-zero value is treated as an incident — RC-M-14.
5.11 Testing the money paths
- A webhook fixture corpus, in the spirit of the VTT’s recorded-session replay suite: every subscribed event type captured from Stripe’s test mode, stored as raw bytes with its real signature header and a fixed clock. CI replays the corpus in order, in reverse order, and with every event duplicated, and asserts the terminal database state is byte-identical in all three runs. This single test covers idempotency, ordering, and rank-guard behaviour simultaneously, and it is the highest-value test in the system.
- A crash-injection test that kills the process between the
INSERT ... ON CONFLICTand theCOMMITand asserts the retry produces a complete, correct result — the property §5.3 argues for, verified rather than reasoned about. - A concurrency test running three workers against an interleaved outbox with ten ordering keys (
RC-M-13). - A ledger property test: for any random sequence of purchases, refunds, partial refunds, disputes, and payouts, the sum of every entry group is zero and the creator’s payable balance equals the closed-form expectation.
5.12 Recurring billing, and the window an entitlement actually carries (ADR-M-047)
ADR-M-047overridesADR-M-027. Recurring billing ships in v1. A subscription’s entitlement window is an absolute expiry copied from Stripe’s period end, never a previous expiry with a period added to it; and a subscription webhook is a trigger to re-read, never a payload to project.
Why the deferral was withdrawn
ADR-M-027 deferred subscriptions and reserved entitlements.expires_at against the day they arrived. That day arrived from the other side of the seam. The VTT’s Dual-Tier content-sharing gate (ADR-132) is built, reviewed and merged, and it is permanently inert: it pools a table’s libraries only when the room’s owner holds urn:descent:sub:premium, and nothing in the Marketplace could grant that urn. ContentModuleResolver says so in its own doc comment, and records it as a dependency rather than a defect.
A shipped feature that can never execute is a worse state than either building it or not building it, because it accumulates the maintenance cost of code with none of the behaviour, and every reader has to rediscover why the branch is dead. The deferral was correct when the only cost was the Marketplace’s; it stopped being correct when a second repository built against it.
What ADR-M-027 got right is why this override is cheap. It reserved the column instead of omitting it, so activating subscriptions is an expand — a new table, a nullable column, two widened CHECKs — and not a change to the shared view at all. vtt_entitlement_v1 projects the same three columns it always did, and a subscription grant simply appears in it.
The decision that carries the correctness: absolute, not incremental
The ordinary implementation of recurring billing adds a period to the entitlement on each successful invoice. It drifts, and it drifts in the direction that costs money. A duplicated webhook adds a period. A retried invoice adds a period. A proration adds a period. A plan change adds a period. Each of those is a normal event that Stripe is explicitly permitted to send more than once.
§5.11 already demands the test that catches this — the corpus replayed “with every event duplicated”, asserting a byte-identical terminal state. An accumulating implementation fails it by construction. The uncomfortable part is that an accumulating implementation passes every test written against a single clean delivery, which is what most of them are.
So the expiry is derived from subscriptions.current_period_end and nothing else:
expires_at = current_period_end + Q-M-085 (trialing, active, past_due)expires_at = current_period_end (canceled — the paid period, and no grace)revoked (unpaid, incomplete, incomplete_expired, paused)unchanged, and reported (any status this system does not recognise)A second delivery computes the same answer. Duplicate handling is a property of the arithmetic rather than of a guard around it, which is a stronger claim than idempotency-by-checking: there is no guard to be bypassed by a code path somebody adds later, and no prior value in the function’s signature to add to.
The decision that carries the ordering: the event is a trigger, not a record
Stripe does not guarantee event ordering, and §5.1’s monotonic rank guard — the mechanism this document uses everywhere else — cannot be applied here. Order states are monotonic; subscription states are not. active → past_due → active is an ordinary recovered payment. canceled → active is an ordinary resubscribe. A rank guard over that vocabulary either freezes a recovery, because recovery looks like a regression, or accepts a stale active and resurrects a cancelled subscription. One fails closed on a paying customer and the other fails open on a non-paying one, and neither is visible without a support ticket.
The resolution is to stop defending against ordering and make it irrelevant. On any subscribed event, the Worker re-reads the subscription from Stripe and projects what it reads. Whichever event arrives, in whatever order, the state written is the state now.
This costs one API call per event and buys three things that are otherwise separate mechanisms: order-independence, self-healing after a missed delivery, and freedom from having to model Stripe’s payload shapes across an API version bump (ADR-M-036) — because the fields being read are read fresh at the pinned version rather than replayed from whatever version rendered the event.
Where the re-read runs is forced, not chosen. It is an unbounded HTTP call, and ARCH-M-08 forbids those inside the webhook handler, whose budget is Q-M-020 (250 ms). So the API claims the event and enqueues; the JIT Worker re-reads and projects. §5.6’s outbox already provides per-ordering_key FIFO, so subscription:{stripe_subscription_id} serialises one subscription’s events with no new mechanism, while leaving different subscriptions fully parallel.
The outbox payload deliberately carries only the subscription id and the event id. There is nothing in it for a future reader to trust instead of re-reading, which is the stale-data path this decision exists to close.
Three consequences that are decisions rather than plumbing
-
An unrecognised status changes nothing. Stripe may add a status. Revoking would strip paying customers on the day the processor ships a feature; granting is an unbounded leak; throwing dead-letters every subscription event at once, turning one upstream change into a total outage of the projection. Doing nothing is safe here specifically, and the reason does not generalise: because the expiry is absolute, an unattended subscription lapses on its own at the previously projected date, so exposure is bounded by one billing period plus
Q-M-085rather than by however long the status stays unknown. A design storing “days remaining” would not have that property and would be forced to pick one of the bad options. It is still a defect, so it is reported and never absorbed (ADR-M-026). -
subscriptions.statusis the one unconstrained vocabulary in the schema, and the asymmetry is the point. Every other status column is our state machine, where a closedCHECKis right because an unknown value is a bug in our code. This one projects a third party’s vocabulary; closing it means the day Stripe adds a status, every subscription write fails and the projection stops tracking reality — an outage caused by somebody else’s release note.ADR-M-033bans native enums because “a value can never be removed, and code N reading an unknown value is undefined”;text+CHECKanswers the removal half and does not answer this half at all. Reading that ban as “text plusCHECKis always the answer” is what put a closed set here in the first draft, and the integration suite refused it within a minute. -
A subscription entitlement with a NULL
expires_atis made unrepresentable. It would be a perpetual grant of premium, and it would be invisible:vtt_entitlement_v1treats NULL as “never expires”, so from the view’s side it is indistinguishable from a purchased cartridge, and the VTT would pool every member’s library for a table whose owner stopped paying in March.entitlement_subscription_shapeis a biconditional — a row claiminggrant_reason = 'subscription'must name a subscription and carry an expiry, and a row naming a subscription must not claim to be a purchase, so a lapsed subscription cannot be laundered into a permanent entitlement by rewriting one text column.
The chart of accounts gains exactly one key
revenue:platform:subscription, kept separate from revenue:platform:commission because they are different revenue for tax and for reporting: commission is a share of a creator’s sale and a subscription fee is the platform’s own. Netting them would make the creator economy’s take rate uncomputable from the ledger, which is the property §7.5 exists to preserve. A $9.99 subscription with $0.82 tax and a $0.61 processing fee posts four entries summing to zero; the worked example is in 0002_recurring_billing.sql.
What this does not settle
Q-M-085is provisional and its weakness is known. It is deliberately shorter than Stripe’s full retry schedule, so a subscription still in dunning past the window loses premium while the processor is still trying to collect. The alternative — granting unpaid premium for the whole schedule — is worse. The right value is a churn measurement nobody has taken.- The Stripe API version is
UNPINNED.ADR-M-036requires the pin to be captured from the account’s actual configured version, and inventing a plausible date here would satisfy every check while pinning nothing. The drift detector is written; what it compares against is owed by whoever provisions the Stripe account. - Nothing has run against Stripe. The projection, the schema and the transaction boundaries are exercised against a real PostgreSQL; the adapter that calls Stripe is written and untested, and no fixture corpus captured from Stripe’s test mode exists yet.
RC-M-34and the §5.11 corpus remain owed.
Enforcement: RC-M-43 (forward / reversed / duplicated replay reaching one terminal state), RC-M-44 (one renewal replayed a hundred times leaves one unchanged window), ARCH-M-26 (the producing side’s assertion of the view’s projected shape).
6. Lightweight Real-Time Push (SSE & PostgreSQL Pub/Sub)
6.1 Why not SignalR
The VTT runs SignalR because it needs bidirectional, binary, sub-frame-latency messaging with a Redis-class backplane and connection affinity to a stateful actor. The Marketplace needs server-to-client, text, one-way, a handful of messages per order, arriving within a second or two of an event that happened days after the user’s last click.
| SignalR | SSE | |
|---|---|---|
| Direction | Bidirectional | Server → client only — which is all we need, and a smaller attack surface |
| Client dependency | @microsoft/signalr bundle, hub proxies | EventSource, native, zero bytes |
| Auth on the wire | Token in query string (WebSocket cannot set headers) — the whole reason Chapter 4 needs a 15-second single-use ticket | HttpOnly cookie, sent automatically, same-origin, never visible to JavaScript |
| Reconnect + gap recovery | Application-level; the client must know what it missed | Last-Event-ID header on reconnect — built into the protocol |
| Backplane for multi-replica | Redis/Garnet — a component to deploy, secure, and pay for | LISTEN/NOTIFY on a database we already run |
| Server cost per idle client | A connection plus hub/circuit state | A connection plus a channel writer |
The decisive row is the third. SSE lets the notification stream inherit the cookie authentication of §4.4 with no token in a URL, no ticket, and no code — precisely the property the VTT cannot have and must build the ticket exchange to work around. Adopting SignalR here would import that entire problem into a subsystem that does not have it.
6.2 The mechanism
sequenceDiagram autonumber participant B as Browser (EventSource) participant A1 as API replica 1 participant A2 as API replica 2 participant PG as PostgreSQL participant W as Worker
B->>A1: GET /api/events (cookie; Last-Event-ID: 4821) A1->>PG: SELECT * FROM user_notifications WHERE user_id=$1 AND id > 4821 ORDER BY id A1-->>B: replay missed events (id: 4822, 4823) A1->>A1: register local subscriber for user_id Note over A1,PG: Replica 1 already holds ONE dedicated<br/>direct connection running LISTEN user_events.
W->>PG: BEGIN; UPDATE order_line -> shipped W->>PG: INSERT user_notifications RETURNING id W->>PG: SELECT pg_notify('user_events', '{"u":"...","seq":4824}') W->>PG: COMMIT PG-->>A1: NOTIFY (fired at commit) PG-->>A2: NOTIFY (every replica receives it) A2->>A2: no local subscriber for that user -> drop A1->>PG: SELECT ... WHERE user_id=$1 AND id > lastSent A1-->>B: id: 4824\nevent: pod_status\ndata: {...}
loop every 20s A1-->>B: ": keepalive" endWire format, with the two lines that are load-bearing:
id: 4824event: pod_statusretry: 3000data: {"orderId":"...","line":2,"status":"shipped","carrier":"...","tracking":"..."}id: is what the browser echoes back as Last-Event-ID, and it is a database sequence value, not a UUID or a timestamp — resumption is a WHERE id > $n range scan on an indexed monotonic column, which is the only reason gap recovery is cheap. retry: sets the client’s reconnect delay explicitly rather than relying on the browser default.
6.3 LISTEN/NOTIFY and its three hard limits
Limit 1 — the payload cap is 8000 bytes, so the payload is not the message. pg_notify carries only {user_id, seq}. The API re-reads the row from user_notifications. This is not a workaround for the cap; it is the correct design regardless, because it makes the notification a hint to read rather than a delivery, and a hint that is lost costs nothing while a delivery that is lost costs a missed event.
Limit 2 — NOTIFY is not durable. It is delivered only to sessions currently executing LISTEN. A replica that is starting, restarting, or was momentarily disconnected misses every notification in that window, permanently. This is why user_notifications is a real table and why Last-Event-ID resumption is mandatory rather than an optimisation. The durable record is the table; NOTIFY is only the low-latency path to it. To make the failure mode bounded rather than silent, each SSE stream also performs a catch-up poll every Q-M-024 seconds (provisional 30) — so the worst case for a missed notification is a 30-second delay rather than an event the user never sees. A push system whose only delivery mechanism is a non-durable signal is a system that loses shipping notifications and cannot tell you when.
Limit 3 — NOTIFY fans out to every listener. One channel means every replica wakes for every user’s event and discards what it does not own. At R replicas and N events per second the system does R × N wake-ups. This is entirely acceptable at launch scale and has a hard ceiling (Q-M-025, ⊙, pending BENCH-M-04). The migration path is stated now so it is not invented under pressure: shard the channel by hash(user_id) mod K, with each replica listening to all K channels initially and to a subset once consistent-hash routing exists at the edge. If that ceiling is reached, the honest alternative is a real broker, and the trigger for reconsidering is a metric (notify_dropped_total, notify_dispatch_lag_seconds), not an opinion.
The connection discipline. The LISTEN connection is one dedicated NpgsqlConnection per replica, opened at startup against the direct (non-pooled) endpoint (§3.6), never returned to a pool, never used for queries, and supervised by a restart loop with backoff that emits listener_reconnects_total. A replica whose listener is down is a replica silently serving stale SSE streams — so the listener’s health is part of the readiness probe, and a replica with a dead listener is removed from ingress rather than left serving.
6.4 Authentication, and why there is no CORS here
EventSource sends cookies automatically for same-origin requests only. Cross-origin requires withCredentials: true, Access-Control-Allow-Credentials: true, an exact-origin Access-Control-Allow-Origin, and a preflight-compatible request shape — all workable, all extra surface, and all avoidable.
ADR-M-012: the SSE endpoint is served on the same origin as the page, via Front Door path routing (§2.2), so the request is same-origin and CORS does not enter the design. new EventSource('/api/events') sends dv_id with no options object and no server-side CORS configuration. The alternative considered — proxying the stream through the SolidStart BFF — was rejected because it would hold one Node event-loop connection per subscriber for the stream’s entire lifetime, doubling the connection footprint and putting a long-lived socket in the process whose job is short SSR renders.
Authorisation is trivial and worth stating anyway: the stream is scoped to the authenticated subject and takes no user-supplied identifier. There is no /api/events?userId=. The subject comes from the token; a query parameter here would be an enumeration vulnerability with a live feed attached.
6.5 SSE versus scale-to-zero, and the edge timeout that forces the design
An open SSE stream pins a replica. A container app with minReplicas: 0 and an open stream cannot scale in, and KEDA’s HTTP scaler counts a streaming connection as active work, so a single idle subscriber holds a replica indefinitely. Combined with §3.1, this is the second independent reason the API does not scale to zero — and it is worth being explicit that the two reasons are independent, because removing one does not remove the constraint.
Stream lifetime is capped by infrastructure, not by us. Azure Front Door applies an origin-response timeout (Q-M-026, ⊙, default and maximum both to be re-verified at RC-M-11), and Azure Container Apps ingress applies its own idle-request timeout (Q-M-027, ⊙, 240 s default). An SSE stream that outlives either is cut by a proxy, and a proxy-cut stream is indistinguishable at the client from a network failure. Rather than tune two third-party timeouts and hope, the server closes each stream deliberately after Q-M-028 (provisional 120 s) with a normal termination; EventSource reconnects automatically after the retry: interval with Last-Event-ID, and resumption is exact. Three benefits: the design is correct regardless of what either timeout is set to, replicas can drain during a deployment without dropping user-visible events, and a stuck stream self-heals within two minutes.
Two further infrastructure requirements, both of which produce a silent failure if missed:
- Response buffering MUST be disabled on the
/api/eventsroute at Front Door and any intermediary. A buffering proxy holds events until the buffer fills, converting a real-time feed into a batch feed with no error anywhere. Enforcement: a synthetic probe measures end-to-end notification latency every minute and alerts aboveQ-M-029—RC-M-15. This is a probe rather than a config assertion because the failure can be introduced by a CDN default change that no repository records. - Compression MUST be disabled on that route. Content-encoding buffers by nature.
The HTTP/1.1 six-connection-per-origin limit is a real constraint now that the stream shares an origin with the page: one SSE stream consumes one of the browser’s six sockets. Under HTTP/2 — which Front Door serves — streams multiplex over one connection and the limit does not apply. HTTP/2 at the edge is therefore a functional requirement of this design, not a performance preference, and a fallback to HTTP/1.1 must open at most one stream per tab, coordinated through a BroadcastChannel leader election in the storefront client.
6.6 What this costs
| Resource | Per what | Note |
|---|---|---|
| One direct PostgreSQL connection | Per API replica | Not pooled, permanent. With Neon’s direct-connection budget this caps API replicas at Q-M-015 — the notification design sets the storefront’s maximum horizontal scale |
| One in-process channel + one socket | Per subscriber | Cheap; the ceiling is socket count and memory, Q-M-031 |
| Neon compute never autosuspends | Whole system | The LISTEN connection is activity. The database’s own scale-to-zero is forfeited by this design (§8.8) |
R × N wake-ups | Per notification | §6.3 Limit 3 |
The trade is stated plainly: LISTEN/NOTIFY avoids deploying and paying for a broker, and pays for it with a database that can never sleep and an API replica ceiling derived from a connection budget. At launch scale that is unambiguously the right trade. §6.7 defines what reverses it and what happens first.
6.7 The exit: fan-out is an abstraction, and the trigger that swaps it
ADR-M-029: LISTEN/NOTIFY is reaffirmed for launch. The fan-out mechanism is placed behind a named interface from the first commit, the successor is named now, the migration trigger is a configured value rather than an observed spike, and the connection budget is protected by a hard cap rather than by the trigger.
The client contract does not participate in this decision. The browser speaks EventSource over /api/events, resumes with Last-Event-ID, and reads the durable user_notifications table through the API. Fan-out is only how a replica learns that a row exists. Swapping it changes one class:
interface INotificationFanout // the seam. Ships in Phase 1, not at migration time.{ Task PublishAsync(AccountId account, long seq, CancellationToken ct); // called inside the tx or just after IAsyncEnumerable<FanoutHint> SubscribeAsync(CancellationToken ct); // one per replica}Two implementations behind it: PostgresListenNotifyFanout (launch) and RedisPubSubFanout (successor). The seam is the load-bearing part of this decision and it is worth more than the choice of successor — retrofitting it under connection pressure means editing the notification path while it is the thing that is failing. Enforcement: the §6 SSE integration suite is parameterised over both implementations and must pass against both from the phase in which SSE ships, with the Redis implementation exercised against a container in CI even though it is not deployed — RC-M-22. An implementation that is never executed is a plan, not an exit.
The successor is Redis/Garnet Pub/Sub. Azure Service Bus is rejected for this role. The rejection matters because Service Bus is the reflexive Azure answer and it is the wrong shape: it is a queue/topic system with competing consumers, so a notification is delivered to one subscriber, while this design requires delivery to every replica because the subscriber’s SSE stream may be on any of them. Making Service Bus work means a durable topic subscription per replica, created and torn down on every scale event and leaking on every ungraceful shutdown — operational machinery in exchange for durability guarantees the design explicitly does not use, since user_notifications is already the durable record (§6.3, Limit 2). Redis Pub/Sub is fire-and-forget fan-out to all subscribers, which is semantically identical to LISTEN/NOTIFY — so the swap changes a connection string and a class, and invalidates none of §6.3’s reasoning. A separate Garnet/Redis instance is provisioned; the VTT’s existing Garnet instance MUST NOT be reused, because sharing it would re-couple the blast radius that §2.1 paid two deployments to separate. Enforcement: RC-M-23 asserts the Marketplace’s cache connection string does not resolve to a host in the VTT’s environment.
The trigger, in two stages
The proposal’s 70% figure is adopted, with a correction to what it is measured against. A trigger evaluated on instantaneous replica count fires during a launch-day spike — the moment at which the system has the least spare capacity to absorb a multi-week migration. It is therefore evaluated against values we set deliberately:
| Stage | Condition | Action | Why here |
|---|---|---|---|
| Dial | Configured maxReplicas would consume > 50% of the usable direct-connection budget | Raise the Neon compute size, which raises the connection limit. Re-derive the cap. | The first response is money, not engineering. A connection limit that scales with compute size means the cheap escape hatch exists and should be used first; reaching for a rewrite when a slider would do is the more expensive mistake. |
| Migrate | Configured maxReplicas would consume > 70% of the usable budget at the largest Neon compute size we are willing to pay for, or Q-M-025’s fan-out ceiling is reached, or notify_dispatch_lag_seconds p99 breaches Q-M-029 under normal load | Cut over to RedisPubSubFanout | 70% leaves headroom for the Worker, migrations, the reconciliation job, operator access, and one deployment’s worth of overlapping old and new replicas — which is the actual consumer that surprises people |
The 30% that 70% leaves is not slack. It is enumerated:
$$\text{usable} = \text{limit} - \underbrace{(\text{worker} + \text{reconciliation} + \text{migration runner} + \text{operator})}_{\text{reserved, }Q\text{-}M\text{-}036} \qquad \text{maxReplicas} \le 0.70 \times \text{usable}$$
This converts Q-M-015 from an absent quantity into a derived one. It is still pending one measured input (BENCH-M-06, the direct-connection limit at our compute size), but the protection now exists independently of the number, which is the distinction that matters — the previous entry was flagged as “absent in a way that makes the protection non-existent”, and that is no longer true.
The threshold is detection; here is the protection
A threshold that fires an alert does not stop connection exhaustion, and the failure mode is bad: a replica that cannot open its listener serves SSE streams that silently never update (§6.3). Three mechanisms, none of which is the trigger:
maxReplicasis a CI-enforced ceiling. The deployment manifest’smaxReplicasis checked against the formula above at build time, using the reserved count and the recorded connection limit from configuration. A manifest that raisesmaxReplicaspast the cap fails the build —RC-M-24. This is the mechanism; the trigger is what tells us to go raise the cap properly.- A replica that cannot acquire its listener connection fails readiness and is removed from ingress. It does not start, and it does not serve. Enforced by the readiness probe already specified in §6.3, extended to fail on listener-acquisition failure rather than only on listener death.
- The catch-up poll (
Q-M-024) is the floor on correctness. Even in a degraded fan-out state, a subscriber’s worst case is a 30-second delay rather than a lost event. This is why §6.3 specified the poll as mandatory rather than as an optimisation, and it is what makes a fan-out incident a latency incident instead of a data-loss incident.
One benefit the migration will not deliver
It is tempting to credit the Redis cutover with restoring Neon’s autosuspend (§6.6). It will not. The always-on Worker polls the outbox continuously and pins the database awake regardless of how notifications are fanned out. The migration buys replica headroom and fan-out throughput; it buys no FinOps saving on the database, and claiming otherwise would put a number in Chapter 8 that never arrives. What it does add is a Redis instance to pay for and operate — so the migration is a net cost increase justified by scale, not an optimisation.
On the reasons given for keeping SSE
The argument that SignalR “imports statefulness” is not quite right and should not be relied on: an SSE stream is also long-lived and also pins a replica (§6.5), so neither option is stateless in the sense that matters to scheduling. The reasons that survive scrutiny are the ones in §6.1’s table — chiefly that SSE carries the HttpOnly cookie same-origin with no token in a URL, so it needs neither CORS nor the 15-second single-use ticket that Chapter 4 exists to build for the VTT. Adopting SignalR here would import that problem into a subsystem that does not have it. That is the load-bearing argument, and the backplane cost is secondary to it.
7. Payments, Taxation & Creator Revenue Split
7.1 The Wyoming LLC, and what a corporate entity is actually for here
The entity exists to make three things possible that are otherwise blocked or unstable:
- A US Stripe account with full Connect capabilities, which requires a US legal entity, a US bank account, and an EIN. Connect’s platform features — onboarding, KYB, payouts to 40+ countries, tax-form generation — are the reason not to build a payout system, and they are gated on this.
- Enforceable global contracts with creators. A creator agreement between an individual and a thousand individuals in eighty jurisdictions needs a single, stable counterparty with a known governing law and a known dispute forum. Wyoming supplies a mature LLC statute, no state income tax, low annual maintenance, and a registered-agent model that works for a non-resident founder.
- A clean merchant-of-record position (§7.2), which is a legal position that a payment configuration merely reflects.
What it does not do, stated because it is commonly assumed: a Wyoming LLC does not remove sales-tax nexus (nexus follows economic activity and physical presence, including a print facility — §7.6), does not remove US-source withholding obligations on royalties paid to non-US persons (§7.7), and does not remove the need for EU/UK VAT registration when selling digital services to consumers there. Incorporation is a prerequisite for compliance, not a substitute for it.
Three items in this chapter require professional advice before launch and are recorded as open items rather than answered: the characterisation of creator payments (royalty versus sale proceeds —
OI-M-05, which determines withholding), the VAT/OSS registration strategy for digital goods (OI-M-06), and whether holding an undisbursed creator balance in the platform’s own ledger implicates money-transmission licensing (OI-M-07). Each has an architectural consequence noted where it appears.
7.2 Connect topology: the decision, and the one that was rejected
Stripe Connect offers two fundamentally different money flows, and the choice determines who the seller is in law, not merely which account the funds land in.
Destination charges (on_behalf_of = creator) | Separate charges & transfers (chosen) | |
|---|---|---|
| Merchant of record | The creator | The platform (the LLC) |
| Who owes sales tax / VAT | The creator, in every jurisdiction the buyer might be in | The platform, once |
| Statement descriptor | The creator’s | The platform’s — consistent, and fewer disputes |
| Multi-creator cart | Impossible in one charge | Natural: one charge, N transfers |
| Refund mechanics | Reverses the destination transfer | Platform refunds; transfer reversal is a separate, independently-retriable step |
| Creator’s compliance burden | Register for VAT OSS, UK VAT, and US state sales tax | None |
ADR-M-013: separate charges and transfers, platform as merchant of record. The deciding argument is the last row. The creator base is hobbyists and small presses; requiring a solo cartographer in Portugal to register for UK VAT before they can sell a $4.99 map pack is not a compliance model, it is a market-entry barrier that would leave the catalogue empty. Centralising the obligation in one entity that can afford one tax engine is the only version of this that works — and it is what DriveThruRPG does, so it is also what creators already expect.
The costs of that choice, all real: the platform carries the tax liability, the platform carries chargeback liability (§7.8), the platform must characterise and possibly withhold on creator payments (§7.7), and the platform’s name is on the buyer’s statement for content it did not create.
Account type: Express. Custom was rejected because it obliges the platform to build and own the entire onboarding, KYB remediation, and dispute-of-verification UX for eighty jurisdictions — a large, permanently-maintained surface whose only benefit is branding. Standard was rejected because the connected account then owns the payment relationship in a way that conflicts with platform-as-MoR. Express gives Stripe-hosted onboarding, Stripe-owned KYB, and a platform-controlled payout schedule, which is exactly the split of responsibilities we want.
7.3 The split base, defined to the cent
“80% to the creator” is ambiguous until the base is named, and the ambiguity is worth thousands of dollars a month. Three candidate bases:
| Base | Creator receives on a $19.99 sale with $1.65 tax collected | Verdict |
|---|---|---|
| Gross including tax | $17.31 | Wrong. Tax is not revenue; it is money held for a government. |
| Gross excluding tax (“net revenue”) | $15.99 | Chosen. |
| Gross excluding tax and processing fees | $15.29 | Rejected — see below |
ADR-M-014: the split base is the tax-exclusive item price, and the platform absorbs all processing fees out of its commission. (Amended by ADR-M-043: the commission is 20% or 25% by exclusivity rather than a flat 20%. The base and the absorption are unchanged — only the rate is now a per-line value.)
Absorbing fees is chosen deliberately over passing them through, for a reason that is competitive rather than accounting: a creator can compute their earnings from the price tag. price × 0.80 is a number a creator can put in a spreadsheet, and it is the single clearest expression of the disruption claim. Passing fees through would make the effective rate 76–79% and variable by the buyer’s card country, which turns a marketing promise into a support conversation.
The consequence is the fixed intercept from §1.2 — and it is the reason the price floor and single-PaymentIntent-per-cart rules exist. They are not incidental optimisations; they are what makes fee absorption survivable.
Currency. The platform charges in the buyer’s local currency where supported and settles in USD. FX conversion is a further cost (Q-M-004), and creator balances are denominated in USD with conversion occurring at charge time, not at payout time. Holding a creator’s balance in a currency and paying it out later in another means the platform silently runs an FX book; denominating in USD at a recorded rate moves that exposure to a single, measured point. The recorded rate is stored on the ledger entry, so a creator’s earnings are reproducible from the record rather than being a function of when a payout happened to run.
7.3.1 Tiered commission by exclusivity
ADR-M-043: the commission is Q-M-078 (20%) for exclusive content and Q-M-077 (25%) for non-exclusive, applied to ADR-M-014’s unchanged tax-exclusive base; the rate in force is frozen onto the order line at purchase and is never re-read afterwards.
Status: Accepted · Date: 2026-08-08 · Amends ADR-M-014 · interacts with ADR-M-015 (allocation), ADR-M-019, ADR-M-020
The rate is frozen at purchase, and this is a ledger constraint rather than a preference
§7.5 forbids UPDATE and DELETE on ledger_entries, and §7.8 takes the platform’s commission as the residual so that an entry group cannot fail to balance on a rounding boundary. Both properties break if the rate is a lookup:
A rate read from the title at payout time makes every historical split a function of the title’s current exclusivity. A creator who switches from exclusive to non-exclusive would retroactively change what the platform earned on sales that already settled — silently, with no ledger entry recording the change, and with the residual recomputed against a base the original group never used.
So order_lines carries commission_bp (basis points), written at checkout from the SKU’s exclusivity at that moment. The allocation in §7.4 is unchanged; only the input to it moves. The residual continues to be taken by the platform — that is what makes the group exhaustive by construction, and it is why the rate can vary per line without the ledger’s balance property depending on the rate at all.
Exclusivity is a contractual attribute with reactive enforcement, and saying so is the point
The platform cannot detect a creator listing the same title elsewhere. There is no crawl, no fingerprint match, and none is proposed. Enforcement is therefore: a declared attribute at publication, a contractual term, a report channel, and demotion to the non-exclusive rate prospectively on a substantiated finding — never a retroactive re-rate, because §7.5’s ledger makes that impossible by design and ADR-M-043 would otherwise be asking for the thing it just forbade.
This is description, not a mechanism (P4). It is written as such rather than dressed in enforcement language, because a control that cannot fail is a control nobody will check.
Q-M-079 bounds the commitment window a creator accepts when declaring exclusivity, so a title cannot oscillate between rates release by release.
What the spread is for, and what it is not
It buys a reason to be exclusive here — the “moat” in §1.1’s terms, and the corpus’s own competitor figure (DriveThruRPG at 30–35%) is the only support offered for the comparison rather than a claim of current market fact. Stated precisely, because §1.6’s M-F-03 was exactly this class of overclaim:
At a 25% non-exclusive rate against a 35% incumbent, the creator keeps 10 percentage points more of the price — on a $20 sale, $15.00 against $13.00, which is 15.4% more revenue to the creator. Those are two different numbers describing one fact and both belong in any material that makes the claim; “a 10% margin increase” is neither of them.
Alternatives Considered and Why Rejected
- A flat 20% for everyone (the status quo). Rejected: it prices exclusivity at zero, so exclusivity is worth nothing to obtain and nothing to keep.
- A flat 25% for everyone. Rejected: it raises the take on the content the platform most wants — and §1.1’s disruption claim is the whole acquisition argument.
- Recovering CDN cost in the spread. Rejected on the corpus’s own model:
ADR-M-020removed per-GB delivery pricing, so there is no per-download bill for the five points to absorb. The size-dependent residual that does exist is handled where it belongs, byADR-M-044. - Retroactive re-rating on a substantiated exclusivity breach. Rejected: it requires mutating settled ledger groups, which §7.5 forbids for reasons that outrank this one.
Consequences (including negative)
- Two rates mean two contribution lines, and every figure derived from “20%” is now rate-dependent. §1.2’s table carries both; §8.8’s illustration and
Q-M-043’s free-egress budget are computed from actual trailing contribution and therefore need no restatement — but they move as the exclusive/non-exclusive mix moves, which makes that mix an operational quantity worth watching rather than a static assumption. - Exclusivity is self-declared. The platform will pay 20% on some content that is not in fact exclusive, and will discover it late or never. That cost is accepted and is the price of not building detection nobody asked for.
commission_bpis a per-line column that must never be back-filled from the SKU. A migration that “corrects” historical lines from current exclusivity would reintroduce exactly the defect this ADR exists to prevent —RC-M-41asserts the column is written only at checkout.
7.4 Multi-creator allocation, and rounding
A bundle can contain work from several creators. A $59.99 Ultimate Bundle split 60/25/15 between a writer, a cartographer, and a 3D artist, at 80% to creators:
- Creator pool:
5999 × 0.80 = 4799.2minor units → the pool itself must be an integer. - Naive per-creator:
2879.52,1199.80,719.88. Rounding each independently gives2880 + 1200 + 720 = 4800, which is one cent more than the pool. That cent comes from nowhere, and over a million transactions it is a ledger that does not balance.
ADR-M-015: all monetary allocation uses integer minor units and the largest-remainder method, and the implementation returns a vector whose sum is asserted equal to the input.
static int[] Allocate(int totalMinor, ReadOnlySpan<int> weights) // weights in basis points{ Span<long> exact = stackalloc long[weights.Length]; var result = new int[weights.Length]; long weightSum = 0; foreach (var w in weights) weightSum += w;
int assigned = 0; for (int i = 0; i < weights.Length; i++) { exact[i] = (long)totalMinor * weights[i]; // scaled by weightSum result[i] = (int)(exact[i] / weightSum); // floor assigned += result[i]; } // Distribute the remainder to the largest fractional parts, ties broken by a STABLE key // (creator id ascending) so the same input always produces the same output. foreach (var i in IndicesByRemainderDescThenId(exact, weightSum, weights)) { if (assigned == totalMinor) break; result[i]++; assigned++; } Debug.Assert(assigned == totalMinor); return result;}Two properties beyond the sum invariant, both of which matter in practice: determinism — tie-breaking on a stable key means a recomputation during reconciliation produces the identical vector, so a reconciliation difference is always a real difference — and no floating point anywhere on a money path, enforced by a Roslyn analyser banning double/float/decimal in the Ledger assembly (ARCH-M-11). decimal is banned alongside the binary types not because it is imprecise but because permitting it invites division, and division is where the cents go.
Enforcement: a property test over ten million random (total, weights) pairs asserting sum(result) == total and determinism under permutation — part of the §5.11 ledger suite.
7.5 The ledger
Money is the one place in this system with append-only semantics, and it gets them as a database constraint rather than as a convention.
CREATE TABLE ledger_entries ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, entry_group uuid NOT NULL, -- one balanced set of postings posted_at timestamptz NOT NULL DEFAULT now(), account text NOT NULL, -- see chart below currency char(3) NOT NULL, amount_minor bigint NOT NULL, -- signed; debits negative, credits positive order_id uuid, order_line_id uuid, subject_ref uuid, -- §8.11: was creator_id. NO foreign key, by design. reason text NOT NULL, -- sale | refund | dispute | withholding | payout | reversal | adjustment actor_ref uuid, -- OI-M-13: who posted an adjustment. NULL for system postings. fx_rate numeric(18,8), -- recorded when the charge currency differs from USD reverses bigint REFERENCES ledger_entries(id));
CREATE INDEX ON ledger_entries (subject_ref, currency, id) WHERE subject_ref IS NOT NULL;CREATE INDEX ON ledger_entries (entry_group);- No
UPDATE, noDELETE. ABEFORE UPDATE OR DELETEtrigger raises an exception unconditionally. A correction is a new entry group referencing the original viareverses. Enforcement: the trigger, plus a migration test asserting the trigger exists on every deployment —RC-M-16. - Every entry group sums to zero per currency, enforced by a
DEFERRABLE INITIALLY DEFERREDconstraint trigger that runs atCOMMIT. Deferred because the postings arrive as separate statements; checked at commit because an unbalanced group must never be durable. - Balances are derived, never stored. A creator’s payable balance is
SUM(amount_minor)over their account. If that aggregate becomes slow, the answer is a materialised rollup with a watermark that can be rebuilt from the entries — never a mutable balance column, which is a second source of truth for the one value in the system that must have exactly one.
Chart of accounts (string-keyed, hierarchical, validated against a closed enum at write time):
| Account | Meaning |
|---|---|
asset:stripe:balance | Funds held at Stripe |
liability:creator:{subject_ref}:payable | Owed to a creator, not yet paid out |
liability:tax:{jurisdiction} | Collected tax not yet remitted |
liability:withholding:us | Withheld from non-US creators, owed to the IRS |
asset:withholding:receivable:us | Over-withheld and already remitted (ADR-M-037) — a claim, netted against the next remittance |
expense:withholding_written_off | A receivable stranded beyond recovery (Q-M-068) |
revenue:platform:commission | The 20% |
expense:processing | Stripe fees |
expense:pod:{partner} | Printer invoices |
expense:refund | Refunded platform commission |
contra:reserve:{subject_ref} | Rolling reserve held against chargebacks (§7.8) |
A single $19.99 domestic sale with $1.65 tax, one creator, produces one entry group of six postings — and the fact that it is six rather than two is the point: every party’s claim on the money is explicit and auditable at the moment it arises, rather than being reconstructed at payout time from a report.
The account key interpolates subject_ref, never account_id or creator_id — and this is not cosmetic. ledger_entries.account is a text column in a Class R table (§8.11). Interpolating an identifier that also appears in logs, Stripe metadata, or the VTT’s entitlement reads would rebuild the re-identification path that the whole design exists to sever, inside the one table erasure is forbidden to touch. The leak would be invisible: the column is a string, no foreign key would flag it, and ARCH-M-17’s FK check cannot see inside a text value. ARCH-M-16 is therefore extended to assert that no account-key template interpolates any identifier other than subject_ref, jurisdiction, or partner — a string-template check, because that is the only kind of check that can catch it.
7.6 Tax
Digital goods (Tiers 1 and 2). Stripe Tax computes at checkout for registered jurisdictions and reports what to remit. The architecture’s obligations are narrower than the compliance problem: a tax quote is captured on the order at the moment of charge and is never recomputed, because a refund six months later must reverse the tax that was actually collected, not the tax that today’s rate table would produce. The quote is stored as a structured record (jurisdiction, rate, taxable base, amount, Stripe’s calculation id) on the order, and the reversal path reads it.
| Surface | Obligation | Architectural requirement |
|---|---|---|
| US sales tax | Economic nexus thresholds vary by state; digital goods taxability varies by state | Per-state registration state in config; Stripe Tax handles calculation; nexus monitoring is an operational review, not a code path |
| EU B2C digital | VAT at the customer’s rate; a US entity uses the non-Union OSS scheme (OI-M-06) | Customer-location evidence (two non-conflicting pieces) captured and stored with the order for the statutory retention period |
| EU B2B | Reverse charge on a validated VAT number | VIES validation at checkout, result stored on the order — a validation performed and not retained is a validation you cannot prove |
| UK | Registration required with no threshold for a non-established seller | Same shape as EU |
| Rest of world | Various digital-services regimes | Registration state is data, not code — adding a jurisdiction must not require a deployment |
Physical goods (Tier 3) are a materially harder problem, and the honest recommendation is to scope the launch around it. A print-on-demand unit is manufactured at a facility, and that facility’s location creates presence-based nexus in its state or country, changes the place of supply, and brings shipping-charge taxability, customs, duties, and import VAT into scope. For EU destinations, whether the unit is produced in-region or imported changes the regime entirely (local supply versus IOSS for consignments under the low-value threshold).
ADR-M-016: Tier 3 launches US-only, and geographic availability is a per-SKU data attribute (fulfilment_regions) evaluated at cart time, not a hard-coded condition. A buyer outside the served region sees Tier 2 as the maximum available tier, with the reason stated, rather than a checkout failure. Expansion is then a data change plus a registration, and the code path is already exercised on day one. Enforcement: a cart-validation rule with a test matrix over (SKU, destination) — RC-M-17.
7.7 Creator onboarding, withholding, and the long-tail problem
Onboarding is Stripe Express hosted: the creator is redirected with an account link, completes identity/KYB with Stripe, and returns. The platform stores the connected account id and a capability state derived from account.updated webhooks. A creator with payouts_enabled = false may still sell; their balance accrues in liability:creator:{id}:payable and is simply not disbursed. Blocking sales on incomplete onboarding punishes the buyer for the creator’s paperwork.
Tax forms and withholding — the part that is usually discovered in year two. As merchant of record paying creators for the right to distribute their work, the payments are plausibly royalties, and US-source royalties paid to non-US persons are subject to withholding (statutory 30%, reduced or eliminated by treaty on a valid W-8BEN/W-8BEN-E). US persons receive information returns instead. The characterisation is OI-M-05 and it is not settled here, but the architecture must be able to represent either outcome:
-- CLASS R, and the SINGLE table permitted to retain identifiable PII past erasure (§8.11).-- Own envelope-encryption key; every read access-audited. Keyed on subject_ref so it links to-- retained financial records without reintroducing account_id into a Class R table.CREATE TABLE creator_tax_profiles ( subject_ref uuid PRIMARY KEY, person_type text NOT NULL, -- us_person | non_us_person form_type text, -- W-9 | W-8BEN | W-8BEN-E form_collected_at timestamptz, form_expires_at timestamptz, -- W-8 series expires; an expired form reverts to statutory rate treaty_country text, withholding_bps int NOT NULL DEFAULT 3000, -- 30% until a valid form says otherwise tin_status text NOT NULL -- pending | validated | mismatch);Three normative consequences, each with an enforcement point:
- The default is the statutory rate, not zero. A creator with no valid form withholds at 30%. Defaulting to zero and correcting later means under-withholding that the platform, not the creator, owes. Enforced by the column default plus a ledger property test asserting no payout allocation applies a rate below
withholding_bps. - Withholding is a ledger posting at the moment of allocation, not a deduction at payout. The creator’s visible balance is net of withholding from the moment the sale posts, so their dashboard never shows money they will not receive. Enforced by the entry-group balance constraint: a sale entry group for a withholdable creator that omits the
liability:withholding:usposting does not balance. - Form expiry is a scheduled state transition. A W-8 that lapses reverts
withholding_bpsto 3000 automatically onform_expires_at, with advance notice to the creator. Enforced by a daily job and a monitored count of expiring-within-60-days forms —RC-M-18.
Reversing withholding that has already reached the IRS (OI-M-17)
ADR-M-037: a withholding reversal debits the liability if the remittance has not yet gone out, and creates a receivable if it has. The 1099 figure is computed from the ledger, never from a stored counter, so a cross-tax-year reversal produces a corrected filing rather than a silently altered one.
§7.7 posts liability:withholding:us at allocation. Remittance discharges it. A refund after remittance has nowhere to reverse to — the money is with the IRS and cannot be clawed back — and this is the case that a chart of accounts designed only for the happy path cannot express.
Two paths, chosen by whether the remittance has left:
| Timing | Postings on a refund |
|---|---|
| Before remittance | Reverse commission, reverse creator payable, debit liability:withholding:us, refund cash. Clean; nothing left over. |
| After remittance | Reverse commission, reverse creator payable, credit asset:withholding:receivable:us, refund cash. The receivable is the bridge, and it self-liquidates against the next period’s remittance — which is what happens in practice: the next deposit is smaller. |
Worked, on a $19.99 sale to a creator at the statutory 30%, refunded after remittance (minor units): commission 400 reverses, creator payable 1119 reverses, asset:withholding:receivable:us +480, cash out 1999. The group balances because 1999 = 400 + 1119 + 480. Without the receivable account it does not balance at all, and §7.5’s deferred constraint would reject the transaction — the missing account would have surfaced as a production write failure on the first post-remittance refund, which is exactly the class of gap OI-M-17 existed to catch.
Two consequences worth stating:
- A stranded receivable is a real outcome. If a creator stops selling, there is no future withholding to net against, and the claim is recoverable only through an adjusted filing. It ages; past
Q-M-068it moves toexpense:withholding_written_offwith an aging report, rather than sitting on the balance sheet as a permanent fiction. - A reversal can cross a tax year, and the prior year’s 1099 is already filed. Because balances are derived and never stored (§7.5), the 1099 for a year is a query over
ledger_entriesfiltered byposted_at, so a January reversal of a December sale does not retroactively alter a filed figure — it lands in the new year and, where material, triggers a corrected filing. A design that kept a runningwithholding_ytdcounter would have silently mutated a number already sent to the IRS. This is the second time (§7.5 was the first) that “balances are derived” pays for itself in a way that was not the reason it was chosen.
The long-tail economics problem, which changes the onboarding design. Connect’s per-active-account monthly fee plus per-payout fees (Q-M-006, Q-M-007, both ⊙) are roughly $2.25 plus 0.25% for a creator paid once in a month. A creator earning $5.00 in that month generates $1.00 of platform commission against ~$2.26 of cost. Long-tail creator accounts are structurally loss-making per payout, and a marketplace whose thesis is “we take less” will have a very long tail.
ADR-M-017: payouts are monthly and gated on a minimum balance (Q-M-008, provisional $25); a Connect account is created at first sale but a payout is only initiated when the threshold is met. Balances below the threshold roll forward and are visible to the creator with the threshold stated. This caps payout frequency at one per creator per month and ensures per-payout costs are only incurred when there is revenue to cover them. An annual sweep pays out any remaining balance regardless of threshold, because indefinitely withholding small balances is both hostile and, in several jurisdictions, unlawful (escheatment).
The variant that would be cheaper still — deferring Connect account creation entirely and holding the balance purely in our ledger until the threshold is met — is not adopted, because holding funds owed to third parties may constitute money transmission (
OI-M-07). The chosen design keeps the funds at Stripe in the platform’s balance under Stripe’s own regulatory posture. If counsel clears the alternative,Q-M-006’s cost disappears for the tail; until then it is a cost of doing business honestly.
7.8 Refunds, disputes, and clawback after payout
One Tier 3 sale — $59.99 item plus $4.95 tax, single non-US creator on the statutory 30% rate — posts as two balanced entry groups, in minor units:
flowchart LR B["Buyer charged 6494<br/>= 5999 item + 495 tax"] --> SB["asset:stripe:balance<br/>+6494"]
SB --> TX["liability:tax:{juris}<br/>+495"] SB --> PC["revenue:platform:commission<br/>+1200"] SB --> WH["liability:withholding:us<br/>+1440"] SB --> CP["liability:creator:X:payable<br/>+3359"]
SB2["asset:stripe:balance<br/>-218"] --> PF["expense:processing<br/>+218<br/>2.9% + 0.30, absorbed by the platform"]
CP --> RES["contra:reserve:X<br/>held Q-M-009 days<br/>shown as PENDING, never as available"] RES --> PO["Monthly payout via Connect<br/>only if balance >= Q-M-008"] PO --> BANK["Creator bank account"] TX --> GOV["Jurisdiction remittance"] WH --> IRS["IRS deposit"]Group 1 balances: 6494 = 495 + 1200 + 1440 + 3359. Group 2 balances: 218 = 218. The creator pool is ⌊0.80 × 5999⌋ = 4799; the platform’s commission is the residual 1200, not an independently rounded ⌊0.20 × 5999⌋ = 1199 — the residual is taken by the platform so that the split is exhaustive by construction and the group cannot fail to balance on a rounding boundary (§7.4). Processing is a separate group because it settles against Stripe on a different schedule than the sale, and merging them would make the sale group’s balance depend on a fee figure we learn later.
Refund policy by product class, because one policy across all four is either too permissive for PDFs or too harsh for a broken Cartridge:
| Class | Window | Entitlement on refund | Note |
|---|---|---|---|
| 14 days, if not downloaded; discretionary after | Revoked | Download is the delivery event, and the file cannot be un-delivered | |
| Asset pack | Same | Revoked | |
| Cartridge | 14 days regardless of use | Revoked → VTT enters RulesetReadOnly | See below |
| PoD (Tier 3 physical line) | Until submitted; after that, only on defect | N/A | submitted is the point of no return (§5.1) |
The Cartridge refund is the one integration point where a commerce decision reaches into a live game. A revoked Cartridge entitlement must not cause a room to fail activation — that would brick a paid campaign for the other players in it. The VTT already defines the correct destination state: RulesetReadOnly (VTT §12.2.6), reached by a lapsed licence, an unloadable cartridge, and unresolvable event types alike. A Marketplace revocation is a fourth cause of that same state, and it MUST reuse it rather than introducing a fifth behaviour. Enforcement: a cross-repository contract test in the VTT’s suite that revokes an entitlement via the _v1 view’s underlying fixture and asserts the room opens read-only — RC-M-19, owned by the VTT’s release checklist and referenced here. This is registered as a dependency in both directions precisely because the VTT corpus records “nobody owns the join” as its most common defect class.
Chargebacks are the platform’s liability under ADR-M-013, and they can arrive after the creator has been paid. A dispute on a $59.99 order that was paid out last month costs the amount plus the dispute fee (Q-M-023) with no creator balance to recover from. Three mechanisms, in order of preference:
- A rolling reserve, set to 100% for the window (
ADR-M-045). Each creator’s allocation is held incontra:reserve:{id}and released afterQ-M-009days (provisional 30). It is visible in the creator dashboard as pending, never as available, because a balance that appears and then disappears is the single most common cause of creator distrust on marketplaces. This is one mechanism at full strength, not a reserve plus a separate clearance hold — see §7.8.1. - Negative balance carry. A clawback that exceeds the reserve creates a negative payable balance, netted against future earnings. Permitted to go negative; never auto-debited from the creator’s bank.
- Write-off. Beyond a threshold and a dunning period, the platform absorbs it and the creator’s account is flagged for review. A creator with a chargeback rate above
Q-M-032is a fraud signal, not a customer-service problem.
7.8.1 Payout economics — the fee the platform absorbs, and the window the hold actually buys
ADR-M-045: the platform absorbs the Connect payout fee, which is affordable only because payouts are monthly and gated on Q-M-008; and the clearance hold is Q-M-009’s rolling reserve set to 100% rather than a second control.
Status: Accepted · Date: 2026-08-08 · Amends ADR-M-017 · interacts with ADR-M-013 (chargeback liability), ADR-M-014
Rule A — absorbing the payout fee, and the arithmetic that makes it safe
Q-M-007’s per-payout fee is 0.25% + $0.25, absorbed by the platform out of commission rather than deducted from the creator’s balance — the same reasoning ADR-M-014 gives for absorbing processing fees: price × 0.80 must remain a number a creator can put in a spreadsheet.
Two existing controls are what make absorption affordable, and neither is new here: payouts run monthly, not on demand, and only when the balance clears Q-M-008 (provisional $25). At exactly that floor the fee is $0.25 + $0.0625 ≈ $0.31, or ~1.25% of the payout — the worst case, since the percentage term is fixed and the fixed term amortises as balances grow. On-demand payouts are what would break this, because the fixed component does not amortise at all when a creator withdraws $25 four times a month instead of $100 once; that is the alternative this ADR rejects rather than an incidental scheduling preference.
Rule B — the hold is the reserve at 100%, and what it does not do
The clearance hold and the rolling reserve are the same mechanism at different strengths, so they are expressed as one: Q-M-009 days during which the allocation is held in full, visible as pending, never available. Two controls sharing one number is the defect this corpus records most often, and writing a “30-day clearance hold” beside a “30-day reserve window” would have been exactly that.
Stated plainly because the honest version is weaker than the intuitive one: a 30-day hold does not protect against chargebacks. It reduces the exposure window. Card-scheme chargeback windows run far past 30 days —
Q-M-050exists precisely to outlast “the longest card-scheme chargeback window we accept” — so the hold covers the earliest slice of the risk and nothing beyond it. Mechanisms 2 and 3 above (negative balance carry, write-off) are what cover the tail, and they are not optional consequences of this ADR; they are the reason it is survivable.
The compliance cost, recorded rather than discovered
Holding 100% of accrued royalties for Q-M-009 days increases both the amount of money the platform holds and the length of time it holds it — which makes OI-M-07 more acute, not less. That open item is a launch blocker requiring counsel: whether holding undisbursed creator balances constitutes money transmission. This ADR does not resolve it, moves it in the harder direction, and is recorded on OI-M-07 so the legal engagement prices the mechanism that is actually specified rather than the one that used to be.
Alternatives Considered and Why Rejected
- Deduct the payout fee from the creator’s balance. Rejected on
ADR-M-014’s grounds: it makes the effective rate variable and turns a marketing promise into a support conversation. - On-demand payouts. Rejected: the fixed fee stops amortising, and the erosion lands on the platform because Rule A absorbs it.
- A separate clearance-hold control alongside the reserve. Rejected as duplication with one number.
- A hold long enough to cover the full chargeback window. Rejected as commercially unserviceable — a creator waiting four months for first revenue is not a creator this platform retains. The tail is covered by clawback, and that trade is stated rather than hidden.
The fraud pattern this is really defending against: a creator lists a product, purchases it repeatedly with stolen cards, and withdraws the payout before the chargebacks land. Every creator marketplace is attacked this way. The reserve window plus monthly payouts plus a first-payout delay (Q-M-033, provisional 14 days after first sale) mean the money is still in the platform’s balance when the chargebacks arrive. Additional detections — purchase velocity against a creator’s own catalogue, buyer/creator device and address correlation, and Radar rules on card-country/IP mismatch — are alerting inputs, and their tuning is deliberately not specified here because a published threshold is a threshold to calibrate against (the same reasoning the VTT corpus applies to its DoS limits).
8. Security & FinOps Cost Optimisation Boundaries
8.1 Threat model
Enumerated along the axis of what an attacker gains, and stated as such because the VTT corpus records “an enumeration is only complete with respect to the axis it chose” as its first recurring failure mode. A different axis — attack surface, or attacker capability — would produce a different and equally incomplete list.
| Goal | Vector | Control | Residual |
|---|---|---|---|
| Obtain paid content without paying | Entitlement forgery | Entitlements are never client-asserted; every download re-checks server-side (§8.2) | — |
| Signed-URL sharing | 5-minute expiry, single-use nonce, per-account byte budget | A determined sharer with a script; accepted | |
| Account sharing via synced passkey | None effective | Accepted and priced in (§4.2) | |
| Direct redistribution after purchase | Watermarking (attribution only) | Full. Stated plainly: every paid PDF is public within days. | |
| Steal an account | XSS on any descentvtt.com subdomain | CSP, no user content on cookie-scoped domains, step-up auth on sensitive operations (§4.4, §4.9) | An XSS still yields browse-level access for up to 60 minutes |
| Subdomain takeover | No wildcard DNS, dangling-CNAME sweep, CAA | Operational vigilance; the design cannot remove this | |
| Email compromise → account recovery | 48-hour time lock; cancellation by passkey assertion; notification on a channel the attacker does not control (§4.10) | Succeeds if the owner has already lost their authenticator — no zero-touch mechanism can distinguish that case from a genuine recovery | |
| Recovery-then-payout-redirect (creator accounts) | Post-recovery quarantine blocks step-up for Q-M-040; the previous bank destination is notified on any change | Bounded to the quarantine window; this is the corollary that makes recovery safe to offer to accounts that hold money | |
| Stolen recovery code (photographed, synced note, screenshot) | Single use; immediate ARCH-M-12 fan-out on redemption; the grant is a quarantined session only, so payout and email changes stay blocked for Q-M-040 (§4.11) | Real: a stolen code is a takeover with no time-lock window, because a code deliberately substitutes for possession. Bounded by quarantine and by the redemption being observable, not prevented | |
| Online guessing of a recovery code | 60 bits of entropy (Q-M-073); Q-M-074 failed attempts locks redemption for Q-M-075; no response distinguishes an unknown code from a wrong account | — | |
| Offline attack on exfiltrated code hashes | HMAC-SHA-256 under a Key Vault pepper that is never in the database or its backups (§4.11) | Requires compromising Key Vault and the database; entropy is the backstop if both fall | |
| Notification fatigue to mask a real recovery | One pending request per account; re-requests do not reset the clock; cooldown after cancellation | — | |
| Move money | Forged webhook | HMAC over raw bytes, 300 s tolerance, constant-time compare (§3.3) | — |
| Card testing on checkout | Radar, per-IP and per-account attempt limits, no $0 authorisations | — | |
| Self-purchase laundering | Reserve + payout delay + velocity detection (§7.8) | The core creator-marketplace risk; mitigated, not eliminated | |
| Refund abuse | Per-account refund-rate tracking; download-before-refund is recorded | — | |
| Execute code | Malicious Cartridge (Tier P0 in the VTT host) | Signature issuance is a reviewed act; the Marketplace is the signing authority (§1.3) | Review quality is the control. There is no technical backstop for P0 — the VTT corpus says so explicitly |
| Malicious UGC mod | None needed here — the VTT’s P1/P2 sandbox owns it | Marketplace provides provenance and a revocation path only | |
| Deny service / burn money | Download flooding | Signed-URL issuance rate limit, per-account daily byte budget, CDN caching (§8.9) | |
| Webhook flood | Body-size cap, signature check before any parsing, per-source rate limit | Unauthenticated by protocol necessity | |
| Free-UGC egress | Three-tier rolling budget with bounded-concurrency admission and a platform breaker (§8.9.1) | Bounded; the residual is a mis-set Q-M-030c | |
| Silence a rival creator | Botnet downloads to burn a competitor’s free-egress budget into Red Zone | Free downloads require authentication; the meter counts distinct authenticated consumption; illegitimate consumption is attributed to abuse rather than to the creator’s quota (§8.9.1, Correction 5) | This row exists because the cost control created the attack. A quota over anonymous downloads is a third-party denial-of-wallet weapon, and it is worse than self-inflicted EDoS because the victim cannot defend |
8.2 Asset delivery
Storage is not delivery. Azure Blob Storage is the system of record with no public access, private endpoint only. Delivery is via CDN with short-lived signed URLs:
- Browser requests a download; the API verifies the entitlement server-side, against the primary, at that moment — a download link rendered into a page five minutes ago is not evidence of anything. Free assets have no entitlement to check, but they still require an authenticated principal and a free-egress tier check (§8.9.1) — there is no anonymous issuance path, and
ARCH-M-13asserts it. - The API issues a signed URL valid for
Q-M-034(provisional 300 s) bound to the object and, where the CDN supports it, to the client IP prefix. - The URL is returned in a response body, never in a redirect location that a referrer might leak, and never logged. A signed URL is a bearer capability; treating it as an ordinary URL is how it ends up in an access log and then in a search index.
Watermarking is attribution, not protection, and the document says so where a creator will read it. Per-purchaser stamping is applied once per (account, product, version) into a derived container with a TTL, regenerated on demand if evicted — the alternative, stamping per download, converts a CPU cost into a per-request cost on the most bursty endpoint in the system. The stamped output MUST be deterministic: the PDF /ID, CreationDate, and ModDate are derived from the order id rather than the clock, so a regenerated file is byte-identical to the evicted one. Without this, cache validators break, checksums published to buyers become wrong, and support gains a class of “my file changed” tickets that is impossible to explain. Enforcement: a unit test stamps the same input twice with different clocks and asserts byte equality — RC-M-20.
User-generated content is served from a different registrable domain (descent-usercontent.com), with no cookie scope over it and its own CSP. This is the mitigation from §4.4 item (d), and it is the only one that removes a class of attack rather than reducing its likelihood.
8.2.1 Opt-in DRM-free raw archives
ADR-M-046: a creator may attach an unencrypted archive of source-form assets to a SKU; it is delivered through the same entitlement-gated signed-URL path, it is ingested and scanned like every other artefact, and it grants its own entitlement URN rather than a fourth product tier.
Status: Accepted · Date: 2026-08-08 · interacts with ADR-M-024 (catalogue review is a security function), ADR-M-031, ADR-M-044, VTT ADR-005 (the 2D bake), VTT ADR-098 (Studio is an untrusted producer)
Why this is consistent with §1.5 rather than an exception to it
§1.5 already states there is no marketplace-side DRM and that watermarking is attribution. §8.1 already prices in the residual in as many words — every paid PDF is public within days. So a DRM-free archive removes an encryption-at-rest step that was never a protection claim, and what it adds is a buyer proposition the platform can make honestly: the files, in source form, playable in tools we do not control.
What it must not be described as is a concession extracted from the security model. The VTT’s own content protection (ADR-060, session-scoped keys) governs what the VTT client can be handed; it says nothing about what a buyer may be sold as a download, and conflating the two would import a constraint from the wrong context.
Scanning is not optional, and this is where the proposal needed correcting
§8.9.1 rejects bring-your-own-storage partly because “we would be signing distribution of bytes we never scanned”. A creator-supplied raw archive is bytes we never baked, which is the same objection arriving by a different route. Therefore:
The archive passes the full ingestion gate —
ADR-M-024’s reviewed-act catalogue admission and the server-side re-verification VTTADR-098mandates — even though it is neither encrypted nor transcoded. Not baking it is a delivery decision; not scanning it would be a trust decision, and this ADR makes only the first.
The archive is never an input to the VTT pipeline: VTT ADR-005 requires a 2D bake produced by our pipeline, so the raw form is a parallel deliverable and never a substitute for the baked bundle. A SKU offering the archive alone, with no VTT-consumable form, is refused at publication.
The three limits, stated where a creator will read them
- Attribution is weaker than for a PDF. Per-purchaser stamping (§8.2) has nowhere comparable to live in an
.stlor a.glb. A leaked archive is traceable to a purchase only by whatever provenance the container itself carries, which is less than a watermark. - Entitlement gates issuance, not possession. The signed-URL path checks entitlement server-side at the moment of download exactly as it does for every other artefact; after that the bytes are the buyer’s, and revocation reaches future downloads only.
- It is the largest payload on the platform, so it counts in full toward
ADR-M-044’s total-SKU-payload band and toward the free-egress metering ofADR-M-031where the SKU is free.
Structure: an entitlement variant, not a tier
The archive grants urn:descent:raw:{title}:{edition} alongside the SKU’s existing grants (§1.4). A fourth tier was rejected: §1.4’s three tiers are distinguished by fulfilment class — synchronous, synchronous, split — and a raw archive changes none of that, so a fourth row would classify along an axis the table does not use. sku_grants already models this fan-out and needs no new shape.
Pricing the archive higher is a creator decision expressed as a separate SKU, not a platform mechanism; the platform’s only interest is that whatever price results clears ADR-M-044’s floor for the total payload.
8.3 Content security
- CSP on the storefront:
default-src 'self'; script-src 'self' https://js.stripe.com; frame-src https://js.stripe.com https://hooks.stripe.com; connect-src 'self' https://api.stripe.com; object-src 'none'; base-uri 'none'; form-action 'self' https://checkout.stripe.com. Nounsafe-inline; SolidStart’s inline hydration payload is nonce-attributed per response. - No third-party script tags. Analytics, if any, are first-party and server-side. Every additional script origin on the storefront is an origin that can read the parent-domain cookie’s consequences (it cannot read the cookie, but it can act as the user), and §4.4 already establishes that the cookie design leaves no margin for this.
Strict-Transport-Securitywith preload,X-Content-Type-Options: nosniff,Referrer-Policy: strict-origin-when-cross-origin.
8.4 Webhook endpoint hardening
The endpoint is unauthenticated by protocol necessity — it must accept a request from an origin that cannot present our credentials. Its defences are therefore all pre-parse:
| Control | Value | Reason |
|---|---|---|
| Body size cap | Q-M-035 (provisional 256 KiB) | Rejected before reading, at Content-Length |
| Signature verified before any parsing | Mandatory | An unauthenticated JSON parser is an attack surface; ours never sees unverified bytes |
| Timestamp tolerance | 300 s | Bounds replay to a window in which processed_stripe_events will catch it anyway — two independent controls |
| Rate limit | Per source IP, generous | Stripe bursts on retry; a tight limit here manufactures an outage |
| Secret rotation | Dual secrets accepted during rotation (§3.3) | |
| Distinct route from the API’s general surface | Own rate-limit policy, own SLO, own dashboard | A checkout spike must not consume the webhook’s error budget |
8.5 Secrets and supply chain
- Managed Identity for Azure resources; no connection strings in configuration for anything that supports it. Stripe keys and the PoD partner key are the exceptions (they have no identity federation) and live in Key Vault, referenced by ACA secret bindings, rotated on a 90-day schedule —
RC-M-21. - The restricted Stripe key used by the API can create sessions and read events; it cannot create transfers, refunds, or payouts. Those capabilities live only in the Worker’s key. A compromise of the public-facing service should not be able to move money out. This is the practical payoff of the AOT/JIT split in §3.3: two processes, two key scopes, and the boundary is a deployment artefact rather than a code comment.
- Dependencies are pinned with lockfiles; CI fails on a security advisory at or above a defined severity for any transitive dependency, and the AOT publish step means the shipped binary’s dependency set is fully enumerable.
- The Cartridge signing key is offline, held in an HSM-backed Key Vault key with signing operations audited. A leaked signing key is arbitrary code execution in every VTT host that trusts it (VTT §6.4 Tier P0), which makes it the single highest-value secret in either system.
8.6 PCI scope
The platform never receives, transmits, or stores cardholder data. Payment collection is a full-page redirect to Stripe-hosted Checkout, or Stripe Elements in Stripe-controlled iframes; in neither case does card data touch our origin’s JavaScript context. This holds the assessment at SAQ A, the smallest scope available.
This is the concrete, defensible form of isolation Argument 3 (§2.1). SAQ-A eligibility depends on the payment page being served from an origin under our control that does not execute untrusted script. The storefront origin qualifies by construction: no third-party scripts (§8.3), no community mods, no user-supplied HTML. The VTT origin would not qualify — it loads community content and runs a WASM sandbox — which is why this argument survives the collapse of Argument 1 in M-F-01.
8.7 The honest FinOps position: what actually scales to zero
Scale-to-zero is a property of jobs in this system, not of services. Working through §2.6:
| Component | Why not zero |
|---|---|
| API | SSE streams pin a replica (§6.5); a cold start on the Stripe webhook burns retry budget and on checkout burns conversion (§3.1). Two independent reasons. |
| BFF | First-visit SSR cold start is measured in seconds and lands on the storefront’s SEO and conversion |
| Worker | ADR-M-041 (§9.2): the outbox drain MUST scale to zero on a KEDA PostgreSQL scaler. Nobody waits on it. The PoD compensation saga stays on the always-on API path. |
| Neon compute | Pinned awake by the LISTEN connection (§3.6, §6.6) |
| Stamping / packaging Job | Genuinely zero. KEDA on queue depth, minutes of work per invocation, no latency requirement |
| Admin API | Genuinely zero (added by ADR-M-034). Human-scale, infrequent, latency-tolerant. The sentence below originally said the Job was the only such component; there are two |
Therefore: the elasticity ACA provides this system is scale-out, not scale-to-zero, and the always-on floor is approximately 1.25 vCPU and 2.5 GiB. Native AOT’s contribution to the bill is that this floor is 1.25 vCPU rather than the next tier up (§3.1). That is a real and recurring saving; it is simply not the saving originally claimed.
8.8 Cost model
All figures ⊙ — derived from published list prices captured 2026-08-03, not measured, not invoiced. They are published in falsifiable form deliberately, in the corpus’s house style: a specific wrong number is more useful than a vague right one.
Fixed monthly floor (pre-traffic):
| Line | Basis | Low (idle-dominated) | High (active-dominated) |
|---|---|---|---|
| ACA compute — 1.25 vCPU, 2.5 GiB always-on | ACA Consumption, idle vCPU billed at a fraction of active; one free monthly grant per subscription | ~$28 | ~$92 |
| Neon PostgreSQL — never suspends (§6.6) | Plan fee + always-on compute | ~$40 | ~$90 |
| Azure Front Door Standard | Base fee + minimal transfer | ~$37 | ~$45 |
| Blob storage — 500 GB catalogue | Hot tier | ~$10 | ~$10 |
| Key Vault, Container Registry, logs | ~$15 | ~$30 | |
| Stripe Connect active-account fees | 50 paid creators/month × Q-M-006 | ~$100 | ~$100 |
| Total floor | ~$230 | ~$367 |
Marginal cost, and the term that dominates:
| Driver | Unit cost | At 10k orders/month |
|---|---|---|
| Stripe processing | already inside the 20% (§1.2) | — |
| Compute | scale-out; roughly linear above the floor | ~$60 |
| Egress — the dominant term | ~$0.087/GB from Azure directly | 10k × 80 MB ≈ 800 GB ≈ $70; at 100k downloads with 3D packs, ≈ 8 TB ≈ $700 |
| PoD cost of goods | Printer invoice, per Tier 3 unit | Pass-through, contributes negatively to margin percentage |
Against §1.2’s contribution line, 10k orders at a $25 average is roughly $38,000 of contribution against roughly $400–500 of infrastructure. Infrastructure is not the business risk. Egress on free content is, because it has no contribution line at all.
ADR-M-020: large binary assets are delivered through a zero-egress-fee CDN tier, with Azure Blob as the private system of record. At 8 TB/month the difference between Azure’s direct egress and a zero-egress object tier is roughly $700/month against roughly $120 of storage — a decision that pays for itself immediately and grows. The cost is a second storage vendor in the delivery path, which is why the system of record stays on Azure and the CDN tier holds only a replicated, regenerable copy: losing it entirely costs a re-sync, not data.
8.9 Cost guardrails
FinOps controls are treated as availability controls, because an unbounded bill and an outage are the same incident with different timing:
- Per-account daily download byte budget (
Q-M-042— this was miscited asQ-M-030in revision 1; they are different controls defending different things, and §8.9.1 explains why both are needed). Exceeding it throttles rather than blocks, and alerts. This is the EDoS control against a single abusive consumer. - Free-UGC bandwidth budget per creator, on a rolling window — the three-tier mechanism of §8.9.1. Sponsored headroom is the
egress:sponsorcapability of §8.12, which is what the “reviewed grant” in §8.9.1 refers to; the reviewer now has a name, a capability, and an audit row. A free-content marketplace with unlimited bandwidth is a marketplace with an unbounded liability, and pretending otherwise until the bill arrives is how it becomes an emergency policy change. - Azure budget alerts at 50/80/100% of a monthly ceiling, wired to the same alert channel as production incidents.
- Per-request cost attribution: egress bytes and compute time are recorded per order and per product, so “which title is expensive to sell” is a query rather than an investigation. This is what makes the price floor of §1.2 adjustable on evidence.
- Cold-path storage lifecycle: catalogue objects untouched for 180 days move to cool tier automatically; the stamped-derivative container has a 30-day TTL and regenerates on demand (§8.2), which is only safe because stamping is deterministic.
8.9.1 Free-UGC egress: three-tier shaping
ADR-M-031: free UGC egress is governed by a three-tier rolling budget per creator — full speed, bounded-concurrency admission, then a hard refusal — with a platform-wide breaker above it, budget credited by attributable revenue, and metering on authenticated distinct consumption.
The shape of the accepted proposal is retained in full: degrade the experience before breaking the capability, enforce at the edge rather than in the API, and route the creator toward monetisation rather than toward a support ticket. Five things about the mechanism are changed, and each is changed because the version as proposed does not do what it says.
Correction 1 — per-stream throttling does not bound aggregate egress
This is the decisive one and it is arithmetic, not opinion.
$$\text{aggregate throughput} = \text{concurrent streams} \times \text{per-stream rate}$$
A 250 KB/s per-stream cap with 100 concurrent downloaders delivers 25 MB/s — 64 TB in a month. The per-stream number bounds one person’s experience and bounds the platform’s exposure not at all; worse, the mechanism’s throughput scales with exactly the variable (popularity) that triggered it. A viral asset arrives with thousands of concurrent downloaders, and per-stream shaping is silent against that.
Tier 2 therefore bounds aggregate throughput per creator, by admission rather than by pacing. A bounded number of concurrent download slots per creator’s free catalogue; the slot is acquired at signed-URL issuance and released on completion or lease expiry. Aggregate is then slots × line rate, and slots is a number we set. Queued requesters receive a position and an estimate rather than an error.
Byte pacing is retained as an optional refinement layered on top, not as the mechanism: with admission control in place, pacing improves fairness between queued users. Without it, pacing controls nothing.
Correction 2 — Tier 2 is not a cost control, and calling it one will surprise us
Throttling reduces bytes per second, not bytes. A user who waits transfers the same payload for the same cost, later. Tier 2’s cost effect comes only from abandonment, which is unmeasured and probably small for content someone wants.
What Tier 2 genuinely buys is runway. Unshaped, a viral asset crosses a 400 GB band in hours; under admission control sized to Q-M-030d, the same band takes days. That is the entire point, and it is a good one: it converts a cliff nobody saw coming into a multi-day window in which the creator is notified, the platform can look at the asset, and monetisation or sponsorship can be arranged. The cost controls are Tier 3 and the platform breaker. Tier 2 is a warning with a service level attached. Q-M-030e (minimum Yellow runway, provisional 72 h) is the quantity Tier 2 is sized against — not a bytes figure, a time figure, because time-to-act is what it produces.
Correction 3 — the named CDNs cannot do what “edge-enforced throttling” implies
The instinct that throttling must not live in the .NET API is correct, and stronger than stated: the API never serves asset bytes at all (§8.2 — it issues signed URLs; the CDN serves), so a Task.Delay in the request path was never available to be rejected. But the proposed replacement is not available either:
| Surface | Per-response byte-rate shaping | What it actually offers |
|---|---|---|
| Azure Front Door Standard | No. Rules Engine routes, rewrites, caches, redirects. WAF rate-limiting counts requests per minute per IP, not bytes per second. | Request-rate limiting, geo/claim-based routing |
| Cloudflare edge rules | No — not from rules alone | Cache control, request-rate limiting |
| Cloudflare Workers | Yes, by streaming the body through a paced TransformStream | Real, but it is bespoke compute on the byte path, with its own cost and limits — the opposite of “zero compute cost” |
Therefore the enforcement point moves to where it is universally available: URL issuance. Admission control needs no edge feature at all — the API already decides whether to mint a signed URL, and a token-bucket check is one indexed read on a decision it is already making. Enforcement is edge-adjacent rather than edge-resident, and it works identically on Front Door, Cloudflare, or a future third CDN. RC-M-25 records the byte-pacing Worker as an optional Phase-6+ refinement, gated on evidence that admission control alone is insufficient.
Correction 4 — per-creator caps do not compose into a platform cap
Two hundred creators each sitting at 499 GB never trip a red zone and together move ~100 TB. Per-tenant quotas bound the tenant, never the fleet. A platform-wide free-egress breaker is therefore required above the per-creator tiers, and it is expressed as a fraction of trailing revenue rather than as a constant:
$$\text{platform free-egress budget} = Q\text{-}M\text{-}043 \times \text{trailing-30-day gross contribution}$$
A cost that scales with revenue is sustainable; a cost that scales with popularity is not. At §8.8’s 10k-order illustration (~$38,500 contribution) and a provisional 5%, that is ~$1,925 — roughly 22 TB at Azure list egress.
The breaker rescales the fleet’s admission pool. It does not escalate tiers, and it is not a switch. An earlier draft of this ADR said breaching it moves every creator up one tier (Green → Yellow, Yellow → Red). That was wrong in the same way Correction 4 says per-creator caps are wrong, only mirrored — tier labels classify individual consumption, and a fleet breach is a property of the sum. The population most likely to have caused it is the long tail sitting comfortably in Green with unmetered concurrency; a response that only tightens Tier 2 leaves that population untouched, and a response that pushes Yellow creators to Red punishes the few individually-visible ones for an aggregate outcome. Neither targets the traffic.
Under breach, therefore:
- Every creator becomes admission-controlled, including those individually in Green. This is the part that actually bites, because it is the first time the long tail is metered at all.
- The fleet holds a total slot pool sized to the target burn rate, and slots are allocated across creators max-min fair, weighted by attributable contribution (the same
Q-M-044signal as the Green-zone credit). Max-min fairness is progressive by construction: the heaviest consumers give up the most, the long tail is barely touched, and nobody is singled out by name. - Refusal is the floor of that same mechanism, not a separate stage. When a creator’s fair allocation rounds below one slot, issuance is refused — with a reason and a
Retry-After, never an unbounded queue. An infinite queue is a worse experience than an honest refusal, and it consumes a connection to deliver it. So “403 across the board” never happens as an event; refusal arrives per creator as the pool tightens, reaching the heaviest first. That is the graded shrink-before-refuse behaviour, expressed as one control rather than two. - Paid downloads are never affected. Restated here because it is easy to lose at the fleet level: a free-egress breach must not touch a paid delivery, which is revenue and a refund liability. The breaker’s scope is free assets, always.
The trigger is a burn rate with hysteresis, not a level. A single threshold on a trailing window flaps: it trips, traffic falls, it releases, traffic surges, it trips again — and the oscillation is worse for creators than either steady state, because nobody can plan against it. The controller therefore trips on projected exhaustion of the window’s budget at the current burn rate, releases at Q-M-046 (provisional 85% of budget), and holds each state for at least Q-M-047 (provisional 6 h) before changing again. Enforcement: RC-M-26 asserts the controller cannot change state more than once per dwell period, and the fleet state is a first-class dashboard value with its own history — a cost control that silently toggles is one nobody can debug.
Per-creator Red (Q-M-030b) remains independent. A single creator can still exhaust their own budget while the fleet is healthy. The two controls compose; neither replaces the other.
Correction 5 — as proposed, this is a denial-of-wallet weapon aimed at a third party
A per-creator budget over anonymous downloads lets anyone silence a rival: a few thousand botnet downloads pushes a competitor’s free mod into Red, unpublished in effect, at trivial attacker cost. That is worse than denial-of-wallet against ourselves, because the victim is a creator who did nothing and cannot defend. Three requirements follow, and they are security requirements rather than FinOps ones:
- Free downloads require an authenticated account. Anonymous free download is the unbounded surface; we already have accounts, and the cost of requiring one is a signup we want anyway. (
ARCH-M-13asserts no signed-URL issuance path is reachable without an authenticated principal.) - The meter counts distinct authenticated consumption, not raw bytes: repeated pulls of the same asset by the same account within the window are counted once. Re-downloads are legitimate — a player reinstalling before a session — and are not the creator’s cost problem to be punished for.
- Consumption from accounts failing basic legitimacy checks is excluded from the creator’s meter and attributed to abuse instead, so an attack shows up on the abuse dashboard rather than on the victim’s quota. The checks themselves are runtime configuration and are not published, on the same principle as §7.8’s fraud thresholds and the VTT’s §3.1 rule on DoS limits.
Correction 6 — the tier a creator sits in must reflect what their free content earns the platform
The upsell path is right, and one refinement makes it defensible rather than coercive. Much free content is promotional: a free quickstart that drives Ultimate Bundle sales, a free map pack that sells the cartridge. Throttling it damages the paid funnel it exists to feed, and nudging that creator to start charging is advice against both parties’ interests.
Budget therefore follows contribution. §8.9’s per-request cost attribution already records which product a download belongs to; attributing downstream purchases to a free asset is the same join. A creator’s effective Green-zone allowance is:
$$\text{allowance} = Q\text{-}M\text{-}030a + \left(Q\text{-}M\text{-}044 \times \text{attributable contribution over the window}\right)$$
so a free asset that demonstrably sells things earns headroom proportional to what it sells, automatically, without anyone filing a request. Content that is strategically valuable but does not convert — a genuine community good — can be sponsored: an explicit, reviewed grant of headroom recorded against a named budget line, so the platform’s generosity is a decision with a number rather than a leak. The automated nudge offers monetisation, sponsorship application, or asset-size reduction; it never says only “start charging.”
The tiers, as adopted
| Tier | Rolling-30-day distinct consumption | Behaviour | Enforcement point |
|---|---|---|---|
| Green | ≤ Q-M-030a (100 GB) + revenue credit | Full-speed signed URLs, unmetered concurrency | — |
| Yellow | Q-M-030a → Q-M-030b (500 GB) | Bounded concurrency: Q-M-030c slots per creator, sized so the band lasts ≥ Q-M-030e (72 h). Queue position and estimate shown. Creator notified at entry, at 50% of the band, and 12 h before projected Red. Storefront states “high-speed quota exhausted — downloads are queued”. | Token bucket at URL issuance |
| Red | > Q-M-030b | Signed-URL issuance refused with an explicit, non-generic reason and the reset date. Paid content by the same creator is unaffected — the circuit breaker is scoped to free assets, because breaking a paid delivery over a free asset’s popularity would be a refund liability created by our own cost control. | API refusal |
| Platform breaker | Fleet-wide, burn rate projected against Q-M-043 × trailing contribution | Rescales the fleet slot pool. All creators become admission-controlled including Green; slots allocated max-min fair weighted by contribution; refusal is the floor case, per creator, heaviest first. Hysteresis Q-M-046 / dwell Q-M-047. Paid downloads exempt. Incident raised. | Fleet slot pool at URL issuance |
Three implementation properties that are easy to get wrong:
- The tier claim MUST be inside the signed portion of the URL, never a query parameter appended after signing, or a client strips it. Enforcement:
ARCH-M-14asserts the signing input includes the tier claim. - The tier is fixed at issuance. A URL minted in Green is honoured at full speed for its
Q-M-034lifetime even if the creator crosses into Yellow. The blast radius is five minutes and this is preferable to revalidating mid-transfer. - Metering is real-time by estimate, reconciled by CDN logs. CDN log delivery lags by minutes, and a guardrail that learns about a spike after it ends is not a guardrail. The counter is therefore incremented at issuance by the object’s known content length — available, exact, and immediate — and reconciled against CDN logs hourly, which corrects for abandoned and range-partial transfers. Overshoot is bounded by one issuance window rather than by log latency (
Q-M-045). This makes the meter slightly pessimistic, which is the correct direction for a cost control.
Implementation notes for the admission bucket
Three properties that are cheap to get right now and expensive to retrofit, each reusing a pattern this document already establishes:
- The claim is one atomic statement, not a read-modify-write. An application-level “read the count, decide, write it back” races under exactly the concurrency the control exists to bound, and the race leaks slots in the direction that costs money. It is a single guarded
UPDATE … WHERE held < limit … RETURNING— the same shape as the ticket redemption in §4.5 and the outbox claim in §5.6, and correct for the same reason: the admission decision and its record commit together or not at all. - A slot is a lease, not a lock. A client that disconnects, crashes, or abandons a download never releases anything, and slots leak until the creator is permanently at zero. Every slot carries
Q-M-048and a reaper reclaims expired ones — the same crash-recovery mechanism as the outbox’slease_until(§5.7), for the same reason: the row lock ends at commit, long before the work does. - PostgreSQL is the right backing store at launch. The issuance path already performs an indexed read against it (§8.2), so the bucket adds a statement to a transaction that exists rather than a new dependency. Introducing Redis for this alone is not justified; if the Redis tier arrives for the fan-out reason in §6.7, moving the bucket there is a later, independent decision measured against
BENCH-M-07.
The enforcement point is the API, and the BFF cannot be it. Blocking anonymous free downloads in the BFF or the frontend is a UX affordance, not a control: ADR-M-005 denies the BFF the credentials to decide anything, and a BFF-only check leaves the API’s issuance path anonymously reachable — which is the whole Denial-of-Wallet vector, unmitigated, behind a front door that merely looks locked. ARCH-M-13 asserts no issuance path is reachable without an authenticated principal, and it asserts it against the API’s route table, not the storefront’s.
What was rejected, and why it is recorded
BYOS is rejected on the grounds given, and they hold: it breaks the zero-trust chain (we would be signing distribution of bytes we never scanned), bypasses the KTX2 pipeline that VTT Profile C depends on (VTT ADR-005 requires a 2D bake produced by our pipeline), and makes availability a property of a third party’s storage bill. A hard cap alone is rejected as customer-hostile mid-session. Both rejections are the proposal’s and both survive review unchanged.
8.9.2 The paid-egress loophole, and the size-banded floor that closes it
ADR-M-044: every paid SKU must clear a minimum price that is the greater of a flat break-even floor and a size band derived from total SKU payload; a creator unwilling to meet it must price the SKU at exactly $0.00, where ADR-M-031’s breaker governs it instead.
Status: Accepted · Date: 2026-08-08 · Amends ADR-M-031 (names and closes the paid exemption’s loophole) · narrows Q-M-005 · interacts with ADR-M-020, ADR-M-043, ADR-M-046
The loophole, stated exactly
ADR-M-031 exempts paid downloads from the free-egress breaker, and for a good reason recorded there: breaking a paid delivery over a free asset’s popularity would be a refund liability created by our own cost control. But an exemption with no floor is a route around the control: a 5 GB SKU priced at $0.99 is metered by nothing, because it is not free.
What the loophole actually costs, which is not what it appears to cost
The proposal that produced this ADR priced the gap as Stripe fixed fee + (GB × per-GB CDN cost × safety). That formula bills a cost the platform stopped paying. ADR-M-020 moved large binaries to a zero-egress-fee CDN tier, and its own record states the consequence: it “removes per-GB delivery pricing, so the residual… is origin-fill egress and vendor-relationship risk, not a per-download bill”.
Working the real numbers changes where the defence belongs:
- At $0.99 the SKU is already unprofitable before any byte moves. The platform keeps $0.198 at 20% and pays Stripe ~$0.33 — a loss of ~$0.14, which is §1.2’s table, first row. The dominant term is the fixed processing fee and it is size-independent.
- The size-dependent cost is origin-fill plus storage, and it is per object, not per download (
Q-M-081,⊙). It therefore amortises over sales volume: a 5 GB file that sells a thousand copies costs a fraction of a cent per sale; one that sells once carries the whole fill.
This is why a linear per-GB price term is the wrong instrument. The expensive case is a large, low-volume SKU, and volume is unknowable at publication. A formula that prices per-GB-per-sale overcharges the popular file and still undercharges the one that sells twice.
The mechanism
- The flat floor stays and does the heavy lifting.
Q-M-005(provisional $2.99) already clears break-even at both commission rates ($1.81 at 20%, $1.39 at 25% — §1.2). Nothing about file size changes that this is the binding constraint for almost every SKU. - A size band adds a step above it, computed against total SKU payload — every artefact the platform hosts for that SKU, including
ADR-M-046’s raw archive — against an assumed amortisation volume (Q-M-082,⊙) and a safety multiplier (Q-M-080). Bands, not a continuous function, because the underlying cost is lumpy (a fill happens or it does not) and a step is auditable by a creator reading the publication error. BelowQ-M-083of total payload no band applies at all and the flat floor binds alone — which is almost every SKU, and saying so keeps the band from reading as a tax on ordinary listings. Total payload is capped outright atQ-M-084, which is a listing bound rather than a price one: past it the answer is not a higher price but a differently-packaged product. - The floor is evaluated at publication and re-evaluated whenever payload grows. Adding a 3 GB raw archive to a $2.99 SKU is a price-floor event, and the validator refuses the payload change, not the price — which is the direction that keeps an already-sold SKU’s price stable.
- Below the floor there is exactly one alternative: $0.00. Not $0.01, not “unlisted” — exactly free, at which point the SKU is metered by
ADR-M-031’s Green/Yellow/Red tiers and the platform-wide breaker, and its cost is bounded by a control that already exists. This is the elegant half of the original proposal and it is adopted unchanged.
Enforcement: the catalogue publication validator (RC-M-03’s existing home) gains the band check, and RC-M-42 asserts that a payload increase re-evaluates the floor rather than only a price decrease.
Consequences (including negative)
- A creator who wanted $0.99 for a large file gets a hard choice: a real price, or free. That is a cliff, and it is deliberate — but it will read as arbitrary to a creator who does not know what origin-fill is, so the publication error must state the band and the payload that triggered it rather than quoting a policy.
- The band is conservative by construction, because it assumes a volume the SKU may exceed. A popular large file therefore over-clears its floor. Accepted: the alternative is a floor that adjusts after sales data exists, which prices the risk after the platform has already taken it.
Q-M-081andQ-M-082are⊙— an assumed fill cost and an assumed volume. Neither is measured, both are falsifiable, and any normative claim resting on the band must say so (registry rule 2).- This ADR does not bound storage growth, only the price of a SKU that causes it. A creator publishing many large free SKUs is
ADR-M-031’s problem, and a creator publishing many large paid ones is a customer. Stated so the gap is visible rather than assumed covered.
8.10 Observability
OpenTelemetry throughout, with the SLOs that matter to this system’s specific failure modes rather than a generic dashboard:
| SLO | Target | Why this one |
|---|---|---|
webhook_handler_duration_seconds p99 | < 250 ms (Q-M-020) | The self-imposed budget of §5.2 |
webhook_5xx_ratio | < 0.1% | A 5xx is a Stripe retry; sustained 5xx is a retry storm |
outbox_oldest_pending_age_seconds | < 60 s | The single best indicator that fulfilment has stopped |
outbox_dead_total | 0, alert on any increase | Every dead letter is a customer with an unfulfilled paid order |
notify_dispatch_lag_seconds p99 | < 2 s | End-to-end SSE freshness, measured by the synthetic probe (RC-M-15) |
listener_reconnects_total | Flat | A flapping listener is silent staleness |
reconciliation_open_findings | 0 | Non-zero is an incident (§5.10) |
vtt_entitlement_primary_fallback_total | Low and flat | Growth means replica lag is hurting buyers (§2.5) |
ledger_unbalanced_groups | 0, hard | Should be impossible by constraint; alerting on it verifies the constraint exists |
admin_audit_refusals_total by admin | Flat and near zero | Rising refusals are the earliest signal of a compromised admin credential or an insider probing boundaries (§8.12) — a successes-only log cannot show this |
break_glass_invocations_total | 0; page on any increment | Not a threshold — any value above zero is an incident by definition |
admin_digest_unacknowledged_age | < Q-M-062 | The asynchronous second pair of eyes on single-actor actions; unacknowledged means it is not happening |
The last row is the pattern worth generalising: where an invariant is enforced by a database constraint, a metric asserting the invariant still holds is not redundant — it is the check that the constraint was actually deployed.
8.11 Data subject rights and the immutable ledger (OI-M-11)
ADR-M-032: every table is classified into one of three retention classes; the link between a natural person and every retained financial record is a single severable row; erasure deletes that row rather than touching the ledger.
The tension, and why it is not actually a paradox
§7.5 forbids UPDATE and DELETE on ledger_entries by trigger. GDPR Art. 17 and CCPA/CPRA grant a deletion right. Read naively these are irreconcilable, and a system that discovers the conflict after it has taken money has no good options.
They reconcile on one observation: Art. 17(3)(b) exempts processing necessary for compliance with a legal obligation, and financial and tax records carry statutory retention. The ledger is retained lawfully. What is not exempt is the linkage between that record and an identified person. So erasure does not delete financial history; it destroys the ability to attribute that history to a human being.
That converts an impossible requirement into a schema decision — which is why it must be made before the DDL exists rather than after.
Three retention classes, and a registry that refuses to be incomplete
| Class | Meaning | Erasure behaviour |
|---|---|---|
| N — No personal data | Catalogue, translations, slugs, SKUs, price points, product metadata | Not in scope. Added by ADR-M-038 — see the note below |
| R — Retained | Financial and tax records held under legal obligation: ledger entries, order financials, tax quotes, withholding, payouts, disputes | Untouched. Keyed on subject_ref and containing no direct identifier |
| E — Erasable | Identity and behaviour: email, name, passkey credentials, recovery-code hashes (account_recovery_codes, ADR-M-042), entitlements, cart, notifications, recovery requests, IP/UA, reviews | Deleted |
| C — Conflicted | Contains PII and has a bounded legitimate need: shipping addresses, customer-location tax evidence, dispute evidence | Minimised on a schedule to the smallest projection that satisfies the need — not retained indefinitely, not deleted immediately |
Class
Nwas added byADR-M-038, and the gap only appeared while doing the localisation schema. The original three classes were defined along the axis of how personal data is treated, and every table was required to pick one. A catalogue table containing no personal data then has no honest answer: filingtitle_translationsasRimplies a legal-obligation retention it does not have, and asEimplies it is deleted on an erasure request, which would remove a publisher’s product listings because a customer asked to be forgotten. A registry that forces a wrong answer is worse than one that admits it does not apply — it launders a misclassification through a passing CI check. Recorded because it is the corpus’s failure mode #1 for the second time in this chapter, and because “every table must pick one of three” sounded complete right up to the moment a fourth kind of table existed.
Every table MUST be registered under exactly one class, and a table with no registration fails the build. This is deliberately modelled on the VTT’s ADR-042 scope registry, and for the same recorded reason: the contradiction that produced ADR-042 arose from a table nobody had classified. Enforcement: ARCH-M-15 enumerates information_schema.tables against the registry and fails on any unregistered table — so adding a table is a decision about its retention class, made at the time the table is added, by the person who knows.
The severable linkage
Two identifiers, not one, and the second exists specifically to bound re-identification:
-- Class E. One row per natural person. Deleting this row IS the erasure act.CREATE TABLE accounts ( account_id uuid PRIMARY KEY, email_cipher bytea NOT NULL, -- envelope-encrypted email_hmac bytea NOT NULL UNIQUE, -- deterministic, for login lookup and dedupe only display_name text, created_at timestamptz NOT NULL DEFAULT now());
-- The severable link. Class E. Referenced by nothing; no FK points at it.CREATE TABLE subject_map ( account_id uuid PRIMARY KEY REFERENCES accounts(account_id) ON DELETE CASCADE, subject_ref uuid NOT NULL UNIQUE -- appears in Class R tables and NOWHERE else);
-- Class E (ADR-M-042). Offline recovery codes. There is deliberately NO plaintext column-- anywhere in this schema: the code exists in the generation response and in the user's-- own keeping, and nowhere else we control.CREATE TABLE account_recovery_codes ( account_id uuid NOT NULL REFERENCES accounts(account_id) ON DELETE CASCADE, code_hmac bytea PRIMARY KEY, -- HMAC-SHA-256(pepper, normalised code). -- PK, not just UNIQUE: verification is ONE -- indexed lookup, which is what removes the -- O(N)-evaluation amplifier Argon2id would -- have created (see section 4.11). batch_id uuid NOT NULL, -- regeneration replaces a whole batch used_at timestamptz, -- NULL = unused; set by the guarded UPDATE created_at timestamptz NOT NULL DEFAULT now());
-- Unused codes per account, for the section 4.11 health warning (Q-M-076). Partial, because the-- only question ever asked of this table by count is "how many are still usable".CREATE INDEX account_recovery_codes_unused ON account_recovery_codes (account_id) WHERE used_at IS NULL;The pepper is not in this schema and must never be added to it (§4.11). A migration introducing a plaintext or reversibly-encrypted code column would defeat the entire mechanism, and RC-M-38 asserts the column set.
Why a second identifier rather than simply dropping the accounts row and leaving account_id in the ledger. account_id leaks by design — into application logs, OTel spans, Stripe metadata, support tickets, the VTT’s entitlement reads, analytics exports. Any one of those archives re-identifies a ledger row that was supposed to be anonymous. subject_ref is minted once, appears only in Class R tables, and is never logged or exported. Severing subject_map therefore leaves the retained records unlinkable even to us, even with the log archive in hand. Enforcement: ARCH-M-16 asserts subject_ref never appears in a log template, a serialisation context, or a response DTO.
Class R tables MUST NOT hold a foreign key to accounts or subject_map. An FK would either block the delete or cascade it into the ledger — the two failure modes this design exists to avoid. Enforcement: ARCH-M-17 reads information_schema.table_constraints and fails if any Class R table references either.
The schema consequence you cannot defer: orders must split
Appendix A’s orders currently carries account_id alongside the financial columns. That single column makes the entire order history Class R and identifying, which is the contradiction in miniature. It splits:
-- Class R: retained, no direct identifier.CREATE TABLE orders ( id uuid PRIMARY KEY, subject_ref uuid NOT NULL, -- deliberately NO foreign key status text NOT NULL, status_rank smallint NOT NULL, currency char(3) NOT NULL, subtotal_minor bigint NOT NULL, tax_minor bigint NOT NULL, total_minor bigint NOT NULL, tax_quote_minimised jsonb NOT NULL, -- jurisdiction, rate, base, calculation id stripe_session_id text UNIQUE, stripe_payment_intent text UNIQUE, created_at timestamptz NOT NULL DEFAULT now(), CHECK (total_minor = subtotal_minor + tax_minor));
-- Class C: bounded need, minimised on schedule.CREATE TABLE order_contacts ( order_id uuid PRIMARY KEY REFERENCES orders(id), account_id uuid REFERENCES accounts(account_id) ON DELETE SET NULL, email_at_purchase_cipher bytea, shipping_address_cipher bytea, -- Q-M-050 after delivery, then minimised location_evidence_cipher bytea, -- Q-M-051, then reduced to jurisdiction + type minimised_at timestamptz);ledger_entries.creator_id likewise becomes subject_ref. Both changes are impossible to retrofit — one is a column on an append-only table, the other splits a table every query touches.
Two tables retain identifiable PII after erasure, and each has a different reason
creator_tax_profiles holds a legal name and a TIN, and filing a 1099 requires both. It cannot be pseudonymised without defeating its purpose. It gets its own envelope-encryption key, its own access audit on every read, and an explicit entry in the DSR response naming the category retained and its basis.
Amended by
ADR-M-034. This paragraph originally claimedcreator_tax_profileswas the only such table. That was true with respect to the axis it enumerated along — data subjects who are customers or creators — and false as a general statement, because §8.12’sadmin_principalsretains an operator’s identity for the audit period under a different basis (employment and financial-audit obligation, not tax reporting). The corpus’s recorded failure mode #1 in one sentence: an enumeration is only complete with respect to the axis it chose. The list is therefore two, the axis is named, andARCH-M-16asserts exactly two tables appear on the retained-PII allow-list — so a third one is a build failure rather than a discovery.
Naming the exceptions is what makes them reviewable; a design where retained PII is scattered has no such property.
The erasure procedure
sequenceDiagram autonumber participant U as Subject participant API as API participant W as Worker participant PG as PostgreSQL participant EXT as Stripe / PoD partner participant VTT as Descent VTT
U->>API: POST /api/dsr/erase (step-up FIDO2 assertion required) API->>PG: eligibility gate — open dispute? open PoD job? negative balance? alt obligation open API-->>U: 202 + the date it becomes eligible, and why else eligible API->>PG: INSERT erasure_requests (unlocks_at = now() + Q-M-054) Note over API,PG: Time-locked and cancellable by passkey —<br/>the §4.10 mechanism, reused. Deletion is more<br/>destructive than recovery, so it is not weaker. W->>U: notify every channel and session (§4.10 fan-out) W->>PG: after unlock, in ONE transaction: W->>PG: DELETE Class E rows (accounts CASCADE -> subject_map, entitlements, ...) W->>PG: minimise Class C rows to retained projections W->>PG: INSERT suppression_register (account_id, email_hmac) Note over PG: ledger_entries and orders are NOT touched.<br/>They now carry an unlinkable subject_ref. W->>EXT: service-provider deletion instruction (CCPA §1798.105(c)) W->>VTT: DSR fan-out — the VTT executes its own erasure (ADR-078/080/085) W->>U: completion notice listing categories erased AND retained endSix properties of that flow that are decisions, not plumbing:
- An erasure request is authenticated at step-up strength and time-locked. An emailed “delete my account” link is a destruction attack, and it is a worse one than the takeover §4.10 defends against, because its damage is irreversible. It reuses §4.10’s mechanism — 48-hour-class cooling-off (
Q-M-054), cancellation by passkey assertion, notification on a channel an email attacker does not control. Reusing the mechanism rather than inventing a second one is the point, on the same reasoning the VTT applies to its three causes ofRulesetReadOnly. - Eligibility is gated on open obligations. An open dispute needs its evidence; an open PoD job needs a shipping address; a negative creator balance is a debt. The response states the date rather than refusing vaguely.
- The suppression register has two jobs, and the second one is technical. It records the accountability trail (Art. 5(2)) and it is the replay list after a database restore — Neon PITR would otherwise resurrect erased data, and a restore runbook that does not re-apply suppressions silently un-erases people. This is the honest reason the register is allowed to hold an
email_hmac: without it, a restore cannot re-identify whom to erase. It carries its own two clocks — the technical need ends when the PITR window rolls past (Q-M-052), the accountability need atQ-M-053. - Deliberate severance is recorded; accidental severance is a finding. Because Class R tables have no FK, nothing at the database level stops a bug writing a
subject_refthat never resolved. §5.10’s nightly reconciliation is extended: everysubject_refin a Class R table MUST either resolve throughsubject_mapor appear in the suppression register. Anything else is a dangling reference and an incident. This is what makes the design auditable — “we cannot identify this row” is only an acceptable answer when we can prove we chose it. - Erasure must not break the ledger’s invariants or reconciliation, or someone will eventually switch one of them off. The integration test creates a full lifecycle — purchase, refund, partial refund, dispute, payout — then erases, then asserts every entry group still balances, reconciliation still passes clean, and a re-identification attempt returns nothing.
RC-M-28. - Processor-held data is not ours to erase, and the DSR response must say so. Stripe and the PoD partner retain what their own obligations require. We issue the service-provider deletion instruction CCPA requires and we report honestly; promising complete erasure of processor-held data would be a false statement in a compliance document.
GDPR and CCPA/CPRA on one mechanism
The mechanism serves both; three differences are worth stating because they change behaviour rather than wording. CCPA requires the service-provider deletion instruction (step 4 above) — GDPR reaches processors contractually instead. CPRA adds an obligation to disclose retention periods, which is why Q-M-049…Q-M-053 are published rather than internal. And GDPR’s one-month response deadline (Q-M-055) is shorter than the time-lock plus a slow Worker would take if the queue backed up, so DSR outbox messages carry elevated priority and their age is an alerting metric, not a dashboard curiosity.
What this does not settle
The architecture is decided. The retention periods are not, and they are jurisdiction-specific, and they are jurisdiction-specific — US federal and state, EU member-state VAT (which reaches ten years in places), UK. Q-M-049…Q-M-053 carry provisional values that are placeholders for advice, and the DSR intake process itself is an operational design rather than an architectural one. That residue is re-scoped as OI-M-24 and inherits OI-M-11’s launch-blocker status; OI-M-11 itself is closed as an architectural question. Splitting it is not bookkeeping — the schema can now be written, which was the thing that was blocked.
8.12 The administrative actor model (OI-M-13)
ADR-M-034: administrators are a separate principal namespace on a separate origin with no recovery path; they hold named capabilities, not roles; they never write to ledger_entries; money-moving actions require maker-checker above a threshold; and every attempt — including every refusal — lands in an append-only audit log.
Three schema columns already assumed this surface existed: entitlements.grant_reason = 'comp', ledger_entries.reason = 'adjustment', and ledger_entries.actor_ref. §8.9.1 additionally posited a “reviewed grant” of egress headroom with no named reviewer. This section is what those four references point at.
An administrator MUST NOT be an accounts row
The tempting shortcut is a boolean or a role on the customer account table. It fails on four counts, and the third is fatal:
- The parent-domain cookie. §4.4 scopes
dv_idto.descentvtt.comand accepts, explicitly, that an XSS on any subdomain yields account access. Applying that to an administrator makes an XSS on the storefront a route to the ledger. - Erasure. §8.11’s
accountsrow is Class E — deletable. An administrator who exercised a deletion right would destroy the actor linkage in a financial audit trail, which is a ledger-integrity failure wearing a privacy costume. - §4.10’s recovery path. Time-locked email recovery is a deliberate, documented route to an account for someone holding the inbox. A 48-hour path to administrative access via an email compromise is not acceptable at any lock duration. Administrators therefore have no recovery flow: a lost authenticator is resolved by another administrator re-enrolling them, which is the correct answer because the population is small and known.
- Passkey syncing. §4.2 accepts synced passkeys for customers as a licence-sharing trade. An administrative credential travelling to a family member’s iPad is a different proposition; admin enrolment requires a device-bound authenticator, and the enrolment ceremony records that it is not a synced credential.
The admin surface therefore lives on its own host, and it gets back the browser guarantee §4.4 gave up. Because it needs no cross-subdomain SSO, its session cookie carries no Domain attribute and can use the __Host- prefix:
Set-Cookie: __Host-dv_admin=<jwt>; Path=/; Secure; HttpOnly; SameSite=Strict; Max-Age=900__Host- means a sibling subdomain cannot set or overwrite this cookie, which is precisely the residual risk the customer design has to accept operationally. SameSite=Strict is available because no admin flow returns from a third-party redirect. Lifetime is Q-M-063 (15 minutes) with no refresh token — an administrator re-authenticates, and the friction is proportionate to what the session can do. Separate JWT audience, separate signing key, separate JWKS.
Capabilities, not roles
A role hierarchy for a team of this size encodes permissions nobody can enumerate. The set is small, closed, and validated against an enum at grant time:
| Capability | Grants | Reversal path | Maker-checker |
|---|---|---|---|
entitlement:comp | Issue a free entitlement | Revoke it | Above Q-M-061 cumulative value |
refund:issue | Refund outside the §7.8 policy window | None — money has left | Always |
ledger:adjust:propose | Propose a balanced adjustment | reverses | — |
ledger:adjust:approve | Approve one | — | Is the check |
payout:release | Release a held payout early | Clawback, unreliable | Always |
egress:sponsor | Grant free-UGC headroom (§8.9.1) | Expires | Above Q-M-061 |
taxprofile:read | Read a TIN | n/a | Per-read audit + step-up |
dsr:execute | Run an erasure (§8.11) | None — irreversible | Always |
admin:enrol | Enrol or retire an administrator | Retire | Always |
A capability whose reversal path is “none” requires approval regardless of amount. That rule, rather than a value threshold alone, is what makes the table defensible: dsr:execute at any scale is unrecoverable, and a threshold expressed in dollars would have let it through.
Every capability maps to a route group carrying both the capability filter and §4.9’s step-up filter. ARCH-M-21 asserts that every route in the Admin.* groups carries both — a route added without them fails the build, the same mechanism as ARCH-M-07 and for the same reason.
Administrators do not write to the ledger
This is the load-bearing decision. ledger_entries is append-only with a deferred balance constraint (§7.5); an administrator poking a single row would either violate the constraint or require them to hand-construct a balanced group, which puts the money invariant in a human’s hands at 2am.
Instead: an administrative action is a proposal. The admin writes admin_actions; on approval, a system component translates it into a balanced entry group and records the group id back onto the action. The invariant never leaves the code that owns it, and the reason is captured as structured data rather than reverse-engineered from postings later.
sequenceDiagram autonumber participant A1 as Admin (proposer) participant A2 as Admin (approver) participant API as Admin API participant PG as PostgreSQL participant W as Worker
A1->>API: POST /admin/ledger/adjust (capability + fresh step-up) API->>PG: conflict check: is subject_ref linked to A1? alt self-dealing API->>PG: INSERT admin_audit (outcome=refused_conflict) API-->>A1: 403 — named reason else clean API->>PG: INSERT admin_actions (status=proposed, justification NOT NULL) API->>PG: INSERT admin_audit (outcome=allowed) end A2->>API: POST /admin/actions/{id}/approve API->>PG: CHECK approved_by <> proposed_by (database, not code) API->>PG: UPDATE admin_actions -> approved API->>PG: INSERT outbox (topic=admin.execute) W->>PG: BEGIN; post BALANCED entry group; set ledger_entry_group; -> executed; COMMIT Note over W,PG: The Worker constructs the group.<br/>No admin route can reach ledger_entries.Enforcement: ARCH-M-20 asserts no code path reachable from an Admin.* route writes to ledger_entries or entitlements directly. Execution goes through the outbox (§5.6), so an admin action inherits the same at-least-once delivery, idempotency key and dead-letter behaviour as every other side effect — rather than being a bespoke path with its own failure modes.
Four refusals enforced in the database, not the application
Application-level checks are bypassed by the next endpoint someone adds. These live in SQL:
| Refusal | Mechanism |
|---|---|
| Self-approval | CHECK (approved_by IS NULL OR approved_by <> proposed_by) on admin_actions |
| Self-granted capability | CHECK (admin_id <> granted_by) on admin_capabilities |
| Immutable proposal | Trigger rejecting any UPDATE touching proposed_by, target, or justification; status moves only up a monotonic rank, the §5.1 pattern |
| Append-only audit | BEFORE UPDATE OR DELETE trigger raising unconditionally, the §7.5 pattern |
Self-dealing needs a linkage the “admins are not accounts” rule would otherwise forbid. admin_principals.linked_account_id exists solely so an action can be refused — never for authentication, never for authorisation, never joined in a read path. It is the deliberate exception, and ARCH-M-22 asserts the column is read only by the conflict check.
The audit log records refusals, and that is the point
An append-only log of successful actions tells you what happened. A log that also records refused_capability, refused_conflict, refused_self_approval and refused_stepup tells you when someone is probing — which is the earliest signal of either a compromised admin credential or an insider testing boundaries. Refusal volume by admin is an alerting metric, not a report.
actor_ref on ledger_entries (Appendix A) is the durable back-reference: given any adjustment posting, the responsible principal and the justification are one join away, permanently, in a table that cannot be rewritten.
Single-actor actions still get a second pair of eyes
Below Q-M-061, an action executes on one administrator’s authority — necessary, because routing a $3 goodwill comp through two people is a process nobody will follow. But justification being mandatory is a weak control: people type filler. So every single-actor action is swept into a weekly review digest that a different administrator must acknowledge, with the acknowledgement itself recorded. Asynchronous review is what actually catches a pattern of small self-serving actions; a minimum-length string check does not. An unacknowledged digest older than Q-M-062 is an alert.
Break-glass is permitted and made loud
Every administrative system has a 3am incident with no approver reachable, and a break-glass path that is undocumented becomes an undocumented backdoor. So it exists, and its cost is noise:
- Requires a device-bound hardware authenticator and a typed justification.
- Sets
admin_actions.break_glass = true, which is indelible. - Pages the entire administrative group immediately — not an email, a page.
- Creates a mandatory post-hoc review that blocks the next release if unresolved past
Q-M-062. break_glass_invocations_totalis expected to be zero; any increment is an incident by definition.
The design goal is not to prevent break-glass. It is to make using it more expensive than waiting for an approver whenever waiting is possible.
Deployment: the second thing that genuinely scales to zero
Administrative traffic is human-scale, latency-tolerant, and infrequent. The admin API is a separate container app with minReplicas: 0, ingress restricted at Front Door by IP allow-list and WAF. Three benefits: blast-radius isolation from the customer API, an independently restrictable network surface, and — for the first time in this document — a service where a cold start is genuinely acceptable. §8.7’s claim that only the stamping Job scales to zero is amended: there are two. The cost is one more revision to deploy and one more image to patch, which is the right trade for the surface that can move money.
9. Architectural Contrast with Descent VTT
Two systems in one ecosystem, built by one team, took opposite decisions on the two most visible axes: compilation model and scale-to-zero. A future engineer reading either document alone will conclude the other is wrong. This chapter exists so that neither conclusion is available.
The short version: both systems optimise for the same thing — cost per unit of served demand — and they arrive at opposite answers because their demand has opposite shapes. The Marketplace serves short, synchronous, spiky, latency-intolerant HTTP. The VTT serves long, stateful, latency-tolerant-at-the-boundary sessions. Nothing else about the contrast is interesting until that is understood.
9.1 The measurement that settled it
BENCH-M-01 is complete. ACA scale-from-zero wall time is 8 969 ms (Q-M-013, now ✓ measured). That figure is the hinge for this whole chapter, and it retires the last of finding M-F-02’s ambiguity:
- It confirms §3.1: Native AOT cannot rescue scale-from-zero. A ~40 ms process start inside a ~9 s wall is noise. Anyone proposing “AOT gives us sub-50 ms cold starts, so we can scale to zero” is quoting the wrong term of the sum.
- It sharpens the boundary. Nine seconds is not merely “slow”; it is disqualifying for one class of workload and completely free for another. The interesting question was never how fast but who is waiting.
9.2 The scale-to-zero boundary: who is waiting?
ADR-M-041: scale-to-zero is decided by whether a human is blocked on the response, not by whether the workload is “background”.
| Workload | minReplicas | Who waits 9 s | Verdict |
|---|---|---|---|
| API — checkout | 1 | The buyer, mid-purchase | A 9 s stall on the Buy button is measurable conversion loss |
| API — Stripe webhook | 1 | Stripe’s retry scheduler | Nine seconds inside a self-imposed 250 ms budget (Q-M-020) is a 5xx and a retry storm |
| API — SSE stream | 1 | Every subscriber | An open stream pins a replica anyway (§6.5) — two independent reasons |
| BFF — SSR | 1 | First-time visitor, and Googlebot | Cold SSR is an SEO and conversion cost, not a latency curiosity |
| Outbox drain | 0 | Nobody | ✅ MUST scale to zero. KEDA PostgreSQL scaler on outbox depth |
| Receipt / notification email | 0 | Nobody | ✅ MUST scale to zero |
| Nightly reconciliation | 0 | Nobody | ✅ MUST scale to zero — a scheduled job by nature |
| PDF stamping / packaging | 0 | Nobody | ✅ Already zero (§8.7) |
| Admin API | 0 | An operator, who can wait | ✅ Already zero (ADR-M-034) |
This amends ADR-M-006, which recorded the Worker as “always-on by design”. That was inherited from the original brief’s phrasing and it was wrong — or rather, it was right about availability and wrong about provisioning. The outbox drain must always be able to run; it does not need a replica sitting idle to be able to run. KEDA’s PostgreSQL scaler queries outbox_messages depth and wakes it.
The coupling this creates, which must not be discovered later. Outbox latency now has two additive terms it did not have before:
$$\text{worst-case outbox latency} = \underbrace{\text{KEDA polling interval}}{Q\text{-}M\text{-}069} + \underbrace{\text{scale-from-zero}}{9\text{ s}} + \text{processing}$$
At a 30 s polling interval that is ~39 s worst case, which fits inside §8.10’s outbox_oldest_pending_age_seconds < 60 s SLO — but only just, and the margin is 21 s. Two consequences, both normative: the polling interval and that SLO are now a single coupled decision and MUST be changed together (enforcement: RC-M-35 asserts Q-M-069 + Q-M-013 + p95 processing < the SLO threshold, computed in CI from configuration rather than trusted to a reviewer); and the PoD compensation saga (§5.8) is exempt — a refund triggered by a printer rejection is money-facing and runs on the always-on API path, not behind a scaler.
Two platform constraints this depends on, verified against current ACA behaviour (2026-08-03) rather than assumed. ADR-M-041 rests on ACA reaching zero on a non-HTTP scaler, so both were checked:
- A PostgreSQL KEDA scaler with
minReplicas: 0is supported. ACA permits any of KEDA’s scalers as a custom scale rule, and scale-to-zero is available for event-based rules — which the outbox-depth query is. - A CPU or memory scale rule on the same app silently prevents zero, by design. Those scalers measure utilisation inside a running container, which cannot be sampled when none exists, so KEDA does not scale them to zero. The failure mode is the dangerous shape: no error, no warning, just a replica that never retires and a bill that never drops. The Worker app therefore MUST carry the outbox rule alone, with no CPU/memory rule “for safety” — the intuitive addition is precisely what breaks it. Enforcement:
RC-M-36asserts the Worker’s scale-rule set contains exactly one rule and that it is thepostgresqltype.
Additionally, ACA guidance for non-HTTP event rules is to keep activeRevisionsMode: single, which the deployment already uses for the rolling-revision reasoning in §3.7 — the two requirements agree, which is worth noting only because they could have conflicted.
What this does not buy. Neon’s compute still never autosuspends, because the API’s LISTEN connection pins it awake (§6.6). The saving is the Worker’s replica floor — roughly 0.25 vCPU / 0.5 GiB of the ~1.25 vCPU floor in §8.8, so on the order of 20% of the always-on compute line. Real, worth taking, and not the transformation the phrase “scale-to-zero” invites you to imagine.
9.3 Why the Marketplace keeps Native AOT anyway
The API abandoned scale-to-zero. Native AOT stays, for three reasons that have nothing to do with cold starts — and each is now backed by a number rather than an intuition.
1. Resident memory, which on an always-on floor is the bill. Because §9.2 forces minReplicas = 1 on three services, the replica size is the recurring cost. AOT’s working set is what permits ACA’s lowest practical tier — 0.5 vCPU / 1.0 GiB — rather than the next one up. On a 24×7 floor, one tier of over-provisioning across three services compounds every month forever. This is the inversion worth internalising: being forced always-on is what makes AOT valuable, not what makes it pointless.
2. Image size, and here the common claim is wrong. “Native AOT produces images under 20 MB” conflates the binary with the image. Measured on the MVP:
| Base image | Binary | Final image | |
|---|---|---|---|
runtime-deps:10.0 (Debian) | 19.3 MB | 245 MB | base was ~92% of the image |
runtime-deps:10.0-noble-chiseled | 19.3 MB | 48.1 MB | ✅ 80% reduction, app verified serving |
Q-M-070 / Q-M-071. The AOT binary was never the problem; the base image was. A 19 MB binary on a full Debian base is a 245 MB pull on every scale-out event, and the AOT decision buys nothing until the base is chiselled too. Two practitioner notes recorded because both cost time:
InvariantGlobalization=trueis a prerequisite, not a coincidence. Chiselled images ship no ICU or tzdata. The property was already set for AOT reasons (§3.2); it is also what makes the small base viable. A globalization-dependent service needs-chiseled-extra, and this is the second place that property constrains where localised receipts may be rendered (OI-M-22(e)).- There is no
-chiseled-aottag on .NET 10. Verified against the MCR tag list on 2026-08-03: the .NET 8-era three-variant scheme (chiseled/chiseled-aot/chiseled-extra) has collapsed to two. Microsoft Learn’s “container images” page still documents the retired scheme (page dated 2024-08), so a tutorial-following engineer will write a tag that does not resolve. Use10.0-noble-chiseled.
3. Scale-out p99, which is the one that protects checkout. A publisher’s launch day is a step function. On scale-out (not from zero) the image is often node-cached, so the remaining terms are container start plus first-request readiness — and AOT removes the JIT warm-up tail from that, entirely. Stated precisely, because §3.1’s honesty rule applies here too: AOT does not remove container scheduling from scale-out; it removes the tiered-compilation warm-up that would otherwise sit on top of it. For a checkout endpoint absorbing a spike, that tail is the difference between a slow first request and a timed-out one.
9.4 The VTT took the opposite path, and was right to
| Axis | Descent VTT | Descent Marketplace |
|---|---|---|
| Compilation | .NET 10 JIT, tiered compilation | .NET 10 Native AOT |
| Scale-to-zero | Yes, aggressively, for game rooms | No for anything user-facing; yes for async workers (§9.2) |
| Cold start | Absorbed by UX — Guardrail 6 “Cold Start Edge-Hold” | Cannot be absorbed; a checkout has no waiting-room affordance |
| Session shape | One room, hours long, stateful | One request, milliseconds, stateless |
| Peak-throughput priority | High — a 20 Hz tick for a 4-hour session | Low — bursty HTTP, bounded by I/O |
| Reflection posture | Required. Marten, Orleans providers, FusionCache, AssemblyLoadContext cartridge loading, Jint scripting | Forbidden. IlcTreatWarningsAsErrors, no suppression allow-list |
The VTT’s cold start is absorbed by a UX affordance the Marketplace does not have. VTT Guardrail 6 catches a 503 or timeout on the SignalR connection, shows “Server is awakening”, and retries with exponential backoff. Critically, the VTT corpus is honest about the budget in a way worth copying: “The Budget Is the Whole Chain, Not the JIT (measured, not asserted)” — ACA scheduling + image pull + CoreCLR/JIT warm-up + Neon compute resume + Orleans cluster join — and it insists on distinct wake states rather than one spinner covering four different waits. A player entering a room accepts a labelled wake animation. A buyer clicking Buy does not accept one, and no amount of UX craft makes a payment redirect feel like a loading screen.
The VTT’s JIT choice is closer to forced than chosen, and the contrast is cleaner once that is said. Its stack is reflection-dependent by design: Marten’s document/event store, Orleans grain-interface proxying, AssemblyLoadContext cartridge loading (VTT §3.1), and the Jint script sandbox (VTT §4.3) all require capabilities Native AOT removes. JIT is not a performance preference there; it is the price of the extension model that makes the VTT a platform. Tiered compilation then turns that constraint into an advantage over a 4-hour session, where warm-up amortises to nothing and peak throughput is what matters.
The sharpest instance of the contrast: Dapper.AOT, adopted and rejected for the same reason
The VTT uses Dapper.AOT. The Marketplace could not. Neither is a mistake, and the brief that prompted this chapter had the causality inverted.
VTT §6.1 records “Despite running on JIT, the architecture heavily favors AOT-optimized libraries (e.g. Dapper.AOT, System.Text.Json Source Generators) in performance hot-paths”, and its read path uses “Flattened Projections via Dapper.AOT + Npgsql”. So Dapper.AOT is not an example of the reflection ecosystem that JIT buys the VTT — it is the VTT choosing compile-time codegen on purpose to avoid reflection on its hot path, while remaining free to use reflective libraries elsewhere.
The Marketplace’s rejection (ADR-M-039) has a different cause, and the distinction matters because a reader who blurs it will draw the wrong lesson:
Dapper.AOTworks by intercepting Dapper’s extension methods, so theDapperpackage must also be referenced.- Dapper’s reflection core then remains statically reachable, producing seven diagnostics —
IL2070/IL2075inDefaultTypeMap,IL3050×3 inSqlMapper.AddTypeHandlerCore,IL2070inTypeExtensions. - On JIT nobody runs the trim analyser, so those same paths are inert and harmless. Under
IlcTreatWarningsAsErrorsthey are a build failure.
Same library, same version, opposite outcomes — and the deciding variable is the runtime, not the library. Dapper.AOT’s own standalone Command<T> API is fully AOT-clean (verified by inspecting the assembly: it does not reference Dapper), but it requires a hand-written CommandFactory<T> per parameter shape and a RowFactory<T> per row type, which for the MVP’s eight queries was more code than raw NpgsqlCommand for identical generated behaviour. So the Marketplace pays a developer-experience price — explicit parameters, ordinal column reads, no mapping layer — to keep a build in which a reflection dependency cannot reach production. That price is the correct trade for payment infrastructure and it would be the wrong trade for a game engine.
9.5 The rule to take away
Ask who is waiting, then ask what the runtime forbids.
If a human or a payment processor is blocked on the response, the service is always-on and its replica size is the bill — which is precisely when Native AOT earns its keep, and when the base image must be chiselled or the saving is notional. If nothing is waiting, scale to zero and let KEDA pay the cold start — but couple the scaler’s interval to the freshness SLO in CI, because the two are now one decision.
And when two documents in this corpus appear to contradict each other on AOT or scale-to-zero, the contradiction is almost always a difference in who is waiting, not a difference of opinion. Where it genuinely is a difference of opinion, that is a finding — raise it with a number, the way
BENCH-M-01settled this one.
Appendix A — Core Relational Schema
Abridged to the tables whose shape is argued for in the body. Types are PostgreSQL 18. All money is bigint minor units; all identifiers are UUIDv7 so that primary-key order approximates creation order, which is what makes the lock-ordering rule of §5.4 and the outbox’s ORDER BY id behave sensibly.
-- ── Catalogue ────────────────────────────────────────────────────────────────-- CLASS N (§8.11 as amended by ADR-M-038): contains no personal data.CREATE TABLE titles ( id uuid PRIMARY KEY, publisher_id uuid NOT NULL REFERENCES creators(id), state text NOT NULL, -- draft | in_review | published | delisted published_at timestamptz -- `slug text NOT NULL UNIQUE` REMOVED (OI-M-22 / ADR-M-038). A title has one slug PER -- LOCALE, which a single column cannot hold, and a global unique breaks on the first -- localised slug. See title_slugs below.);
CREATE TABLE title_translations ( -- CLASS N. One row per locale; NOT a jsonb blob, title_id uuid NOT NULL REFERENCES titles(id), -- so "which titles have German?" is indexable locale text NOT NULL, -- BCP-47 name text NOT NULL, description text, PRIMARY KEY (title_id, locale));-- Fallback chains (de-CH -> de -> en) resolve at read time. Fallback is query behaviour,-- never duplicated rows: a materialised fallback goes stale the moment a translation lands.
CREATE TABLE title_slugs ( -- CLASS N title_id uuid NOT NULL REFERENCES titles(id), locale text NOT NULL, slug text NOT NULL, is_current boolean NOT NULL DEFAULT true, retired_at timestamptz, PRIMARY KEY (title_id, locale, slug));-- Uniqueness spans RETIRED rows too: a retired slug must never be reused by another title,-- or its 301 would redirect to the wrong product.CREATE UNIQUE INDEX title_slug_global ON title_slugs (locale, slug);-- Exactly one live slug per (title, locale); the rest are kept to serve 301s rather than 404s,-- which is the difference between a rename costing nothing and costing the page's ranking.CREATE UNIQUE INDEX title_slug_current ON title_slugs (title_id, locale) WHERE is_current;
CREATE TABLE skus ( id uuid PRIMARY KEY, title_id uuid NOT NULL REFERENCES titles(id), tier smallint NOT NULL CHECK (tier BETWEEN 1 AND 3), price_minor bigint NOT NULL CHECK (price_minor >= 0), currency char(3) NOT NULL DEFAULT 'USD', fulfilment_regions text[] NOT NULL DEFAULT '{}', -- §7.6 ADR-M-016; empty = digital, worldwide standalone_sellable boolean NOT NULL, -- §1.2 price floor; false => bundle-only is_exclusive boolean NOT NULL, -- ADR-M-043. Self-declared; enforcement is -- contractual and reactive, NOT technical. -- Demotion is prospective only, because §7.5 -- makes retroactive re-rating impossible. exclusive_until timestamptz, -- ADR-M-043 / Q-M-079 commitment window payload_bytes bigint NOT NULL CHECK (payload_bytes >= 0), -- ADR-M-044: TOTAL hosted payload for this SKU, -- including ADR-M-046's raw archive. The -- size-band floor is computed from this, and a -- change to it re-evaluates the floor (RC-M-42). UNIQUE (title_id, tier));
-- Which resources a SKU unlocks. One row per granted entitlement.CREATE TABLE sku_grants ( sku_id uuid NOT NULL REFERENCES skus(id), resource_urn text NOT NULL, -- urn:descent:cartridge:coc7e:2 product_kind text NOT NULL, -- pdf | asset_pack | cartridge | mod PRIMARY KEY (sku_id, resource_urn));
-- Revenue attribution, in basis points, summing to 10000 per SKU. §7.4CREATE TABLE sku_contributors ( sku_id uuid NOT NULL REFERENCES skus(id), creator_id uuid NOT NULL REFERENCES creators(id), share_bps int NOT NULL CHECK (share_bps > 0), PRIMARY KEY (sku_id, creator_id));-- Deferred constraint trigger: SUM(share_bps) = 10000 per sku_id at COMMIT.
-- ── Carts (CLASS E) and the inbound idempotency guard (§5.1.1, ADR-M-035) ────CREATE TABLE carts ( id uuid PRIMARY KEY, account_id uuid NOT NULL REFERENCES accounts(account_id) ON DELETE CASCADE, version integer NOT NULL DEFAULT 1, -- the If-Match ETag source status text NOT NULL, -- open | checked_out | abandoned order_id uuid UNIQUE, -- FK direction forced by ARCH-M-17: Class E may -- reference Class R, never the reverse (§5.1.1) created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now());
-- ── Orders ───────────────────────────────────────────────────────────────────-- CLASS R (§8.11). Note: NO account_id, and subject_ref deliberately carries NO foreign key.CREATE TABLE orders ( id uuid PRIMARY KEY, subject_ref uuid NOT NULL, -- §8.11 severable linkage; no FK by design status text NOT NULL, status_rank smallint NOT NULL, -- §5.1 monotonic guard currency char(3) NOT NULL, subtotal_minor bigint NOT NULL, tax_minor bigint NOT NULL, total_minor bigint NOT NULL, tax_quote jsonb NOT NULL, -- §7.6: captured at charge, never recomputed. -- Minimised form only: jurisdiction, rate, base, -- amount, calculation id. Raw evidence lives in -- order_contacts and expires (Q-M-051). stripe_session_id text UNIQUE, stripe_payment_intent text UNIQUE, session_attempt integer NOT NULL DEFAULT 1, -- §5.1.1: Stripe Idempotency-Key is -- {order_id}:{session_attempt}. Must NOT be -- order_id alone — Stripe's 24 h key window and -- the Checkout Session's 24 h expiry coincide. created_at timestamptz NOT NULL DEFAULT now(), CHECK (total_minor = subtotal_minor + tax_minor));CREATE INDEX ON orders (subject_ref);-- Trigger: refuse any UPDATE where NEW.status_rank <= OLD.status_rank.
CREATE TABLE order_lines ( id uuid PRIMARY KEY, order_id uuid NOT NULL REFERENCES orders(id), sku_id uuid NOT NULL REFERENCES skus(id), kind text NOT NULL, -- digital | physical amount_minor bigint NOT NULL, -- §5.8: allocated at checkout, largest-remainder commission_bp integer NOT NULL, -- ADR-M-043. FROZEN at checkout from the SKU's exclusivity -- at that moment. NEVER re-read and never back-filled: a -- rate looked up later makes every settled split a function -- of the title's CURRENT exclusivity (§7.3.1). RC-M-41. fulfilment_status text NOT NULL, fulfilment_rank smallint NOT NULL);-- Deferred constraint trigger: SUM(amount_minor) = orders.subtotal_minor.
-- ── Entitlements: the ONE shared surface (§2.5) ──────────────────────────────CREATE TABLE entitlements ( id uuid PRIMARY KEY, account_id uuid NOT NULL, resource_urn text NOT NULL, product_kind text NOT NULL, granted_at timestamptz NOT NULL DEFAULT now(), expires_at timestamptz, -- ADR-M-027: nullable, reserved for future subscriptions revoked_at timestamptz, revoke_reason text, source_order_id uuid REFERENCES orders(id), grant_reason text NOT NULL -- purchase | bundle | gift | comp | migration);CREATE UNIQUE INDEX entitlement_live ON entitlements (account_id, resource_urn) WHERE revoked_at IS NULL;
CREATE VIEW vtt_entitlement_v1 AS SELECT account_id, resource_urn, granted_at FROM entitlements WHERE revoked_at IS NULL AND (expires_at IS NULL OR expires_at > now());-- GRANT SELECT ON vtt_entitlement_v1 TO vtt_reader; -- and nothing else, ever. RC-M-08.
-- ── Payments plumbing (Chapter 5) ────────────────────────────────────────────-- processed_stripe_events, outbox_messages: see §5.3 and §5.6.
CREATE TABLE user_notifications ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, -- SSE Last-Event-ID account_id uuid NOT NULL, kind text NOT NULL, payload jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now());CREATE INDEX ON user_notifications (account_id, id);-- Retention: 90 days. A notification log is not an audit log.
CREATE TABLE pod_jobs ( id uuid PRIMARY KEY, order_line_id uuid NOT NULL UNIQUE REFERENCES order_lines(id), partner text NOT NULL, partner_job_id text, status text NOT NULL, tracking jsonb, submitted_at timestamptz, -- non-null == point of no return (§5.1) last_event_at timestamptz);
-- ── Administrative actors (§8.12, ADR-M-034) ─────────────────────────────────-- CLASS R. The SECOND of exactly two tables permitted to retain identifiable PII-- past a customer erasure (§8.11, as amended) — basis: financial-audit obligation.CREATE TABLE admin_principals ( admin_id uuid PRIMARY KEY, login_hmac bytea NOT NULL UNIQUE, display_name text NOT NULL, status text NOT NULL, -- active | suspended | retired linked_account_id uuid, -- CONFLICT DETECTION ONLY (ARCH-M-22). -- Never authentication, never authorisation, -- never joined in a read path. created_at timestamptz NOT NULL DEFAULT now(), retired_at timestamptz);
CREATE TABLE admin_capabilities ( admin_id uuid NOT NULL REFERENCES admin_principals(admin_id), capability text NOT NULL, -- validated against a closed enum at grant time granted_by uuid NOT NULL REFERENCES admin_principals(admin_id), granted_at timestamptz NOT NULL DEFAULT now(), expires_at timestamptz, PRIMARY KEY (admin_id, capability), CHECK (admin_id <> granted_by) -- nobody grants themselves a capability);
CREATE TABLE admin_actions ( -- a PROPOSAL. Admins never write ledger_entries. id uuid PRIMARY KEY, capability text NOT NULL, subject_ref uuid, -- §8.11 pseudonym, not account_id target jsonb NOT NULL, -- order id / entitlement urn / amount + currency justification text NOT NULL, proposed_by uuid NOT NULL REFERENCES admin_principals(admin_id), proposed_at timestamptz NOT NULL DEFAULT now(), approved_by uuid REFERENCES admin_principals(admin_id), approved_at timestamptz, executed_at timestamptz, ledger_entry_group uuid, -- written by the Worker, never by an admin reverses_action uuid REFERENCES admin_actions(id), break_glass boolean NOT NULL DEFAULT false, status text NOT NULL, -- proposed | approved | executed | rejected | expired status_rank smallint NOT NULL, -- monotonic, §5.1 pattern CHECK (approved_by IS NULL OR approved_by <> proposed_by));-- Trigger: reject any UPDATE touching proposed_by, target, justification or break_glass;-- reject a status_rank regression.
CREATE TABLE admin_audit ( -- append-only; records REFUSALS as well as successes id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, at timestamptz NOT NULL DEFAULT now(), admin_id uuid, -- no FK: a retired admin keeps their trail capability text, action_id uuid, outcome text NOT NULL, -- allowed | refused_capability | refused_conflict -- | refused_self_approval | refused_stepup request_meta jsonb NOT NULL -- route, ip, ua, amr, step-up age);CREATE INDEX ON admin_audit (admin_id, at DESC);CREATE INDEX ON admin_audit (outcome, at DESC) WHERE outcome <> 'allowed'; -- probing detection-- Trigger: BEFORE UPDATE OR DELETE -> raise unconditionally (§7.5 pattern).
CREATE TABLE admin_review_digests ( -- the real control on single-actor actions period_start date NOT NULL, period_end date NOT NULL, action_ids uuid[] NOT NULL, acknowledged_by uuid REFERENCES admin_principals(admin_id), acknowledged_at timestamptz, PRIMARY KEY (period_start, period_end));
-- ── Retention classes & the severable linkage (§8.11, ADR-M-032) ─────────────-- accounts / subject_map: see §8.11. Class E; deleting the accounts row IS erasure.
CREATE TABLE order_contacts ( -- CLASS C: bounded need, minimised on schedule order_id uuid PRIMARY KEY REFERENCES orders(id), account_id uuid REFERENCES accounts(account_id) ON DELETE SET NULL, email_at_purchase_cipher bytea, shipping_address_cipher bytea, -- minimised Q-M-050 after delivery location_evidence_cipher bytea, -- minimised Q-M-051; §7.6 VAT evidence minimised_at timestamptz);
CREATE TABLE data_class_registry ( -- ARCH-M-15 reads this; an unregistered table fails CI table_name text PRIMARY KEY, class char(1) NOT NULL CHECK (class IN ('N','R','E','C')), -- 'N' added by ADR-M-038 rationale text NOT NULL);
CREATE TABLE erasure_requests ( -- reuses §4.10's time-lock shape id uuid PRIMARY KEY, account_id uuid NOT NULL REFERENCES accounts(account_id) ON DELETE CASCADE, token_sha256 bytea NOT NULL UNIQUE, status text NOT NULL, -- pending | cancelled | completed | ineligible requested_at timestamptz NOT NULL DEFAULT now(), unlocks_at timestamptz NOT NULL, -- requested_at + Q-M-054 eligible_at timestamptz, -- set when an open obligation defers it completed_at timestamptz);CREATE UNIQUE INDEX erasure_one_pending ON erasure_requests (account_id) WHERE status = 'pending';
CREATE TABLE suppression_register ( -- two clocks: Q-M-052 technical, Q-M-053 accountability account_id uuid PRIMARY KEY, -- no FK: the accounts row is gone by now email_hmac bytea NOT NULL, -- restore-replay key; see §8.11 property 3 subject_ref uuid NOT NULL, -- lets reconciliation distinguish deliberate severance erased_at timestamptz NOT NULL, categories_erased text[] NOT NULL, categories_retained text[] NOT NULL, pitr_clear_after timestamptz NOT NULL, purge_after timestamptz NOT NULL);
-- ledger_entries.creator_id becomes subject_ref (§8.11). Impossible to retrofit: the table-- is append-only, so a late change means a parallel table and a migration of live money.
-- ── Identity ─────────────────────────────────────────────────────────────────CREATE TABLE auth_tickets ( -- §4.5; high churn, aggressive autovacuum ticket_sha256 bytea PRIMARY KEY, account_id uuid NOT NULL, audience text NOT NULL, grant_hint uuid, -- §2.5 after_grant_id expires_at timestamptz NOT NULL, consumed_at timestamptz) WITH (fillfactor = 70, autovacuum_vacuum_scale_factor = 0.01);
CREATE TABLE account_recovery_requests ( -- §4.10 id uuid PRIMARY KEY, account_id uuid NOT NULL, token_sha256 bytea NOT NULL UNIQUE, -- the token itself is never stored status text NOT NULL, -- pending | cancelled | consumed | expired requested_at timestamptz NOT NULL DEFAULT now(), unlocks_at timestamptz NOT NULL, -- requested_at + Q-M-038 (48h) expires_at timestamptz NOT NULL, -- unlocks_at + Q-M-041; an unredeemed grant must not stay armed cancelled_at timestamptz, consumed_at timestamptz, request_ip inet, request_ua text, notified_at timestamptz[] NOT NULL DEFAULT '{}' -- T+0, T+24h, T+44h fan-out receipts);-- ONE pending request per account: blocks the notification-fatigue attack (§4.10, correction 5).CREATE UNIQUE INDEX recovery_one_pending ON account_recovery_requests (account_id) WHERE status = 'pending';CREATE INDEX recovery_sweep ON account_recovery_requests (unlocks_at) WHERE status = 'pending';
ALTER TABLE accounts ADD COLUMN recovery_cooldown_until timestamptz, -- set on cancel; Q-M-039 ADD COLUMN quarantine_until timestamptz; -- set on completed recovery; Q-M-040-- A session on an account inside quarantine_until can never satisfy step-up (§4.9, ARCH-M-07).
-- ── Identity, added 2026-08-10 by M-F-09 / M-F-10 / M-F-11 ───────────────────-- This block was MISSING and Chapter 4 was unimplementable without it. See §4.2's finding-- blockquote: §4.5 says "verify credential, bump sign counter" and nothing held a credential;-- §4.3 specifies refresh rotation with reuse detection and nothing held a token; §4.7 and-- §4.10 increment a column that was never added. Shipped as `0003_identity.sql`.
CREATE TABLE webauthn_credentials ( -- §4.2. M-F-09. credential_id bytea PRIMARY KEY, -- authenticator-chosen; 16..1023 bytes account_id uuid NOT NULL REFERENCES accounts(account_id) ON DELETE CASCADE, public_key_cose bytea NOT NULL, -- raw COSE_Key: the format every verifier accepts cose_alg integer NOT NULL, -- CHECK (-7, -257) — OUR policy, so closed (cf. subscriptions.status) sign_count bigint NOT NULL DEFAULT 0, aaguid uuid, backup_eligible boolean NOT NULL DEFAULT false, -- §4.2: RECORDED, never a reason to refuse backed_up boolean NOT NULL DEFAULT false, uv_at_registration boolean NOT NULL DEFAULT false, created_at timestamptz NOT NULL DEFAULT now(), last_used_at timestamptz, revoked_at timestamptz -- soft: §4.10 revokes all prior credentials);CREATE INDEX webauthn_credentials_live ON webauthn_credentials (account_id) WHERE revoked_at IS NULL;
CREATE TABLE webauthn_ceremonies ( -- a single-use challenge, in §4.5's shape challenge_sha256 bytea PRIMARY KEY, kind text NOT NULL, -- register | authenticate | stepup | recovery-cancel account_id uuid REFERENCES accounts(account_id) ON DELETE CASCADE, -- NULL for username-less login uv_required boolean NOT NULL, expires_at timestamptz NOT NULL, consumed_at timestamptz) WITH (fillfactor = 70, autovacuum_vacuum_scale_factor = 0.01);
CREATE TABLE refresh_tokens ( -- §4.3. M-F-10. token_sha256 bytea PRIMARY KEY, family_id uuid NOT NULL, -- the unit of revocation; the row is the unit of use account_id uuid NOT NULL REFERENCES accounts(account_id) ON DELETE CASCADE, session_version integer NOT NULL, -- captured at issue; §4.7 compares once per refresh amr text[] NOT NULL DEFAULT '{}', -- carried, never re-derived: a rotation is not an authentication auth_time timestamptz NOT NULL, -- §4.9's window survives rotation; see M-F-13 issued_at timestamptz NOT NULL DEFAULT now(), expires_at timestamptz NOT NULL, consumed_at timestamptz, -- NEVER deleted: a consumed row is what detects the reuse revoked_at timestamptz);CREATE INDEX refresh_tokens_live_family ON refresh_tokens (family_id) WHERE consumed_at IS NULL AND revoked_at IS NULL;
CREATE TABLE revoked_tokens ( -- §4.7's cache, PostgreSQL form; Q-M-017 may move it jti text PRIMARY KEY, account_id uuid NOT NULL REFERENCES accounts(account_id) ON DELETE CASCADE, expires_at timestamptz NOT NULL, -- TTL = the token's own remaining lifetime, so bounded by Q-M-016 revoked_at timestamptz NOT NULL DEFAULT now()) WITH (fillfactor = 70, autovacuum_vacuum_scale_factor = 0.01);
ALTER TABLE accounts ADD COLUMN session_version integer NOT NULL DEFAULT 0; -- M-F-11, §4.7
-- Two foreign keys `0001` did not declare, added by `0003`. `auth_tickets` and-- `account_recovery_requests` carried an unreferenced `account_id` while their neighbours did-- not, and `ADR-M-032`'s erasure is a DELETE relying on cascade — so an unreferenced table is-- one the erasure silently misses. For a pending recovery request that outlives its account,-- that matters.ALTER TABLE auth_tickets ADD CONSTRAINT auth_tickets_account_fk FOREIGN KEY (account_id) REFERENCES accounts(account_id) ON DELETE CASCADE NOT VALID;ALTER TABLE account_recovery_requests ADD CONSTRAINT account_recovery_requests_account_fk FOREIGN KEY (account_id) REFERENCES accounts(account_id) ON DELETE CASCADE NOT VALID;
-- NOT HERE, DELIBERATELY: no signing key and no email pepper. §4.3's ES256 private keys and-- the address HMAC/data keys live in the container app's secret set, for the reason-- `ADR-M-042` gives for the recovery-code pepper — a database exfiltration alone must yield-- no offline attack. A `signing_keys` table would put the one secret that mints identities in-- the one place an application-level SQL injection can already reach.
-- ── Ledger (§7.5) and creator_tax_profiles (§7.7): see body.Appendix B — ADR-M Index
Namespace note. These are Marketplace decisions. A bare ADR-nnn anywhere in this corpus refers to the VTT whitepaper’s index. Where a decision here creates an obligation on the VTT side, the link is recorded in both directions or listed in Appendix D as an unowned join.
| # | Decision | Status | Links |
|---|---|---|---|
| M-001 | Physical isolation from the VTT is retained, but rebased onto blast radius, PCI scope, and release cadence; the COEP/SharedArrayBuffer argument is downgraded to a constraint the Marketplace is immune to, because VTT ADR-052 does not establish it | Accepted | Disposes M-F-01; Depends-on VTT ADR-052 (private linear memory) |
| M-002 | No event sourcing. Orders are relational with a monotonic state machine; append-only semantics are applied locally to the ledger where they are a database constraint | Accepted | Contrast VTT §7.1 |
| M-003 | Native AOT is adopted for resident memory, image size, and scale-out p99 — not for scale-from-zero, which it does not rescue | Accepted | Disposes M-F-02; Amends M-006 |
| M-004 | The API is AOT with a hand-written typed Stripe client; the Worker is JIT and uses Stripe.net | Accepted | Depends-on M-003 (the AOT constraint is what forces the split) |
| M-005 | The BFF may compose and shape; it may not decide. Enforced by withholding database and Stripe credentials from its secret set | Accepted | |
| M-006 | The API, BFF, and Worker do not scale to zero; only the stamping Job does | Accepted | Amended by M-003; Depends-on M-012 (SSE pins a replica) |
| M-007 | FIDO2 RP ID is the registrable parent domain descentvtt.com | Accepted | Amended by M-030 (a passkey is no longer the only credential path) |
| M-008 | Identity JWT is ES256; EdDSA rejected for lack of BCL support under AOT | Accepted | |
| M-009 | Identity cookie is parent-domain scoped, forfeiting __Host-; mitigated operationally and by serving all user content from a separate registrable domain | Accepted | Depends-on M-023 (user-content domain); depended on by M-030 (quarantine suspends the step-up mitigation) |
| M-010 | The VTT ticket is a single-use database row with a 15-second lifetime, not a signed token, because single-use cannot be expressed statelessly | Accepted | |
| M-011 | Token revocation is a bounded fail-open cache, not a session store; “log out everywhere” is effective within the token lifetime | Accepted | Amended by M-030 (completed recovery revokes credentials immediately, not within the token lifetime) |
| M-012 | SSE over LISTEN/NOTIFY, served same-origin via edge path routing so no CORS and no token-in-URL is required | Accepted | Depends-on M-006 (pinned replicas); Amended by M-029 (fan-out placed behind a seam with a named successor and trigger) |
| M-013 | Separate charges and transfers; the LLC is merchant of record; Express connected accounts | Accepted | |
| M-014 | Split base is the tax-exclusive item price; the platform absorbs processing fees | Accepted | Amended-by M-043 (the rate becomes 20%/25% by exclusivity; base and absorption unchanged); Depends-on M-019 (cart-level charge) and the price floor, without which fee absorption is loss-making |
| M-015 | Integer minor units and largest-remainder allocation with stable tie-breaking; no floating point or decimal on money paths | Accepted | |
| M-016 | Tier 3 launches US-only; geographic availability is per-SKU data, not a code branch | Accepted | |
| M-017 | Monthly payouts gated on a minimum balance; Connect account created at first sale, payout deferred until the threshold | Accepted | Amended-by M-045 (the payout fee is absorbed; the reserve goes to 100% for the window); Depends-on OI-M-07 for the cheaper variant |
| M-018 | Withholding defaults to the statutory rate and is posted at allocation time, not deducted at payout | Accepted | |
| M-019 | One PaymentIntent per cart, never per line | Accepted | |
| M-020 | Large binaries are delivered through a zero-egress-fee CDN tier; Azure Blob remains the private system of record | Accepted | Amended by M-031 — which also records that this ADR removes per-GB delivery pricing, so the residual free-UGC liability is origin-fill egress and vendor-relationship risk, not a per-download bill |
| M-021 | Idempotency of Stripe webhooks is a property of the transaction boundary: the claim insert and all effects commit together, or neither does | Accepted | |
| M-022 | Outbox delivery is per-ordering_key FIFO with cross-key concurrency; SKIP LOCKED alone is insufficient for PoD | Accepted | |
| M-023 | All user-generated and creator-uploaded content is served from a separate registrable domain with no cookie scope | Accepted | Depends-on M-009; Amended by M-031 (free downloads now require an authenticated principal at issuance) |
| M-024 | Catalogue review for Cartridges is a security function: the Marketplace is the code-signing authority for VTT Tier P0 code | Accepted | Depends-on VTT §6.4 (P0 has no technical backstop) |
| M-025 | A refunded Cartridge entitlement lands the VTT room in the existing RulesetReadOnly state; no new behaviour is introduced | Accepted | Depends-on VTT §12.2.6; requires the reverse link — OI-M-08 |
| M-026 | Reconciliation detects and reports; it never self-heals | Accepted | |
| M-027 | Entitlements carry a nullable expires_at so subscriptions remain possible without being present in v1 | Superseded by M-047 | Amended-by M-047 (the deferral is withdrawn; the reserved column is what makes the override an expand rather than a reshape) |
| M-028 | Rolling reserve plus monthly payouts plus a first-payout delay; specific fraud thresholds are runtime configuration and are not published | Accepted | Mirrors VTT §3.1’s rule on unpublished DoS thresholds |
| M-029 | LISTEN/NOTIFY reaffirmed for launch behind an INotificationFanout seam shipped from Phase 1; successor is Redis/Garnet Pub/Sub (Service Bus rejected — wrong delivery shape); two-stage trigger measured on configured maxReplicas, not observed spikes; the connection budget is protected by a CI-enforced cap, not by the trigger | Accepted | Amends M-012; Depends-on M-006 (pinned replicas are what consume the budget) |
| M-030 | Account recovery is a 48-hour time-locked email request, cancellable only by a passkey assertion, unlocked atomically at redemption, granting a quarantined session that cannot satisfy step-up | Accepted | Closes OI-M-04; Amends M-007, M-011; Amended-by M-042 (withdraws its manual-review residual; adds the third factor); Depends-on M-009 (step-up is what quarantine suspends) |
| M-035 | Inbound checkout idempotency is a guarded state transition on a versioned cart resource (If-Match), not a client-supplied idempotency key; a replay returns the existing order’s session, a changed cart returns a priced 409 diff; the Stripe idempotency key includes session_attempt because Stripe’s 24 h key window and the Checkout Session’s 24 h expiry coincide exactly | Accepted | Closes OI-M-14; Depends-on M-032 (the retention classes force carts → orders, not the reverse) |
| M-036 | The Stripe API version is pinned in two independent places (outbound header and webhook-endpoint version), as a compiled-in constant rather than configuration; drift is detected by comparing the already-recorded api_version against the pin; a bump is an expand-contract migration carrying fixtures at both versions | Accepted | Closes OI-M-15; Depends-on M-004 (the hand-written client makes the header trivial), M-033 (bump uses the same expand-contract shape) |
| M-037 | A withholding reversal debits the liability before remittance and creates asset:withholding:receivable:us after it, self-liquidating against the next remittance and ageing to write-off if stranded; the 1099 figure is a ledger query, so a cross-tax-year reversal produces a corrected filing rather than mutating a filed number | Accepted | Closes OI-M-17; Amends M-015 (chart of accounts); Depends-on M-032 (derived balances) |
| M-038 | Localised catalogue text lives in per-locale translation rows, not a jsonb blob; titles.slug is replaced by title_slugs with UNIQUE (locale, slug) spanning retired rows and one current slug per (title, locale) so renames serve 301s; retention class N is added for tables holding no personal data, because forcing a catalogue table to pick R/E/C launders a misclassification through a passing CI check | Accepted | Closes OI-M-22’s Phase-1 half; Amends M-032 (three classes became four) |
| M-034 | Administrators are a separate principal namespace on their own host with __Host- cookies, a 15-minute session, no refresh token, no recovery path and device-bound authenticators only; named capabilities rather than roles; an action is a proposal and only the Worker posts to the ledger; maker-checker above a value threshold and unconditionally where no reversal path exists; self-approval, self-granted capability, proposal immutability and audit append-only all enforced in SQL; refusals are audited to expose probing; single-actor actions are swept into a second-admin weekly digest; break-glass is permitted but pages everyone and blocks the next release; the admin API is a separate scale-to-zero container app | Accepted | Closes OI-M-13; Amends M-032 (there are two retained-PII tables, not one — the original claim was axis-limited), Amends M-006 (a second component genuinely scales to zero); Depends-on M-030 (admins are excluded from the recovery path it defines), M-022 (execution rides the outbox) |
| M-033 | Expand-contract, forward-only schema evolution: expand before code, contract a release later, no down migrations, no renames, no native enums; a pre-deploy ACA Job (not AOT, direct endpoint, session advisory lock, migrator role) rather than startup migration; lock_timeout mandatory; a CI linter over the forbidden-operation list; the previous release’s test suite run against the new schema as the actual backward-compatibility assertion; pending contract steps in a shrink-only debt ledger | Accepted | Closes OI-M-12; Depends-on M-004 (the AOT/JIT split is why the runner may use a conventional migration library), M-032 (ledger_entries cannot be backfilled; backfills must consult the suppression register); Amends M-002 (append-only now constrains schema evolution, not only rows) |
| M-032 | Three retention classes (R/E/C) with a CI-enforced registry; the person-to-record link is a single severable subject_map row and subject_ref appears only in Class R tables; erasure deletes the linkage and never touches the ledger; Class R tables may hold no FK to identity; orders splits into financial + order_contacts; creator_tax_profiles is the single permitted retainer of identifiable PII; erasure is step-up authenticated and time-locked reusing §4.10; deliberate severance is registered so accidental severance is a reconciliation finding | Accepted | Closes OI-M-11 architecturally; opens OI-M-24 (retention periods, counsel) and OI-M-23 (DSR fan-out to the VTT); Amends M-002 (append-only now has a stated erasure interaction), M-015 (ledger key), M-030 (time-lock mechanism reused); Depends-on VTT ADR-078/080/085 (the VTT executes its own half) |
| M-031 | Free-UGC egress is a three-tier rolling per-creator budget — full speed, bounded-concurrency admission, hard refusal — enforced at URL issuance rather than at the CDN edge (neither named CDN can shape bytes without bespoke Worker compute); a platform-wide breaker sits above it, expressed as a burn rate against a fraction of trailing contribution, which rescales the fleet slot pool rather than escalating tiers — all creators including Green become admission-controlled, allocation is max-min fair weighted by contribution, and refusal is the floor case reached heaviest-first; allowance is credited by attributable revenue; metering counts distinct authenticated consumption so the budget cannot be weaponised against a creator by a third party | Accepted | Closes Q-M-030; Amends M-020 (delivery tier), M-023 (user-content domain now requires an authenticated principal); Depends-on M-019 and the §8.9 cost-attribution join (revenue credit has no input without it) Amended-by M-044 (the paid-download exemption gains a size-banded price floor, closing the route around this breaker) |
| M-039 | Data access uses raw NpgsqlCommand (or standalone factories) instead of Dapper.AOT interceptors to preserve IlcTreatWarningsAsErrors=true in Native AOT | Accepted | Closes M-F-03; Amends M-004 |
| M-041 | Scale-to-zero is decided by who is waiting, not by whether work is background: API/BFF pinned at minReplicas=1; outbox drain, email, reconciliation and stamping MUST scale to zero on KEDA; the scaler interval and the outbox freshness SLO become one coupled decision checked in CI | Accepted | Amends M-006 (Worker was recorded always-on); Depends-on M-003 (settled by BENCH-M-01) |
| M-040 | Checkout session creation MUST be a synchronous API-to-Worker call, because an outbox pattern cannot immediately return the redirect URL to the user’s browser | Accepted | |
| M-043 | Commission is tiered by exclusivity — Q-M-078 20% exclusive, Q-M-077 25% non-exclusive — on ADR-M-014’s unchanged tax-exclusive base, with the rate frozen onto the order line at checkout and never re-read, because a rate looked up at payout time would make every settled split a function of the title’s current exclusivity and §7.5 forbids mutating the groups that would need correcting. The platform’s commission remains the residual, so the ledger’s balance property does not depend on the rate at all. Exclusivity is self-declared with contractual, reactive enforcement and demotion is prospective only — stated as description because no detection mechanism exists or is proposed (P4). The spread is an exclusivity incentive and explicitly not CDN cost recovery, since ADR-M-020 removed the per-download bill the five points would have absorbed | Accepted | Amends M-014; interacts with M-015, M-019, M-020 |
| M-044 | Every paid SKU clears the greater of a flat break-even floor (Q-M-005) and a size band computed from total SKU payload; below it the only alternative is exactly $0.00, where ADR-M-031’s breaker governs it. The proposal’s linear GB × per-GB × safety term is rejected because it bills a cost ADR-M-020 removed — the residual is origin-fill and storage, which is per object and therefore amortises over sales volume, so the expensive case is a large low-volume SKU and volume is unknowable at publication. Bands rather than a continuous function, because the underlying cost is lumpy and a step is auditable by the creator who hits it. The floor re-evaluates when payload grows, not only when price falls | Accepted | Amends M-031; narrows Q-M-005; interacts with M-020, M-043, M-046 |
| M-045 | The platform absorbs the Connect payout fee, affordable because payouts are monthly and gated on Q-M-008 — on-demand payouts are the rejected alternative, since the fixed component stops amortising. The clearance hold is Q-M-009’s rolling reserve set to 100%, one mechanism at full strength rather than a second control sharing one number. It reduces the exposure window and does not protect against chargebacks — scheme windows run far past 30 days, and clawback plus write-off are what cover the tail. Recorded consequence: holding 100% for longer makes OI-M-07’s money-transmission question more acute, not less | Accepted | Amends M-017; interacts with M-013, M-014 |
| M-046 | A creator may attach an opt-in unencrypted archive of source-form assets to a SKU, granting urn:descent:raw:… alongside the existing grants rather than adding a fourth tier — §1.4’s tiers are distinguished by fulfilment class and a raw archive changes none of it. Consistent with §1.5 rather than an exception: it removes an encryption-at-rest step that was never a protection claim, and §8.1 already prices the residual. Ingestion scanning is not optional — a creator-supplied archive is “bytes we never baked”, the same objection §8.9.1 raises against BYOS, so ADR-M-024’s reviewed admission and VTT ADR-098’s server-side re-verification both still apply. Never an input to the VTT pipeline (VTT ADR-005 requires our own 2D bake), and counted in full toward ADR-M-044’s payload band | Accepted | interacts with M-024, M-031, M-044, VTT ADR-005, VTT ADR-098 |
| M-047 | Recurring billing ships in v1, overriding ADR-M-027’s deferral (§5.12), because the VTT’s ADR-132 content-sharing gate is merged and permanently inert waiting on urn:descent:sub:premium. Two decisions carry it. The entitlement window is an absolute expiry copied from Stripe’s period end, never a previous expiry plus a period — an accumulating implementation drifts on every duplicated webhook, retried invoice and proration, and passes every test written against one clean delivery. A subscription webhook is a trigger to re-read, never a payload to project — §5.1’s monotonic rank guard cannot be used because subscription states are not monotonic (active → past_due → active is a recovered payment, canceled → active a resubscribe), so ordering is made irrelevant rather than defended against. The re-read is an unbounded HTTP call, so ARCH-M-08 forces it into the JIT Worker; §5.6’s outbox supplies per-subscription FIFO with no new mechanism. subscriptions.status is deliberately the one unconstrained vocabulary in the schema, because closing a third party’s status set turns their release note into our outage | Accepted | Amends M-027; Depends-on M-004 (the AOT/JIT split is what puts the re-read in the Worker), M-021 (the claim and the enqueue share one transaction), M-022 (per-ordering_key FIFO is the ordering mechanism), M-033 (the override ships as an expand); interacts with M-026 (an unrecognised status is reported, never self-healed), M-036 (the pin is still UNPINNED); closes the VTT-side dependency recorded in ADR-132, whose reverse link is owed by the VTT corpus — see OI-M-26 |
| M-048 | FIDO2 ceremonies are verified in-house against BCL primitives rather than through a WebAuthn library (§4.2). §4.2’s attestation: none removes the attestation-statement formats, X.509 chain building and the FIDO Metadata Service — the large, reflective half of any such library — leaving a CBOR decode of two fixed shapes, two SHA-256 comparisons and one signature verification. The argument is ADR-M-008’s and ADR-M-042’s applied a third time: a third-party crypto dependency on the auth path of a binary built with IlcTreatWarningsAsErrors and no suppression allow-list is avoidable risk, and the response to a transitive IL2xxx would be a suppression that ships an unexercised failure path. Verified by publishing: exit 0, zero IL2xxx/IL3xxx, 25 MB win-x64, with the whole auth stack in the binary. System.Formats.Cbor needs no package — NU1510 says it is in the .NET 10 shared framework. What is given up is verifiable authenticator provenance, which §4.2 had already given up. Exit cost, recorded at adoption: the seam is two static methods and credentials are stored as raw COSE keys, so a replacement is one file and no migration | Accepted | Depends-on M-003 (the AOT constraint forces it), M-007 (the RP ID it verifies against); applies the reasoning of M-008 and M-042 |
| M-042 | Offline recovery codes are the third identity factor: 8–12 single-use codes in Crockford Base32, stored only as HMAC-SHA-256 under a Key Vault pepper that never enters the database, redeemed by one guarded UPDATE … WHERE used_at IS NULL … RETURNING for an immediate quarantined session that must complete a passkey registration. A code restores the possession factor ADR-M-030 substituted time for, so it carries no time lock. Argon2id rejected on three grounds — a third-party crypto dependency on an AOT auth path (§4.3’s own precedent), memory-hardness answering low entropy this design does not have, and a per-hash salt forcing 8–12 evaluations per unauthenticated request, which is a DoS amplifier. Manual review is withdrawn as an unwritten last resort: with all three factors lost the account is unrecoverable by construction, because a verification queue means identity documents, and those are a retained-PII class nobody bounded | Accepted | Amends M-030; interacts with M-032, M-005 |
Appendix C — Quantity Registry (Q-M)
⊙ = derived from published third-party pricing or documentation, not measured and not normative. ◈ = provisional product decision, not yet ratified. ✓ = fixed by design.
| ID | Quantity | Value | Status |
|---|---|---|---|
| Q-M-001 | Card processing — percentage component | 2.9% | ⊙ |
| Q-M-002 | Card processing — fixed component | $0.30 | ⊙ |
| Q-M-003 | International card surcharge | +1.5% | ⊙ |
| Q-M-004 | Currency conversion surcharge | +1.0% | ⊙ |
| Q-M-005 | Standalone-sale price floor | $2.99 | ◈ |
| Q-M-006 | Connect monthly active-account fee | $2.00 | ⊙ |
| Q-M-007 | Connect per-payout fee | 0.25% + $0.25 | ⊙ |
| Q-M-008 | Minimum payout balance | $25.00 | ◈ |
| Q-M-009 | Rolling reserve release window | 30 days | ◈ |
| Q-M-010 | Entitlement replica lag, p99 target | 500 ms | ⊙ — pending BENCH-M-05 |
| Q-M-011 | AOT process-start to first 200 | < 50 ms | ⊙ — gated by BENCH-M-01 |
| Q-M-012 | JIT equivalent, for comparison | 150–250 ms | ⊙ |
| Q-M-013 | ACA scale-from-zero wall time | ~9 s | ✓ — measured via BENCH-M-01: 8969 ms; completely refutes scale-to-zero in §3.1 |
| Q-M-014 | Published API image size ceiling | 80 MB | ◈ |
| Q-M-015 | Max API replicas | ⌊0.70 × (limit − Q-M-036)⌋ | derived (§6.7) — pending one input, BENCH-M-06; the CI cap RC-M-24 protects it regardless |
| Q-M-016 | Identity JWT lifetime | 60 min | ✓ |
| Q-M-017 | Revocation cache lookup budget | 5 ms | ◈ |
| Q-M-018 | Step-up assertion freshness window | 5 min | ◈ |
| Q-M-019 | Stripe webhook response window | ~30 s | ⊙ — external, deliberately not built against |
| Q-M-020 | Webhook handler p99 budget | 250 ms | ✓ (SLO) |
| Q-M-021 | Front Door Standard base fee | ~$35/mo | ⊙ |
| Q-M-022 | Outbox max attempts before dead-letter | 12 (~9 h) | ◈ |
| Q-M-023 | Dispute fee | $15.00 | ⊙ |
| Q-M-024 | SSE catch-up poll interval | 30 s | ◈ |
| Q-M-025 | NOTIFY fan-out ceiling (events/s × replicas) | pending | absent — pending BENCH-M-04 |
| Q-M-026 | Front Door origin-response timeout | pending re-verification | ⊙ |
| Q-M-027 | ACA ingress idle-request timeout | 240 s default | ⊙ |
| Q-M-028 | Deliberate SSE stream lifetime | 120 s | ◈ |
| Q-M-029 | End-to-end notification latency alert threshold | 5 s | ◈ |
| Q-M-030a | Free-UGC Green-zone allowance, per creator, rolling 30 days, distinct authenticated consumption | 100 GB | ◈ (§8.9.1) |
| Q-M-030b | Free-UGC Red-zone threshold (hard refusal) | 500 GB | ◈ (§8.9.1) |
| Q-M-030c | Yellow-zone concurrent download slots per creator | derived from Q-M-030e; provisional 6 | ◈ — the aggregate cap; per-stream rate is not a bound (§8.9.1, Correction 1) |
| Q-M-030d | Optional per-stream pace inside a Yellow slot | 400 KB/s | ◈ — refinement only, gated on RC-M-25. Not the 250 KB/s originally proposed: at 2 GB that is 2 h 13 m, and §1.3 puts asset packs at up to 2 GB |
| Q-M-030e | Minimum Yellow-zone runway — the quantity Tier 2 is sized against | 72 h | ◈ — a time target, because runway is what Tier 2 produces (§8.9.1, Correction 2) |
| Q-M-030f | Free-asset size above which shaping applies | 50 MB | ◈ — small objects are not the cost driver; shaping them is friction without saving |
| Q-M-031 | Max concurrent SSE subscribers per replica | pending | pending BENCH-M-06 |
| Q-M-032 | Creator chargeback-rate review threshold | not published | ✓ (unpublished by design) |
| Q-M-033 | First-payout delay after first sale | 14 days | ◈ |
| Q-M-034 | Signed download URL lifetime | 300 s | ◈ |
| Q-M-035 | Webhook body size cap | 256 KiB | ◈ |
| Q-M-036 | Reserved direct connections (worker, reconciliation, migrations, operator) | 6 | ◈ — the term that makes Q-M-015 computable |
| Q-M-037 | Fan-out migration trigger — dial stage / migrate stage | 50% / 70% of usable budget | ✓ (§6.7) |
| Q-M-038 | Recovery time-lock duration | 48 h | ◈ |
| Q-M-039 | Cooldown after a cancelled recovery | 7 days | ◈ |
| Q-M-040 | Post-recovery step-up quarantine | 7 days | ◈ |
| Q-M-041 | Redemption window after unlock | 7 days | ◈ |
| Q-M-042 | Per-account daily download byte budget (consumer-side EDoS control) | 20 GB | ◈ — distinct from Q-M-030a; see §8.9.1 |
| Q-M-043 | Platform free-egress breaker, as a fraction of trailing-30-day gross contribution | 5% | ◈ — scales with revenue by construction |
| Q-M-044 | Revenue-credit multiplier: extra free-egress allowance per unit of attributable contribution | pending calibration | ◈ — mechanism defined, coefficient unmeasured |
| Q-M-069 | KEDA polling interval for the outbox scaler | 30 s | ◈ — coupled to the outbox freshness SLO by RC-M-35; the two MUST change together (§9.2) |
| Q-M-070 | API image size, runtime-deps:10.0 (Debian) base | 245 MB | ✓ measured 2026-08-03 — the base was ~92% of it |
| Q-M-071 | API image size, runtime-deps:10.0-noble-chiseled base | 48.1 MB | ✓ measured 2026-08-03 — 80% reduction, same 19.3 MB binary, app verified serving |
| Q-M-045 | Metering overshoot bound (one issuance window) | 5 min | ✓ — follows from Q-M-034, since the meter increments at issuance |
| Q-M-046 | Fleet breaker release watermark | 85% of Q-M-043 | ◈ — the gap to 100% is the hysteresis band |
| Q-M-047 | Fleet breaker minimum dwell in a state | 6 h | ◈ — prevents flapping, which is worse for creators than either steady state |
| Q-M-049 | Financial/tax record retention (Class R) | 7 years | ◈ — placeholder for counsel, jurisdiction-specific; EU member-state VAT reaches 10 years in places (OI-M-24) |
| Q-M-050 | Shipping address retention after delivery, then minimised | 180 days | ◈ — must outlast the longest card-scheme chargeback window we accept |
| Q-M-051 | Raw customer-location tax evidence retention before minimisation | until the period’s return is filed + 90 days | ◈ |
| Q-M-052 | PITR window after which erasure is complete in backups | pending Neon plan | ⊙ — the honest boundary on “erased”; published in the privacy notice |
| Q-M-053 | Suppression-register accountability retention | 3 years | ◈ |
| Q-M-054 | Erasure request cooling-off (time-lock) | 48 h | ◈ — reuses §4.10’s shape; deletion is more destructive than recovery so it is never shorter |
| Q-M-055 | DSR response deadline | 30 days | ✓ — statutory (GDPR Art. 12(3)); drives DSR outbox priority |
| Q-M-056 | lock_timeout for every DDL statement | 3 s | ◈ — the absence of this is the most common way a “safe” migration causes an outage (§3.7) |
| Q-M-057 | Minimum gap between expand and contract | 1 full release, and old revision confirmed deactivated | ✓ (§3.7) |
| Q-M-058 | Backfill batch size, rows per transaction | 5 000 | ◈ — tune against replica lag Q-M-010, not against throughput |
| Q-M-059 | Maximum age of a contract-debt entry before CI fails | 90 days | ◈ — forces “temporary” nullable columns to become a stated decision |
| Q-M-061 | Maker-checker threshold — cumulative value above which an admin action needs a second approver | $100 | ◈ — and irrelevant where no reversal path exists; those always require approval (§8.12) |
| Q-M-062 | Deadline for weekly-digest acknowledgement / break-glass post-hoc review | 7 days | ◈ — an unacknowledged digest past this is an alert; an unresolved break-glass blocks the next release |
| Q-M-063 | Admin session lifetime | 15 min | ◈ — no refresh token; re-authenticate |
| Q-M-065 | Pinned Stripe API version | a compiled-in constant, captured at pin time | ✓ — not a configuration value; drift is alerted (§5.5.1) |
| Q-M-066 | Corrected-1099 materiality threshold for a cross-tax-year reversal | pending | ◈ — part of OI-M-24’s legal engagement |
| Q-M-067 | Checkout Session lifetime assumed when deciding to re-issue | 24 h | ⊙ — external Stripe constant; the session_attempt design is correct regardless of its value |
| Q-M-068 | Withholding receivable ageing before write-off | 18 months | ◈ — long enough to net against a seasonal creator’s next active period |
| Q-M-064 | admin_audit and admin_principals retention | 7 years | ◈ — aligns with Q-M-049; a financial-audit trail outliving the records it explains is the point |
| Q-M-060 | statement_timeout for the migrator role | 30 min | ◈ — deliberately long; the guard against a stuck migration is Q-M-056, not this |
| Q-M-048 | Admission slot lease TTL | 2× the p99 download duration for Q-M-030f-sized assets | ◈ — a slot held by a client that vanished must expire; same crash-recovery shape as the outbox lease (§5.7) |
| Q-M-072 | Recovery codes minted per set | 10 (range 8–12) | ◈ (§4.11) — one batch, displayed once; regeneration replaces the batch |
| Q-M-073 | Entropy per recovery code | 60 bits (12 Crockford Base32 symbols) | ◈ (§4.11) — safe only while the pepper is out of band; removing the pepper requires raising this |
| Q-M-074 | Failed redemption attempts before lockout | 5 | ◈ (§4.11) — per account and per source |
| Q-M-075 | Recovery-code redemption lockout duration | 1 h | ◈ (§4.11) — a notified event, not a silent counter |
| Q-M-076 | Unused-code count at which the account-health warning appears | 2 | ◈ (§4.11) — a product signal, not a security bound |
| Q-M-077 | Commission — non-exclusive content | 25% | ◈ (§7.3.1) — neither rate carried an id before ADR-M-043; the flat 20% lived only in prose |
| Q-M-078 | Commission — exclusive content | 20% | ◈ (§7.3.1) — unchanged in value from the flat rate it replaces |
| Q-M-079 | Exclusivity commitment window a creator accepts on declaring a title exclusive | 12 months | ◈ (§7.3.1) — stops a title oscillating between rates release by release |
| Q-M-080 | Size-band floor safety multiplier | 2× | ◈ (§8.9.2) |
| Q-M-081 | Origin-fill + storage cost per GB of hosted payload | $0.09/GB | ⊙ (§8.9.2) — per object, not per download; ADR-M-020 removed per-GB delivery pricing. Derived from §8.8’s Azure list egress, not measured |
| Q-M-082 | Assumed amortisation volume for the size band | 50 sales per SKU | ⊙ (§8.9.2) — the band’s weakest input: volume is unknowable at publication, so the band is deliberately conservative and a popular SKU over-clears it |
| Q-M-083 | Total-payload threshold above which a size band applies at all | 500 MB | ◈ (§8.9.2) — below it the flat floor Q-M-005 binds alone |
| Q-M-084 | Maximum total SKU payload, including a raw archive | 20 GB | ◈ (§8.2.1 / §8.9.2) — a listing bound, not a price bound |
| Q-M-085 | Subscription entitlement grace beyond current_period_end (the dunning window) | 7 days | ◈ (§5.12) — known to be shorter than Stripe’s full retry schedule. A subscription still in dunning past this loses premium while the processor is still collecting; the alternative is granting unpaid premium for the whole schedule, which is worse. The right value is a churn measurement nobody has taken |
| Q-M-086 | Age at which a subscription projection is stale and the §5.10 sweep reports it | 24 h | ◈ (§5.12) — reports, never self-heals (ADR-M-026). Its floor is the outbox freshness SLO: a threshold tighter than the queue’s own latency alerts on a healthy system |
| Q-M-087 | WebAuthn ceremony challenge lifetime | 2 min | ◈ (§4.2) — a ceremony’s wall-clock upper bound, a user reaching for a security key on a lanyard. Not measured, and it bounds a replay window that the single-use row already closes, so a wrong value costs a retry rather than a defence |
No quantity now remains absent in a way that makes the corresponding protection non-existent. Both entries that were in that category have left it, and by the same route: a formula plus an enforcement point, with one input still unmeasured.
Q-M-030 was the second. It is now the tier set Q-M-030a…Q-M-030f plus the platform breaker Q-M-043, enforced by admission control at URL issuance (§8.9.1). Two of its constants are provisional and one (Q-M-044) is uncalibrated — but the mechanism refuses at Q-M-030b regardless of whether the constants are well-chosen, so a wrong number produces a badly-tuned guardrail rather than no guardrail. Q-M-030c is the entry to watch: it is the only one whose value determines whether Tier 2 does anything at all, and it must be derived from Q-M-030e against measured asset sizes rather than picked.
Q-M-015 was the first and no longer is. §6.7 gives it a formula, Q-M-036 supplies the reserved term, and RC-M-24 enforces the resulting cap at build time. One input is still unmeasured (BENCH-M-06), but the protection does not depend on knowing it — a wrong input produces a conservative cap, not an unprotected system. That is the distinction the VTT corpus draws between a figure that is untuned and one whose absence removes a mechanism, and it is why this entry moved category without becoming precise.
Appendix D — Open Items
One table, one row per identifier, one status. This appendix was rewritten on 2026-08-03 because the incremental closure of seven items had left OI-M-15, OI-M-17 and OI-M-22 each appearing twice with contradictory states, and the closing note still described OI-M-11 as an open blocker after it had been closed. An open-items register that disagrees with itself is worse than none — it was the corpus’s recurring failure mode #4 (intermediate documents drift) occurring inside the document that exists to track drift. Provenance for closed items lives in the ADR that closed them, not here.
Status of every OI-M identifier
| ID | Status | Item / disposition |
|---|---|---|
OI-M-01 | OPEN — cross-corpus | The VTT records no ADR for its cross-origin-isolation posture. M-F-01 shows two documents implying different answers. Owner: VTT corpus. |
OI-M-02 | OPEN — cross-corpus | entitlements is Global-scoped under VTT ADR-042, and additions to Global are review-gated under ADR-082. This table needs a VTT-side ADR that does not exist. |
OI-M-03 | CLOSED | Spike executed 2026-08-03, PASS. Microsoft.IdentityModel.JsonWebTokens 8.22.0 + JwtBearer 10.0.10 are AOT-clean. Hand-rolled ES256 verifier cancelled. Two mandatory settings promoted to §4.3.1. See spikes/OI-M-03_identitymodel_aot/RESULT.md. |
OI-M-04 | CLOSED | ADR-M-030 — time-locked email recovery (§4.10). |
OI-M-05 | OPEN — LAUNCH BLOCKER | Characterisation of creator payments: royalty versus sale proceeds. Requires counsel. Determines whether US withholding applies to non-US creators; the wrong answer means the platform owes the tax. |
OI-M-06 | OPEN — LAUNCH BLOCKER | VAT/OSS registration strategy for digital goods. Requires counsel. Determines whether EU sales are lawful at launch. |
OI-M-07 | OPEN — worsened 2026-08-08 | Money-transmission analysis for holding undisbursed creator balances. Requires counsel. Decides whether ADR-M-017’s cheaper variant is available; ~$2/creator/month at stake. ADR-M-045 moves this in the harder direction and does not resolve it: the clearance hold holds 100% of accrued royalties for Q-M-009 days, increasing both the amount held and the duration. The engagement must price the mechanism as now specified, not the percentage reserve that preceded it. |
OI-M-08 | OPEN — cross-corpus | ADR-M-025 asserts an outcome (RulesetReadOnly on revocation) whose mechanism lives in the VTT. The reverse link and RC-M-19 are owed by the VTT’s release checklist. |
OI-M-09 | CLOSED | ADR-M-029 — Q-M-015 is now derived (§6.7) and capped in CI (RC-M-24). (This id was a dangling reference in revision 1 — cited by Q-M-015 and never written. Recorded as closed rather than deleted.) |
OI-M-10 | OPEN | Refund window and download-before-refund policy per product class. §7.8’s table is a proposal; support policy cannot be written without a ruling. |
OI-M-11 | CLOSED | ADR-M-032 (§8.11) — retention classes, subject_map, orders/order_contacts split. Residue re-scoped to OI-M-24. |
OI-M-12 | CLOSED | ADR-M-033 (§3.7) — expand-contract migration discipline. |
OI-M-13 | CLOSED | ADR-M-034 (§8.12) — administrative actor model. |
OI-M-14 | CLOSED | ADR-M-035 (§5.1.1) — versioned cart resource with If-Match. |
OI-M-15 | CLOSED | ADR-M-036 (§5.5.1) — two-place Stripe API version pinning. |
OI-M-16 | OPEN — Phase 1–2 | Tax-inclusive display versus a tax-exclusive split base. EU consumer law expects VAT-inclusive display; ADR-M-014 sets the split base as tax-exclusive so price × 0.80 stays true for creators. Both hold only if skus.price_minor is explicitly the tax-exclusive base and the displayed total is derived per destination — meaning EU buyers see a different headline number for the same SKU. Normal, but it must be a stated decision. |
OI-M-17 | CLOSED | ADR-M-037 (§7.7) — asset:withholding:receivable:us and write-off ageing. |
OI-M-18 | OPEN — Phase 1–2 | Timezone for every window boundary. “Rolling 30 days”, monthly payouts, daily download budget, trailing-30-day contribution — none states one. Payout runs, budget resets and reconciliation windows must agree. UTC everywhere with local display is the obvious answer and should be written down. |
OI-M-19 | OPEN — Phase 1–2 | PII in logs and traces has no named enforcement point. §8.2 forbids logging a signed URL and nothing enforces it; OTel spans carry account and order ids by default. Needs a redaction processor plus a CI check over log templates — otherwise it is a MUST the house style would reject. |
OI-M-20 | OPEN — registry only | No DR posture, no stated RPO/RTO. The VTT has ADR-030; the Marketplace has nothing. For a system holding financial records this has a compliance flavour. Neon’s PITR window is the first input. |
OI-M-21 | OPEN — registry only | No general API rate-limit policy. Webhook and download issuance are covered; checkout, catalogue search, ticket exchange and recovery initiation are not. An unlimited ticket-exchange endpoint is a free amplifier against the auth path. (Narrowed 2026-08-08: ADR-M-042 gives recovery-code redemption its own bound, Q-M-074/Q-M-075. The general policy is still absent — a per-endpoint bound is not a policy, and the remaining endpoints are still uncovered.) |
OI-M-22 | PARTIALLY CLOSED | Phase-1 schema half closed by ADR-M-038 (title_slugs with UNIQUE (locale, slug), title_translations, retention class N). Still open for Phase 2+: locale-in-path SEO routing with reciprocal hreflang and self-canonical (never Accept-Language redirects); the locale / currency / tax-jurisdiction axes staying independent in the storefront; and the constraint found by spike OI-M-03 — InvariantGlobalization=true means localised receipts, invoices and tax documents must not be rendered inside the AOT API; they belong in the JIT Worker. Depends on OI-M-18. |
OI-M-23 | OPEN — cross-corpus | The DSR fan-out to the VTT has no owner. An erasure must reach both systems; the Marketplace deletes its half, the VTT executes ADR-078/080/085 for its half, and neither repository records the join. A half-executed erasure is a reportable failure, not a bug. |
OI-M-24 | OPEN — LAUNCH BLOCKER | Retention schedule and DSR intake process. ADR-M-032 settles the mechanism; Q-M-049…Q-M-053 and Q-M-066 are placeholders for advice and are jurisdiction-specific (US federal and state, EU member-state VAT reaching ten years, UK). CPRA additionally obliges us to publish the periods, so they cannot stay provisional. Same engagement as OI-M-05 and OI-M-06. |
| OI-M-25 | OPEN — Phase 1 | Creator erasure versus attribution of published works has no recorded disposition. creators is registered Class C provisionally in 0001_baseline.sql, which neither destroys a catalogue on an erasure request nor claims a retention basis nobody has established. The question is whether a creator exercising a deletion right removes their name from titles that remain on sale, and it is a legal question before it is a schema one. Same engagement as OI-M-24. (Raised 2026-08-09 while writing the retention registry: the gap only became visible because ARCH-M-15 forces every table to pick a class, which is the mechanism working as designed.) |
| OI-M-26 | OPEN — cross-corpus | ADR-M-047 closes a dependency the VTT recorded, and the reverse link is owed by the VTT. ADR-132, ADR-121, Q-086 and Q-087 are each conditional on a recurring-billing surface existing; it now does, at the schema and Worker level. The VTT corpus has to decide whether that is enough to promote them, and nothing here can make that decision. Same shape as OI-M-08. |
| OI-M-27 | OPEN — Phase 2 | There is no subscription offer, only a subscription projection. Full record below. The premium offer is served from API configuration because nothing in the schema says what may be bought. |
| OI-M-28 | OPEN — Phase 4 | No column says which SKUs have a physical component. Full record below. Every order_lines.kind is written digital and the dev seed carries no Tier 3 SKU rather than offer one that would half-fulfil. |
| OI-M-29 | CLOSED 2026-08-10 — the oracle, not the gap | The 409 is gone. Both branches now perform one indexed lookup, one signup_invites row and one outbox message; only the notification’s topic differs and it is delivered to an inbox. The implementation states plainly that this is indistinguishable, not constant-time — the two paths are not instruction-identical and the threat is a remote caller whose network jitter dwarfs an indexed insert. What stays open is the part that was never an oracle: Chapter 4 still specifies no sign-up flow, so the shape above is invention and a product decision could replace it. Recorded rather than reopened under a new number, because the security defect is the half that was closed. Original text: Chapter 4 specifies no sign-up flow, and the implemented one is an enumeration oracle. Full record below. A 409 on an address that already has an account tells the caller it is registered; the alternatives considered were an account takeover and an undebuggable decoy. |
| OI-M-30 | CLOSED 2026-08-10 | The envelope ships. A per-process data key encrypts the address and the KEK encrypts only the data key, so a rotation re-wraps a blob rather than re-encrypting every row. KeyVaultKeyEncryptionKey is the deployed provider — the key never leaves the vault — and local is refused outside Development. The two new columns are nullable for one revision and contract_debt.tsv carries the deferral. Original text: accounts.email_cipher is a single-tier encryption, not §8.11’s envelope. Full record below. AES-GCM under a configured data key gives the exfiltration property and not the rotation property. Additive: a Key Vault integration and a wrapped-key column. |
Two rows added retroactively, 2026-08-10.
OI-M-27andOI-M-28were raised on 2026-08-09 and recorded only as blockquotes below, so this table — which opens by claiming “one table, one row per identifier, one status” — did not list them for a day. That is the same drift this appendix was rewritten on 2026-08-03 to end, reappearing by omission rather than by contradiction. The counting note above is correspondingly wrong and is corrected here rather than in place, because the sequence of counts is itself the evidence.
Also closed: Q-M-030 by ADR-M-031 (§8.9.1) — three-tier free-UGC egress shaping.
Blocked on measurement
| ID | What it decides |
|---|---|
BENCH-M-01 | ACA scale-from-zero wall time (Q-M-013) — Completed. Confirmed §3.1’s central argument that AOT cannot rescue scale-to-zero (measured at 8.97s). |
BENCH-M-02 | Published image size (Q-M-014). Early data point already in hand: 16.2 MB from spike OI-M-03. Second data point 2026-08-09: the real API’s Native AOT publish on ubuntu-latest, linux-x64, self-contained — an 18 405 544-byte binary in a 56 MB output directory. ⊙ This does not close the benchmark and must not be read as though it does: Q-M-014 is a ceiling on the published container image, and this is the publish directory, which contains no base layer and is not what gets pushed to a registry. It is one measured input, taken from a job that runs on every pull request, and the remaining work is building and measuring the image against Q-M-071’s 48.1 MB chiselled baseline |
BENCH-M-03 | Ticket round-trip cost — PostgreSQL versus Garnet for auth_tickets |
BENCH-M-04 | NOTIFY fan-out ceiling (Q-M-025) |
BENCH-M-05 | Neon replica lag distribution (Q-M-010) |
BENCH-M-06 | SSE subscribers per replica and the direct-connection ceiling (Q-M-031, Q-M-015) |
BENCH-M-07 | Admission-bucket claim latency under contention (§8.9.1); also supplies the p99 download duration sizing Q-M-048 |
Summary
- Total
OI-Midentifiers: 26. Closed: 9 (03,04,09,11,12,13,14,15,17). Partially closed: 1 (22). Open: 16. - Launch blockers: 4 —
OI-M-05,OI-M-06,OI-M-24(three questions for one legal engagement) andRC-M-19(owed by the VTT corpus). - Phase-1 gates: none. All six items that gated the schema are closed. The DDL is writable end to end — and as of 2026-08-09 it is written:
0001_baseline.sqland0002_recurring_billing.sql. - No open blocker requires an architectural decision. Two revisions ago this document claimed that, had to retract it when
OI-M-11was found, and can now make it honestly.
Updated 2026-08-09. The count moved from 24 to 26: OI-M-25 (creator erasure versus attribution) and OI-M-26 (the VTT’s reverse link for ADR-M-047). Neither is a launch blocker and neither gates further schema work. OI-M-25 was found by ARCH-M-15 forcing a class decision on creators, which is the registry doing exactly what §8.11 built it for.
Updated 2026-08-10. The count is 30, not 26 — OI-M-27 and OI-M-28 were raised the same day as the line above and never reached the table, and Chapter 4’s implementation adds OI-M-29 (sign-up is unspecified) and OI-M-30 (the email envelope is single-tier). None is a launch blocker. Two of the four exist because implementing a chapter forces questions its prose could leave open, which is the same mechanism OI-M-25 came from.
Next identifier numbers
For whoever continues: ADR-M-049, Q-M-088, OI-M-31, ARCH-M-27, RC-M-45, BENCH-M-08, M-F-15.
Advanced 2026-08-10 (second time that day) by the identity debt sweep: M-F-14 is §4.10’s GET redemption against §4.6’s prohibition. OI-M-29 and OI-M-30 are closed rather than allocated, and RC-M-37…RC-M-40 are discharged. ADR-M, Q-M, ARCH-M and RC-M are unmoved: no new decision, quantity, invariant or checklist item — Q-M-072, Q-M-074, Q-M-075 and Q-M-076 already existed and now have code reading them.
Advanced 2026-08-10 by Chapter 4’s implementation: ADR-M-048 (in-house WebAuthn verification), Q-M-087 (the ceremony challenge lifetime), OI-M-29 (sign-up is unspecified) and OI-M-30 (the email envelope is single-tier), and five findings M-F-09…M-F-13. ARCH-M, RC-M and BENCH-M are unmoved: this work allocated no new invariant, checklist item or benchmark — it satisfied RC-M-04, RC-M-27, ARCH-M-07, ARCH-M-12 and ARCH-M-25, which existed and had nothing asserting them. Verified against the maxima in the document and the source tree, per the 2026-08-09 correction.
Advanced 2026-08-09 (second time that day) by the storefront endpoints: M-F-07 is §5.1’s rank rule contradicting its own diagram for disputed, M-F-08 is ADR-M-040’s Worker hop contradicting four other sections, OI-M-27 is the missing subscription offer and OI-M-28 the missing physical-component attribute. All four are in Appendix D. ADR-M, Q-M, ARCH-M and RC-M are unmoved: this work allocated no new decision, quantity, invariant or checklist item — it implemented existing ones and amended ADR-M-040 in place. Verified by reading the maxima across the whitepaper and the source tree, which is the check the two corrections below institutionalise.
Advanced 2026-08-09 by ADR-M-047 (§5.12, recurring billing): Q-M-085/Q-M-086 are the dunning grace and the projection-staleness window, OI-M-25 is creator erasure versus attribution and OI-M-26 the VTT’s reverse link, ARCH-M-26 and RC-M-43/RC-M-44 are below, and M-F-05/M-F-06 are the two findings that change ends this section. Verified against the actual maxima rather than against the previous line, which is the check the correction below exists to institutionalise.
Corrected 2026-08-08. This line read ADR-M-039, Q-M-069, ARCH-M-24, RC-M-35 — every one of them already allocated, some of them three records earlier. A “next identifier” line that lags is worse than none: it hands the next author a number that is already taken, which is the collision ADR-045 §2 exists to prevent and which the VTT corpus has already had to flag once (ADR-108’s numbering note). Verified against the actual maxima at the time of writing: ADR-M-042, Q-M-076, ARCH-M-25, RC-M-40.
Enforcement items added by ADR-M-042 (§4.11)
Named here because the Marketplace was specification-only in this repository when they were written — there was no Descent.Marketplace project, so these were obligations on the implementation rather than tests that existed. Recorded as identifiers so that the implementation could not quietly omit them.
Discharged 2026-08-10. All four are implemented and passing, in
RecoveryGrantTests. The blockquote this replaces said “None ofRC-M-37…RC-M-40is implemented —account_recovery_codeshas its table and nothing reads it”, and closed by observing that they were “owed by code that exists rather than by code that does not, which is a harder place to leave them unwritten unnoticed”. That turned out to be the right prediction about the mechanism: the table having no reader is what made the gap findable.One of them changed the implementation rather than merely checking it.
RC-M-39’s parenthesis — “the guardedUPDATEunder real contention, not a mocked repository” — is why the concurrency case runs twelve parallel connections against one code; a read-then-write implementation passes every sequential assertion in that file.RC-M-38’s column-set check is what stopped0004from adding a convenience column toaccount_recovery_codeswhile implementing the redemption that reads it.
| Id | Asserts |
|---|---|
RC-M-37 | A plaintext code is never persisted. Generate a set, then assert that no column in account_recovery_codes contains any issued plaintext, and that the value returned to the caller appears nowhere in the database or in a log template. |
RC-M-38 | The column set is the specified one — a migration adding a plaintext or reversibly-encrypted code column fails the check (§Appendix A). |
RC-M-39 | A code redeems exactly once. Redeem, then redeem the same code again and assert the second attempt is refused; and assert two concurrent redemptions of one code produce exactly one grant (the guarded UPDATE under real contention, not a mocked repository). |
RC-M-40 | The brute-force bound holds. Q-M-074 failed attempts locks redemption for Q-M-075, per account and per source, and the lockout emits its notification. |
Enforcement items added by ADR-M-043…ADR-M-046 (2026-08-08)
Same standing as the block above, and the same 2026-08-09 correction applies to its premise. RC-M-41 and RC-M-42 are still unimplemented: order_lines.commission_bp and skus.payload_bytes are columns in 0001_baseline.sql with no checkout or publication path writing them yet, so there is nothing for either check to assert against.
| Id | Asserts |
|---|---|
RC-M-41 | order_lines.commission_bp is written only at checkout and never back-filled. Sell a line, flip the SKU’s is_exclusive, and assert the settled line’s rate and the ledger group are byte-identical to before — the retroactive-re-rate defect ADR-M-043 exists to prevent. A migration that populates the column from current SKU state fails the check. |
RC-M-42 | The size-band floor re-evaluates on payload growth, not only on a price change. Publish a compliant SKU, add payload that crosses a band, and assert publication is refused with the band and the triggering payload named — not a generic policy message (§8.9.2). |
M-F-04 | Finding, raised 2026-08-08 while landing ADR-M-043. docs/business/Descent_VTT_Financial_Overview.md contradicts itself on the take rate: its Stage-2 narrative states a “20% platform commission… against DriveThruRPG’s 30–35%”, while its own revenue table in the same stage projects “30% commission becomes the primary line”. Both predate this ADR, so neither was made wrong by it — the tiered rate merely forced the contradiction into view. Corrected in that document to the Q-M-077/Q-M-078 pair with the projection restated as a blended rate; recorded here because a silent fix would have destroyed the evidence that the business model and the architecture disagreed about the platform’s own revenue line. |
ARCH-M-25 | A recovery-code session cannot satisfy step-up. The token minted by redemption carries an amr set marking the recovery path, and the ARCH-M-07 filter refuses it regardless of assertion freshness. The assertion must read FindAll("amr"), per §4.3.1’s second mandatory setting — a single-claim read fails closed on exactly the multi-value token this path produces. |
M-F-03 was allocated 2026-08-08 — the §1.6 DRM overclaim, found by the VTT corpus’s
Epic C disclosure pass and dispositioned by rewriting §1.6 (see the finding blockquote
there; the ruling is VTT ADR-114 clause 4).
Enforcement items added by ADR-M-047 (§5.12, 2026-08-09)
These three exist, which is a different standing from the two blocks above. The Marketplace is no longer specification-only: db/marketplace/, apps/marketplace-api/, apps/marketplace-worker/ and tests/marketplace/ were added on 2026-08-09, and .github/workflows/marketplace-ci.yml runs them. Where a row below is not yet satisfied it says so in its own words rather than inheriting a blanket disclaimer.
| Id | Asserts |
|---|---|
RC-M-43 | Forward, reversed and every-event-duplicated replays reach one terminal state. §5.11’s shape applied to subscriptions: three accounts driven by the same event sequence in three orders, asserting identical expiry, status and row count. Written and passing. What it does not cover is stated in the test itself — it does not exercise Stripe’s API, only the claim that given a re-read returning current state, order stops mattering. |
RC-M-44 | One renewal replayed a hundred times leaves one unchanged window. The accumulating implementation this rules out would hand the account eight years of premium while looking completely healthy: one row, no errors, a plausible future date. Written and passing. |
ARCH-M-26 | The producing side’s assertion of vtt_entitlement_v1’s projected shape, in exact equality both ways — §2.5 condition 4’s Marketplace half, of which the VTT has held the consumer half since ADR-097. An added column fails it as hard as a missing one, because expand-only means a new view and never a widened one. Written and passing. |
Two findings raised 2026-08-09 while implementing ADR-M-047
M-F-05—M-F-03is allocated twice, to two unrelated findings.§3.4 is titled “Data Access: The Dapper.AOT reflection collision (
M-F-03)” andADR-M-039’s row records that it closesM-F-03. The paragraph immediately above this one allocatesM-F-03to the §1.6 DRM overclaim, on 2026-08-08. Both are real findings and both were genuinely dispositioned; what collided is the identifier.Dispositioned by leaving both in place and recording the collision here, rather than renumbering either. Renumbering would break the citations in
ADR-M-039’s links column and in §3.4’s heading, and a corpus whose identifiers move is worse than one whose identifiers are occasionally ambiguous — the VTT corpus reached the same conclusion aboutADR-108. The lesson is the one the “next identifier numbers” correction of 2026-08-08 already drew and did not finish: that line lagged forADR-M,Q-M,ARCH-MandRC-M, and it lagged forM-Ftoo. The check is to read the maxima, not the line.
M-F-06— the order’s captured tax quote has two names in two authoritative sections.Appendix A declares
tax_quote jsonb NOT NULLwith the comment “Minimised form only”. §8.11’sorderssplit declarestax_quote_minimised jsonb NOT NULL. They agree on the contents and disagree on the identifier.This would be cosmetic anywhere else in the document. It is not cosmetic here, because
0001_baseline.sqlis the migration that fixes the name permanently andADR-M-033forbidsRENAMEoutright — so the choice had to be made once, by whoever wrote the DDL, with no route back.Dispositioned to Appendix A’s
tax_quote. Appendix A is the schema appendix, it is whereMARKETPLACE_HANDOVER_PROMPT.mdsends the implementer, and its own comment already states that the contents are the minimised form, so the shorter name loses no information. Recorded rather than silently picked, because a coin-flip that becomes permanent is exactly the kind of decision a future reader will assume was reasoned about.
Two findings raised 2026-08-09 while implementing the storefront endpoints
M-F-07— §5.1’s monotonic rank rule and §5.1’s own state diagram cannot both hold fordisputed.§5.1 states that “every state carries an integer
rank, and a transition to a state of lower or equal rank is rejected by aCHECK-backed guard”, and0001_baseline.sql’sorders_rank_monotonicimplements exactly that. The same section’s diagram carriesFulfilled --> DisputedandDisputed --> Fulfilled: dispute won— a round trip.No single rank expresses it. Rank
disputedabovefulfilledand the return leg is a regression the trigger refuses; rank it below and the outbound leg is. Ranking them equal fails too, and fails for a reason the trigger states well: “two states sharing a rank makes the guard unable to order them, which is a modelling error rather than a data one.”Dispositioned by ranking
disputedabovefulfilledinOrders/OrderStatus.csand recording that the return leg is unimplementable as specified. Nothing in the repository writes either state — dispute handling is not built — so the choice costs nothing today and the finding is what has value. The likely resolution when disputes are built is that “dispute won” is not a transition back tofulfilledat all, but a distinct state that outranksdisputed, which keeps the rank monotonic and keeps the history readable. That is anADR-Mand is deliberately not decided here.
M-F-08—ADR-M-040says the Checkout Session is created by an API-to-Worker call; four other places say the API calls Stripe directly.§5.1’s
ADR-M-040reads: “Checkout session creation MUST be a synchronous API-to-Worker call.” The handover’s trap table agrees, spelling the fix asAPI -> Worker.Against that: §2.4’s sequence diagram has
API->>S: Create Checkout Sessionwith no Worker in the hop; §3.3’sADR-M-004says “the API needs only a narrow slice of Stripe: create a Checkout Session… Session creation is one form-encodedPOST… trivially AOT-safe when written directly”;Directory.Packages.props’s own comment says the API “hand-writes the four Stripe operations it needs”; and the handover’s key-scoping paragraph says “the API’s restricted Stripe key can create sessions and read events”, which is only meaningful if the API is the caller.The Worker hop is also unimplementable as specified, which settles it on more than a count of citations. §2.6 gives the Worker
minReplicas: 0scaled by outbox depth (KEDA, PostgreSQL scaler). A synchronous HTTP call to it has no scaler to wake it: at zero replicas nothing observes the request, and the outbox — the only trigger configured — is empty precisely becauseADR-M-040’s own rule forbids enqueuing the session creation. Even granting a replica,BENCH-M-01measures ACA container scheduling at 8 969 ms, on the buyer’s Buy click. It would also route a public, unauthenticated-adjacent request path into the one process holding the key that can move money, which is the property §3.3’s split exists to prevent.Dispositioned in favour of the direct call, and
ADR-M-040is amended rather than overruled. Its load-bearing half is correct and is preserved exactly: session creation is synchronous and is never outboxed, because the API must return the redirect URL to the browser; the outbox stays reserved for post-payment fulfilment. What is corrected is the mechanism clause.ARCH-M-01is not weakened by this — it is strengthened, since no Stripe SDK is reachable from the API’s dependency closure at all and the call is one form-encodedPOSTunder the restricted key.Implemented in
apps/marketplace-api/…/Checkout/CheckoutService.cs; the first realcs_test_…session was created on 2026-08-09.
Five findings and two open items raised 2026-08-10 while implementing Chapter 4
M-F-09,M-F-10,M-F-11— Appendix A’s Identity block cannot express Chapter 4.Three mechanisms are specified normatively in prose and had nowhere to store their state.
M-F-09: §4.5’s sequence diagram has the API “verify credential, bump sign counter” and §4.2 fixes an RP ID, an algorithm preference and a UV policy — and no table held a credential id, a public key or a counter, so §4.2 was unimplementable as written.M-F-10: §4.3 specifies refresh tokens as “single-use with rotation-on-use and reuse detection” and §4.7 requires invalidating a family; no table held a token and none held a family.M-F-11: §4.7 and §4.10 both incrementaccounts.session_version, and Appendix A’sALTER TABLE accountsaddsrecovery_cooldown_untilandquarantine_untiland not that column.All three closed by
0003_identity.sql, with Appendix A amended in the same change. They are one finding in three parts because they share a cause: a chapter written as an argument, whose schema appendix was assembled from the parts of it that already looked like tables. The parts that looked like behaviour — a counter bumped, a family killed, a version incremented — did not get rows.
0003also adds the two foreign keys0001left offauth_ticketsandaccount_recovery_requests. Their neighbours in the same block have them, the asymmetry is recorded nowhere as a decision, andADR-M-032’s erasure is aDELETE FROM accountsrelying on cascade — so an unreferenced table is one erasure silently misses. Forauth_ticketsthe residue is fifteen seconds of rows; for a pending recovery request it is a 48-hour time lock that outlives the account it was for.
M-F-12— the published JWKS URL is not on a path §2.2 routes to the API. Recorded in full at §4.3. Dispositioned by mapping both paths and naming which one is correct until Front Door carries an explicit rule.
M-F-13— §4.3’s enumerated claim set omits theauth_time§4.9 requires. Recorded in full at §4.3. The reason it is not cosmetic is thatiatis the obvious substitute and is wrong: a refresh mints a newiatwhile performing no authentication, soQ-M-018’s five-minute freshness window would renew itself on every rotation, indefinitely, without anyone touching an authenticator.
OI-M-29— there is no specified sign-up flow, and the implemented one is an enumeration oracle.Chapter 4 begins at “FIDO2 assertion” (§4.5) and specifies credential parameters (§4.2), token design (§4.3) and recovery (§4.10). It never says how an account comes into existence. The implementation returns 409 when the address already has one, which tells the caller that address is registered.
The alternatives were weighed and are worse. Silently adding a passkey to an existing account is an account takeover with a one-request exploit. A decoy ceremony that cannot complete is a mechanism whose failure mode is a user who can never sign up, with no error anywhere. §4.10 refuses enumeration for recovery, where the attacker’s prize is a 48-hour lock on somebody else’s account; here the prize is knowing an address is registered, which a login page leaks the moment it works.
Not resolved because the resolution is a product decision about the sign-up experience, not an architectural one — and because writing an ADR for a flow the chapter does not describe would be inventing the requirement and the answer in one step.
OI-M-30—accounts.email_cipheris encrypted under a configured data key, not a Key Vault envelope.§8.11 says “envelope-encrypted”. What ships is AES-GCM under a 256-bit data key read from the container app’s secret set, with a fresh nonce per row and a separate key for the deterministic
email_hmac. That gives the property that matters today — a database exfiltration alone yields no addresses — and not the one that matters at rotation: a real envelope wraps the data key with a KEK, so rotating re-wraps one blob instead of re-encrypting every row.Additive, and recorded so it is not mistaken for done. It is a Key Vault integration and a wrapped-key column, with no change to
email_hmacand no change to any query.
Two open items raised 2026-08-09 by the same work
OI-M-27— there is no subscription offer, only a subscription projection.
0002_recurring_billing.sql’ssubscriptionstable is where a bought subscription lands — one row per account, projected from Stripe. Nothing in the schema describes what may be bought: no name, no price, no benefit list, no publication state.GET /api/storefronttherefore serves the premium offer from the API’s own configuration.The awkwardness is that
0002maderesource_urna column specifically so a second subscription product would need no migration, and on the offer side a second product today needs a configuration edit and a redeploy. The rigidity moved one layer up rather than being removed. Not resolved here because an offer table is a catalogue decision — whether an offer is atitlesrow, a fifthproduct_kind, or its own table is exactly the modelling question §1.3’s taxonomy owns.
OI-M-28— no column says which SKUs have a physical component.§1.2 makes Tier 3 the tier that is “simultaneously fulfilled and unfulfilled” — a print-on-demand hardcover beside the digital files — and §5.1 makes fulfilment per-line for exactly that reason. But
skushas no attribute distinguishing a SKU that carries a PoD unit, andsku_grants.product_kind’sCHECKset (asset_pack,cartridge,mod,raw) is entirely digital.order_lines.kindhas thedigital/physicalsplit ready and nothing to derive it from.Checkout therefore writes every line as
digital, anddb/marketplace/dev/seed.sqldeliberately seeds no Tier 3 SKU rather than offer one that would silently fulfil half of itself. Inferring “physical” fromtier = 3was rejected: it makes the tier number load-bearing for fulfilment shape, and §1.4’s tiers are a pricing ladder that a future tier could extend without implying a printer.
Appendix E — Delivery Roadmap
Phased by what becomes irreversible, not by feature grouping. Anything that changes a primary key, a money invariant, or a published contract is pulled as early as possible; anything that is additive is pushed late.
Phase 1 — Foundations that cannot be retrofitted
- Wyoming LLC, EIN, US bank, Stripe account with Connect enabled. Everything else is blocked on this.
- The ledger, with its constraints, before the first sale. Retrofitting double-entry onto a system that has taken money means reconstructing history from Stripe reports. The balance trigger, the append-only trigger, and the largest-remainder allocator ship together with the property test.
- The entitlement table, the
_v1view, the restricted VTT role, and the contract tests in both repositories (§2.5). This is the shared contract; it is expensive to change once the VTT reads it. - Order state ranks and the regression guard (§5.1). Adding a monotonic guard after out-of-order events have already corrupted orders is data repair, not a migration.
- Native AOT pipeline with
IlcTreatWarningsAsErrors, andBENCH-M-01in CI from the first commit.Done, 2026-08-04. ACA scale-to-zero wall time measured at 8969 ms; scale-to-zero is officially rejected for the API. A project that adopts AOT late discovers its reflection dependencies as a rewrite.BENCH-M-01 Done, 2026-08-03. The spike passed; the composition root isOI-M-03resolved.AddJwtBearerwithMapInboundClaims = false(§4.3.1), and the hand-rolled ES256 verifier is cancelled. Two settings and one test (RC-M-27) carry forward from it.settled byOI-M-11ADR-M-032(§8.11). The DDL that follows from it is Phase-1 work and is not optional: theR/E/Cregistry withARCH-M-15,subject_map, theorders/order_contactssplit,ledger_entries.subject_ref, and theRC-M-28erase-then-reconcile test. The two changes that cannot be retrofitted at all are the ledger’ssubject_refcolumn and theorderssplit — one is a column on an append-only table, the other is touched by every query.settled byOI-M-12ADR-M-033(§3.7). Phase-1 deliverables from it: themigrator/app_rwrole split, the migration linter (RC-M-29), the previous-release compatibility test (RC-M-30), and the contract-debt ledger (RC-M-31) — all three CI gates before the first migration, because a discipline whose checks arrive later is a discipline that was optional for every migration written in between.settled byOI-M-13ADR-M-034—ledger_entries.actor_refand the fiveadmin_*tables land with the ledger, not after it, because the audit trail for an adjustment cannot be retrofitted onto adjustments already posted.all settled (OI-M-14,OI-M-15,OI-M-17,OI-M-22ADR-M-035…ADR-M-038). Every Phase-1 gate is now closed and the DDL is writable end to end.
Phase 2 — The money path
- Checkout, single
PaymentIntentper cart, tax quote capture. - Webhook endpoint with raw-stream HMAC,
processed_stripe_events, pessimistic locking — and the fixture corpus replayed forward, reversed, and duplicated (§5.11) from the first webhook handled, not added once bugs appear. - Outbox with per-key ordering, lease reaper, jittered backoff, dead-letter alerting.
- Tier 1 and Tier 2 end to end. Digital-only launch is possible at the end of this phase.
- Reconciliation job (§5.10) — it must exist before there is a backlog for it to find.
Phase 3 — Identity and the VTT seam
Updated 2026-08-10: the first four lines are struck where they are done. What remains under each is what is actually left, rather than a tick that hides a half.
FIDO2 with the parent-domain RP ID, JWKS, ES256, refresh rotation with reuse detection.Done.ADR-M-048verifies ceremonies in-house;0003_identity.sqlsupplies the four tablesM-F-09…M-F-11found missing. JWKS is served on two paths forM-F-12’s reason. Left: the Front Door rule that makes §4.3’s published URL resolve to the API, which is an edge change this repository does not own.Ticket exchange, redemption endpoint, and the VTT-side integration.All done, and the middle clause of this line was wrong for a day. Corrected 2026-08-10. It read “Left: the Orleans side — nothing in the VTT repository calls the redemption endpoint, so the seam is built from one end.”ADR-130built the VTT half andADR-131the entitlement read behind it, both before the Marketplace half existed; the claim was made from one tree without reading the other. What was actually missing is the cross-context contract test thatHttpTicketRedeemer’s own doc comment records as owed — “the shape ADR-097 clause 3 owes a fixture test against — which does not exist yet” — and it now exists on the producing side, asserting both directions of the wire shape and the redemption path.Step-up authentication on the sensitive route groups, withDone forARCH-M-07gating.Identity.Sensitive. The filter is attached to the route group rather than per route, which is what makes §4.9’s “a route added to those groups without the filter fails the build” true. Left:Ledger.Payout, which has no routes yet — payouts are not built, so the group does not exist andARCH-M-21’sAdmin.*groups do not either.Left in full: the revocation cache’s second implementation.Done 2026-08-10.GarnetRevocationStoreships beside the PostgreSQL one and is exercised againstghcr.io/microsoft/garnet:1.0.86— the real thing rather than a Redis stand-in, because testing a substitute for the substitute would leave the deployment target unexercised.Q-M-017still has no measurement; what it now chooses between is two built things.Left in full: theDone 2026-08-10, and hosted in the API rather than in the Worker. §2.6 gives sweeps to the always-on Worker andauth_ticketssweep.Descent.Marketplace.Workeris a class library with no entry point — nothing hosts it, no workflow publishes it, no revision runs it — so a sweeper placed there would be precisely the “present, plausible, never executed” stateADR-M-029warns about. A session advisory lock picks one replica. The deviation is the finding worth carrying forward: the Worker has no host, and the outbox nothing drains is the larger version of the same gap.§4.10’s recovery, complete, including the quarantine.Done 2026-08-10, withM-F-14’s correction: the redemption is aPOSTbecause §4.6 forbids a state-changingGET. §4.11’s offline codes ship with it, dischargingRC-M-37…RC-M-40.- Time-locked recovery (§4.10) ships in this phase, complete, including the quarantine. It belongs where the auth system is being touched, not in a hardening pass — and specifically, the quarantine must ship with the recovery flow rather than after it, because a recovery path without a step-up quarantine is a clean route around every payout control in §7.8. Shipping the two halves in different phases would open that route for the length of the gap.
- Second-authenticator enrolment prompts after the first significant purchase. This is the mechanism that keeps §4.10’s usage rate low, and it is cheaper than every part of the recovery flow it prevents.
Phase 4 — Physical fulfilment and real-time push
- PoD partner integration,
pod_jobs, the compensation saga (§5.8), and the point-of-no-return rule. - SSE,
LISTEN/NOTIFY,user_notifications,Last-Event-IDresumption, the deliberate stream lifetime, and the synthetic latency probe (RC-M-15). - The
INotificationFanoutseam and theRedisPubSubFanoutimplementation, both exercised in CI (RC-M-22), even though only the PostgreSQL implementation is deployed (§6.7). Writing the second implementation now costs a day; writing it during a connection-exhaustion incident costs the incident. Plus the CImaxReplicascap (RC-M-24), which must land before the first autoscaling event, not after. - Tier 3, US-only, with
fulfilment_regionsexercised on day one. BENCH-M-04andBENCH-M-06, which setQ-M-015andQ-M-025before the ceilings are found in production.
Phase 5 — Creator scale
- Express onboarding, capability handling, tax-profile collection, withholding postings.
- Payout scheduling with reserve, threshold, and first-payout delay.
- Creator dashboard: pending versus available balances, the withholding line, the payout threshold stated plainly.
- Fraud detection tuning against real traffic, thresholds unpublished (
ADR-M-028).
Phase 6 — Efficiency
- Zero-egress CDN tier (
ADR-M-020) — worth doing only once egress is measurable. - Deterministic stamping with the derivative TTL, storage lifecycle policies.
- Per-request cost attribution, budget guardrails, and free-UGC three-tier shaping (§8.9.1), in three steps whose order is forced by a dependency:
- Per-creator Red refusal, plus a crude fleet kill-switch. Two comparisons at URL issuance. Protection exists from this point; it is abrupt, and that is acceptable as an interim state because the alternative is no protection.
- Admission control and the fleet slot pool. This upgrades the kill-switch into the graded breaker — the burn-rate controller has nothing to rescale until the pool exists, so the graded breaker cannot precede admission control. (An earlier draft of this roadmap said the breaker ships first alongside Red. That was written against the tier-escalation design and no longer holds.)
- Queue UI, revenue-credit join, notification ladder, hysteresis tuning.
Optional byte pacing (
Q-M-030d,RC-M-25) is not in this phase and may never be.
Q-M-030cderived from measured free-asset sizes against theQ-M-030erunway target, andQ-M-044calibrated against the first cohort of promotional free content. Both are provisional until then, and the document says so.
Revision note — ADR-M-029, ADR-M-030, ADR-M-031
Three proposals were reviewed and all three accepted, each with amendments where the mechanism as proposed did not defend against the failure it named:
-
ADR-M-029(§6.7) — SSE overLISTEN/NOTIFYreaffirmed. Amendments: the trigger measures configuredmaxReplicasrather than observed replica count (a spike-triggered migration fires when there is least capacity to run one); a dial stage precedes the migrate stage because raising Neon’s compute is cheaper than a rewrite; a CI-enforced cap provides the protection that a threshold does not; Azure Service Bus is rejected by name for having the wrong delivery shape; and the migration is explicitly not credited with restoring Neon’s autosuspend, which the always-on Worker pins regardless. -
ADR-M-030(§4.10) — time-locked recovery accepted, closingOI-M-04. Amendments: email cannot be the load-bearing notification channel, since it is the channel the attacker owns; cancellation requires a passkey assertion, not a link; the unlock is evaluated atomically at redemption rather than granted by the Worker; a recovered session is quarantined and cannot satisfy step-up; and one-pending-request plus a cancellation cooldown closes the notification-fatigue bypass. -
ADR-M-031(§8.9.1) — three-tier free-UGC egress shaping accepted, closingQ-M-030. Amendments: per-stream throttling does not bound aggregate egress (concurrency × rate), so Tier 2 bounds concurrency instead; Tier 2 is reframed as a runway mechanism rather than a cost control, because throttling defers bytes without reducing them; enforcement moves from the CDN edge to URL issuance, because neither Azure Front Door nor Cloudflare rules can shape response byte-rate without bespoke Worker compute; a platform-wide breaker is added above the per-creator tiers, since per-tenant quotas never compose into a fleet cap; free downloads require authentication and the meter counts distinct authenticated consumption, because the proposed design let any third party push a rival creator into Red Zone; and allowance is credited by attributable revenue so promotional free content is not throttled for feeding the funnel it exists to feed.
End of document. Two findings (M-F-01, M-F-02) were raised against the premises this document was written from; both are disposed of in the body, and both changed a decision’s justification rather than its outcome. A pre-implementation scan on 2026-08-03 added twelve further items, six of which gate Phase 1. All six are now closed — spike OI-M-03 by measurement, and OI-M-11, OI-M-12, OI-M-13, OI-M-14, OI-M-15, OI-M-17 and OI-M-22’s schema half by ADR-M-032…ADR-M-038. No Phase-1 gate remains open; the DDL is writable. Twelve open items remain and four launch blockers — OI-M-05, OI-M-06 and OI-M-24 are three questions for one legal engagement, and RC-M-19 is owed by the VTT corpus. No launch blocker now requires an architectural decision, which is a different and stronger claim than the one this document made two revisions ago and then had to retract.