Appearance
Swappable seams
The architecture is organised around a few interfaces. Each has several implementations and a resolver that picks one. Every one of these has actually been swapped, and each swap is the evidence that the seam earned its place.
| Seam | Implementations | What the swap proved |
|---|---|---|
SupplyAdapter | Cinemark, Best Buy, Nike, Toast, Square, in-store barcode, Mock | The pipeline is not merchant-shaped |
SecretVault | LocalEnvelope (custodial), Chipotle on Lit V3 (custodial today: its unlock rule is our signature), in-memory | One contract suite passes on all three. No setting picks one: the resolver picks by owner, and the places that open a card use the local vault directly |
RedemptionExecutor | Browser, action-only, Square | Where plaintext lives is swappable |
SettlementLifecycle | Atomic, deferred | Seconds and weeks are the same shape |
DeliveryConfirmer | ParcelsApp, simulated, unwired | A carrier can be replaced under load |
BalanceReader | Adapter-backed, unwired | Fact-based vs evidence-based settlement |
SupplyAdapter
The contract every supply source implements: advertise, checkBalance, execute, and a declared formFactor, banSurface and settlementCapabilities.
The proof it works: MockAdapter is a bearer supply source that has never heard of Cinemark, and the entire fulfilment pipeline runs against it end to end. Its test file imports no Cinemark adapter, no Playwright driver, no theater data. If a change made the core pipeline cinema-shaped again, that file would stop compiling.
settlementCapabilities is required. An optional field is how a future adapter ends up balance-blind by accident: silence would mean "assume the strongest", the one default that fails unsafely. Making it required turned a possible runtime mis-settlement into eight compile errors.
SecretVault
seal(secret, condition) and unseal(ref, proof), plus a backend identifier the custody guard classifies.
The backend string is what custody classification reads. A backend that is not explicitly classified as non-custodial cannot hold a third party's card.
The resolver selects; the guard refuses. Two mechanisms, deliberately. A resolver bug produces a refusal, never a custody violation.
Opening is not wired the same way. The four places that open a card (the reveal and settle routes, scripts/reconcile.ts and scripts/redeem-intent.ts) build LocalEnvelopeVault directly. Before a card is sealed anywhere else, they need a dispatcher on ref.algorithm, and the public sell screen has to stop recording a stranger's card as the operator's.
RedemptionExecutor
Applying a card decomposes into four steps, and exactly one touches plaintext. Both paths are built behind one interface:
BrowserExecutor: all four steps locally. Plaintext in the page context. Correct regardless, and therefore the default.ActionOnlyExecutor: designed so step 3 runs in a TEE against asecretRef, and our process would never hold plaintext. Correct if session portability works, which has never been measured. Its in-enclave apply step is not built. See Custody.
Each result records custody: 'plaintext_never_local' | 'plaintext_local', so the property is asserted in tests rather than assumed.
Nothing in production reads REDEMPTION_EXECUTOR today. Its only reader is the registry, and nothing calls the registry outside tests. Setting action_only changes nothing and raises no error. Once wired, the registry refuses to fall back: selecting the action-only path without its apply step throws rather than silently using the browser path. A silent custody downgrade is not recoverable. A loud failure is.
Square's executor declares plaintext_local because its step 3 is a DOM field, not an HTTP call. Best Buy's is a PUT, which is relocatable; a form input is not. So for Square the action-only path is unbuilt, and cannot currently even be expressed.
SettlementLifecycle
Two models, chosen by order kind:
- Atomic (
ticket,gift_card): seconds. If the buyer has nothing by the deadline, give the money back. - Deferred (
credit_line): days to weeks, staged release.
Keeping these apart stops the refund daemon reasoning about shipping. The daemon implements one settlement model and filters to atomic kinds, rather than being taught about parcels.
BalanceReader
Introduced late, and it decides which settlement tier a merchant gets:
AdapterBalanceReaderdrives the merchant's own public balance page (Best Buy, Nike) → settle on a real delta.UnwiredBalanceReaderreturnsnullfor everyone else → route to evidence tiers.
hasPublicBalanceLookup is the single predicate that decides, so adding a merchant is a data change rather than a branch somebody must remember.
The adapters are dynamically imported here. A static import would drag Playwright (a heavy native dependency) into the Next bundle and break the production build. This repo has already been caught by that trap once.