docs/plan.md
Rama main · réplica de lectura del repositorio original.
# Payment aggregate integration plan
## Goal
Replace the current payment callback records with one durable payment system,
built from the strongest parts of two branches, delivered to `main` as pull
requests that each leave a complete, smoke-testable system. This file is the
working plan: status, binding rules, decided behavior, milestones, and the fault
ledger.
## Sources
- `origin/base/payment-aggregate` (#1962) is the operational reference: storage,
claims, provider reads, reconciliation, booking completion, refunds,
maintenance, migration, redaction, and owner pages. It is never merged or
copied as-is — its own TODO records unresolved faults and its coverage gate is
incomplete.
- `origin/claude/great-fermi-l2n29f` (#1973) is the reference for pure payment
rules: money, provider observations, conflicts, stored records, and owner
decisions. Its best idea is one `outcomeOf` diagnosis shared by live
resolution and validation of stored evidence. It has no production callers and
never merges as a standalone layer.
- Neither branch contains the other (merge base `15e48fac7`), and neither merges
into `main`. Work lands as fresh milestone pull requests.
## Where we are
| Milestone | Status |
| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| M1 safety behavior (was PR 1) | Merged as #2020. Also landed the M2 pure modules: `src/shared/payment/money.ts`, `resource-id.ts`, `refund-state.ts`, and `validated-session.ts`. |
| M2 money/resource vocabulary (was PR 2) | Core modules merged inside #2020. Any provider parsing still off those schemas rides with M3 or M4. |
| M3 provider ownership (was PR 3) | Complete: #2048 (payment processing core), #2050 (bounded registration delivery), #2060 (observation boundary + SumUp callback wiring; F2 closed). |
| M4 settled-money diagnosis (was PR 4) | Part A complete for the current path: #2065 (one durable `payment_charges` authority, one provider-send permit, one owner-recovery lifecycle), #2079 (resumable placeholder completion; each machine declared as a checked spec feeding the `/admin/schema` map), #2084 (the declared seam between the machines, witnessed in every crash test, with a live stored-data check on the map), and #2109 (the SumUp checkout-recovery task on the current path). Whole-checkout diagnosis, stable booking obligations, allocation, and durable Refund All jobs remain later milestones. |
| M5 cases (was PR 5) | The current-row review/acknowledgement slice landed in M4 Part A. Remaining aggregate case kinds ship with their M6–M8 producers and actions, not as a dormant layer. |
| M6–M11 atomic aggregate cutover | Not started as a production cutover. #2056 started its read-only verifier early; that pull request is still open and unmerged. Creation, reads, jobs, completion, delivery, site effects, and bounded history migration activate together; no intermediate layer may merge as a live parallel path. |
| M12–M13 | Not started. |
Budgets below count `src/` lines only. Observed totals run 4–15x the `src/`
figure once tests, stories, and catalog copy are included (#2020: 714 src lines,
10,519 total), so this plan no longer publishes total-diff estimates — they were
consistently wrong and governed nothing.
## Delivery rules
1. **Every merged PR stands alone indefinitely.** Each merge leaves one complete
production system the owner can smoke test, with no dormant foundations, no
release trains, and no code activated only by a later PR. Every new
production export has a production caller in the same PR. A durable case or
due-work kind ships with every owner action and scheduled recovery path it
needs — or links to a still-live admin tool that resolves it. Each PR must
improve the system that is live when it merges; a test, schema, repository,
or helper needed only by a later PR does not qualify.
2. **One production path per behavior.** Migrate every caller and delete the
displaced implementation in the same PR. No aliases or compatibility wrappers
between the branches' names, no dual writes, and no runtime read-through over
the displaced store. A bounded migration may decode an old record only inside
its explicit owner-authenticated ceremony. It must finish and verify before
the canonical runtime is enabled; the same release then deletes every old
runtime caller. M4 follows this rule by cutting every live refund sender over
to the canonical authority at once.
3. **Size.** Keep each PR under 800 changed `src/` lines (insertions plus
deletions, recounted after formatting). One exception: an atomic cutover that
would otherwise need a throwaway compatibility layer may exceed the cap — a
bigger honest PR beats building a bridge in one PR and demolishing it in the
next. Say so in the description. docs/pr-workflow.md's "repository source-line
limit" is this rule, exception included. Tests, fixtures, and documentation
do not count against the cap; never weaken them to shrink a diff. #2065 is
the approved current-path exception: its `14881` gross changed `src/` code
lines cut every refund caller, provider adapter, persistence writer, and
recovery surface over together. Imports, comments, and blank lines are
excluded from that audited total. Splitting it at a live seam would have
required the legacy and canonical authorities to run in parallel, which this
plan forbids.
4. **Gates.** `devenv shell deno task precommit` passes before review. Run
targeted mutation on the payment modules the PR changed and list the runs in
the description. The branch-level `devenv shell deno task precommit:mutation`
gate runs before merge as AGENTS.md requires — no per-PR waivers. If an
exceptional cutover genuinely cannot run the branch gate, the owner first
changes the policy in AGENTS.md; this plan cannot loosen a repository rule.
5. **Every bug fix ships a regression test** reproducing the bug.
6. **Deployments are forward-only and fleet-wide**, on the `release` tier only.
No old-version support, no code rollback, no mixed-version reads or writes.
Bunny's roughly two-second script handoff is operational overlap, not a
compatibility window — it must not cause a runtime branch, schema adapter, or
legacy replay path. The aggregate cutover release first fences old writes,
completes its bounded owner-authenticated migration, and verifies every
source disposition. Only then does one epoch switch enable canonical runtime
reads and writes. No request can see a partly migrated store.
7. **The aggregate tables are already shipped.** M4 Part A activated the
previously unwritten `payment_charges` table as the sole durable authority
for provider refunds. Its migration refuses to reinterpret a non-empty table.
The still-dormant `payment_sessions`, `payment_completion_effects`,
`payment_completion_deliveries`, `payment_cases`, and
`payment_case_decisions` tables are defined in
`src/shared/db/migrations/schema/payments/`; do not add drop-and-recreate
churn against them. Each must hold a complete production role when the atomic
M6–M11 cutover activates or be dropped in that same release. Their existence
is not permission to land unused repositories, codecs, indexes, or exports.
Their currently dormant buyer-bearing columns accept DB-key `enc:1`
ciphertext. No production writer may use that shape: before activation,
rebuild every buyer-bearing or raw-reference field to require owner-key
`hyb:1` ciphertext, or drop the table if it has no complete production role.
8. **Port the source branch's tests** when adapting one of its modules; adapt
existing tests rather than authoring from scratch. Never copy great-fermi's
test-only-export exemptions.
9. **PR descriptions state**: the immediate current-system value and the exact
production route, worker, page, or write path that receives it; the summed
`src/` line count; the database and provider call budget whenever the slice
touches providers or adds queries (Bunny's hard limit is 50 subrequests per
request); the fault-ledger rows closed; the tests and mutation commands run;
and the old path deleted. The full field list is in steps 2, 5, and 6 of
`docs/pr-workflow.md`. Review pure schemas, transactions, provider parsing, and
orchestration as distinct commits where that helps.
10. **Machines, seams, and witnesses ship with the first slice.** Any new
stateful lifecycle — M5's cases, the M6–M8 aggregate states, M9–M10's
delivery and site work — lands with its declared machine spec, its seam
declarations against every machine it shares reality with, witness
assertions in its crash manufactures, an enumerated flows × crash-windows
table in the PR description (each window closed by an idempotent-redelivery
proof or a fault-injected test), and its section on the `/admin/schema` map.
Retrofitting exactly this onto the refund machines took three large PRs
(#2065, #2079, #2084); declaring it at design time is the cheap path. The
method and its reference files are AGENTS.md's "Checked forwards and
backwards" section.
Size check:
```bash
git diff --numstat <parent>...HEAD -- src/
```
## Decided behavior
These binding product decisions are requirements. Each attaches to the milestone
that ships the behavior:
- A failed checkout that shows captured money stops automatic work and creates
an owner case. The owner must complete the booking or refund it.
- A completed refund counts immediately even when the provider's cumulative
total lags. New overlapping refunds stay blocked while reconciliation reads
the provider again.
- Every captured charge is stored, but more than one captured charge on one
payment requires owner review. If several coherent captures sum exactly to the
signed total, the booking may proceed with that review and no automatic
refund. If the multi-capture reading also fails currency, total, parentage, or
another validation rule, it parks: no booking and no automatic refund. A
coherent single-charge mismatch keeps the automatic-refund remedy. The review
is a durable case. Its best-effort ntfy alert contains only a finite case code
— no buyer/provider identifiers or amounts — and points the owner to the
owner-protected record where the evidence and required action live.
- A provider-partially-refunded charge observed before booking parks even when
its captured and expected totals otherwise agree: no paid booking and no
automatic refund. The buyer is retained for owner review. On an existing
booking, Refund and Refresh preserve the exact partial-return work rather than
treating it as zero or complete.
- Contradictory provider parentage is an action-level no-refund gate regardless
of which conflict label wins. Conflict ordering may choose one headline, but
must evaluate refusal-shaped conditions before proceed-shaped review and must
retain the complete observed charge evidence.
- Money already proved fully returned never creates a paid booking. A new
booking takes the stable refused-and-refunded result; a balance remains unpaid
and records the captured-and-returned cash round trip against the existing
attendee rather than creating a placeholder.
- If a reviewed or conflicted payment later fails ordinary booking work, that
downstream failure cannot route it into an automatic one-charge refund. The
buyer record and exact owner-review evidence remain authoritative.
- A legacy provider charge may fund one or more booking obligations. Before an
automated refund or ledger reversal can use it, the owner either records an
exact allocation of its captured Money across those obligations or rejects the
automated action. Every part is positive, uses the charge currency, and the
parts sum exactly to the captured amount. The system never invents an equal,
proportional, first-attendee, or current-row split.
- Returning cash and cancelling a booking obligation are separate effects. A
confirmed provider refund records only the cash that charge returned. An
obligation cancellation reverses its sale, modifier, and fee facts exactly
once under the stable obligation identity. Neither effect implies the other.
- When one payment under an obligation returns while another remains captured,
the owner makes a required, revision-fenced choice with no default: keep the
booking and make the returned amount due, return all remaining cash and then
cancel, or cancel now while keeping the retained cash as visible refund work
owed to the buyer.
- A queued owner email uses the current business address at send time. Its body
and buyer facts remain the stored payment snapshot.
- An incomplete or contradictory retained payment record is copied without
invented facts, marked for owner review, and does not stop the rest of the
aggregate migration. An attendee payment id that survives only inside old PII
is outside that promise: current in-app refunds deliberately leave it
unsupported rather than decrypting attendee history to recover it.
- A buyer whose paid booking needs review sees that payment was received, that
the booking needs review, and that they must not pay again. The approved copy
is: “We received your payment. Your booking needs a manual check. Do not pay
again — we will contact you.” Reloading shows the same stable result.
- Post-terminal provider evidence is merged, never used to replay an
irreversible effect. Unchanged evidence is a replay; the application's own
later-visible refund confirms its recorded attempt. An unambiguous external
full refund of the single known charge remains eligible for Refresh to record
the returned cash in Money without implying booking cancellation. Partial,
sibling-capture, or contradictory evidence blocks instead. Provider array
order and mutable attributes are not resource identity.
- No aggregate checkout path becomes authoritative until the owner can view its
evidence and perform every supported required action. Until a case kind's own
page action ships (M7/M8), a supported action may be a link to a still-live
admin tool that genuinely resolves that case kind.
- Retry and replay rules apply only to duplicate callbacks or interrupted work
handled by the current version. The system never emulates an older version's
payment behavior.
- A refused checkout's terminal completion records its existing refund authority
under the same durable session identity, so a later delivery of that session —
a webhook redelivery, or the buyer opening the success page — short-circuits
through the same engine and cannot make a second ticket for money already
returned. No second processed-payment refund marker is added beside the
canonical identity.
- A buyer whose paid checkout was refused gets a truthful answer for every exit:
money taken with no usable reference says so and asks them to get in touch; a
return that awaits the provider or a required owner choice says which; money
reported paid but not proved returned says that. A proved return says that the
money was returned and that no booking was created. Reloads and redeliveries
replay that same answer without another effect. Unverifiable or foreign price
proof keeps the generic not-found answer, because it may belong to another
site's buyer. The copy comes from the whole-checkout engine's exhaustive buyer
outcome, never from a second parallel rejection-response state.
## Target architecture
### Pure rules (adapt from great-fermi)
All pure payment rules live in `src/shared/payment/`; do not open a second
directory. The common vocabulary is `money.ts`, `resource-id.ts`,
`refund-state.ts`, and `validated-session.ts`. M4 Part A added the refund-only
vocabulary in `resources.ts`, `conflict.ts`, `diagnose.ts`, `refund.ts`, and
`admit-refund.ts`: `ChargeMoney` is the complete fact one charge can prove,
`refundOutcomeOf` is its sole judge, and the pure `PaymentConflict` union
contains only the three disagreements that judge can produce. Those names are
not a second stored review schema: a durable provider disagreement retains its
exact Money in the canonical authority. Exact zero or full-return evidence can
become `needs_owner_choice/provider_conflict`; partial or inconclusive evidence
becomes `needs_provider_check/provider_conflict` and admits no owner money
answer. `provider-read.ts`, `provider-failures.ts`, `refund-attempt.ts`, and
`refund-network.ts` make provider reads, attempts, failures, and bounded
reconciliation total and exhaustive. `row-state.ts`, `claim.ts`, `review.ts`,
and `admit-move.ts` declare the live row lifecycle and derive its operator and
writer rules.
The whole-checkout observation, ownership proofs, charge legs, expected-money
comparisons, and whole-payment `outcomeOf` are deliberately NOT present. They
arrive only with the M6 reader that can supply their complete evidence. Build
them against that reader, widen stored unions only when a real reading can
produce the new case, and do not recreate a second refund judge.
### Persistence and runtime (keep and harden from payment-aggregate)
M4 Part A supplies the live runtime discipline: provider-aware indexed
references, revision-fenced attendee claims, typed provider reads and attempts,
one durable `payment_charges` authority shared by admin and buyer callbacks, one
admin single/bulk/refresh orchestration path, exact ledger findings, structured
confirmations, and declared lifecycles for provider work, local row work, owner
choice, and terminal outcome. The aggregate still needs payment, allocation,
case, decision, effect, and delivery repositories; persist-before-provider
checkout creation; durable booking and refund jobs; scheduled recovery; bounded
migration; backup, restore, and redaction.
New raw provider references in `processed_payments` and `payment_charges` are
encrypted to the owner's public key. Equality uses a DB-keyed one-way index, and
SQL-only consumers see plain state words, never the reference. With a modern v2
password-wrapped owner key, a holder of the database and `DB_ENCRYPTION_KEY`
alone cannot open those new raw references or attendee PII. A dormant legacy v1
owner wrap is the explicit exception: its KEK is derivable from the stored
password hash plus `DB_ENCRYPTION_KEY`, so that holder can unwrap the data key
and site private key until the next successful login upgrades the wrap to v2.
This is owner-key-protected at-rest storage, not an authorization claim that
only owner requests possess the site private key: manager requests share that
key, while owner-only refund detail and decisions are enforced at the route
boundary. This claim is deliberately about data stored in this database:
provider credentials are DB-key encrypted settings, so somebody holding the
database and environment key may be able to use those credentials to query data
held by the provider.
`processed_payments.failure_data` has a deliberately different boundary: it is
DB-key encrypted, so that same holder can open its lifecycle metadata. Keep it
limited to attendee and command ids, times, finite review/claim/outcome reasons,
and terminal display text (which may include a listing name); raw provider
references and buyer PII must never enter it. Every current writer uses the one
`PaymentRowState` stored schema. Live readers reject historical bare terminal
failure and review shapes instead of normalizing them into a parallel runtime.
Historical plaintext `processed_payments.payment_reference` values are not
readable by live refund code. Saving or merging that attendee cannot turn a PII
payment id into refund authority: neither action can prove its provider, and
neither writes a payment row. Old DB-key-encrypted refund-warning notes remain
display history until the owner deletes them or the retention/redaction ceremony
removes them. They are never refund-reference migration input. No interactive
request performs a population decrypt or rewrite.
Do not copy `payment-runtime/legacy-replay.ts`, `legacy-sumup.ts`,
`operator-legacy-read.ts`, or any equivalent runtime branch selected by record
age or origin. Migration code may decode an old retained payment-row format only
to write a canonical current payment when that row already proves the identity,
or a terminal accounting case when it does not. It may not search attendee PII
or notes for another reference. After that write, only the current engine reads,
reconciles, refunds, completes, or displays it. Provenance and unknown facts may
preserve evidence; they must not dispatch to different runtime behavior. Every
write validates the complete prospective record with the pure rules, writes with
revision or lease fencing, and validates the returned row. There must not be
parallel raw-row and decoded-domain implementations of the same rule.
## Data laws
Eight laws govern how every part of this program behaves around data. Each
milestone contract instantiates them for the data it touches and says which law
admits each new state, consumer, or fact — so a review finding of one of these
shapes is answered by the law, and a design that satisfies them up front rules
the shape out as a class. M4's first production instantiations are
`PaymentRowState` plus the exhaustive `PAYMENT_ROW_LIFECYCLE` declaration,
`RefundAuthorityState` plus its exhaustive refund lifecycle, the exact claim and
revision transactions, and `ATTENDEE_DATA_RULES`; M5's cases, M6's aggregate
rows, M7's refund jobs, M8's completions, and M11's migration copies are all
data these laws bind.
1. **One authority per fact.** Each fact has one canonical representation and
one place that computes it; every other appearance derives from that copy and
is regenerated, never restated. Two definitions of one value — a second
judge, a second serialization, a second total — are a defect even while they
agree.
2. **Facts carry provenance: signed, stored, or observed — never ambient.** A
decision consumes only facts the datum carries, evidence a read proved, or
values signed at creation. Live settings may order a search; they never
decide a fact. A fact nothing carries is explicitly not evaluable, or an
honest failure naming what is missing — never filled from today's
configuration.
3. **Identity is immutable; everything else is an attribute.** Compare, merge,
deduplicate, and index by the smallest immutable identity. A changed
attribute updates its datum in place and is itself a recordable event; it
never mints a second datum.
4. **Stored state is a declared machine with a total lifecycle.** Every state
names what creates it, every consumer that must recognize it — readers and
writers alike, including cleanup, merge, delete, restore, and replay — what
retires it, and how it ages out under retention. A consumer that cannot read
the authoritative record routes on a mirror written in the same statement,
never on a proxy. A blocking state also names a real operator route and a
rendered control that can reach it, with a visitor journey proving the state
can end. A state representing unresolved money requires an explicit choice;
acknowledgement alone never clears it. A state and all its consumers ship in
one slice.
5. **Decisions bind to complete, versioned evidence.** A path that acts reads
the full declared evidence shape for its source — no path decides on less
than the source's declared observation — and every consequential write is
fenced on the exact evidence it judged: changed evidence forces a re-judge,
and recorded evidence only grows, by merge, never replacement.
6. **Data never moves to weaker protection.** A fact under a stronger key or
boundary is never copied under a weaker one. Cross-boundary comparison uses
one-way codes; a plaintext mirror carries a state word, never contents. A
migration that cannot preserve this rule leaves the old record with reduced
functionality rather than decrypting a population or copying its contents
under the database key.
7. **External parties are capability records.** What a provider guarantees — an
idempotency key, a cumulative total, an event authority — is declared once,
and every behavior derives from the declared capability, never a per-party
arm, so a new party inherits the whole discipline by declaration.
8. **Atomicity before compensation.** Facts that must agree and live in this
database change in ONE atomic write — the same statement, or one libsql batch
or interactive transaction, which commit whole and roll back whole, with a
conditional write's affected-rows count deciding a winner (the helpers in
AGENTS.md's Transactions and Batches section). Never write one half and
defend the gap with a check, a retry, or a repair pass. Guards, fences,
staleness rules, and re-judges are reserved for the one gap atomicity cannot
close — an external call or a concurrent request in the middle — and each one
names the gap it spans. When a review offers "add a guard, or make the write
atomic", atomic wins.
## Milestones
Milestones are behavior units, not normally a PR count: one milestone may land
as several standalone PRs (M3 already has), and each PR still satisfies every
delivery rule on its own. The M6–M11 aggregate replacement is the deliberate
exception: its work packages may be developed and reviewed as commits or
unmerged branches, but they activate in one PR and one fleet-wide cutover
because any merged intermediate would require a live compatibility path.
Provider cutovers move Stripe, Square, and SumUp together behind exhaustive
records keyed by provider, so adding or omitting a provider is a compile error.
The approved work has one current-path stack and one atomic aggregate cutover.
The current-path stack now has M4 Part A's exhaustive provider outcomes,
provider-tagged reference admission with explicit refusal for older untagged
references, exact admin claims, one callback/admin provider authority and send
permit, bounded selected-page execution, exact ledger repair, and owner-visible
recovery. The aggregate cutover turns whole-checkout problems into durable cases
without duplicating that lifecycle, makes attendee merge atomic, moves checkout,
reads, completion, delivery, and site effects to stable booking obligations with
exact allocations, migrates retained history under the owner key, then removes
every old runtime reader and writer before activation. Its internal work
packages are individually testable, but are not live release layers.
### Stack A — finish the current path (M3–M4; M5 behavior follows its producers)
#### M3: Check provider ownership on the current path (was PR 3, complete)
Src target: remainder of ~800.
- Adapt the strict provider observation boundary. Allow an unrecorded child only
when it is a charge under the same pending checkout.
- Wire SumUp's current callback path first; block unknown unsigned callbacks
from causing unbounded provider reads.
Standalone value: current SumUp callbacks cannot attach unrelated resources or
be used to amplify outbound requests.
#### M4: One diagnosis for settled money — fail-closed cutovers only (was PR 4)
Status: Part A is built. The code named here and its mirrored tests are the
authority for this live slice. It hardens admin single refunds, Refund All,
Payment Refresh, buyer-callback refunds, payment-row merge/delete/retention, and
the provider and checkout boundaries they use. It activates `payment_charges` as
the one provider-refund authority. Its machines are declared as checked specs,
the seam between them is a declared illegal-combinations table witnessed by
every crash test, and the `/admin/schema` map renders both and checks the stored
data against them — the reference for delivery rule 10. It does not build a
whole-checkout observation, allocate one charge across stable booking
obligations, or write the other dormant aggregate tables. The former
`PR4_PLAN.md` has been retired; this as-built map and the fault ledger preserve
the useful decisions without leaving a second planning document to drift from
the code.
As-built module map:
| Contract | Exported entry points | Focused authority tests |
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Charge evidence and judgment | `chargeMoneyRead`; `refundMoneyReturned`; `refundMoneyAccountedFor`; `refundMoneyMatchesCapture`; `refundOutcomeOf`; `resolveRefund`; `admitRefund`; `admitObservedRefund`; `admissionReason` | `test/shared/payment/{resources,diagnose,refund,admit-refund}.test.ts` |
| Provider reads and attempts | `ProviderRead`; `providerFailure`; `RefundAttemptResult`; `RefundRequest`; `refundOutcomeAfterReread`; `refundWithOneReread`; `SquarePaymentStatus`; `isSquarePaymentStatus` | `test/shared/payment/{provider-failures,refund-attempt}.test.ts`; `test/shared/stripe-provider/{outcomes,refund-outcomes}.test.ts`; `test/shared/square/payment-outcomes/refund.test.ts`; `test/shared/sumup/{transaction,provider-money}.test.ts`; `test/shared/square-provider/{metadata,webhook-fields}.test.ts` |
| Durable provider state | `RefundAuthorityState`; `RefundAuthorityStateName`; `readRefundAuthorityState`; `writeRefundAuthorityState`; `validateRefundAuthorityState`; `refundStateMirror`; `refundLocalMirror`; `refundNextActionAt`; `readyRefund`; `armRefundSend`; `markRefundObservationDue`; `rearmKeyedRefund`; `returnRefundToReady`; `markRefundCompleted`; `markRefundLocalRecorded` | `test/shared/payment/refund-authority-state.test.ts`; `test/shared/payment/refund-authority/state.test.ts`; `test/shared/provider-refunds/state/contracts.test.ts`; `test/integration/refund-authority-architecture.test.ts` |
| Conflict, choice, and lifecycle | `RefundConflictDecisionSchema`; `refundConflictDecision`; `refundConflictNeedsProviderCheck`; `markRefundOwnerChoiceNeeded`; `mayReplaceRefundWithFreshEvidence`; `markRefundProviderConflict`; `refundOwnerChoices`; `resolveRefundOwnerChoice`; `refundEvidenceActionAllowed`; `refundLifecycleFor`; `refundAuthorityWorkSql`; `refundAuthorityPrunableSql`; `refundMoveRefusalOrNull` | `test/shared/payment/refund-authority-choice.test.ts`; `test/shared/payment/refund-authority-lifecycle.test.ts`; `test/shared/payment/refund-authority/state.test.ts`; `test/shared/provider-refunds/{state,target-conflict}.test.ts`; `test/integration/refund-authority-architecture.test.ts` |
| Provider permit and one engine | `ProviderRefundEvidence`; `ProviderRefundTarget`; `ProviderRefundResult`; `RefundAuthorityReceipt`; `ProviderRefundDependencies`; `RefundEngineProvider`; `loadRefundProvider`; `AuthorizedRefundRequest`; `authorizeDurableRefundSend`; `requireProviderRefundAuthorization`; `requestProviderRefunds`; `requestProviderRefund`; `recordProviderRefunds` | `test/shared/provider-refunds/{target,state,work,send,budget}.test.ts`; `test/shared/provider-refunds/send/outcomes.test.ts`; `test/shared/provider-refunds.test.ts`; `test/integration/refund-authority-architecture.test.ts` |
| Atomic authority storage | `prepareRefundAuthority`; `createOrLoadRefundAuthority`; `bindRefundCallbackIfChargeExists`; `transitionRefundAuthority`; `resolveRefundAuthorityMoney`; `completeRefundAuthority`; `markRefundAuthorityRecorded` | `test/shared/db/provider-refund-authority.test.ts`; `test/shared/provider-refunds/{target,state,work,send}.test.ts` |
| Callback and placeholder cutover | `requestSessionRefund`; `refundRejectedCharge`; `storeRefundedBooking`; `attendeePaymentProvenance`; `finishPlaceholderRefund`; `resumePlaceholderSession`; `completePlaceholderMoney`; `advanceSessionFailure`; `loadAnchorRowWork` | `test/features/api/payment-processing/refunds/{provider-result,rejected-charge}.test.ts`; `test/features/api/payment-processing/{index/refunds,store-refund,store-refund-authority,placeholder-resume}.test.ts`; `test/shared/db/processed-payments/outcome-advance.test.ts`; `test/shared/db/payment-anchor/held-work.test.ts`; `specs/payments/unreadable-payment-kept.feature`; callback integration suites under `test/integration/server/` |
| Admin claims and dispatch | `claimLeaseMs`; `checkingClaimFor`; `claimAttendeeRows`; `underAttendeeClaim`; `runRefundReadiness`; `rememberReadinessFailureFindings`; `requestReadyRefund`; `dispatchRefundBatch`; `refreshClaimedPayment`; `AuthorityBearingReference`; `recordedRefundAuthorities` | `test/shared/payment/claim.test.ts`; `test/shared/db/payment-claim/{admission,take,unrecorded-date}.test.ts`; `test/shared/db/payment-claim/take/shared-references.test.ts`; `test/features/admin/refunds/{claim,readiness-failure-evidence}.test.ts`; `test/features/admin/refunds/readiness-findings/authority-failure.test.ts`; `test/features/admin/refunds/dispatch/{budget-lifecycle,write-order}.test.ts` |
| Owner recovery | `ProviderRefundCase`; `listProviderRefundCases`; `loadProviderRefundCase`; `resolveProviderRefundCase` | `test/shared/db/provider-refund-cases{,-validation}.test.ts`; `test/shared/provider-refunds/state-owner-revision.test.ts`; `test/integration/server/privacy-refund-recovery{,-active-revision,-race}.test.ts`; `test/ui/templates/admin/provider-refund-cases.test.tsx`; `specs/payments/resolving-uncertain-refunds.feature` |
| Local work and destructive moves | `PaymentWorkStatus`; `PaymentRecoveryAction`; `PAYMENT_ROW_LIFECYCLE`; `paymentWorkFor`; `paymentWorkForMirrors`; `mirroredMoveRefusalOrNull`; `mirrorFor`; `PaymentMoveSnapshot`; `loadPaymentMoveSnapshot`; `assertRowsFreeToMove`; `PaymentRowsBusyError`; `ATTENDEE_DATA_RULES` | `test/shared/payment/admit-move.test.ts`; `test/shared/db/payment-admit-move.test.ts`; `test/features/admin/attendee-page/actions.test.ts`; `test/integration/server/attendees/delete.test.ts`; `test/shared/db/attendees/{dependent-data,delete}.test.ts`; `test/shared/db/prune/payments.test.ts`; `specs/payments/recovering-the-money-record.feature` |
| Confirmation and local review | `confirmRefund`; `insertRefundConfirmation`; `PAYMENT_REVIEW_RETIREMENT`; `getPaymentReviewState`; `acknowledgeCurrentPaymentReview` | `test/features/admin/refunds/confirmation.test.ts`; `test/shared/db/{refund-confirmations,payment-review}.test.ts`; `test/features/admin/attendee-payment-review.test.ts` |
| Exact Money repair | `refundMoneyReturned`; `refundMoneyAccountedFor`; `computeAttendeeRefunds`; `RefundLedgerResult`; `REFUND_LEDGER_BATCH_DATABASE_CALLS` | `test/shared/payment/resources.test.ts`; `test/shared/refund-ledger/plan/{partial,reference-placement,whole-account}.test.ts`; `test/shared/refund-ledger/record/batch.test.ts`; `test/features/admin/refunds/provider/batch/{ledger-findings,ledger}.test.ts` |
| Bounded command support | `refundReadinessSubrequestCost`; `refundPreparedSubrequestCost`; `REFUND_SETTLEMENT_SUBREQUEST_RESERVE`; `REFUND_LEDGER_SUBREQUEST_RESERVE`; `REFUND_OBSERVED_AUTHORITY_DATABASE_CALLS`; `REFUND_ACTIVE_AUTHORITY_DATABASE_CALLS`; `REFUND_TERMINAL_AUTHORITY_DATABASE_CALLS`; `REFUND_RETRY_HEADROOM_DATABASE_CALLS`; `REFUND_RESULT_DATABASE_RESERVE`; `withSubrequestReserve`; `withDeferredErrorReports`; `getRefundCandidates`; `getRefundAllSummary`; `loadRefundAllBatch` | `test/features/admin/refunds/{budget,claim-settlement,candidates}.test.ts`; `test/features/admin/refunds/provider/batch/budget.test.ts`; `test/features/admin/refunds/dispatch/budget-lifecycle.test.ts`; `test/features/admin/refunds/readiness-run/refresh-budget.test.ts`; `test/shared/provider-refunds/{budget,send}.test.ts`; `test/shared/subrequest-budget.test.ts`; `test/integration/logger/log-error.test.ts` |
| Private indexed references | `storePaymentReference`; `loadPaymentReference`; `prepareClaimedAttendeePaymentAnchor`; `attendeePaymentProvenance`; `getRefundPaymentReferences`; `MAX_REFUND_REFERENCES_PER_ATTENDEE` | `test/shared/db/payment-anchor/{attendee,reference}.test.ts`; `test/shared/db/payment-references{,/readiness,/storage}.test.ts`; `test/shared/db/{refund-all-candidates,attendees/create,attendees/pii}.test.ts`; `test/features/api/payment-processing/{store-refund,store-refund-authority}.test.ts`; `test/integration/refund-authority-architecture.test.ts` |
| Attendee refund controls | `refundWorkRemains`; `loadAttendeeForEdit`; `attendeePage` | `test/features/admin/attendee-page-data/{refund-actions,refunds-ui}.test.ts`; `test/features/admin/attendee-page/actions.test.ts`; `test/integration/server/attendees/payment.test.ts`; `specs/payments/checking-before-a-refund.feature` |
| Declared machines and their map | `MachineNode`; `MachineEvent`; `MachineMoves`; `movesIn`; `machineRep`; `factsAndStart`; `factsFromNode`; `nodeIdsWhere`; `derivedNodeIds`; `atlasMachineFrom`; `ROW_NODES`; `ROW_EVENTS`; `ROW_MOVES`; `rowNodeOf`; `rowWorkMirrorSql`; `REFUND_NODES`; `REFUND_EVENTS`; `REFUND_MOVES`; `refundNodeOf`; `refundNodeSendsMoney`; `REVIEW_NODES`; `reviewNodeOf`; `SCHEMA_ATLAS_MACHINES` | `test/shared/payment/{row,refund,review}-machine-spec.test.ts`; `test/shared/payment/{row,refund}-machine-spec/graph.test.ts`; `test/test-utils/machine-graph.{ts,test.ts}`; `test/ui/templates/admin/schema-atlas.test.tsx`; `test/integration/refund-authority-architecture.test.ts` |
| Machine seams and stored checks | `AuthorityFact`; `JointRowFact`; `ILLEGAL_JOINT_STATES`; `authorityFactOf`; `jointRowFactOf`; `illegalJointReasonOrNull`; `assertJointStateLegal`; `scanSchemaAnomalies`; `SchemaAnomaly`; `listUnansweredSumupMoney` | `test/shared/payment/joint-state.test.ts`; `test/shared/db/schema-anomaly-scan.test.ts`; `test/test-utils/joint-state.{ts,test.ts}` (the witness every crash manufacture runs); `test/test-utils/db-fault.ts` (real SQLite faults); `test/features/admin/schema-atlas/server.test.ts` |
- **Authority module ownership.** `payment/refund-authority-state.ts` owns the
one stored union, its parser, serializer, validation, and SQL mirrors.
`payment/refund-authority.ts` owns pure automatic transitions;
`payment/refund-authority-choice.ts` owns the explicit owner/conflict
transitions, legal-choice derivation, and the exhaustive
`mayReplaceRefundWithFreshEvidence` rule;
`payment/refund-conflict-decision.ts` turns fresh Money evidence into the
exact `not_sent | returned | wait` decision; and
`payment/refund-authority-lifecycle.ts` is the one exhaustive declaration of
each state's admitted evidence actions (`check_provider`, `observe_pending`,
and `replace_with_fresh_evidence`), blocker, clearer, choice requirement,
operator route, and pruning rule. Both the destructive-work SQL and its exact
pruning inverse derive from that declaration. The database identity and state
writers remain `db/provider-refund-authority.ts` and
`db/provider-refund-authority-change.ts`; owner decisions plus their activity
audit remain one transaction in `db/provider-refund-case-resolution.ts`. These
are parts of one authority, not interchangeable implementations.
- **Refund-only rules.** `src/shared/validation/kind.ts` and
`src/shared/payment/{resources,conflict,diagnose,refund,admit-refund}.ts`
define one charge's captured/refunded facts and one `refundOutcomeOf`
judgment. Its pure `PaymentConflict` result can name `refund_exceeds_capture`,
`partial_refund`, or `multiple_pending_refunds`; those are charge-judgment
values, not variants stored in the local `PaymentReviewReason` schema. Durable
disagreements use the canonical provider authority's `provider_conflict`
evidence. Only exact zero or full return admits a real owner decision in
`needs_owner_choice`; partial or inconclusive evidence lives in
`needs_provider_check` and can only be observed again. Both retire through the
one canonical Refund recovery route. `failed_refund` is deliberately absent:
an attempt that certainly moved no money may be retried, while a failure
reported beside returned money is diagnosed from the returned facts. The judge
never invents the signed checkout total or booking allocation that this slice
cannot read.
- **Reference identity and storage.** Migrations
`2026-08-10_refund_authority_records.ts`, `tables-attendees.ts`,
`db/payment-reference-store.ts`, `db/payment-reference-rows.ts`,
`db/payment-references.ts`,
`db/payment-anchor/{reference,session,attendee}.ts`, and
`db/attendees/payment-provenance.ts` keep a new raw reference under the owner
key and a DB-keyed blind equality index beside it. That index is deliberately
NOT unique: several attendee/payment rows may legitimately represent one
provider charge, and the claim must expand to all of them. `protected_state`
is only a plaintext state word. `getRefundPaymentReferences` accepts an
explicitly named, decrypted `currentPaymentId` and returns the exhaustive
`complete | legacy_unindexed | provider_unknown | too_many_references`
`RefundPaymentReferenceSet`; it never exposes only an unsafe subset. Its SQL
names only the selected attendee ids and retrieves at most the declared ten
references plus one proof of overflow per attendee. A non-empty historical
`processed_payments.payment_reference` with a blank `payment_reference_index`,
or a current PII payment id absent from that attendee's indexed identities,
makes the attendee `legacy_unindexed` before provider I/O. An eleventh
reference makes it `too_many_references` before decryption or provider I/O. An
indexed reference without an authenticated provider tag makes it
`provider_unknown`. Once any refusal is proved, the attendee cannot reach
provider I/O; unindexed and oversized sets are also refused before their
reference bodies are decrypted.
`loadPaymentReference` requires owner-key `hyb:1` ciphertext and throws at the
storage boundary for a raw or DB-key value. There is no live plaintext
decoder. Historical rows with no usable owner-encrypted index stay unavailable
to in-app refunds until the owner rebuilds them at
`/admin/migrate/rebuild-payment-references`. The page lists the rows one run
would rewrite, because a site can change provider and the stored row never
says which. The action decrypts each stored reference with the request's
private key. It tags each reference with the provider the owner states for
that row, and an untagged row with no stated provider fails the run. It writes
the encrypted value and its blind index back. Guarded conditional writes stop
a replayed or concurrent run from overwriting a row another path has indexed.
The action writes the attendee provenance pointer only for the row whose
reference equals the payment id in that attendee's PII blob - the comparison a
booking finalize makes - and reports the rows that left their attendee
unqualified, so those attendees stay refused rather than being offered an
action the refund path would reject. The rebuild contacts no payment provider
and moves no money. Outside that action there is no population decrypt, no
request-time recovery, no dual read, and no compatibility writer. Neither
attendee save nor merge backfills them. A payment stored before the reference
column existed has no row to rebuild. Those payments stay on the manual
provider-refund path.
`attendees.pii_payment_session_id` makes that boundary queryable without
opening PII. `NULL` means historical or otherwise unqualified; `''` proves the
encrypted PII had no payment id; a non-empty value names the exact
`processed_payments.payment_session_id` whose owner-encrypted, blind-indexed
reference proves the PII id. A normal paid booking writes the pointer in its
existing all-or-nothing booking transaction. A callback placeholder writes it
through `attendeePaymentProvenance` in the placeholder transaction. A later
balance payment never changes the pointer, so it cannot make an older PII-only
deposit look complete. Merge propagates a source `NULL` to the target in the
same merge transaction, so deleting the source cannot erase evidence of hidden
old history; a source `''` or non-empty qualified pointer leaves the target's
provenance unchanged. Pruning retains the exact processed row named by a
non-empty pointer. The PII-free Refund All summary treats `NULL` as
`legacy_unindexed`, even beside a newer indexed balance, and refuses before
selecting, decrypting, or calling a provider.
`prepareClaimedAttendeePaymentAnchor` accepts only a provider-tagged identity
and is called only by the validated callback placeholder transaction. It
prepares the owner-encrypted anchor and its canonical `PaymentRowState`
`checking` claim as one insert; the attendee, booking rows, indexed reference,
destructive-write fence, and conservative original-session outcome therefore
commit atomically. It cannot be called by attendee save or merge; the
architecture test pins that sole production caller. Distinct old deposit,
balance, merge, session, and PII-only references remain unsupported by the
current in-app refund path. `legacy_unindexed` and `provider_unknown` are
derived historical-data refusals, not runtime compatibility paths or persisted
work, and neither acknowledgement nor a generic clear retires them. Historical
application behavior never assigned one payment ID to separate attendees, so
this path does not scan or decrypt unrelated attendee PII for a hypothetical
old sharing case; indexed representations and merges still expand by blind
identity.
- **Provider ownership.** New checkout, callback, and placeholder writes carry
the provider inside the owner-encrypted reference. Admin admission accepts
only that provider-tagged identity. An untagged indexed reference returns
`provider_unknown` before loading any provider; it is never searched for,
inferred from current configuration, or bound by a runtime repair. The old
provider-discovery and binding modules were deleted with their callers and
tests, and the architecture gate rejects their return. Historical returned
markers do not attest a provider or amount. A tagged provider is
authoritative, not a search hint: if it is unconfigured or its read is
missing, unavailable, or invalid, the row stops there and no other adapter is
tried. Every refund-facing adapter load receives the complete
`TaggedPaymentReference` and goes through `loadRefundProvider`, which loads
only `reference.provider` and verifies that the returned adapter matches the
tag. Refund code cannot accept a bare provider type, consult current/last
configuration, enumerate stored credentials, call raw `loadPaymentProvider`,
or import provider adapters directly. Readiness, capability, and dispatch
remain per reference, so one merged attendee may safely carry payments from
different providers. `provider_unknown` is a bounded typed limitation, not
recoverable work. The attendee page explains that the old row did not record
its provider and does not render a Refresh form whose route can only refuse;
Single Refund, Refund All admission, and a direct Refresh request all remain
fail-closed with zero provider calls. The old ambient provider-to-dashboard
guess was deleted too: an untagged payment id is inert display text, with no
provider dashboard link or external-link target even when one provider is
currently configured. The owner handles that historical payment in the
provider dashboard; current refund code never qualifies it.
- **Provider boundary.**
`payment/{provider-read,provider-failures,refund-attempt,refund-network}.ts`
and the Stripe, Square, and SumUp adapters replace nullable reads and booleans
with `found | missing | unavailable | invalid` and
`completed | accepted | rejected | not_sent | uncertain`. Stripe and Square
send the exact captured minor units and currency admitted from the read. SumUp
exposes only a full-refund-by-transaction operation, so the exact admitted
transaction is its send boundary rather than an amount argument. Stripe checks
`captured`, `paid`, and `status`, then derives Money from the documented
`amount_captured`, `currency`, and `amount_refunded` fields; the provider's
boolean `refunded` flag is never enough. Square validates payments, refunds,
and orders at the wire boundary and treats only an explicit 404 as missing.
Its payment webhook admits exactly `APPROVED`, `PENDING`, `COMPLETED`,
`CANCELED`, and `FAILED`: a missing, non-text, empty, or unknown status throws
at the boundary; a known non-completed status is the only status acknowledged
without processing; and `COMPLETED` must also carry its Order id. The shared
`isSquarePaymentStatus` declaration and
`test/shared/square-provider/webhook-fields.test.ts` pin that set, so a new
provider word cannot silently become a safe skip. SumUp never turns a missing
proof into a full return: a top-level `REFUNDED` transaction without at least
one refund event is invalid `missing_documented_resource`. A `SUCCESSFUL`
transaction with an absent or empty event list means zero returned. Every
present event must name a known type, a chargeback invalidates the read, and
every refund event must have a supported status plus a valid amount in the
transaction currency.
`providerFailure` gives every adapter the same read-side transport table: 404
is `missing`; 400/422 is invalid `rejected_request`; 408/504 is unavailable
`timeout`; 429 is unavailable `rate_limited`; every other HTTP failure is
unavailable `provider_error`; a malformed answer is invalid; and a connection
failure is unavailable. An ambiguous send gets one bounded reread, never an
adapter loop. SumUp also treats a rejected response as local evidence about
that request, not proof that no refund happened beside it. Every SumUp result
except `not_sent` takes exactly one fresh charge read. Its rejection goes
through the same `refundOutcomeAfterReread` judgment Stripe and Square use: a
fully returned charge completes from that fresh observation, and only a fresh
`found` charge that is still `ready` preserves rejection. Pending or
conflicting money becomes `uncertain: observed_refund`; mismatched captured
money becomes `uncertain: mismatched_money`; a missing read becomes
`uncertain: missing_documented_resource`; and an unavailable or invalid read
becomes uncertain with that read's exact reason. None invent completion, and
none lets a request-local rejection release the claim as conclusive when the
fresh charge itself is unreadable.
`test/shared/sumup/{transaction,provider-money}.test.ts` and
`test/shared/payment/refund-attempt.test.ts` pin the wire meaning, one-read
count, and this evidence table.
- **Canonical authority storage.**
`db/migrations/2026-08-10_refund_authority_records.ts` rebuilds the previously
unwritten `payment_charges` table only after proving it empty; a populated
table makes deployment fail rather than reinterpreting unknown data. Its
schema stores provider, owner-key-encrypted reference, blind reference and
callback replay identities, exact captured/refunded Money, provider
capability, one validated state document, its SQL mirrors, next due time, and
revision. Reference and callback identities are each globally unique. The
JSON/mirror checks make a state disagreeing with its indexed work columns
unstoreable. Exactly two modules form its one writer boundary:
`db/provider-refund-authority.ts` creates identities and binds callback
evidence, while `db/provider-refund-authority-change.ts` owns every Money and
state transition as one revision-conditional statement. The architecture test
rejects any third writer. `db/provider-refund-cases.ts` owns bounded case
reads and decrypts the reference only for the owner-only detail page;
`db/provider-refund-case-resolution.ts` applies the exact decision and writes
its activity audit in one transaction.
- **Exact claims and retry capability.**
`payment/{row-state,claim,review,admit-move}.ts`,
`db/payment-claim{,/scope,/take}.ts`, and
`features/admin/refunds/{claim,dispatch,authority}.ts` admit only a complete
indexed attendee set. Inside the claim transaction, the command rereads every
non-empty reference row owned by the selected attendees, including blank-index
rows, plus indexed sharing rows. That expansion is hard-bounded:
`MAX_SHARED_PAYMENT_ROWS_PER_CLAIM` admits at most 100 rows outside the
selected attendee set, and the SQL reads at most 101 so the last row is only
an overflow sentinel. Seeing it returns the typed `too_many_reference_holders`
refusal before any sharing row's encrypted `failure_data` is decrypted, before
a claim write, and before provider or ledger I/O. The refund and Refresh
surfaces map that finite result to exact operator copy; they never truncate
the sharing set. A blank-index row present at initial load yields
`legacy_unindexed`; an indexed but untagged reference yields
`provider_unknown`; and a row that appears between load and claim makes the
exact row-set comparison return `changed`. None can reach the provider
authority. A blank index is a separator, never an equality value: it is
omitted from the sharing lookup, two blank indexes never make rows holders of
one charge, and a selected set with no non-empty indexes skips the sharing
query entirely. The transaction also fences each attendee's exact `pii_blob`
revision and exact `(attendee, session, reference index)` set. Any PII
revision change or payment-row addition, deletion, or reassignment between
load and claim returns `changed` for the whole command; no row receives a
partial claim. The claim expands matching indexed representations and
preserves the initiating attendee scope after a merge. `processed_payments`
holds only this short-lived `checking` fence; it stores no provider send
phase, capability, or completion projection. `checkingClaimFor` is the one
constructor for that fence, used by both ordinary claim acquisition and the
automatic placeholder anchor. There is no second hand-built claim shape for
callback recovery.
`test/shared/db/payment-claim/take/shared-references.test.ts` proves the
blank-index separator and zero-index-query rules, proves the 100-row edge is
accepted, and proves 101 is refused without decrypting even deliberately
corrupt shared state. `test/shared/db/payment-claim/{admission,take}.test.ts`
prove the exact row-set and attendee-revision fence. The readiness admission
regression proves the typed refusal remains finite at the feature boundary.
`payment/refund-{authority,authority-state,authority-choice,authority-lifecycle,conflict-decision,provider-authorization,replay-window,request-identity}.ts`,
`db/provider-refund-authority.ts`, and `shared/provider-refunds.ts` and
`shared/provider-refunds/{target,send,state,work,budget}.ts` own the
irreversible lifecycle. One globally unique blind reference index identifies
the charge, one unique callback index deduplicates callback delivery, and
every transition compares an exact revision. `ready` means evidence permits
one attempt; `send_armed` means that exact generation may have crossed the
network boundary; `observing` waits for fresh evidence; `completed` separately
tracks provider return and local Money recording; `needs_owner_choice` means
the stored evidence schema admits at least one specific owner answer; and
`needs_provider_check` means it admits none and only another observation may
advance it. Both attention states block sends and destructive deletion. An
observe-only check may retire an ordinary ambiguity when fresh provider
evidence proves the money fully returned. Fresh partial, invalid, backward,
wrong-currency, excessive, or pending evidence instead replaces that ordinary
ambiguity with `needs_provider_check`, advances the authority revision, and
preserves or raises the proved returned-Money floor. It therefore invalidates
a stale “not sent” form rather than letting an old guess erase newer evidence.
Identical provider-check evidence is the only recheck that preserves the
current revision. Any changed returned amount or changed conclusion advances
it, including evidence that moves backwards and therefore becomes `wait`. A
conclusive `provider_conflict` owner-choice revision is immutable under later
reads. Stripe and Square declare `keyed`; SumUp declares `keyless`. Keyed
replay uses the same identity only inside its finite provider window. Keyless
work never automatically resends an armed generation. A fresh `accepted`
result stays `observing` until provider evidence proves the return; an
idempotency key is not settlement proof.
`ProviderRefundTarget` makes send versus observation explicit. A clean
observe-only read with no existing authority returns `unchanged`; it does not
invent durable work. Once an authority exists, callback and admin entry points
both reconcile that same row through the exhaustive `changed`,
`needs_owner_choice`, `needs_provider_check`, `pending`, `ready`, `returned`,
`unchanged`, or `withheld` result. `unchanged` is possible only for a clean
observe-only probe with no authority to create; `changed` is the explicit lost
revision result.
A `ready` authority cannot remain a dead Send control when its provider read
is unreadable. `missing` or `invalid` evidence moves it immediately to
`needs_owner_choice/provider_unreadable`; `unavailable` gets one finite
five-minute grace from `readyAt`, then the same transition if it still cannot
be read. Every such attempt makes zero provider refund sends. The owner page
explains that no refund was sent and requires the ordinary explicit
provider-returned/provider-not-sent choice; it never offers a generic clear or
a second send path.
Only `shared/provider-refunds/send.ts` can mint `AuthorizedRefundRequest`, and
every provider adapter requires that permit. The architecture test rejects any
second mint import, adapter/API caller, `payment_charges` writer, legacy
refund path, or live read/write of `provider_refunded_at`. It also rejects
current/last provider selectors, `existingPaymentProviderState`, the deleted
`orderedCredentialedPaymentProviderTypes`, raw `loadPaymentProvider`, and
direct adapter imports in refund-facing code. `loadRefundProvider` has an
exact production allowlist: admin refund readiness and the provider-refund
authority. Attendee-claim settlement still matches the exact command id, lease
time, and `checking` phase, so a stalled predecessor cannot release a
successor's fence.
`paymentRowStateStatement` writes the encrypted `failure_data` record and its
derived plaintext mirror in ONE conditional statement, matching the exact
prior `failure_data`. `PaymentRowSettlement` requires an explicit claim
keep/release decision and changes only the optional books/review facts the run
proved; an absent decision never clears an older repair target, review, or
terminal outcome. A CAS loser changes nothing and cannot overwrite a
successor. Paid-balance completion uses
`db/payment-finalize.ts:balanceFinalizeStatements`, whose transaction guard
aborts while an admin refund claim holds that attendee; the callback retries
instead of racing a booking-finalization write against money leaving.
- **Bounded orchestration.**
`features/admin/refunds/{attempt,budget,candidates,claim,dispatch,provider,provider-requests,readiness-run,waves}.ts`,
`subrequest-budget.ts`, and `db/client.ts` price physical provider retries,
database retries, rollback, settlement, and caller tails at several
checkpoints before fresh provider I/O. The readiness budget prices every
active tagged reference from its stored provider identity. It has no
configured-provider argument and never assigns zero cost because credentials
appear absent; ambient settings cannot change refund admission. A selected
command that cannot fit refuses without narrowing or sending. Dispatch
reserves the exact post-arm send, bounded reread, and returned-money recording
allowance around the arm transaction itself. Claim acquisition runs inside the
settlement reserve; it is not counted a second time in the plan. An arm retry
therefore cannot consume the permission it is about to persist: exhaustion
before commit is proved `not_sent`, while a durable keyless `send_armed` phase
is reserved for a genuinely uncertain dispatch. The engine's declared database
envelopes price each statement once: one call for an already-known terminal
authority, four for an observation that creates durable work, five for an
active send, plus one shared retry ladder per admission plan for the odd
contended statement. Pricing every statement at every physical attempt refused
ordinary one- and two-payment refunds under real route spend, so admission no
longer does that; a run that outlives its shared headroom stops at the
subrequest guard and finishes through the durable recovery states. The
reserves guarding irreversible windows still price every physical attempt:
eight calls held while a provider call is in flight, and eight for settlement.
Returned-work retirement is not pre-reserved: a completed return whose local
books are due is canonical recoverable work, not an unsafe partial send. A
deposit-and-balance pair now fits from the request boundary; five
independently retryable sends, or eight observations, still refuse before
either provider read. Provider calls overlap by at most five. One attendee may
still carry up to ten references, so even the one-attendee Refund All batch
can refuse safely before its first provider call.
`logger.ts:withDeferredErrorReports` queues non-critical activity,
notification, and Sentry fan-out until the money-critical command finishes,
and flushes the queue even on a throw, so diagnostics cannot consume the
subrequests reserved for settlement or rollback. Nested scopes share one outer
flush boundary, and independent or overlapping errors each persist; only
recursive reporting caused by that persistence attempt is suppressed
(`test/integration/logger/log-error.test.ts`).
- **Refund All admission.** `db/refund-all-candidates.ts` first computes a
PII-free whole-listing count and detects any visible `review`, `unrecorded`,
or incomplete historical-payment blocker among that same complete refundable
set. Its booking CTE requires the event-group-scoped ledger sale and payment
facts, so a paid booking with no reference-bearing processed-payment row
contributes a `legacy_unindexed` blocker without reading its PII; a free
booking or an abandoned checkout with only a sale contributes nothing.
Together those facts prove only that the booking took money, not its raw
provider reference or provider ownership. Settled non-candidates keep their
own protection and repair state but cannot strand an unrelated refund. The
summary runs before selection, so any SQL-visible blocker on a refundable
candidate refuses the whole command. The GET/summary route decrypts zero
attendee PII blobs. The POST batch loads at most one encrypted blob, with
existing claims first, and decrypts zero when the summary is blocked or empty
and exactly one otherwise. It never decrypts an attendee array and then slices
it. Typed candidate admission catches a current PII payment id with no indexed
identity and a row that appears after the summary; an incomplete attendee
rejects the request before provider I/O. That historical reference remains a
manual provider-dashboard refund; no later milestone is expected to qualify it
for this command. The selected attendee passes through the same claim and
budget admission as a single refund.
`features/admin/refunds/candidates.ts:getRefundCandidates` drops quantity-zero
rows and deduplicates by attendee before loading references, so several
booking rows for one person consume one place, one tally, and one
orchestration run. One submission retires at most one person and reports the
remaining count; another submission takes the next person. This conservative
batch size follows the proved Bunny envelope for one attendee's complete
reference set; the provider overlap ceiling remains five within that attendee.
M7 still owns a durable cursor/job that remembers and resumes the operator's
whole-listing intention after a crash.
- **Money settlement.** `shared/accounting/{queries,store}.ts`,
`shared/refund-ledger/{plan,result,record,log}.ts`, and
`features/admin/refunds/{ledger-findings,result-findings,provider,claim,refresh}.ts`
carry exact `recorded`, `unrecorded`, and `review` findings back to each held
reference and settle each attendee independently, so doubt for one person
cannot hold proved-finished neighbours. A provider-confirmed return is
conservatively `unrecorded` until the ledger disproves it. Every returned
observation enters the settlement findings before the fallible canonical
authority write. If that write fails for the same charge or a sibling, all
returns already proved in the wave remain exact `unrecorded` work; the
authority error still propagates. If another provider read fails, successful
sibling observations are likewise not discarded: returned money remains exact
`unrecorded` work, in-flight money retains doubt, a contradiction becomes
review, and ambiguous provider identity beside money movement stays
`in_doubt`. An unexpected preparation throw likewise keeps every
still-unproved row in doubt. `underAttendeeClaim` attempts settlement on both
the returned and thrown paths; it never promises that settlement itself cannot
fail. A failed settlement may retain the claim and is propagated when work
otherwise succeeded.
A ledger throw first turns every provider-confirmed return into exact
`unrecorded` work and keeps every still-unproved row in doubt, then attempts
settlement before propagating the original error. A secondary settlement
failure is reported without replacing that root failure, and a successful
return is not exposed to the caller until settlement lands. Accumulated
evidence — never control-flow optimism — decides what may release. Repeated
repair preserves the first `unrecorded.returnedAt` date
(`test/shared/db/payment-claim/unrecorded-date.test.ts`). The batch ledger
uses one bounded snapshot: a stored conflict parks only its attendee, while a
database failure marks every unproved plan unrecorded. The bound is
executable, not aspirational: `REFUND_LEDGER_BATCH_DATABASE_CALLS` is four,
and `test/shared/refund-ledger/record/batch.test.ts` proves thirty returned
attendees still take exactly those four database round trips.
`test/features/admin/refunds/readiness-findings/authority-failure.test.ts`
pins the before-authority ordering for one returned charge and returned
siblings.
- **Confirmation and review.** `features/admin/refunds/confirmation.ts`,
`db/refund-confirmations.ts`, `db/payment-review.ts`,
`db/notes/{queries,types}.ts`, and migration
`2026-08-10_refund_authority_records.ts` store one replay-safe confirmation
keyed by the attendee and sorted provider-aware blind indexes. The
confirmation, held-row assertion, generic activity, and optional named
confirmation note commit together. Confirmation never scans, decrypts, or
deletes historical note history, including for an old anchor. Old
manual-refund warnings are compatibility display history only: a successful
indexed confirmation leaves them visible beside the authoritative
confirmation, and the owner may delete each note through the ordinary note
action. This deliberately trades automatic cleanup of old display history for
constant request work; do not restore a history scan to tidy it.
Local `PaymentReviewReason` stores only `shared_reference` and
`partially_returned_obligation`. Acknowledgement stamps the exact case
revision to record that the owner saw it; it does not clear the marker,
authorize a refund, or decide an allocation. The evidence-owned clearer is
exhaustive: `shared_reference` retires only after one unique indexed
representation is proved, and `partially_returned_obligation` only after every
exact reference is returned and recorded.
Provider uncertainty and charge conflicts never enter that local review
schema. `provider-refund-cases.ts` exposes the canonical authority's own
revision. `payment/refund-authority-choice.ts` derives the only legal action
from the exact stored evidence: ordinary uncertainty offers returned or not
sent; an exact zero-return conflict offers only not sent; an exact full-return
conflict offers only returned; and a partial return or invalid, backward,
wrong-currency, excessive, or pending evidence offers no money answer and can
only be checked again. The stored union makes those last cases
`needs_provider_check`, while `needs_owner_choice` is constructible only with
a non-empty legal choice set. A fresh partial or inconclusive reading replaces
an ordinary ambiguous choice instead of leaving a stale not-sent answer
available. `money_recorded` is the separate proof that the local ledger caught
up. There is no generic clear. `PaymentWorkStatus` is exactly
`clear | moving | needs_money_record |
needs_provider_recovery | needs_review`.
A live claim comes first, then an explicit owner review, then mechanical
ledger repair, then canonical provider recovery. In particular, a generic
repair action cannot hide the decision it is waiting for. The local-review
form HMAC binds the complete sorted `[sessionId, caseId, reason]` set; its
transaction rereads that set, stamps only unacknowledged cases, and writes one
activity entry. Any claim blocks acknowledgement, regardless of age or phase.
Managers can neither acknowledge review nor send refunds, in the rendered UI
or at GET/POST authorization boundaries. Refresh intentionally remains
available to an authenticated manager because it observes and repairs existing
work but has no provider-send permit. The current pending copy says both “Do
not send the refund again” and “Refresh payment status after it completes”; it
never labels a resend as the recovery action.
- **Canonical Refund recovery.** `db/provider-refund-cases.ts`,
`features/admin/privacy.ts`, and
`ui/templates/admin/provider-refund-cases.tsx` derive the owner queue directly
from `payment_charges`; there is no copied review or warning-note index. The
queue keyset-pages twenty summaries without decrypting references. Only its
owner-only detail opens the reference. `ready` renders one clearly marked Send
action; `send_armed` and `observing` render observation only; `completed/due`
requires the separate Money-recorded confirmation; `needs_owner_choice`
renders only the answer justified by the exact conflict table above; and
`needs_provider_check` renders only Check again, never a returned/not-sent
choice. Stored Money is displayed with its own currency divisor, symbol, and
code, never the site's current currency. A not-sent answer increments the
generation and leaves `ready` work; saving the choice itself sends nothing.
Every form binds the exact id and revision. The server reloads the current
evidence, rederives its legal choices, and rejects a crafted submitted answer
that the evidence does not admit; the authority stays unchanged. A ready Send
carries the bound pair into authority admission and loses before provider I/O
if the inspected revision changed. The same fence covers an active transition:
a form rendered for revision 1 cannot check or send after revision 2 has
become `send_armed`. An owner money decision compares its pair inside the
transaction that changes the authority and writes its finite activity audit.
Check again is observe-only: its POST first refuses a stale submitted
revision, and any concurrent change after the read loses the transition CAS
and is reread instead of overwriting newer evidence. An audit failure rolls an
owner decision back. The HTTP and state-level races are pinned by
`test/integration/server/privacy-refund-recovery{,-active-revision,-race}.test.ts`,
`test/shared/db/provider-refund-cases-validation.test.ts`, and
`test/shared/provider-refunds/state-owner-revision.test.ts`.
`refund-authority-lifecycle.ts` exhaustively declares admitted evidence
actions, the clearer, whether a choice is required, the real route, pruning,
and merge/delete policy for every state. The architecture test proves every
declared clearer is exported and every route is registered; the Cucumber story
proves each rendered control reaches the real exit.
- **Review retirement and reachable repair.** `payment/admit-move.ts` has one
exhaustive `PAYMENT_ROW_LIFECYCLE` entry for every non-settled
`PaymentRowState` field; the winning entry supplies its mirror, status,
recovery action, refusal, and delete/merge behavior. The recovery action is
constrained to the real attendee-action schema. `db/payment-admit-move.ts`
turns those row mirrors plus canonical charge work into one
`PaymentMoveSnapshot`: a `delete | merge` admission record and the
operator-facing `PaymentWork`. Both page rendering and transactional writers
consume that same decision. The attendee Actions page therefore does not offer
Delete to either an owner or a manager while payment work blocks it. A direct
GET of the delete URL returns 400 and renders the reason without a
confirmation form. Claims block both delete and merge; review, unrecorded
Money, and canonical provider work block deletion but remain mergeable because
their indexed row moves and the global charge authority does not.
`features/admin/{attendee-page,attendee-page-data,attendee-payment-review,attendees-route-helpers}.ts`
`ui/templates/admin/attendees.tsx`, and
`ui/templates/admin/attendees/delete-confirm.tsx` render from authoritative
indexed payment work, not legacy PII `payment_id`, so a merge cannot hide the
only Refresh form. The per-attendee Refund action is admitted from that same
complete reference set, `refundWorkRemains`, and an active booking line. It is
absent when no tagged automatic payment exists, no active booking remains, or
the exact authority says the cash work is finished; a legacy PII id or paid
display value cannot make the action appear. These rules are pinned by
`test/features/admin/attendee-page-data/refund-actions.test.ts`,
`test/features/admin/attendee-page-data/refunds-ui.test.ts`,
`test/features/admin/attendee-page/actions.test.ts`,
`test/integration/server/attendees/{delete,payment}.test.ts`, and the real
visitor journey in `specs/payments/recovering-the-money-record.feature`. Clean
evidence retires only the exact local review it proves gone.
`PAYMENT_REVIEW_RETIREMENT` is exhaustive over its two stored reasons:
`shared_reference` needs one unique indexed representation, while
`partially_returned_obligation` needs every exact reference returned and
recorded. `partial_refund`, `refund_exceeds_capture`,
`multiple_pending_refunds`, and uncertain keyless sends live only in the
canonical provider authority and retire through Refund recovery. They are
never copied into a parallel local-review lifecycle. Acknowledgement retires
neither local reason.
- **Every destructive consumer.** `db/payment-admit-move.ts`,
`db/attendees/dependent-data.ts`, `db/attendees/delete.ts`, `db/prune.ts`,
`merge/attendee-merge.ts`, and `db/orphan-attendees.ts` make claims block
merge and delete, while review and unrecorded money block delete and travel
with a merge. The same admission joins matching `payment_charges`: every
unfinished generation, owner choice, or completed return whose Money is due
blocks deletion. Merge is relocation rather than destruction, so it may move
the indexed processed reference while the globally unique charge authority
stays in place; merge never copies or reparents that authority. The surviving
attendee therefore reaches the same unresolved row and its real recovery
route. The preflight is guidance only: merge and delete repeat the same
admission on their own write transaction, so a claim that arrives after the
page rendered still wins and rolls the destructive write back.
The two lifecycle-derived plaintext mirrors gate prune and orphan purge. Empty
local-row work permits a referenced payment row to age out only when that
row's exact blind reference index matches a prunable canonical charge
authority. An attendee-wide `refund_cash` leg and a returned sibling charge
are never deletion authority; an ambiguous legacy reference with no exact
canonical match stays retained. This preserves ordinary retention for the
exact settled row without deleting another charge that is still paid.
Conversely, any non-empty `protected_state` or non-prunable refund-authority
state prevents pruning however old or stale it is: staleness can make recovery
possible, but never makes a safety record disposable
(`test/shared/db/prune/payments.test.ts`).
Merge admits both source and target inside the transaction, because the source
rows move and the target set grows. It relocates only reference-bearing rows
already admitted by that transaction; it never mints a row from either
attendee's PII payment id. This prevents both a false `shared_reference` case
and a provider-less identity from becoming live authority.
`ATTENDEE_DATA_RULES` exhaustively declares delete, repoint, or retention for
every attendee-linked table, and production delete statements derive from it.
Its schema tests walk the live database, reject undeclared attendee-id-like
columns and payment tables, require every named column to exist, require
children before parents, and prove production emits the declared operations
(`test/shared/db/attendees/{dependent-data,delete}.test.ts`).
Protected orphans are excluded from both scheduled and manual purge. The
owner-only “Outstanding payment work” queue at `/admin/privacy` starts at the
partial state index, selects distinct attendee ids, and keyset-pages twenty
plus one lookahead without loading or decrypting attendee PII. It links by
attendee id to the still-live attendee page. Review and Refresh are
attendee-scoped, so their route and rendered control remain usable after the
final listing is deleted (`test/integration/server/privacy.test.ts`,
`test/features/admin/attendee-page/actions.test.ts`, and
`test/features/admin/attendee-payment-review.test.ts`). Canonical provider
work has its own “Refund recovery” list on the same owner page and links by
authority id, so it remains reachable even when the listing no longer exists.
- **Migration and restore parity.** Migration
`2026-08-10_refund_authority_records.ts` installs the partial
`processed_payments(attendee_id) WHERE protected_state != ''` recovery-queue
index. `schema-sync.ts` emits partial predicates and treats columns used only
by a predicate as dependencies; historical restore support finds the same
dependencies from `sqlite_master.sql`. Fresh installs, upgrades, and
old-schema restores therefore converge on the same bounded index.
- **Fail-closed booking/provider errors.** `payment/checkout-failure.ts` exposes
only provider, a finite reason, and an optional numeric status; Stripe maps it
in `stripe/runtime.ts`, SumUp in `sumup.ts`, and Square in
`square/checkout.ts`. Recognized provider/network/timeout/invalid-response
failures carry no raw body, message, cause, request id, session id, or buyer
contact into retained diagnostics. Unknown application errors keep their
identity and propagate. `classifySessionIntent` distinguishes `ready`,
foreign/unverifiable input, and unreadable trusted input: a valid price proof
with a malformed booking intent, a provider network/API failure, or a
malformed non-null checkout response fails before reservation, booking, or
refund and returns a retryable 503 where appropriate. Only expected
`PaymentUserError` input becomes a visitor-facing refusal; Square's
buyer-correctable email/phone validation remains in that class. A completed
Square callback whose ticket-shaped metadata omits or corrupts `price_proof`
is unreadable trusted input and remains retryable; it is never acknowledged as
foreign. For that early Square malformed-versus-foreign distinction, any
non-empty `_origin` remains an application marker across a hostname change; it
is not compared with today's domain. It is not ownership proof — only the
signed `price_proof` supplies that — but it prevents an older app checkout
with damaged signed metadata from being terminally acknowledged as an
unrelated order. `processed-payments.ts:parseSessionFailure` likewise lets
decryption or schema corruption throw at the stored-data boundary instead of
inventing a generic handled failure. The booking integration tests pin thrown
database and application errors, malformed signed intents, the hostname-change
marker, and the real Square webhook cases so none can become a silent success
response.
- **Recoverable automatic placeholders.**
`db/payment-anchor/{reference,attendee,session}.ts`,
`db/attendees/{create,create-batch,payment-provenance}.ts`,
`db/provider-refund-authority.ts`, and
`features/api/payment-processing/{refunds,store-refund}.ts` prepare the paid
session's provider-tagged reference, ready refund authority, and conservative
terminal result before any provider refund I/O.
`prepareClaimedAttendeePaymentAnchor` derives the same canonical `checking`
claim as admin work through `checkingClaimFor`; `prepareRefundAuthority`
prepares the same `payment_charges` insert used by ordinary refund admission.
One `createAttendeeAtomic` transaction then writes the quantity-zero attendee,
every booking row, the owner-public-key-encrypted reference and blind index
under a synthetic `legacy:` anchor, the exact `PaymentRowState` claim, the
attendee's non-empty provenance pointer, the `ready` charge authority, and the
terminal result on the original payment session. The prepared statements run
as one batch, and every expected write is checked. Failure to write any member
rolls the attendee, bookings, terminal result, provenance, anchor, and
authority back together. There is no committed state in which the callback is
terminal but the refund has no durable recovery authority. The synthetic
anchor attests only that this attendee owns that tagged reference: it is not a
sent attempt or provider completion.
After commit, `requestSessionRefund` passes the already-validated callback's
captured Money to `requestProviderRefund`, which loads and advances that same
ready authority before a fresh provider read; it does not create a second
authority or refund path. The read still gates every send. An unavailable read
therefore leaves reachable canonical work instead of a note that deletion can
erase; a read whose captured Money disagrees becomes a durable
`provider_conflict` owner choice. The quantity-zero attendee cannot be deleted
or pruned while that authority is active. It can be merged once its
short-lived claim settles because the authority and indexed reference travel
safely. The explanatory system note contains only the finite placeholder
reason and is never a safety authority, named work item, or clearing
mechanism. No warning-note refund path remains.
`storeRefundedBooking` releases the anchor claim only after the provider
result, placeholder ledger post, any canonical-authority Money recording,
refund activity, and explanatory note have all finished. A throw before that
point leaves the atomically minted claim protecting the attendee; it cannot
expose an unclaimed deletion window between attendee creation and recovery
work. Settlement uses the prepared exact command, timestamp, session, and
`checking` phase, so it cannot release a successor's claim. Only after that
settlement does `sessionFailure.replace` compare-and-set the conservative
terminal outcome to the exact final result; it cannot overwrite a racing or
unrelated outcome.
The terminal outcome and ready recovery authority already exist when
placeholder creation commits. If the caller loses that committed reply, a
redelivery replays the same attendee, indexed anchor, and authority without a
second create or provider send. The indexed anchor makes the attendee's real
Payment Details render immediately; canonical recovery lives in the owner-only
Refund recovery queue, and no attendee save or population decrypt is needed.
This does not make the provider result or placeholder ledger post part of the
creation transaction. M7 still owns the original checkout's durable
handled/effect marker after `processed_payments` retention and the
placeholder-ledger replay-marker gap; it must attach that work to this
authority, never resurrect a legacy replay path or reinterpret the synthetic
anchor as the marker.
`test/features/api/payment-processing/{store-refund,store-refund-authority}.test.ts`
exercises all-row rollback, atomic terminal/anchor/provenance/authority
creation, tagged identity, recovery after a failure immediately after commit,
and deletion refusal while work remains.
`test/features/api/payment-processing/index/refunds.test.ts` exercises the
lost committed-reply replay.
- **Privacy-safe diagnostics.** `shared/payment-review.ts`,
`features/admin/refunds/report.ts`, `shared/refund-ledger/log.ts`, and
`shared/invariant-errors.ts` accept closed reasons and row/count context, not
arbitrary provider references, attendee names, or payment-session ids. The
safe description and caught exception travel separately, preserving the
original stack for Sentry without copying money identifiers into console,
notification, or retained activity text. Adding a raw identifier is therefore
a visible type-boundary change rather than an easy string interpolation.
`payment_charges` deliberately exposes provider, Money, state, revision, and
due timestamps to SQL, because the database must schedule and police the
lifecycle. Its raw provider reference is owner-key ciphertext; its reference,
callback, and request identities are DB-keyed one-way indexes. It stores no
buyer name, email, phone, address, attendee PII blob, raw callback session id,
provider response, or credentials. A database holder with `DB_ENCRYPTION_KEY`
can read the finite explanatory placeholder note and lifecycle metadata. With
a v2 password-wrapped owner key they cannot open new raw payment identifiers
or buyer PII. A dormant v1 owner wrap can expose the data key and site private
key from the same database and environment key, and provider credentials
encrypted with the DB key may allow provider-side queries; both remain the
explicit system-wide caveats above.
- **Visitor-level proof.** The Cucumber stories
`specs/payments/{checking-before-a-refund,waiting-for-a-refund,recovering-the-money-record,refunding-a-booking,refunding-from-two-windows,only-owners-refund,refunding-everyone-at-once,resolving-uncertain-refunds}.feature`
cover the pre-send check, a delayed provider result, repair of returned money
missing from the books, two-window races, owner-only controls, bounded Refund
All requests, whole-listing blocker admission, and per-request failure
isolation. `resolving-uncertain-refunds.feature` drives the real Privacy queue
through ready send, provider observation, required owner choice, and separate
Money recording. The stories submit the rendered forms and cross the real
provider and ledger boundaries. Refund-safety purchases use the public booking
page: Stripe follows the production completion route, while SumUp follows the
real checkout staging write and unsigned webhook handler. No story rewrites a
stored payment's provider to manufacture the state it tests. The focused
regressions exercise Delete disappearing while work remains and returning
after recovery, one old blank-index row stopping every Refund All send, and a
PII-only deposit remaining a blocker even beside a later indexed balance. The
final branch gate, rather than this plan, is the authority for pass counts.
Known limits are deliberate and remain visible. Part A protects old history by
refusing an incomplete or untagged selected attendee; it does not make that
history refundable and never decrypts or backfills an attendee population.
Attendee save, merge, and M11 do not mint payment rows from old PII. A
historical or unqualified attendee provenance remains a permanent fail-closed
boundary for current in-app refunds; the owner handles it directly in the
provider dashboard. A later indexed balance cannot erase that boundary. This is
a product choice, not deferred compatibility work: do not add a whole-table
scan, a re-save backfill, a migration-only PII refund reader, or a parallel
legacy refund engine. Refund All's PII-free summary can use booking ledger facts
and the provenance marker to detect and block the incomplete history, but it
cannot recover or provider-qualify the raw PII reference. Part A does not erase
historical plaintext references or old DB-key-encrypted warning notes. It does
not cut the buyer callback classifier over: `payment-processing/classify.ts`
still judges a paid session from its signed total and currency and does not read
`refundOutcomeOf` or charge-level refund evidence. A provider session that still
reads paid after its charge was externally returned can therefore still enter
the current booking completion path. The atomic cutover combines M6's
whole-checkout reader with M8's durable completion and deletes that displaced
writer before activation.
Before a provider-tagged refund target exists, the current checkout entry still
uses `shared/existing-payment-provider.ts` to choose the ambient current or last
provider. A provider switch can therefore strand or misroute an in-flight
checkout. A paid callback with a blank provider reference can still be
terminally acknowledged, and an authenticated malformed Stripe completion can
still be terminally dropped. These are whole-checkout admission and completion
defects for the atomic M6/M8 cutover; they are not alternate paths the M4 refund
authority should learn to tolerate. M6–M11 must replace and delete
`getPaymentProviderForExistingPayments` and every runtime payment-ownership
caller in the same activation. After that cutover, credential enumeration may
exist only inside settings/activation recovery. It is never payment ownership
and must not enter refund admission, budgeting, provider loading, observation,
sending, or dashboard links.
Part A also does not solve stable booking obligations, exact allocation, ledger
order identity, a durable Refund All intention, or the original checkout's
durable handled marker described below. Those are authorities at different
layers, not piecemeal extensions to the provider-refund lifecycle. In
particular, the automatic placeholder's atomic attendee/reference/terminal-
outcome creation and `checking` fence plus its pre-read provider authority close
the missing recovery-control, duplicate-create, and destructive-cleanup faults.
They do not supply the original checkout's durable effect marker after
`processed_payments` retention or close the placeholder-ledger replay-marker
gap. M7 owns those exits on the same authority.
Standalone value: no live admin refund path or admitted provider-tagged callback
refund target can guess a provider, default malformed evidence into success,
send without the one durable permit, silently lose a known returned refund after
a local failure, decrypt a whole attendee population, or make a manager decide
or move money. Whole-checkout callback admission remains the explicit M6/M8
cutover debt above; this statement does not turn its ambient provider selection
or legacy terminal acknowledgement into M4 guarantees.
#### M5: Payment cases — remaining aggregate case work (was PR 5)
M4 Part A already ships the current-row case needed by live admin refunds:
revisioned review evidence, an owner-only view and acknowledgement, automatic
evidence-owned retirement, a reachable Refresh action, manager refusals, and
merge/delete/retention rules. Acknowledgement records that the owner saw one
exact revision; it deliberately neither clears unresolved money nor grants a
send permit.
There is no remaining honest standalone M5 foundation. Whole-checkout and
booking-level cases cannot exist until M6 can read and persist their complete
evidence, and their money-moving or completion choices cannot exist before the
M7/M8 engines that execute them. Land each remaining case kind with that real
producer and every action that resolves it, reusing the M4 lifecycle
declarations instead of opening a parallel review system:
- M6 adds revisioned cases produced by the whole-payment judge, the owner list
and detail evidence, stable buyer result, and retryable current-revision
alert. The durable case commits before best-effort notification; the ntfy copy
carries only a finite case code, never buyer/provider identifiers or amounts,
and directs the owner to the protected detail. Every rendered link is gated by
the same permission and existence fact as its target.
- M7 adds refund/allocation decisions and closes both the provider result and
Money before retiring their case. M8 adds complete-booking decisions and
durable completion effects.
- A genuine ambiguity stores immutable reviewed evidence and a required choice
with no default. A generic acknowledgement is never a substitute. A case
re-judges on evidence change and closes only when the exact condition is gone
or its required decision completed atomically.
- Aggregate cases are repointed or settled inside attendee merge/delete
transactions and are covered by the same declared storage lifecycle as every
other attendee-linked table.
Milestone value: every aggregate problem becomes visible and resolvable in the
same slice that first makes it possible, with no dormant case vocabulary and no
second source of truth beside M4's live-work rules.
### Atomic aggregate cutover — work packages M6–M11
M6–M11 ship as one cutover PR and one release. The package boundaries below are
for implementation, review, and focused tests; none may become an independently
merged live layer. Before activation, the release pauses payment creation,
callbacks, refund commands, completion, attendee mutation, and pruning behind
one durable cutover fence. Its explicit owner-authenticated migration reads the
old tables in bounded resumable pages, writes canonical rows, adopts every
in-flight checkout, and gives every source row either a verified canonical
identity or a complete owner decision. After verification, one transaction
raises the cutover epoch. Only then may canonical readers and writers serve
requests. The displaced readers, writers, interfaces, and maintenance paths are
deleted in this same release; there is no read-through, dual write, runtime
fallback, or mixed-store answer. Frozen old tables remain only as migration
audit input for M12/M13 redaction and removal.
This is also the rule for every later change to M4's live refund authority, not
just this named aggregate cutover. Extend the canonical state and every consumer
in place, or fence requests, migrate and verify every retained row, switch one
epoch, and delete the displaced schema, readers, writers, routes, and jobs in
the same release. Record age, missing optional fields, provider kind, and
deployment version may preserve evidence or cause a typed refusal; none may
select a legacy runtime, fallback authority, dual write, or alternate refund
engine. If a future change cannot make that atomic replacement safely, it does
not activate.
Every dormant aggregate table must hold its complete production role at that
epoch or be dropped. The cutover cannot activate without the owner's private
key: owner-encrypted retained payment evidence and buyer snapshots cannot be
opened under `DB_ENCRYPTION_KEY`. That ceremony must not scan attendee PII to
recover old payment ids. Until the owner performs it, the current M4 runtime
stays wholly in charge; after it, old unindexed payments still require a manual
provider refund rather than a legacy engine.
#### Work package M6: Aggregate checkout creation and reads
Src target: 1,200–1,800 within the sanctioned atomic-cutover exception. Creation
and reads move together precisely so no `sumup_checkouts` projection,
checkout-metadata preservation bridge, or projection-repair machinery exists.
The dormant `payment_sessions` buyer-bearing fields currently accept DB-key
`enc:1` ciphertext. Before the first M6 writer exists, rebuild those fields to
require owner-key `hyb:1` ciphertext or drop the dormant table and replace it
with the one canonical owner-key-protected session store. The current shape may
never become a live compatibility store.
The M6 release itself carries the restore-deploy guard (as its own commit is
fine): once the cutover release has shipped,
`.github/workflows/restore-deploy.yml` refuses to deploy any commit that
predates the aggregate cutover — regardless of what the restored database
contains, because a pre-cutover backup carries no aggregate marker and its
recorded commit would restart the old writers. Restoring an old backup means
loading it into the current application and completing this same migration
ceremony before activation. Document this in the operator restore guide beside
the backup's recorded commit; the guard is live before the first aggregate write
can happen in production.
- Creation: save immutable expected money and booking intent before every
provider call; claim creation; one payment identity and idempotency key; adopt
the original provider resource after an uncertain response; return the same
buyer URL on replay; release or schedule every failed claim. All three
providers at once. SumUp keeps its local payment, checkout, and transaction
IDs distinct and uses stored currency. A checkout spanning several listings
stays one payment with one shared order: the stored intent allocates the
ticket-line money across its listing lines exactly — the existing
largest-remainder rules make the parts sum to the whole — and keeps every
booking fee or price-modifier extra as its own fact, never folded into a
listing's share, because Money credits those to their own accounts. Listing
lines plus extras sum to the payment total, reconciliation validates money
against that allocation, and no listing is ever credited the full payment or
another line's fee; the shared order is never collapsed. A deposit
(reservation) checkout stores two levels for every line and extra: the amount
charged now, whose parts sum to this payment's total, and the full obligation
it represents, so the later balance payment knows what remains and the full
modifier fact is never lost to the deposit fraction — today's pricing folds
the deposit share into ticket lines while Money records the whole modifier,
and both facts must survive as themselves. One canonical allocator produces
this record for the whole payment — every listing line, every extra, and every
price-modifier application as its own signed fact, discount or surcharge, even
where today's pricing folds it into line prices — in one pass, each part with
a stable identity. A reservation's deposit and balance payments share one
booking-level obligation identity: the line, extra, and modifier identities
are created once, under that obligation, when the deposit's allocation is
stored, and the balance payment's allocation references those same identities
— the full obligation is stored once and referenced, never duplicated, which
is what lets M7 cancel it exactly once and M8 lease every payment that shares
it. The record is stored with the payment: reconciliation, refund routing, and
balance completion read the stored allocation and never re-derive it, so no
two consumers can disagree about a part. Today's ticket-only
`allocateReservationDeposit` does not survive the move. Store provider and
currency on every aggregate charge. M4 already tags new current-store
references and refuses every older indexed-but-untagged reference before
provider I/O. The aggregate copy preserves old incomplete evidence as
unsupported accounting history; it does not qualify that evidence for an
automatic refund. Provider and currency are intrinsic to every charge the new
runtime may act on. No migration or request-time attendee scan, runtime
provider-discovery fallback, or configuration guess exists. Old PII-only and
untagged references remain manual provider work.
- Reads: keep M4's tagged `ProviderRead`/`RefundAttemptResult` contract for one
charge, then add the whole-checkout observation only here, beside the reader
that supplies its complete signed intent, session, charges, account, currency,
amount, parent, and provider-specific child evidence. Square payment IDs are
named by validated orders, not short-list scans. Missing, invalid,
unavailable, pending, paid, free, and failed stay distinct. Only this complete
reading may introduce the expected-money conflict kinds and whole-payment
judge that M4 deliberately did not ship. The provider-specific traps remain
binding:
- Square coalesces the webhook-named payment with its own order tender so one
charge cannot look duplicated, uses the order total as session total rather
than one tender's amount, and reads refund evidence for every payment-backed
captured sibling. An absent documented `refunded_money` means zero; a
present but incoherent Money value is invalid. A card tender counts only
when its status proves capture. A captured non-payment tender without a
payment id is real captured money with unknown refund evidence: it parks for
owner review, is never defaulted to zero, and is not subjected to an
impossible payment read.
- A checkout-linked SumUp observation is the named transaction plus the
checkout's vouched successful children and, for a multi-child reading, each
child's transaction refund evidence. A checkout-less historical reference
can honestly carry only its transaction evidence.
- Square tender and SumUp child lists are provider-controlled and therefore
have one declared cap shared by callbacks, admin, and refresh. Exceeding the
cap records bounded owner work without chasing the remaining list or moving
money.
- One claimed reconciliation function serves signed callbacks, buyer returns,
manual refresh, case refresh, and scheduled retries: it reads, resolves via
`outcomeOf`, and persists once — evidence, charges, state, due time, revision,
and case changes in a single transaction. Store every charge identity once and
reject cross-payment reuse. Multiple captures open an M5 case. Reconciliation
fingerprints a stable, order-independent resource set: unchanged terminal
evidence replays; later visibility of the application's own refund confirms
it; genuinely new external evidence is merged and surfaced without re-running
booking or refund. Evidence merges by stable provider-plus-resource identity.
A lagging subset never removes a known charge; cumulative refunds keep their
maximum and capture state moves only forward. Conflicting facts with no safe
ordering, including parentage, currency, or session total, are retained as
contradictions that block money movement. The whole merged reading is
re-judged; an earlier outcome is never carried forward. A compare-and-set
loser carrying distinct evidence retries boundedly or answers retryably, never
acknowledges that evidence away.
- At activation, the aggregate never references a deleted attendee:
`applyAttendeeMerge` repoints aggregate payment sessions, charges, cases,
effects, deliveries, and queued work to the surviving attendee inside its
transaction, while `deleteAttendee` settles or repoints them before the
attendee row goes. The migration fence pauses merge, delete, PII edits,
pruning, and stale-reservation deletion before it takes its source snapshot,
so no historical booking or payment-identifying fact can disappear between
verification and copy. Pre-fence privacy deletion remains intentional; like a
backup, the migration copies what still exists when the fence rises and never
recreates data already removed under the current retention rules.
- The aggregate readers replace `resolveWebhookSession` and `retrieveSession`
for every caller — signed webhook callbacks, buyer return and cancel pages,
and paid-session validation — and the displaced methods leave the
`PaymentProvider` interface and all three provider implementations in this
same cutover. They feed the durable completion and effect machinery described
in M8–M10; no old completion, finalize, delivery, or maintenance writer
remains active beside them. Every admin and callback refund already writes
M4's canonical `payment_charges` authority. M6 links that same row to the
complete payment and charge observation; it never copies its state into a
second refund record. The claimed whole-payment reconciliation folds both
provider evidence and that authority's current revision. Pull-only SumUp work
is already due through `next_refund_action_at`, so no writer-through marker or
provider-specific side queue is needed. This is an atomic replacement, not a
selector between generations: after the epoch changes there is no runtime
fallback to either displaced checkout reader, no read-through to old payment
storage, no dual write, and no second completion or refund authority.
Migration-only decoders remain unreachable from request handlers and are
removed with their frozen sources.
- During the fenced migration, adopt or expire every in-flight pre-cutover
checkout (all three providers) atomically and idempotently: same claim,
identity, and validation rules; concurrent callbacks and migration runs bind
to one claim; defined outcomes for paid, pending, expired, and unavailable
sessions. No paid checkout is stranded without an aggregate row.
- Panels, exports, statistics, refund targets, callbacks, and maintenance switch
to the canonical repositories only after every retained source row verifies.
The old runtime readers and writers are absent from the activated build. The
migration itself folds a source row's local completion facts — attendee,
ticket result, and recorded failure — onto its canonical identity before
declaring that source settled, so identity deduplication never drops the
booking outcome.
Cutover contribution: no live checkout can lose its intent or create a second
provider checkout after an interrupted request, and every route, worker, page,
and export gets the same authoritative answer for the same payment.
#### Work package M7: Refund jobs and stable booking obligations
M4 Part A is the authority, not work to repeat: admin single, one-attendee
Refund All, Refresh, and buyer callbacks already share tagged reads, the
revisioned `payment_charges` state machine, one provider-send permit,
conservative settlement, required owner choices, and local Money repair. M7 adds
a durable whole-listing intention and stable booking-obligation effects around
that same provider authority. It does not build a second aggregate refund
engine. The cutover links the current authority to stable aggregate identities
and removes the processed-payment claim and reference paths whose callers have
all moved before activation.
- **Durable Refund All.** Before the first provider call, persist the operator's
whole-listing intention, every immutable payment identity it covers, and a
cursor. Each request claims, budgets, and processes one bounded page, records
every result, and advances only past terminal items. A permanent refusal opens
required owner work; a transient failure remains due for bounded retry and
cannot be skipped by the cursor. A crash after page one therefore leaves a
visible resumable job naming the remainder. M4's PII-free whole-listing safety
summary and one-attendee interactive request remain the safe input shape, but
repeated manual submission is no longer the only continuation.
- **Extend one callback/admin lifecycle.** M4 already persists the exact charge,
capability, captured Money, callback replay identity, generation identity, due
time, and revision before a validated callback can send. A duplicate makes no
second call. Stripe and Square may replay only the exact keyed generation
inside its finite window; SumUp never automatically replays an armed keyless
generation. `accepted`, lost responses, and contradictory reads all have
durable, reachable exits in Refund recovery. The missed-SumUp-checkout task
already runs on the current path as `sumup_checkout_recovery`; M7 adopts that
task instead of building it again. M7 attaches scheduled refund retries and
the original checkout's handled marker to this same lifecycle. It must not
reinterpret a placeholder's `legacy:` anchor as the original checkout, a sent
attempt, provider completion, or durable replay marker.
- **Durable job inputs around the existing engine.** Individual, selected bulk,
balance, automatic, callback, and case-decision refunds continue through
`requestProviderRefunds`, now fed by M6's stable aggregate charge and
allocation identities. Job items are self-contained and never rebuild their
target from live attendee PII or current settings. Provider success followed
by a local failure remains due on the authority and repairs Money
idempotently. Refunds stay available while new sales are disabled.
- **Stable booking obligations.** Cash return and booking cancellation are
separate durable effects. A payment page returns and posts only the cash that
charge moved. The stable obligation identity owns the sale, modifier, and fee
facts; its cancellation is claimed with its decision and applied exactly once.
If one payment returns while another remains captured, require the decided
revision-fenced owner choice — keep the booking and make the return due,
return the rest then cancel, or cancel while retained cash remains visible
refund work. Recovery can retry either effect but cannot infer one from the
other or cancel twice.
- **Shared and ambiguous legacy evidence.** An indexed current-store reference
with multiple representations remains M4 `shared_reference` review; its
acknowledgement is not an allocation. The aggregate action either records
positive Money parts, all in the captured currency and summing exactly to the
capture, across stable obligations, or records an explicit owner rejection.
Likewise, an ambiguous provider choice is limited to providers a fresh read
revalidates as `found`. Every choice is fenced on the exact held rows and
evidence revision. No equal, proportional, first-attendee, configuration, or
current-row default is permitted.
- **Changed evidence and destructive writers.** Re-run the outcome and blocking
case checks in the claiming transaction before each provider call. New
captured money, an external refund, a changed allocation, or a new case parks
the item for the owner rather than acting on a stale snapshot. A queued refund
built from pre-merge Money facts blocks merge/delete until it settles or is
explicitly cancelled; repointing cannot make a frozen allocation safe after a
merge has posted its own adjustments.
- **Migration boundary and removal.** The cutover's M11 work package migrates
and verifies every retained `processed_payments` record before enabling the
aggregate runtime. It copies only payment references already present in
retained payment storage; it never searches attendee PII for another one.
Unsupported history receives a terminal accounting disposition and remains a
manual provider refund, not an actionable canonical charge. This work package
removes the processed-payment claim and reference paths as their final callers
move, and the architecture gate continues to prove that only one send permit
mint, provider API path, and `payment_charges` writer exist.
Cutover contribution: a whole-listing intention survives request death, the one
durable retry/evidence machine gains stable allocation and scheduled work, and
cash or booking obligations can never move twice or be silently inferred from
each other.
#### Work package M8: Durable paid completion, including its failure path
Src target: 1,000–1,500 — cutover exception; the success and failure paths are
one machine and merge together.
- Persist the exact capacity, attendee, answer, modifier, package, balance, and
Money effects before running them; snapshot paid facts so listing edits cannot
change delivery. Complete each effect idempotently, schedule unfinished work,
and stop one permanent failure from starving later payments. A multi-listing
payment draws each line's capacity, tickets, and Money from M6's stored
allocation, but the commit is all lines or none in one transaction — as
`createBookingAtomic` commits the shared order today — so one line selling out
after payment sends the whole completion down the failure path (refund or
owner case) rather than half-booking the order. That refund is an
unhonoured-payment refund: it returns cash only and posts no booking-level
obligation cancellation, because the all-or-none commit never posted the
obligation — a cancellation runs only with proof the booking obligation effect
completed. Before the effect runner claims its first payment, the fenced
migration classifies every retained completion: a completed source result is
marked done and is never re-booked or re-posted to Money; a paid payment with
no completion result becomes due work; and a recorded completion failure
becomes the matching durable failure effect — its chosen refund path or an
owner case — never marked done and never re-run as a fresh booking. The
migration begins only after in-flight old commits drain or fail and it
revision-checks every source before settlement. Adoption also gates on a
completion-safe `outcomeOf` state with no open blocking case: a paid payment
stopped for owner review — captured money on a failed checkout, multiple
captured charges — stays in its case workflow, because due work must never
bypass a required owner choice. Refund and completion claims are mutually
exclusive through one payment-wide claim: the payment session row's shipped
lease (`lease_token`, `lease_expires_at`). Refund jobs, the adoption pass, and
the effect runner each acquire that lease atomically before acting and verify
no unfinished refund job or effect owns the payment, so two runners can never
both read "nothing done yet" and act, and a booking can never complete while
its irreversible refund is in flight. For a reservation whose deposit and
balance are separate payments, the claim spans them: a runner acquires the
lease of every payment sharing the booking-level obligation, always in one
fixed order, before acting — so refund and completion can never split one
booking between two sessions.
- At activation, completion stops storing payment references in attendee PII —
the aggregate owns the attendee-to-payment link. M4 already keeps every new
`processed_payments` reference under the owner key and refuses to scan
PII-only history. The cutover carries forward only links already proved by
retained payment storage; it does not consume old attendee PII before deleting
the writer.
- When completion cannot be honoured, persist the chosen refund path and record
the provider refund and local Money completion as separate durable effects
driven by the M7 engine, with explicit provider, database, and total
subrequest budgets.
- The M5 complete-a-proven-booking case action lands here.
- Extend M4's current-row merge/delete/prune/orphan protections to aggregate
cases and effects. Fence listing deletion against pending payment work,
establishing the claim before any irreversible step: `performListingDelete`
now deletes the database rows before removing the stored attachment, and the
fence must precede both. Fence attendee deletion the same way: an attendee
with unfinished completion work or durable effects cannot be deleted until
that work settles or is repointed, checked inside the deleting transaction.
Repoint payment work and open cases during attendee merges. The cutover fence
blocks merge, delete, prune, PII edits, and `deleteAllStaleReservations` while
historical rows are being copied, so activated maintenance code can be
canonical-only; it never needs a copied/not-copied branch or migration
snapshot.
- Install the old-write fence before the first migration page and verify it in
every old committing transaction until all in-flight requests drain. Provider
refunds need no exemption: M4 already writes their state only to canonical
`payment_charges`. The cutover epoch rises only after the last source row is
settled, and every old writer is deleted before requests resume.
Cutover contribution: interrupted paid bookings resume without charging,
booking, or recording Money twice; a provider refund that outruns a local
failure repairs itself; and no admin action can orphan payment work.
#### Work package M9: Durable messages and outgoing webhooks
Src target: 300–600. Store prepared message and webhook bodies plus buyer facts
before delivery; resolve the owner recipient from the current business address
at send time; attempt and schedule each delivery independently, keeping each run
within explicit provider, database, and total subrequest budgets; mark permanent
failures without blocking later work. Every delivery carries a stable identity:
webhooks send it in a header consumers deduplicate, and email uses the
provider's idempotency key where the provider offers one. Where it does not, the
attempt record persists the provider's acceptance evidence before the delivery
is marked complete; if that evidence write itself is lost, the retry may
duplicate an email — an accepted, documented outcome for messages only, never
for webhooks or money.
The dormant `payment_completion_deliveries.data` schema currently accepts DB-key
`enc:1` ciphertext even though the payload can contain the buyer's name, email,
phone, and address. Before the first M9 writer exists, change that column to
require owner-key `hyb:1` ciphertext or drop the dormant table and replace it
with the one canonical owner-key-protected delivery store. It may never become a
live DB-key-readable queue or a compatibility mirror beside a new queue.
Cutover contribution: the M8 completion runner's messages and webhooks use the
current owner address, recover on schedule after an interruption, and one
permanently failing destination no longer blocks the rest of the queue.
#### Work package M10: Durable site assignment and renewal
Src target: 300–600. Persist site assignment and renewal effects before remote
work; serialize concurrent paid renewals; keep remote calls outside
transactions; repoint queued site work during attendee merges; schedule
unfinished work within explicit provider, database, and total subrequest
budgets. Each persisted renewal effect carries a stable provider idempotency
key, or the runner reads the remote state before re-attempting — an uncertain
success is never blindly replayed, so a site cannot be extended twice.
Assignment claims a site atomically, and that claim already exists:
`claimBuiltSiteStatement` (`src/shared/db/built-sites/claims.ts`) requires the
site to still be assignable inside the mutating statement itself, and the loser
moves to the next site or builds one. #2418 shipped that claim and its
racing-claim regression test, which closes fault F45. A site build
gets the same no-blind-replay rule as renewals: the persisted effect carries a
stable creation identity, and a retry after a lost response reads the provider
and adopts the site that identity already created instead of provisioning a
second one or failing on the duplicate name.
Cutover contribution: paid site delivery and renewal recover safely after an
interruption, a concurrent payment, or an attendee merge.
#### Work package M11: Verify and copy retained payment records
Src target: verifier 400–600; copy 800–1,200 (cutover exception). The verifier
is read-only and parallelizable — #2056 already started it, and that pull
request is still open. The copy is the
activation gate for the complete M6–M11 runtime, not a later bridge.
- The verifier reads the retained payment stores — `processed_payments`,
`checkout_stages`, `sumup_checkouts`, their explicit merge links, and M4's
canonical authority — into one lossless model without writing cases. It does
not scan attendee PII or old free-text notes for payment references. Group one
provider payment before pagination, convert old timestamps, and report
contradictions through operator diagnostics and backup verification. An
owner-encrypted reference already present in payment storage is opened only
through the request-scoped private-key path; key material and raw references
never enter progress records, logs, audits, or backups. If required retained
evidence cannot be opened, block the copy and preserve the source row. Back up
databases before migrating.
- The cutover fence stops every old payment writer and drains in-flight commits
before copying. It pauses payment creation and callbacks, admin refund
commands, completion, attendee merge/delete, pruning, and stale-reservation
deletion while relationships are established. It need not pause PII edits to
recover hidden payment ids because hidden ids are not migration input.
Existing provider work already lives in M4's `payment_charges` authority and
is linked, never copied into a second state machine. Lease ownership, renewal,
timeout, and safe abort details are fixed in the cutover behavior contract
before the first cursor page.
- Copy every retained payment row by stable cursor in bounded, verified pages.
Never split one provider payment across pages, mistake an empty joined page
for the end, let deleted booking rows block, or resurrect ticket-use state. A
tagged, owner-encrypted reference already present in retained payment storage
may become a canonical charge. A blank-index, untagged, PII-only, or otherwise
unproved reference does not: preserve its SQL-visible accounting facts as a
terminal unsupported record and direct any cash action to the provider
dashboard. Current provider, last-active provider, credential order,
identifier spelling, owner guess, a note, or a dashboard URL can never qualify
it. Do not read attendee PII, decrypt a population, or make an interactive
request perform the copy.
- A reference already owned by M4's authority is linked to the aggregate
payment, never copied as a new charge. Before a source is settled, fold its
local completion facts — attendee link, ticket result, and recorded failure —
idempotently onto canonical completion records. Preserve unknown or
contradictory facts without inventing them. A terminal unsupported or
unmigratable disposition keeps only a bounded accounting record: an allowlist
of non-buyer facts already present in retained payment storage, encrypted for
the owner-only case page, with no buyer PII, ticket tokens, credentials, raw
note text, or newly recovered reference. That lets M13 retire the old table
without pretending the current engine can refund it.
- A source is settled when it is either copied and verified into a canonical
payment or terminally preserved as unsupported/unmigratable. Record progress
and release leases within the call budget; interruption resumes from the same
cursor. A final verifier proves counts, identities, Money, local outcomes, and
provider-work links before the cutover transaction raises the epoch.
- The activated application has only canonical panels, exports, statistics,
refund targets, callbacks, completion, effects, and maintenance. Every old
runtime reader and writer is deleted in this release. Migration-only decoders
for retained payment rows cannot be imported by request handlers; the
architecture gate rejects a second refund writer, completion projection,
read-through, or dual write.
Cutover contribution: every retained payment row receives a verified canonical
or terminal accounting disposition. Only proved current evidence becomes
actionable; unsupported historical refunds stay manual.
### Follow-on history retirement (M12–M13)
#### M12: Redact terminal payment secrets (was PR 15)
Src target: 400–700. Redact intent, evidence, ticket tokens, completion
payloads, historical DB-key-encrypted refund-warning notes containing raw
provider references, and the stored delivery records from M9 — prepared message
and webhook bodies plus their buyer facts — only after all work that needs them
is terminal, including deliveries that permanently failed. The bounded
accounting record M11 preserves on an unmigratable-row case is permanently
retained: it carries no buyer secrets by construction, and once M13 drops the
old tables it is that payment's only copy, kept as documented accounting
history. Eligibility is defined for every terminal outcome — completed, fully
refunded, failed, cancelled, expired, and free — each either redacts once its
work is terminal or documents why its data is retained, with a cleanup test per
state; page cleanup so one bad or ineligible record cannot block later eligible
rows.
Standalone value: deployed sites keep accounting history while shedding buyer
secrets and ticket credentials they no longer need.
#### M13: Retire old payment storage (was PR 16)
Src target: 600–1,000, mostly deletions — the atomic-cutover exception where
this exceeds rule 3's cap: the table drop and every reader, codec, and gate it
orphans leave together, because splitting them would hold dead readers alive
across a merge. Release only after every fleet database reports every source row
settled — copied and verified, or terminally preserved as unmigratable — and no
request, worker, page, export, or maintenance command reads an old table.
- Drop the old tables and delete the migration reader, progress gates, old
codecs, stale TODO entries, temporary exemptions, and dead exports together —
except an explicit restore-time conversion step, documented in the operator
restore guide, so a pre-aggregate backup can complete the same fenced ceremony
before the application starts. It is not importable by runtime request code.
- The table-drop migration must not be an unconditional `DROP TABLE` in
`initDb`: a restore of an old backup would drop `processed_payments`,
`checkout_stages`, and `sumup_checkouts` before the retained reader has
anything to read. Make the drop conditional on verified copy progress, or
convert at restore time before schema migrations run.
- Run full coverage, the quality audit, Cucumber specs, exhaustive targeted
mutation, and the final branch mutation gate. Update operator and database
documentation.
Standalone value: one smaller payment implementation, faster cold starts, and no
ambiguity about which path is authoritative.
## Fault ledger
Every row is a mandatory input to its owning milestone: close it with a
mechanism and a regression test, or — if implementation proves the finding wrong
— with a short proof in the PR. Never silently drop one.
| # | Finding | Owner |
| --- | -------------------------------------------------------------------------------------- | ---------------------------------------- |
| F1 | Disabling new payments also disabled existing-payment refunds | Closed by #2020 |
| F2 | Unknown unsigned SumUp callbacks triggering outbound reads | Closed by #2060 |
| F3 | Pending and completed refunds together exceeding captured money | Closed by M4 |
| F4 | One failed decision blocking all reconciliation | M5, M6 |
| F5 | Permanent provider or delivery errors retrying forever or blocking a queue | M5, M6, M9 |
| F6 | Attendee merge or delete removing records with an open case or unfinished work | M4 current rows closed; M6–M8 aggregate |
| F7 | Restore-deploy workflow allowing incompatible code onto a migrated database | M6 |
| F8 | Cross-payment duplicate provider charges | M6 |
| F9 | Account lookup failure retaining a claim | M6 |
| F10 | SumUp return IDs interpreted differently by different routes | M6 |
| F11 | Square fallback reads scanning too short a list | M6 |
| F12 | Delayed work using live currency rather than stored currency | M4 admin closed; M6 aggregate |
| F13 | Charges without a stored provider guessed after a provider switch (#2020 gap) | Closed by M4 permanent refusal |
| F14 | In-flight pre-cutover checkouts paid after the cutover, stranded without a row | M6 |
| F15 | Old rows changing during aggregate migration | M11 fence |
| F16 | Old payment-reference readers surviving after migration | M11, M13 |
| F17 | Owner refund decisions closing a case without closing Money | M7 |
| F18 | Completed provider refunds missing from Money | Closed by M4 |
| F19 | Provider success followed by a local failure having no repair path | Closed by M4 |
| F20 | Refund-all conflicting forever with unfinished completion | M7, M8 |
| F21 | The same indexed provider reference refunded twice through admin bulk refunds | Closed by M4 |
| F22 | A completed refund resurfacing as refundable on an uncopied row | Closed by M4 canonical authority |
| F23 | Attendee-only payment references skipped, or refunded without verified facts | Closed by M4 permanent refusal |
| F24 | Delayed completion rebuilding facts from edited live data | M8 |
| F25 | Listing attachments deleted before a payment fence succeeds | M8 |
| F26 | `deleteAllStaleReservations` deleting uncopied legacy rows under the fence or mid-copy | M8, M11 |
| F27 | Concurrent renewals racing | M10 |
| F28 | Queued site work retaining a deleted attendee ID after merge | M10 |
| F29 | SumUp identities split across migration pages | M11 |
| F30 | A merged migration page mistaken for end-of-input | M11 |
| F31 | Deleted booking rows blocking migration forever | M11 |
| F32 | Ticket-use state resurrected during migration | M11 |
| F33 | Migrated charges omitted from refund targets | M11 |
| F34 | Late refund-completion writes landing after a row was copied and verified | Closed by M4 canonical authority |
| F35 | Migration silently skipping retained evidence whose owner key or source is unavailable | M11 |
| F36 | Terminal buyer details, completion data, or ticket tokens never redacting | M12 |
| F37 | An unconditional table drop destroying a restored old backup before it migrates | M13 |
| F38 | Attendee merge or delete destroying attendee-held payment facts before the copy | M11 fence |
| F39 | A migrated source payment appearing twice after activation | M6, M11 |
| F40 | An attendee PII edit changing legacy payment references mid-copy | Closed: PII is not migration input |
| F41 | Redacting the preserved evidence that is an unmigratable payment's only record | M11, M12 |
| F42 | A historical identity deduplication dropping its local booking facts | M11 |
| F43 | An already-canonical payment reference migrated again as new input | M4, M11 |
| F44 | A legacy deletion stripping folded local facts from a sale the aggregate represents | M6 |
| F45 | Two concurrent paid completions claiming the same built site | Closed by #2418 |
| F46 | A site build replayed after a lost response, provisioning a second site | M10 |
| F47 | A parallel refund adapter completing after its retirement pass | Closed by M4; no adapter exists |
| F48 | A multi-listing payment credited in full to each listing, or its shared order lost | M6, M7, M8 |
| F49 | Source booking facts dropped when its refund authority is already canonical | M11 |
| F50 | Unmigratable evidence keeping buyer PII or ticket tokens forever | M11, M12 |
| F51 | Two classifiers disagreeing about the same refund evidence | Refund side closed by M4; checkout M6/M8 |
| F52 | Checkout fees or price modifiers misallocated into a listing's income | M6, M8 |
| F53 | A selected refund command overrunning budget after sending only an initial subset | Closed by M4 |
| F54 | One sold-out line half-booking a multi-listing order after payment | M8 |
| F55 | The completion runner re-completing a sale already finished before migration | M8, M11 |
| F56 | A deposit checkout losing the full modifier fact to the charged fraction | M6, M8 |
| F57 | Migration racing an in-flight old commit and re-running its completion | M8, M11 |
| F58 | Adoption turning an owner-review payment into due work, bypassing the required choice | M8 |
| F59 | A queued refund page stranded by an attendee merge or delete in the M7 window | M7 |
| F60 | A refund-all crash after its first page losing the unrecorded remainder | M7 |
| F61 | An attendee merge rewriting Money while refund pages are still queued | M7 |
| F62 | A reservation refund confusing money charged now with the full obligation | M7 |
| F63 | A deposit-plus-balance refund reversing the booking obligation twice or not at all | M7 |
| F64 | Adoption stranding payments whose folded result records a completion failure | M8 |
| F65 | A cursor advancing past a transiently failed refund, finishing the job around it | M7 |
| F66 | A booking completing while its payment's irreversible refund is in flight | M8 |
| F67 | A retried refund minting a fresh provider idempotency key and refunding twice | Closed by M4 |
| F68 | Transient and permanent refund failures collapsing into one boolean | Closed by M4 |
| F69 | An obligation cancellation without a stable identity re-running or never retrying | M7 |
| F70 | Two runners both reading "nothing done yet" and acting on one payment | M8 |
| F71 | A consumer re-deriving the allocation and disagreeing with the stored record | M6 |
| F72 | A booking split across two payment leases, refunding one while completing the other | M8 |
| F73 | Repointing replacing the merge fence and replaying a pre-merge allocation | M7, M8 |
| F74 | A queued refund acting on stale evidence after the payment's outcome moved on | M7 |
| F75 | Cancelling a booking obligation that the failed completion never posted | M8 |
| F76 | A discount folded into line prices losing its signed modifier fact | M6 |
| F77 | Deposit and balance allocations minting separate identities for one obligation | M6 |
| F78 | An indexed refund omitting a PII-only or blank-index sibling charge | Closed by M4 provenance refusal |
| F79 | A rejected SumUp request hiding a refund completed beside it | Closed by M4 |
| F80 | An automatic quantity-zero placeholder hiding its only payment recovery control | Closed by M4 |
| F81 | A rejected send plus an unreadable reread releasing its claim as conclusive | Closed by M4 |
| F82 | An anchor-only returned charge hiding a manual-money booking obligation | M7 stable obligations |
## Done means
- Every ordinary milestone is merged in dependency order and stands alone. The
M6–M11 work packages activate only as their one atomic cutover PR; none is a
separately merged compatibility layer.
- One production payment path remains; all three providers share one canonical
create/read/refund contract; every provider action and local Money action is
independently durable and resumable; genuine ambiguity requires an explicit
owner choice.
- Every provider boundary validates documented data before it reaches booking or
money code. Expected visitor errors are values; unavailable or malformed
external evidence fails closed; unexpected application and database errors
propagate with their identity. No booking/refund path converts an unexpected
throw into a successful acknowledgement, empty reading, or default result.
- No request discovers payment work by loading or decrypting an attendee
population. Whole-population questions use indexed, PII-free summaries;
decryption is limited to one bounded page; work larger than a request has a
durable cursor and visible continuation. Every physical provider and database
retry is priced before the selected irreversible work begins.
- New raw provider references and buyer PII require the owner's private key;
database-keyed indexes and mirrors reveal only blind equality and state words.
With a v2 owner wrap, a database plus `DB_ENCRYPTION_KEY` cannot recover that
private key; a dormant v1 wrap remains explicitly weaker until login upgrades
it. Historical plaintext or DB-key-encrypted evidence is migrated under the
stronger boundary, preserved only in a finite owner case or frozen source
awaiting redaction, or redacted. It is never readable by the activated runtime
and never silently copied under `DB_ENCRYPTION_KEY`.
- Every blocking state is declared exhaustively with its writers, consumers,
delete/merge/retention behavior, reachable operator action, exact retirement
evidence, and a visitor journey that proves it ends. Unresolved money can end
only through provider/ledger proof or a required revision-fenced owner choice,
never a generic clear.
- Old backups migrate forward into the current version; the restore-deploy guard
refuses pre-aggregate code on a forward-migrated database; old code is never
redeployed and mixed application versions are never supported.
- Payment secrets and buyer details redact once all required work is terminal.
- `devenv shell deno task precommit` passes; coverage is 100% and deterministic;
changed-source mutation score is 100%; Cucumber payment stories pass; every
fault-ledger row and open question is closed.