diff --git a/GITHUB_ISSUES.md b/GITHUB_ISSUES.md index 2a95d468..615f9595 100644 --- a/GITHUB_ISSUES.md +++ b/GITHUB_ISSUES.md @@ -64,7 +64,7 @@ Every issue inherits `AGENTS.md` and the definition of done in `IMPLEMENTATION_P | 10 | ABUSE-001 | Enforce native App Check and privacy-preserving abuse limits | P0 | L | partial: Enterprise browser source/tests tracked in [#159](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/159); provider config, native enforcement, rate limits, and live proof open | CI-001 baseline | | 11 | PAY-001 | Add strict request schemas and immutable monetary snapshots | P0 | L | partial: PAY-001A source/tests tracked in [#157](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/157); B/C/D and endpoint adoption open | ABUSE-001 interface agreed | | 12 | PROMO-001 | Disable unmodeled Stripe promotions until discounts are authoritative | P0 | S | source/tests complete under open GitHub issue #102; provider inventory and deployment remain owner action | PAY-001 for any future discount contract | -| 13 | PAY-002 | Implement idempotent payment commands and explicit state machines | P0 | L | partial: PAY-002A1 pure state source/tests tracked in [#161](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/161); PAY-002B1 pure command identity tracked in [#163](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/163); registered-only journal pair tracked in PAY-002B2A [#165](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/165); PAY-002B2B lease/fence source/tests tracked in [#169](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/169); PAY-002B2C1 immutable initial plan tracked in [#173](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/173); PAY-002B2C2 pre-send evidence/cutoff tracked in [#182](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/182); PAY-002B2C3A pure closed reconciliation decision tracked in [#184](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/184); evidence persistence/authorization/runtime/migration/sagas open | CONFIG-001, PAY-001 | +| 13 | PAY-002 | Implement idempotent payment commands and explicit state machines | P0 | L | partial: PAY-002A1 pure state source/tests tracked in [#161](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/161); PAY-002B1 pure command identity tracked in [#163](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/163); registered-only journal pair tracked in PAY-002B2A [#165](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/165); PAY-002B2B lease/fence source/tests tracked in [#169](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/169); PAY-002B2C1 immutable initial plan tracked in [#173](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/173); PAY-002B2C2 pre-send evidence/cutoff tracked in [#182](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/182); PAY-002B2C3A pure closed reconciliation decision tracked in [#184](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/184); PAY-002B2C3B immutable candidate persistence tracked in [#206](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/206); authorization/runtime/migration/sagas open | CONFIG-001, PAY-001 | | 14 | PAY-003 | Build idempotent, async-aware Stripe webhook ingestion | P0 | L | PAY-003A source merged in #101; PAY-003B/C remain open; not deployed | CONFIG-001, PAY-001/PAY-002 target contract | | 15 | RACE-001 | Add transactional race-capacity reservations | P0 | L | proposed | PAY-002, PAY-003 event contract | | 16 | MERCH-001 | Add SKU variants and transactional inventory reservations | P0 | L | proposed | PAY-002, PAY-003 event contract | @@ -630,7 +630,7 @@ The local webhook/creator patch is a safety default, not a complete discount fea ## PAY-002 — Implement idempotent payment commands and explicit state machines **Labels:** `priority:P0`, `type:reliability`, `type:security`, `area:stripe`, `area:firebase`, `size:L`, `needs-migration` -**Status:** Partial. PAY-002A1 pure state source/tests are tracked in live [#161](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/161). PAY-002B1 pure command-identity source/tests are tracked in live [#163](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/163). PAY-002B2A registered-only journal source/tests are tracked in live [#165](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/165). PAY-002B2B lease/fence source and tests are tracked in live [#169](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/169). PAY-002B2C1 immutable initial-plan source/tests are tracked in live [#173](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/173). PAY-002B2C2 pre-POST evidence and retry-cutoff source/tests are tracked in live [#182](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/182). PAY-002B2C3A pure closed reconciliation-decision source/tests are tracked in live [#184](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/184). Verified evidence persistence, safe attempt advancement, runtime adoption, real legacy inventory/migration, and checkout sagas remain open. +**Status:** Partial. PAY-002A1 pure state source/tests are tracked in live [#161](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/161). PAY-002B1 pure command-identity source/tests are tracked in live [#163](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/163). PAY-002B2A registered-only journal source/tests are tracked in live [#165](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/165). PAY-002B2B lease/fence source and tests are tracked in live [#169](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/169). PAY-002B2C1 immutable initial-plan source/tests are tracked in live [#173](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/173). PAY-002B2C2 pre-POST evidence and retry-cutoff source/tests are tracked in live [#182](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/182). PAY-002B2C3A pure closed reconciliation-decision source/tests are tracked in live [#184](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/184). PAY-002B2C3B immutable reconciliation-candidate persistence source/tests are tracked in live [#206](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/206). Safe attempt advancement, runtime adoption, real legacy inventory/migration, and checkout sagas remain open. **Depends on:** CONFIG-001 and PAY-001 ### Current atomic boundary @@ -647,9 +647,13 @@ PAY-002B2C2 [#182](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/182) ow PAY-002B2C3A [#184](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/184) owns only the unused pure closed attempt-1 evidence policy. Its exact flat version-1 record contains closed evidence categories and no provider-object, business, member, account, metadata, amount, timestamp, URL, free-text, or raw-status-code identifier/payload value. Only the complete matching tuple for trusted proof that endpoint execution never began, or the complete matching tuple for an exact verified expired/unpaid Session with verified expiry and an explicitly eligible new logical generation, returns `new_attempt_candidate`. The complete matching exact-open or verified-success tuple returns `existing_attempt_found`; every single-field difference, timeout, lost connection, provider error, old/pruned key, missing/not-found object, empty/partial search, unknown/processing state, mismatch, or conflict returns `reconciliation_required`. All outputs are fixed/frozen and non-identifying. -C3A is not a reconciler or authorization boundary. It stores nothing, calls nothing, has no runtime/index import, cannot send/execute/advance or create attempt `2`, and does not replay a provider result. C3B must persist immutable paired verified evidence; C3C must separately authorize/version a later attempt only after that proof, an allowed business transition, and a fresh lease. PAY-002C/D, PAY-003B, RACE-001B, MERCH-001B, PAY-004, and PAY-005 depend on the completed B2C chain. +C3A is not a reconciler or authorization boundary. It stores nothing, calls nothing, has no runtime/index import, cannot send/execute/advance or create attempt `2`, and does not replay a provider result. -B2A/B2B/C1/C2 add no TTL. Deleting both C2 partners would look like first use, so [#110](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/110) must approve retention and a server-only tombstone or equivalent durable duplicate barrier before any journal record is removed. CI-001B3 [#167](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/167) runs the exact demo-project/opt-in command-journal suite as a separate hosted release prerequisite; #169, #173, and #182 expand that same file. Hosted results remain synthetic source proof only. The current foundations, including C3A, are unused: they add no endpoint/index export, Firebase deployment, Stripe/provider configuration, production read/write, website change, or officer behavior. +PAY-002B2C3B [#206](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/206) owns only the unused immutable reconciliation-candidate record/audit pair. It snapshots one exact C3A input, then validates the exact B1 registration, B2 lease history, C1 attempt-1 plan, and complete C2 record/audit. It may persist only an exact `new_attempt_candidate`, only at or after C2's stored 23-hour deadline, and only when the current validated lease is expired. Before either time gate, it writes nothing and returns the unchanged C3A candidate. The deterministic record and audit carry command-bound complete-plan/send commitments, closed evidence enums, observed fence/expiry, trusted time, and a complete-record digest. Exact retries are read-only. Changed valid evidence conflicts; orphan, malformed, future, or foundation-mismatched records fail closed without repair. No raw account/key/parameters/IDs/money/free text/provider response or attempt `2` is stored or returned. + +C3B's fixed `requires_separate_authorization` result is not provider retrieval/truth, caller authorization, send permission, response replay, or a business transition. C3C must separately authorize/version a later attempt only after the persisted proof, an allowed business transition, and a fresh lease. PAY-002C/D, PAY-003B, RACE-001B, MERCH-001B, PAY-004, and PAY-005 depend on the completed B2C chain. + +B2A/B2B/C1/C2/C3B add no TTL. Deleting both C2 or C3B partners could remove a durable duplicate barrier, so [#110](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/110) must approve retention and a server-only tombstone or equivalent durable barrier before any journal record is removed. CI-001B3 [#167](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/167) runs the exact demo-project/opt-in command-journal suite as a separate hosted release prerequisite; #169, #173, #182, and #206 expand that same file. Hosted results remain synthetic source proof only. The current foundations, including C3A/C3B, are unused: they add no endpoint/index export, Firebase deployment, Stripe/provider configuration, production read/write, website change, or officer behavior. ### Problem diff --git a/GITHUB_ISSUE_SLICES.md b/GITHUB_ISSUE_SLICES.md index 962ad7ab..b2cd869f 100644 --- a/GITHUB_ISSUE_SLICES.md +++ b/GITHUB_ISSUE_SLICES.md @@ -71,7 +71,7 @@ An agent-ready child has one observable outcome, one trust boundary, explicit de | PAY-002B2C1 | source/tests tracked in live [#173](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/173); PAY-002B2B/#169; unused/no runtime or deploy | While the exact current lease/fence is active, atomically bind one immutable attempt-1 Stripe account/mode/API-version/static `checkout_session_create` POST mapping/parameters/key plan and deterministic audit. Reject arbitrary or object-ID-bearing paths. Store only command-bound commitments; this is not account proof or send permission. | Mock plus #167-hosted emulator races for exact bind/recovery/conflict/expiry/takeover/malformed partners and byte-preserved B2A/B2B records; explicit browser-denial tests. | | PAY-002B2C2 | source/tests tracked in live [#182](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/182); PAY-002B2C1/#173 and CI-001B3/#167; unused/no runtime or deploy | Recompute the exact B1/C1 attempt-1 plan under the current lease/fence, then atomically create a separate pre-POST marker and deterministic audit. Bind both to a command-bound digest of every immutable C1 plan field. The persisted version-1 deadline is 23 hours after the marker. The first atomic creation, and an exact retry, return fixed `send_permitted` only after a post-transaction trusted-time check remains before the transaction-validated lease's captured expiry and deadline; this does not re-read lifecycle state. Equality, later time, rollback, missing/unreadable paired time, or plan replacement reconciles or fails closed. Never create attempt 2 or make a provider request. | Mock and #167-hosted emulator concurrency, post-transaction lease expiry, self-consistent plan replacement, commit-failure, lost-response, takeover, nanosecond cutoff, unknown-time, timeout/connection-loss/5xx ambiguity, conflict, malformed-partner, byte-preservation, redaction, static no-call/random, and explicit browser-denial tests. | | PAY-002B2C3A | source/tests tracked in live [#184](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/184); PAY-002B2C2/#182 and merged PAY-003A/#101 vocabulary; unused/no runtime or deploy | Define only the pure exact flat version-1 Stripe attempt-1 evidence classifier. Closed enums produce fixed frozen `existing_attempt_found`, `new_attempt_candidate`, or `reconciliation_required`; a candidate is never send/advance permission. Only verified never-began or exact verified expired/unpaid evidence can be a candidate. | Exhaust the closed matrix and every single-field mutation; timeout/connection/provider-error/old-key/missing-reference/not-found/empty-or-partial-search/unknown/conflict advancement denied; hostile shape/proxy/accessor/Unicode/redaction/no-side-effect/static no-adoption tests. | -| PAY-002B2C3B | proposed; PAY-002B2C3A/#184 | Append one immutable paired reconciliation evidence record/audit only after a trusted verifier produces an exact C3A input. Keep all prior command, lifecycle, plan, send, and provider-attempt evidence immutable. Do not authorize or create attempt `2`. | Mock plus emulator atomic-pair/exact-retry/orphan/conflict/concurrency/immutability/browser-denial tests; verifier inputs are synthetic and contain no production/provider data. | +| PAY-002B2C3B | source/tests tracked in live [#206](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/206); PAY-002B2C3A/#184 and PAY-002B2C2/#182; unused/no runtime or deploy | Append one immutable paired reconciliation candidate record/audit only after exact C1/C2 validation, the stored 23-hour cutoff, current lease expiry, and an exact C3A candidate. Bind complete plan/send evidence plus observed lease proof; keep all prior records immutable. Do not authorize or create attempt `2`. | Mock plus emulator atomic-pair/exact-retry/orphan/conflict/concurrency/immutability/lost-ack/browser-denial tests; verifier inputs are synthetic and contain no production/provider data. | | PAY-002B2C3C | proposed; PAY-002B2C3B | Under a fresh lease and allowed business transition, consume the persisted C3B result to authorize and version a later provider attempt. Never advance on candidate classification alone; keep every earlier attempt immutable. PAY-003B may later consume this result. | Safe never-began and expired/unpaid advancement tests plus timeout/5xx/old-key/missing-reference/partial-search/stale-lease/conflict denial and retry/concurrency invariants. | | PAY-002C | proposed; PAY-001B, PAY-002B2C3C, RACE-001B | Convert race checkout to persistence-first record/hold plus deterministic Stripe Session saga. | One record/Session on retry at every failure boundary. | | PAY-002D | proposed; PAY-001C, PAY-002B2C3C, MERCH-001B | Convert merchandise/catalog creation to persistence-first order/hold plus deterministic Stripe keys. | One order/Session/catalog object on retry; failure recovery tests. | diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index aee7cda5..d40b7a63 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -125,7 +125,7 @@ Exit gate: - Concurrent last-seat and last-SKU tests prove no oversell. - Session creation is persistence-first, and known failures release holds once. -**Current work:** A PAY-003 webhook safety slice is implemented and unit-verified. PAY-002A1 is tracked in live [#161](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/161) for the pure versioned structural transition contract and synthetic legacy classification only. PAY-002B1 is tracked in live [#163](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/163) for the pure UUID, caller-scoped command-key, canonical payload-fingerprint, and deterministic Stripe-key contract only. PAY-002B2A is tracked in live [#165](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/165) for one unused server-only atomic registered command/revision-1 audit pair and exact existing/conflict classification. PAY-002B2B source/tests are tracked in live [#169](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/169). They keep those B2A records immutable and add a separate lifecycle record with a fixed 60-second server lease, UUID-holder fingerprint, monotonic fence, terminal commitment, and deterministic transition audit. PAY-002B2C1 source/tests are tracked in live [#173](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/173). While an exact lease/fence is active, they bind immutable attempt-1 Stripe account/mode/version/endpoint/operation/parameters/key commitments and a deterministic audit without permitting a send. PAY-002B2C2 source/tests are tracked in live [#182](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/182). They add a separate atomic pre-send marker/audit, a command-bound digest of every immutable C1 plan field, and a fixed 23-hour cutoff. The first marker creation, and an exact attempt-1 retry, get the narrow `send_permitted` classification only after a post-transaction trusted-time check remains before the transaction-validated lease's captured expiry and the persisted deadline; that second check does not re-read lifecycle state. Equality, later time, rollback, missing/unreadable paired time, or plan replacement requires reconciliation or fails closed. PAY-002B2C3A source/tests are tracked in live [#184](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/184). That unused pure policy accepts only closed exact attempt-1 evidence and returns fixed frozen existing/candidate/reconcile classifications. Only complete trusted proof that execution never began, or an exact verified expired/unpaid Session with verified expiry and an eligible new logical generation, can be a candidate; a candidate is not authorization. Exact open/successful attempts stop advancement, while every transport/provider/key/reference/search/state ambiguity requires reconciliation. These classifications are not caller authorization, configured-account proof, provider execution/outcome retrieval, persistence, attempt advancement, or response replay. CI-001B3 [#167](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/167) runs the exact focused emulator file as a named hosted release prerequisite, which #169, #173, and #182 expand. C3B evidence persistence, C3C later-attempt authorization/versioning, and every runtime adopter remain open. Real migration, capacity, inventory, deployment, provider configuration, and production operations remain open. +**Current work:** A PAY-003 webhook safety slice is implemented and unit-verified. PAY-002A1 is tracked in live [#161](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/161) for the pure versioned structural transition contract and synthetic legacy classification only. PAY-002B1 is tracked in live [#163](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/163) for the pure UUID, caller-scoped command-key, canonical payload-fingerprint, and deterministic Stripe-key contract only. PAY-002B2A is tracked in live [#165](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/165) for one unused server-only atomic registered command/revision-1 audit pair and exact existing/conflict classification. PAY-002B2B source/tests are tracked in live [#169](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/169). They keep those B2A records immutable and add a separate lifecycle record with a fixed 60-second server lease, UUID-holder fingerprint, monotonic fence, terminal commitment, and deterministic transition audit. PAY-002B2C1 source/tests are tracked in live [#173](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/173). While an exact lease/fence is active, they bind immutable attempt-1 Stripe account/mode/version/endpoint/operation/parameters/key commitments and a deterministic audit without permitting a send. PAY-002B2C2 source/tests are tracked in live [#182](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/182). They add a separate atomic pre-send marker/audit, a command-bound digest of every immutable C1 plan field, and a fixed 23-hour cutoff. The first marker creation, and an exact attempt-1 retry, get the narrow `send_permitted` classification only after a post-transaction trusted-time check remains before the transaction-validated lease's captured expiry and the persisted deadline; that second check does not re-read lifecycle state. Equality, later time, rollback, missing/unreadable paired time, or plan replacement requires reconciliation or fails closed. PAY-002B2C3A source/tests are tracked in live [#184](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/184). That unused pure policy accepts only closed exact attempt-1 evidence and returns fixed frozen existing/candidate/reconcile classifications. Only complete trusted proof that execution never began, or an exact verified expired/unpaid Session with verified expiry and an eligible new logical generation, can be a candidate; a candidate is not authorization. Exact open/successful attempts stop advancement, while every transport/provider/key/reference/search/state ambiguity requires reconciliation. PAY-002B2C3B source/tests are tracked in live [#206](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/206). The unused journal persists only an exact C3A candidate in one immutable record/audit pair after complete C1/C2 validation, the stored 23-hour cutoff, and current lease expiry. Exact retry is read-only; changed valid evidence conflicts; malformed/orphan/future/foundation-mismatched evidence fails closed. The fixed result still requires separate authorization. CI-001B3 [#167](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/167) runs the exact focused emulator file as a named hosted release prerequisite, which #169, #173, #182, and #206 expand. C3C later-attempt authorization/versioning and every runtime adopter remain open. Real migration, capacity, inventory, deployment, provider configuration, and production operations remain open. ### Phase 3 — Complete lifecycle and customer safety @@ -213,7 +213,7 @@ The first tranche deliberately combines one urgent safety repair with the most c 3. **SEC-001 — Firestore trust boundaries:** #100 merged through [PR #123](https://github.com/Run-MPRC/Run-MPRC.github.io/pull/123) at `a7fc301e85b0aeabe396e771faea21d3fc8e7b2b`. Source and 295 Rules tests remove recursive browser-admin authority. Firebase deployment explicitly skipped, so deployed/live Rules remain unproven under #105. 4. **PROMO-001 — fail-safe monetary policy:** [#102](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/102) disables promotion entry and automatic tax in both current creators, adds exact payload tests, and quarantines unknown or nonzero discount/tax/shipping adjustments on success, failure, and expiry. The private pre-change Session/provider inventory, protected deployment, provider readback, and live verification remain owner actions; no discount, tax, or shipping feature is authorized. 5. **Identity/config next:** AUTH-001A [#98](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/98) merged at `ce22c110`, proving source/tests for unverified-target rejection at the existing role-grant endpoints. AUTH-001B [#196](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/196) tracks the next bounded source/test outcome: role-based Firestore access requires an exact verified-email token while UID-bound safe profile recovery remains available. Firebase deployment is unproven, and Functions guards, verification-mirror refresh, revocation/audit, authoritative membership, and legacy-sync retirement keep the AUTH-001 parent open. CONFIG-001A merged through [#149](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/149) / PR #150 as `2c62b8dd`, but remains undeployed. CONFIG-001B1 is tracked in [#151](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/151) for fail-closed read-only admission; B2 retains the protected audited operator control, provider-object recovery, and drill. Follow the live issues and do not duplicate these slices. -6. **PAY-001/PAY-002:** PAY-001A source/tests are tracked in [#157](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/157) as the shared validation/logging foundation. PAY-002A1 is tracked in [#161](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/161) for pure separate state reducers and synthetic legacy classification. PAY-002B1 is tracked in [#163](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/163) for pure command/fingerprint/Stripe-key derivation. PAY-002B2A is tracked in [#165](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/165) for the immutable registered command/audit pair only. PAY-002B2B [#169](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/169) owns the separate unused lease/fence/terminal-commitment source and tests without send permission. PAY-002B2C1 [#173](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/173) owns only the unused lease-bound immutable initial plan and audit. PAY-002B2C2 [#182](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/182) owns only the unused separate pre-send marker/audit, exact-attempt retry classification, and persisted 23-hour cutoff. PAY-002B2C3A [#184](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/184) owns only the unused pure closed reconciliation decision. PAY-001B/C/D must still wire exact endpoint schemas/snapshots; C3B/C3C must persist verified reconciliation evidence and separately authorize/version safe attempt advancement; PAY-002C/D must then add persistence-first sagas; PAY-003B must adopt the shared reducer. No current endpoint imports these PAY-002 foundations, and none of #169/#173/#182/#184 deploys or configures Firebase/Stripe. +6. **PAY-001/PAY-002:** PAY-001A source/tests are tracked in [#157](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/157) as the shared validation/logging foundation. PAY-002A1 is tracked in [#161](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/161) for pure separate state reducers and synthetic legacy classification. PAY-002B1 is tracked in [#163](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/163) for pure command/fingerprint/Stripe-key derivation. PAY-002B2A is tracked in [#165](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/165) for the immutable registered command/audit pair only. PAY-002B2B [#169](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/169) owns the separate unused lease/fence/terminal-commitment source and tests without send permission. PAY-002B2C1 [#173](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/173) owns only the unused lease-bound immutable initial plan and audit. PAY-002B2C2 [#182](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/182) owns only the unused separate pre-send marker/audit, exact-attempt retry classification, and persisted 23-hour cutoff. PAY-002B2C3A [#184](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/184) owns only the unused pure closed reconciliation decision. PAY-002B2C3B [#206](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/206) owns only immutable candidate evidence/audit persistence after the C2 cutoff and lease expiry; its result is not authorization. PAY-001B/C/D must still wire exact endpoint schemas/snapshots; C3C must separately authorize/version safe attempt advancement under a fresh lease; PAY-002C/D must then add persistence-first sagas; PAY-003B must adopt the shared reducer. No current endpoint imports these PAY-002 foundations, and none of #169/#173/#182/#184/#206 deploys or configures Firebase/Stripe. 7. **Then:** RACE-001 and MERCH-001 transactional reservations. Why this order: SAFETY-001 removes immediate accidental-production and callback hazards without schema impact. PAY-003 closes the most dangerous “unpaid looks paid” and replay gaps while establishing test fixtures. The subsequent trust-boundary and command/state work completes the surrounding architecture; no live launch is authorized by the partial webhook patch alone. diff --git a/SECURITY.md b/SECURITY.md index 4c634b4b..6a49fb56 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -97,8 +97,8 @@ These entries are implementation evidence, not a production risk-acceptance deci | --- | --- | --- | | Immediate input-containment part of RISK-020 | PAY-005A1 is tracked in live [#200](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/200). Both existing admin refund Functions share one non-coercing validator: a partial amount must be a primitive positive safe integer below the stored positive safe-integer original cents. Invalid caller or stored values stop before Stripe construction/refund creation and Firestore mutation; valid partials always include the exact `amount`; only the explicit full action omits it. Provider exceptions expose no detail and return a fixed result-not-confirmed, do-not-retry, and escalate message. Focused tests cover both race and merchandise paths, fixed errors, valid boundaries, provider failure, and malformed/equal/over/non-finite/coerced inputs. | This immediate guard does not prove the remaining refundable Stripe balance, cumulative prior refunds, command identity/idempotency, concurrency safety, pending/event-finalized state, capability/MFA/recent-auth, approved policy, Firebase deployment, Stripe configuration, or live behavior. An unconfirmed provider result remains ambiguous and requires reconciliation, not a blind retry. Complete PAY-005A, its PAY-002/PAY-003 dependencies, AUTH-003, and every listed residual gate before authorizing production refunds. | | Immediate resolved-result containment part of RISK-020 | PAY-005A2 is tracked in live [#204](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/204). Both existing refund Functions build one immutable expectation before Stripe from a bounded stored PaymentIntent, positive original cents, exact `usd`, action, and admitted partial cents. Only a plain succeeded Refund with a bounded ID, exact primitive PaymentIntent/currency binding, and permitted amount returns a frozen minimal projection. Partial cents must match exactly; explicit full accepts the positive full-remaining amount up to the original total. Invalid stored targets stop before Stripe. Thrown, malformed, mismatched, pending, action-required, failed, cancelled, unknown, or post-provider local-write failures return the same fixed ambiguous message and never return local success. A rejected Stripe result causes no local success write attempt; a later local-write error leaves the record state unknown. The audit uses only the validated refund ID and actual returned cents. | The provider call precedes response validation and the local write, so an unconfirmed provider result may already have moved money. A local write that reports an error may have committed or may remain unchanged, especially when its acknowledgement is lost. Do not retry either outcome; reconcile it. This slice adds no durable pre-send operation/ID, idempotency key, remaining-balance retrieval, cumulative amount, concurrency fence, webhook-finalized state, capability/MFA/recent-auth, approved policy, provider rehearsal, Firebase deployment, or live proof. Complete PAY-002B2C3B/C, PAY-005A, PAY-003B, AUTH-003, reconciliation, and protected release before authorizing production refunds. | -| Foundation only for parts of RISK-011, RISK-020, RISK-021 | PAY-002A1 is tracked in live [#161](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/161). The source-only child defines versioned pure payment, registration, fulfillment, confirmed-refund, and per-dispute structural reducers; fixed cross-dimension review/rejection; and a deterministic synthetic legacy classifier/aggregate report with no IDs. Orders/registrations carry no singular canonical dispute state. Forbidden regressions fail closed, refunds/disputes do not overwrite payment state, and `paid_after_cancellation` remains paid plus cancelled plus review rather than becoming a payment enum. | This does not yet mitigate those risks in a runtime. An unchanged enum does not prove duplicate Event/command/evidence, authorization, amount monotonicity, provider state, or club policy. No endpoint imports the library and no business record is read/written or migrated. PAY-002B1 [#163](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/163) supplies pure command/attempt keys; PAY-002B2A [#165](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/165) supplies only unused registration/audit persistence; PAY-002B2B [#169](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/169) supplies unused lease/fence/terminal-commitment source and tests without provider permission; PAY-002B2C1 [#173](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/173) supplies only unused lease-bound attempt-1 plan commitments/audit; PAY-002B2C2 [#182](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/182) supplies only unused pre-send evidence/audit and the exact persisted retry cutoff; PAY-002B2C3A [#184](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/184) supplies only an unused pure closed evidence classifier with no persistence or authorization; C3B/C3C must still persist verified proof and authorize/version any later attempt; PAY-002C/D persistence-first checkout; PAY-003B Event/version/evidence adoption including same-state amount changes and separate dispute records; PAY-004/PAY-005 cancellation/refund operations; MERCH-002/LEGAL-001 return/policy gates; #113 and a later migration child real inventory/dry-run. Firebase/Stripe deployment and live behavior remain unproven. | -| Foundation only for retry/duplication parts of RISK-003 and RISK-020 | PAY-002B1 is tracked in live [#163](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/163). The pure source child defines a canonical UUID v4 boundary, a domain-separated caller/environment command hash, a command-type plus canonical frozen-payload fingerprint, and a deterministic environment/mode/operation/command/provider-attempt Stripe key. Invalid production/test or non-production/live pairs fail. Raw caller, UUID, operation, and payload values never appear in outputs. PAY-002B2C1 is tracked in live [#173](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/173) for an immutable lease-bound attempt-1 plan and deterministic audit whose account, canonical parameters, and B1 key are stored only as command-bound commitments. PAY-002B2C2 is tracked in live [#182](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/182) for a separate atomic pre-send marker/audit, a complete command-bound C1-plan digest in both partners, a post-transaction lease/deadline freshness check, and persisted 23-hour attempt-1 cutoff. PAY-002B2C3A is tracked in live [#184](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/184) for the pure exact attempt-1 reconciliation evidence policy. | Hashes and commitments are pseudonymous server-only equality evidence—not anonymization, authorization, configured-account proof, durable replay, or provider-execution proof. No endpoint imports these libraries. PAY-002B2A/#165 registers only a command/audit pair. PAY-002B2B/#169 adds a separate fixed-duration lease, holder fingerprint, monotonic fence, terminal commitment, and transition audit without provider permission. C1 does not permit a send. C2's fixed `send_permitted` is only a retry-safety classification under the exact lease/fence/plan; it is not caller authorization, configured-account proof, a Stripe call/outcome, or response replay. C2 makes no provider request and never advances attempt `1`. A changed/replaced plan fails its stored digest. Lease/deadline equality, later time, rollback, or missing/unreadable paired time returns `reconciliation_required`. C3A returns only fixed `existing_attempt_found`, `new_attempt_candidate`, or `reconciliation_required` results from closed enum evidence; a candidate is not send/advance permission. Timeout, connection loss, provider error, old/pruned key, missing/not-found object, partial/empty search, processing/unknown state, and conflict cannot become a candidate. C3B/C3C must persist verified evidence and separately authorize/version any later attempt. PAY-002C/D and PAY-005 must adopt the complete B2C chain with endpoint schemas and failure-boundary tests. Deployment, provider configuration, and live behavior remain unproven. | +| Foundation only for parts of RISK-011, RISK-020, RISK-021 | PAY-002A1 is tracked in live [#161](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/161). The source-only child defines versioned pure payment, registration, fulfillment, confirmed-refund, and per-dispute structural reducers; fixed cross-dimension review/rejection; and a deterministic synthetic legacy classifier/aggregate report with no IDs. Orders/registrations carry no singular canonical dispute state. Forbidden regressions fail closed, refunds/disputes do not overwrite payment state, and `paid_after_cancellation` remains paid plus cancelled plus review rather than becoming a payment enum. | This does not yet mitigate those risks in a runtime. An unchanged enum does not prove duplicate Event/command/evidence, authorization, amount monotonicity, provider state, or club policy. No endpoint imports the library and no business record is read/written or migrated. PAY-002B1 [#163](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/163) supplies pure command/attempt keys; PAY-002B2A [#165](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/165) supplies only unused registration/audit persistence; PAY-002B2B [#169](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/169) supplies unused lease/fence/terminal-commitment source and tests without provider permission; PAY-002B2C1 [#173](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/173) supplies only unused lease-bound attempt-1 plan commitments/audit; PAY-002B2C2 [#182](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/182) supplies only unused pre-send evidence/audit and the exact persisted retry cutoff; PAY-002B2C3A [#184](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/184) supplies only an unused pure closed evidence classifier; PAY-002B2C3B [#206](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/206) supplies only unused immutable candidate evidence/audit persistence after the cutoff and expired-lease gate. Neither child authorizes a send. C3C must still authorize/version any later attempt under a fresh lease; PAY-002C/D persistence-first checkout; PAY-003B Event/version/evidence adoption including same-state amount changes and separate dispute records; PAY-004/PAY-005 cancellation/refund operations; MERCH-002/LEGAL-001 return/policy gates; #113 and a later migration child real inventory/dry-run. Firebase/Stripe deployment and live behavior remain unproven. | +| Foundation only for retry/duplication parts of RISK-003 and RISK-020 | PAY-002B1 is tracked in live [#163](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/163). The pure source child defines a canonical UUID v4 boundary, a domain-separated caller/environment command hash, a command-type plus canonical frozen-payload fingerprint, and a deterministic environment/mode/operation/command/provider-attempt Stripe key. Invalid production/test or non-production/live pairs fail. Raw caller, UUID, operation, and payload values never appear in outputs. PAY-002B2C1 is tracked in live [#173](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/173) for an immutable lease-bound attempt-1 plan and deterministic audit whose account, canonical parameters, and B1 key are stored only as command-bound commitments. PAY-002B2C2 is tracked in live [#182](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/182) for a separate atomic pre-send marker/audit, a complete command-bound C1-plan digest in both partners, a post-transaction lease/deadline freshness check, and persisted 23-hour attempt-1 cutoff. PAY-002B2C3A is tracked in live [#184](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/184) for the pure exact attempt-1 reconciliation evidence policy. PAY-002B2C3B is tracked in live [#206](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/206) for the deterministic candidate record/audit pair bound to complete C1/C2 commitments and an observed expired lease. | Hashes and commitments are pseudonymous server-only equality evidence—not anonymization, authorization, configured-account proof, durable replay, or provider-execution proof. No endpoint imports these libraries. PAY-002B2A/#165 registers only a command/audit pair. PAY-002B2B/#169 adds a separate fixed-duration lease, holder fingerprint, monotonic fence, terminal commitment, and transition audit without provider permission. C1 does not permit a send. C2's fixed `send_permitted` is only a retry-safety classification under the exact lease/fence/plan; it is not caller authorization, configured-account proof, a Stripe call/outcome, or response replay. C2 makes no provider request and never advances attempt `1`. A changed/replaced plan fails its stored digest. Lease/deadline equality, later time, rollback, or missing/unreadable paired time returns `reconciliation_required`. C3A returns only fixed `existing_attempt_found`, `new_attempt_candidate`, or `reconciliation_required` results from closed enum evidence. C3B can persist only an exact candidate at or after both the stored cutoff and current lease expiry; early/active candidates write nothing, exact retry is read-only, and changed/malformed/orphan/future evidence fails closed. Its fixed result still requires separate authorization. C3C must separately authorize/version any later attempt under a fresh lease. PAY-002C/D and PAY-005 must adopt the complete B2C chain with endpoint schemas and failure-boundary tests. Deployment, provider configuration, and live behavior remain unproven. | | Foundation only for registration/audit parts of RISK-003, RISK-020, and RISK-032 | PAY-002B2A is tracked in live [#165](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/165). The unused server-only transaction creates one immutable `checkoutRequests/{commandKeyHash}` registered record and deterministic revision-1 `auditEvents` partner atomically. Exact retries are read-only. Same-key type/schema/payload conflicts and corrupt, orphan, future, or unexpected stored pairs fail closed without repair. Environment/caller-scope changes derive different B1 keys; a stored mismatch under one key is corruption, not a cross-scope lookup. Fixed results expose no raw identity, UUID, payload, hash, path, or Firestore detail. CI-001B3 [#167](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/167) runs its dedicated exact demo-project/opt-in suite as a named hosted job and protected-release prerequisite. | Registration is neither authorization nor execution permission. #169 keeps the root and revision-1 audit immutable, places lifecycle state in the separate server-only subcollection, fails closed on malformed partners, and proves in synthetic tests that stale workers cannot finalize. The hosted emulator result is still synthetic source proof, not deployment or provider proof. No TTL is safe: [#110](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/110) must approve retention and a server-only tombstone or equivalent durable duplicate barrier before deletion. Source/tests/merge do not prove Firebase deployment, Stripe configuration, or live behavior. | | RISK-008 | ABUSE-001A1 is tracked in live [#159](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/159). The source-only slice replaces the browser reCAPTCHA v3 constructor with the pinned Firebase SDK's Enterprise provider and preserves local/test/capability-callback shutdown, missing-key behavior, token refresh, Analytics shutdown, and fixed redacted failure diagnostics. Synthetic tests use no real key or provider call. | No Enterprise key, allowed-domain policy, provider console setting, protected build variable, token exchange, staged metric, callable enforcement, website/Firebase publication, or live behavior is configured or proven. Release `29254280177` failed closed before build because the public site-key variable was absent. Complete #113/#133, ABUSE-001A2, #136/WEB-001, staged metrics, and provider readback before enforcement or a live claim. | | RISK-017 | PAY-001A is tracked in live [#157](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/157). The source slice adds dependency-free strict-root-object, root-budget, bounded string/array, integer-cents, fixed-currency, calendar-date, canonical-URL, conservative email, fixed-error, and closed-enum safe-log primitives. Synthetic hostile-input tests prove deep immutable copies and reject prototypes, accessors, and unknown configured root keys. The libraries contain no network, Firebase, Stripe, logger, clock, or random dependency and make no such call themselves; a supplied array item parser must be trusted, pure, and synchronous. | This library is not imported by an endpoint and is not deployed. PAY-001B/C/D must define exact race, merchandise, lookup, refund, admin, nested-object, and custom-field schemas; apply smaller business limits before every write/provider call; store immutable schema/price snapshots; and add endpoint no-side-effect tests. Safe-log primitives do not replace approved observability purpose, retention, access, transport, or provider controls. | @@ -116,7 +116,7 @@ These entries are implementation evidence, not a production risk-acceptance deci | Part of RISK-014 | SUPPLY-001C [#202](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/202) pins the Firebase browser SDK to 11.10.0 and its Rules helper to 4.0.1. A fresh Node 20 production audit falls from 19 findings (1 critical, 5 high) to 6 (0 critical, 3 high); the root production `protobufjs` path resolves to 7.6.5 and Firebase's root `undici` path is removed. The upgraded Rules helper first failed closed because Jest lacked `fetch`; a dedicated Node 20 environment exposes only the host's native web APIs, after which 348/348 Rules cases pass. Focused Firebase/Auth/App Check tests pass 71/71, the full frontend passes 198/198, and applicable Functions units pass 1,077/1,077. | The remaining 6 root and 9 Functions production findings are not accepted as safe. An older dev-only `protobufjs` remains nested under `firebase-tools`; the build chain still accounts for the root findings. Reproducible clean builds change the main optimized bundle from `main.fdd058b4.js` to `main.56edbdb7.js`, +8.29 kB gzip (about 3.3%); overall build output grows 180 KiB (about 1.0%). Complete SUPPLY-001D/F/G through separate reviewable changes. This source/test result does not publish the website, deploy Firebase, change a provider, or prove live behavior. | | Part of RISK-024 | SEC-001 protects token documents from every browser client. | OAUTH-001 must still add transactional refresh/versioning, scope/account binding, disconnect/revocation, IAM/encryption decision, and redacted lifecycle audit. | | Part of RISK-027 | #99 blocks App Check, Firebase Analytics, and Sentry in local/test and on initial capability-callback startup. OBS-001A1 [#134](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/134) source disables Sentry replay, tracing, browser session tracking, client reports, member user context, breadcrumbs, attachments, transactions, and unapproved default integrations. OBS-001A2 [#139](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/139) source removes every application runtime Firebase Analytics import, initialization, and emission, preserves existing wrapper calls as provider-free no-ops, and removes the direct Waiver SDK bypass. OBS-001A3 [#142](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/142) source routes the remaining render, Auth-email, members-only, and App Check browser diagnostics through five fixed outcome identifiers and prohibits direct console calls elsewhere in application/public runtime source. Synthetic tests use mocks/canaries and make no provider call. | #110/#111 still own any future approved telemetry purpose, consent, event/parameter schema, provider settings, and residual hosted boundary. Source/tests and merge evidence do not prove website publication, the exact `runmprc.com` revision, provider transport/collection, IP/cookie behavior, console history, historical data deletion, retention, access, consent, deletion, or vendor terms. Sentry provider behavior is likewise unverified. Callback protected handoffs remain with ABUSE-001A, DATA-001A, and OAUTH-001C in #88. | -| Part of RISK-035 | The Jest environment is repaired, #124 adds the complete frontend suite as a named blocking hosted step, and #126 adds the standalone SPA callback suite. CI-001B3 [#167](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/167) merged the lockfile-bound Node 20/Java 17 `Commerce command journal emulator` job using exact demo project `demo-pay002b2-test`, Firestore only, and explicit opt-in. CI-001B4 [#186](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/186) merged through [PR #189](https://github.com/Run-MPRC/Run-MPRC.github.io/pull/189) as `bec7d5e365eacb418563a172029f241f660d9768`, adding the no-new-dependency exact-record checker over 102 frontend files, named blocking lint step, and clean-checkout step. Exact PR run `29291402007` and post-merge run `29291515653` passed every GitHub job. | The reviewable lint baseline deliberately records 125 configured errors and 7 warnings as individual file/line/column/rule/message records; it is not a clean-lint claim. Any added, removed, moved, or changed record requires review, every fatal parser/configuration failure rejects with a fixed diagnostic, and the baseline must not be regenerated just to pass. The clean check covers tracked and non-ignored untracked files; ignored dependency/build outputs are outside this proof. Hosted source checks do not configure branch protection, deploy Firebase, or prove Stripe/live behavior. #169, #173, and #182 expand the focused commerce suite; configure/prove branch-required checks and add the broader TEST-001 suite. | +| Part of RISK-035 | The Jest environment is repaired, #124 adds the complete frontend suite as a named blocking hosted step, and #126 adds the standalone SPA callback suite. CI-001B3 [#167](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/167) merged the lockfile-bound Node 20/Java 17 `Commerce command journal emulator` job using exact demo project `demo-pay002b2-test`, Firestore only, and explicit opt-in. CI-001B4 [#186](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/186) merged through [PR #189](https://github.com/Run-MPRC/Run-MPRC.github.io/pull/189) as `bec7d5e365eacb418563a172029f241f660d9768`, adding the no-new-dependency exact-record checker over 102 frontend files, named blocking lint step, and clean-checkout step. Exact PR run `29291402007` and post-merge run `29291515653` passed every GitHub job. | The reviewable lint baseline deliberately records 125 configured errors and 7 warnings as individual file/line/column/rule/message records; it is not a clean-lint claim. Any added, removed, moved, or changed record requires review, every fatal parser/configuration failure rejects with a fixed diagnostic, and the baseline must not be regenerated just to pass. The clean check covers tracked and non-ignored untracked files; ignored dependency/build outputs are outside this proof. Hosted source checks do not configure branch protection, deploy Firebase, or prove Stripe/live behavior. #169, #173, #182, and #206 expand the focused commerce suite; configure/prove branch-required checks and add the broader TEST-001 suite. | | RISK-026, RISK-036 | #135 merged through PR #138 as `9eafab1217aff7058c42240aaba72d7b93f8ed24`, replacing automatic frontend-first/fail-open GitHub deployment with a tested manual exact-current-commit gate. Post-merge staging and synthetic production probes failed closed before authentication or mutation, and published neither Firebase nor Pages. | #133 must configure protected environments and least-privilege OIDC/WIF; #136 must prove staged/target deployment and clearing/readback of the existing Pages `runmprc.com` claim; a protected WEB-001 child must establish Netlify publication and rollback. No Firebase, Pages, live-host, or provider-setting change is proven by source/static tests alone. | | RISK-039 | The #118 source slice uses an empty authenticated callable request, bounded Firebase Auth identity fields, one transactional create-only helper shared with signup, constant responses, generic failures, and a UI that hides Edit until setup and the Rules-protected read succeed. Signup and recovery never change custom claims; browser profile creation remains denied. | Source review/merge is not deployment. Under #105, deploy the exact #100 Rules plus both `createMemberOnSignUp` and `ensureMemberProfile` before the website, prove App Check policy, use a synthetic staged account, verify rollback, then record website, Function, Rules, and live behavior separately. Never repair a real profile manually. | | RISK-040 | AUTH-MAIL-002A [#145](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/145) merged as `46557c7`: account creation returns `accepted` or `unavailable` without exposing provider details. AUTH-MAIL-002B [#153](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/153) merged as `23bca8c8`: My Account makes no false “sent” claim, blocks rapid repeats, and applies the same 60-second browser cooldown after either outcome. Its protected release run `29252492614` stopped before build because the required public App Check key was absent, so neither frontend revision is published. AUTH-MAIL-002C1 [#155](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/155) tracks one byte-equivalent password-reset request result after provider success or failure. AUTH-MAIL-002C2 [#194](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/194) tracks the source-only `/auth/action` verification path: the initial capability suppresses Sentry/App Check, the page removes native and router query/fragment state, a scanner-style page load makes no action-code check/apply and no account mutation, and one deliberate action requires provider `VERIFY_EMAIL` before apply. Fixed results expose no email, code, raw provider error, account identity, or provider-directed navigation. Synthetic tests use mocks and canary values only. | Publish and verify each exact frontend revision separately. The existing #99 Pages bridge briefly uses tab-local session storage for the return route and deletes it before React; a failed root load can leave it until tab close, while direct-rewrite hosting avoids that residual. #194 adds no storage write. #118 profile source is merged but live behavior is unproven. Keep delivery, Spam, DNS, templates, provider handler choice, and private Firebase email-enumeration-protection readback under #119. Firebase uses one custom handler for verification, password reset, and email recovery; never point its global action URL at the verification-only #194 route until every enabled mode is safely handled, or keep the default multi-mode handler. Accepted requests do not prove delivery, browser cooldowns are not abuse controls, Auth verification does not grant membership, and the Firestore verification mirror remains unfinished AUTH-001 work. | diff --git a/STRIPE_COMMERCE_DESIGN.md b/STRIPE_COMMERCE_DESIGN.md index 65f168fb..750b2743 100644 --- a/STRIPE_COMMERCE_DESIGN.md +++ b/STRIPE_COMMERCE_DESIGN.md @@ -328,7 +328,31 @@ The final row includes timeout, connection loss, conflict, external-dependency f All three results are frozen and contain only the policy version, classification, and state. Invalid or hostile input produces one fixed non-identifying error. The module has no Firebase, Stripe, network, logger, environment, clock, random, filesystem, persistence, or runtime/index edge. It cannot return send/execute/advance permission, create an attempt-2 plan/key/document, replay a response, or apply a business transition. -This child does not complete PAY-002B2C3. C3B must append immutable paired reconciliation evidence only after a trusted verifier can produce it. C3C must require that persisted proof, an allowed business transition, and a fresh lease before it can authorize and version a later provider attempt. Runtime adoption, Firebase/Stripe deployment, provider configuration, production data, and live behavior remain unchanged. +### Immutable candidate-evidence target (PAY-002B2C3B) + +PAY-002B2C3B source/tests are tracked in live [#206](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/206). The unused journal accepts the same immutable Stripe plan-identity fields as C1 plus one exact C3A evidence object. It snapshots and classifies that object once before Firestore. It never accepts a caller-selected lease, fence, attempt, key, time, path, authorization, provider result, or action flag. + +```mermaid +flowchart LR + C1["Exact attempt-1 plan"] --> C2["Complete pre-send record + audit"] + C2 --> Cutoff{"23-hour cutoff reached?"} + Lease["Current validated lease"] --> Expired{"Lease expired?"} + Evidence["Exact C3A closed evidence"] --> Candidate{"Candidate?"} + Cutoff --> All{"All three conditions true?"} + Expired --> All + Candidate --> All + All --> Pair["Create immutable evidence + audit pair"] + Pair --> Stop["Persisted; separate authorization still required"] + Stop -. "No attempt 2 or Stripe call" .-> C3C["Future C3C fresh-lease decision"] +``` + +Text alternative: C3B creates one immutable evidence/audit pair only when the exact C1 plan and complete C2 pair validate, the stored 23-hour cutoff is reached, the current validated lease is expired, and C3A returns the exact candidate classification; the result still cannot send or advance. + +The record path is `checkoutRequests/{commandKeyHash}/providerAttempts/0000000001/reconciliationEvidence/0000000001`. Its deterministic partner is `auditEvents/commerce_provider_reconciliation_{commandKeyHash}_0000000001_0000000001`. The record stores only schema versions, closed evidence enums, command-bound C1/C2 commitments, observed fence/lease expiry, and trusted record time. The audit repeats the non-identifying binding and carries a complete-record commitment. It contains no raw account, key, parameters, provider/business/member ID, amount, URL, free text, provider response, or attempt `2`. + +C3B reads and validates B1, B2, C1, and both C2 partners before either create. It may create only when the trusted time is at or after both the persisted C2 deadline and the current lease expiry. Before either gate, a candidate is returned unchanged with no write. At equality, persistence is safe because C2 already denies automatic POST and the lease is no longer active. An exact persisted retry is read-only. Any valid evidence change conflicts; orphan, malformed, future, or foundation-mismatched partners fail closed and are never repaired. Transaction retry callbacks reuse the same snapshot and time, and an acknowledged-lost commit is recovered through an exact read-only retry. + +The fixed C3B result is `reconciliation_candidate_persisted` with `requires_separate_authorization`. It is durable candidate evidence, not verified provider retrieval, caller authorization, execution permission, response replay, a business transition, or a later attempt. C3C must require this persisted proof, an allowed business transition, and a fresh lease before it can authorize and version any later provider attempt. No endpoint or Functions index imports the journal. #206 makes no Stripe/network call and performs no Firebase/Stripe deployment, provider configuration, production data action, website publication, or live behavior change. ## 7. Persistence-first checkout saga diff --git a/SYSTEM_DESIGN.md b/SYSTEM_DESIGN.md index 8f0e4714..8490a20f 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -272,7 +272,8 @@ The first release may continue using `admin` in the UI, but server endpoints and | `checkoutRequests/{commandKeyHash}/lifecycle/current` | PAY-002B2B/#169 server-only lease, monotonic fence, and terminal commitment source. It is unused and is not provider-send permission. | | `checkoutRequests/{commandKeyHash}/providerAttempts/0000000001` | PAY-002B2C1/#173 immutable lease-bound initial Stripe plan. It stores command-bound commitments, is unused, and is not account proof or provider-send permission. | | `checkoutRequests/{commandKeyHash}/providerAttempts/0000000001/sendEvidence/first` | PAY-002B2C2/#182 separate server-only pre-POST marker with a complete C1-plan digest, its originating fence, trusted time, and persisted 23-hour automatic-retry deadline. It is unused and does not say Stripe received or completed a request. | -| `auditEvents/{eventId}` or bounded per-record audit subcollections | Append-oriented operational and security audit trail. B2A's first event is `commerce_command_{commandKeyHash}_0000000001`; B2B appends one deterministic event for each real lifecycle change; C1 binds the first plan with `commerce_provider_attempt_{commandKeyHash}_0000000001`; C2 pairs the pre-send marker with `commerce_provider_send_{commandKeyHash}_0000000001`. | +| `checkoutRequests/{commandKeyHash}/providerAttempts/0000000001/reconciliationEvidence/0000000001` | PAY-002B2C3B/#206 immutable server-only C3A candidate evidence. It is bound to the exact C1 plan, complete C2 record/audit, observed expired lease, and trusted time. It is unused and grants no later-attempt or send permission. | +| `auditEvents/{eventId}` or bounded per-record audit subcollections | Append-oriented operational and security audit trail. B2A's first event is `commerce_command_{commandKeyHash}_0000000001`; B2B appends one deterministic event for each real lifecycle change; C1 binds the first plan with `commerce_provider_attempt_{commandKeyHash}_0000000001`; C2 pairs the pre-send marker with `commerce_provider_send_{commandKeyHash}_0000000001`; C3B pairs candidate evidence with `commerce_provider_reconciliation_{commandKeyHash}_0000000001_0000000001`. | | `events/{id}.capacityCounters` | Transactionally maintained participant reservations, paid seats, and released seats | | `products/{id}/variants/{variantId}` | SKU, option values, price, on-hand, reserved, and sold counts | | `orders.paymentStatus` and `orders.fulfillmentStatus` | Separate money state from physical fulfillment state | @@ -303,9 +304,13 @@ The fixed C2 result is narrow retry-safety evidence. It is not caller authorizat PAY-002B2C3A source/tests are tracked in live [#184](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/184). The unused pure policy accepts only one exact flat version-1 Stripe attempt-1 evidence record made from closed enums. It returns one of three frozen non-identifying results: `existing_attempt_found` with `do_not_advance`, `new_attempt_candidate` with `requires_persistence_and_authorization`, or `reconciliation_required` with `requires_reconciliation`. Only the matching complete tuple for trusted proof that endpoint execution never began, or the matching complete tuple for an exact verified expired/unpaid Session plus verified expiry and an explicitly eligible new logical generation, can be a candidate. The matching complete exact-open or verified-success tuple identifies an existing attempt. Every single-field difference, timeout, lost connection, provider error category, old/pruned key, missing reference, not-found result, empty/partial search, processing/unknown state, mismatch, or conflict requires reconciliation. -C3A classification is not authorization or provider truth. The module accepts no IDs, keys, accounts, metadata, money, timestamps, business/member values, free text, URLs, or caller-selected status codes. It stores nothing, calls nothing, is imported by no runtime entry point, and cannot create attempt `2`. C3B must later persist immutable paired verified evidence; C3C must separately authorize and version any later attempt only after that proof, an allowed business transition, and a fresh lease. No Firebase/Stripe deployment, provider configuration, production read/write, website, or officer behavior changes in C3A. +C3A classification is not authorization or provider truth. The module accepts no IDs, keys, accounts, metadata, money, timestamps, business/member values, free text, URLs, or caller-selected status codes. It stores nothing, calls nothing, is imported by no runtime entry point, and cannot create attempt `2`. -CI-001B3 [#167](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/167) runs the exact opt-in command-journal emulator suite as a named hosted release prerequisite; #169, #173, and #182 expand that same suite. These are synthetic source checks only. The current journal source remains unused and makes no endpoint, Firebase deployment, Stripe/provider configuration, production data, website, or live/officer change. +PAY-002B2C3B source/tests are tracked in live [#206](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/206). The unused journal imports the unchanged C3A classifier and may persist only its exact `new_attempt_candidate` result. The deterministic evidence/audit pair is created atomically only when the complete C2 record/audit is valid, the 23-hour automatic retry deadline has arrived, and the current validated lease is expired. Equality is allowed because C2 stops automatic POST at its deadline and lease expiry. A still-active lease or an early candidate returns the unchanged C3A candidate without writing. Exact retries are read-only; a changed valid evidence tuple conflicts; a missing, malformed, orphaned, future, or foundation-mismatched pair fails closed without repair. + +C3B stores only closed C3A enum values, schema versions, command-bound C1/C2 commitments, the observed fence/expiry, and one trusted time. A digest covers the complete evidence record and is carried by its deterministic audit. Raw account, key, parameters, provider/business/member IDs, money, URLs, free text, response data, and attempt `2` are absent. The fixed persisted result says only `requires_separate_authorization`. C3C must still require this proof, an allowed business transition, and a fresh lease before it can authorize/version a later attempt. Neither C3A nor C3B retrieves provider truth, calls Stripe, replays a response, changes a business record, or has a runtime/index edge. No Firebase/Stripe deployment, provider configuration, production read/write, website, or officer behavior is changed by #184/#206 source. + +CI-001B3 [#167](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/167) runs the exact opt-in command-journal emulator suite as a named hosted release prerequisite; #169, #173, #182, and #206 expand that same suite. These are synthetic source checks only. The current journal source remains unused and makes no endpoint, Firebase deployment, Stripe/provider configuration, production data, website, or live/officer change. ## 7. Business invariants @@ -399,7 +404,7 @@ sequenceDiagram F-->>W: Confirmed, processing, failed, or support-required state ``` -If Stripe Session creation fails, the function releases the reservation in a compensating transaction. If the function crashes after Stripe creates the Session, PAY-002B2C2 retries only the exact B2C1 plan/key inside its stored safe-send window. After that deadline—or when first-send time is unknown—it stops POSTing. C3A can classify already-verified evidence but cannot retrieve, store, authorize, or advance anything; C3B/C3C must complete those gates before a later POST. A scheduled job releases abandoned reservations and reconciles records that missed a webhook. +If Stripe Session creation fails, the function releases the reservation in a compensating transaction. If the function crashes after Stripe creates the Session, PAY-002B2C2 retries only the exact B2C1 plan/key inside its stored safe-send window. After that deadline—or when first-send time is unknown—it stops POSTing. C3A can classify already-verified evidence, and C3B can persist only a candidate after the retry and lease gates; neither retrieves provider truth, authorizes, or advances. C3C must complete the separate fresh-lease authorization gate before a later POST. A scheduled job releases abandoned reservations and reconciles records that missed a webhook. ### 8.2 Free or volunteer registration @@ -422,7 +427,7 @@ The ingress verifies method, raw payload, signature, and secret. Relevant event Stripe and Firestore cannot share a distributed transaction. Checkout and refunds are therefore explicit sagas: - A Firestore transaction protects local uniqueness and scarce-resource counters. -- A stable PAY-002B1 Stripe key identifies one logical provider generation. A lease takeover or HTTP retry is not a new generation. PAY-002B2C1 binds the immutable attempt-1 plan; B2C2 may retry only that exact plan/key inside its conservative stored send window; after that window or with unknown send time, it stops automatic POST. C3A classifies only closed verified evidence, while C3B/C3C must persist proof and separately authorize any later generation because Stripe may have pruned the old key. +- A stable PAY-002B1 Stripe key identifies one logical provider generation. A lease takeover or HTTP retry is not a new generation. PAY-002B2C1 binds the immutable attempt-1 plan; B2C2 may retry only that exact plan/key inside its conservative stored send window; after that window or with unknown send time, it stops automatic POST. C3A classifies only closed verified evidence, C3B persists only the exact candidate after the stored cutoff and current lease expiry, and C3C must separately authorize any later generation because Stripe may have pruned the old key. - The business record stores the saga step and external ID. - A compensating transaction releases a reservation after a known failure. - A webhook advances successful external state. diff --git a/functions/commerceCommandJournal.emulator.test.js b/functions/commerceCommandJournal.emulator.test.js index 76f0a6a0..e6f4dff2 100644 --- a/functions/commerceCommandJournal.emulator.test.js +++ b/functions/commerceCommandJournal.emulator.test.js @@ -15,6 +15,7 @@ const { bindInitialStripeProviderPlan, completeCommerceCommand, failCommerceCommand, + recordInitialStripeReconciliationEvidence, recordInitialStripeSendEvidence, registerCommerceCommand, } = require('./commerceCommandJournal'); @@ -74,6 +75,13 @@ const COMMAND_IDS = Object.freeze({ providerSendReplacement: '76ace024-7913-4bdf-8ace-68ace0247913', providerSendCommitFailure: '74ace024-7913-4bdf-8ace-68ace0247913', providerSendOrphan: '75ace024-7913-4bdf-8ace-68ace0247913', + providerReconciliationConcurrent: '81ace024-7913-4bdf-8ace-68ace0247913', + providerReconciliationConflict: '82ace024-7913-4bdf-8ace-68ace0247913', + providerReconciliationLostResponse: '83ace024-7913-4bdf-8ace-68ace0247913', + providerReconciliationTakeover: '84ace024-7913-4bdf-8ace-68ace0247913', + providerReconciliationCommitFailure: '85ace024-7913-4bdf-8ace-68ace0247913', + providerReconciliationOrphan: '86ace024-7913-4bdf-8ace-68ace0247913', + providerReconciliationMalformedSend: '87ace024-7913-4bdf-8ace-68ace0247913', }); const BASE_NOW_MILLIS = 1800000000123; const LEASE_DURATION_MILLIS = 60000; @@ -172,6 +180,63 @@ function providerPlanArgs( }; } +function reconciliationArgs(providerArgs, reconciliationEvidence) { + return { + db: providerArgs.db, + environment: providerArgs.environment, + callerScope: providerArgs.callerScope, + commandId: providerArgs.commandId, + commandType: providerArgs.commandType, + endpointSchemaVersion: providerArgs.endpointSchemaVersion, + payload: providerArgs.payload, + stripeAccountId: providerArgs.stripeAccountId, + stripeMode: providerArgs.stripeMode, + stripeApiVersion: providerArgs.stripeApiVersion, + endpointPath: providerArgs.endpointPath, + providerOperation: providerArgs.providerOperation, + providerParameters: providerArgs.providerParameters, + reconciliationEvidence, + }; +} + +function dispatchNeverBeganCandidate() { + return Object.freeze({ + reconciliationPolicySchemaVersion: 1, + provider: 'stripe', + providerAttempt: 1, + planBinding: 'exact', + evidenceSource: 'trusted_dispatch_history', + evidenceCompleteness: 'complete', + dispatchEvidence: 'execution_never_began', + responseEvidence: 'none', + idempotencyEvidence: 'not_relied_upon', + providerObjectEvidence: 'none', + paymentEvidence: 'none', + eventEvidence: 'none', + searchEvidence: 'none', + businessTransitionEvidence: 'same_operation_eligible', + }); +} + +function expiredAttemptCandidate() { + return Object.freeze({ + reconciliationPolicySchemaVersion: 1, + provider: 'stripe', + providerAttempt: 1, + planBinding: 'exact', + evidenceSource: 'verified_provider_and_event', + evidenceCompleteness: 'complete', + dispatchEvidence: 'execution_started', + responseEvidence: 'accepted', + idempotencyEvidence: 'not_relied_upon', + providerObjectEvidence: 'exact_expired', + paymentEvidence: 'unpaid', + eventEvidence: 'verified_expiry', + searchEvidence: 'exact_lookup_complete', + businessTransitionEvidence: 'new_generation_eligible', + }); +} + function assertExactSafeEmulator() { if (process.env[JOURNAL_EMULATOR_OPT_IN] !== '1') { throw new Error('The commerce command journal emulator opt-in is required.'); @@ -289,6 +354,10 @@ describeWithEmulator('commerce command journal Firestore transaction', () => { const providerSendRef = trackRef(providerPlanRef.collection('sendEvidence').doc('first')); const providerSendAuditRef = trackRef(db.collection('auditEvents') .doc(`commerce_provider_send_${commandKeyHash}_0000000001`)); + const providerReconciliationRef = trackRef(providerPlanRef + .collection('reconciliationEvidence').doc('0000000001')); + const providerReconciliationAuditRef = trackRef(db.collection('auditEvents') + .doc(`commerce_provider_reconciliation_${commandKeyHash}_0000000001_0000000001`)); const auditRef = auditRefFor(commandKeyHash, 1); // Track every revision this focused suite can create before a concurrent // assertion runs, so even an early failure cannot leak synthetic audits. @@ -302,6 +371,8 @@ describeWithEmulator('commerce command journal Firestore transaction', () => { providerAuditRef, providerSendRef, providerSendAuditRef, + providerReconciliationRef, + providerReconciliationAuditRef, }; } @@ -354,6 +425,17 @@ describeWithEmulator('commerce command journal Firestore transaction', () => { }; } + async function readProviderReconciliationEvidence(pair) { + const [evidenceSnapshot, auditSnapshot] = await Promise.all([ + pair.providerReconciliationRef.get(), + pair.providerReconciliationAuditRef.get(), + ]); + return { + evidence: snapshotEvidence(evidenceSnapshot), + audit: snapshotEvidence(auditSnapshot), + }; + } + async function readFoundationEvidence(pair, lifecycleRevision) { const [registration, lifecycle] = await Promise.all([ readPair(pair), @@ -437,6 +519,26 @@ describeWithEmulator('commerce command journal Firestore transaction', () => { expect(Object.isFrozen(result)).toBe(true); } + function expectProviderReconciliationResult(result) { + expect(result).toEqual({ + journalSchemaVersion: 1, + providerReconciliationEvidenceSchemaVersion: 1, + reconciliationPolicySchemaVersion: 1, + outcome: 'reconciliation_candidate_persisted', + state: 'requires_separate_authorization', + }); + expect(Object.isFrozen(result)).toBe(true); + } + + function expectNewAttemptCandidate(result) { + expect(result).toEqual({ + reconciliationPolicySchemaVersion: 1, + classification: 'new_attempt_candidate', + state: 'requires_persistence_and_authorization', + }); + expect(Object.isFrozen(result)).toBe(true); + } + async function setupLeasedCommand(commandId, reference, leaseIndex) { const commandPayload = payload(reference); const leaseId = deterministicLeaseId(leaseIndex); @@ -471,6 +573,21 @@ describeWithEmulator('commerce command journal Firestore transaction', () => { return { ...setup, args }; } + async function setupProviderReconciliationFoundation( + commandId, + reference, + leaseIndex, + ) { + const setup = await setupBoundProviderPlan(commandId, reference, leaseIndex); + expectProviderSendResult( + await recordInitialStripeSendEvidence(setup.args), + 'send_permitted', + ); + const send = await readProviderSendEvidence(setup.pair); + const automaticRetryDeadlineAt = send.evidence.data.automaticRetryDeadlineAt; + return { ...setup, send, automaticRetryDeadlineAt }; + } + async function cleanupTrackedRefs() { if (!trackedRefs) return; const refs = [...trackedRefs.values()]; @@ -2253,4 +2370,358 @@ describeWithEmulator('commerce command journal Firestore transaction', () => { ); expect(await readProviderSendEvidence(setup.pair)).toEqual(unknownBefore); }); + + test('cutoff blocks early persistence and 24 identical calls create one immutable pair', async () => { + const setup = await setupProviderReconciliationFoundation( + COMMAND_IDS.providerReconciliationConcurrent, + 'provider-reconciliation-concurrent', + 330, + ); + const args = reconciliationArgs(setup.args, dispatchNeverBeganCandidate()); + const deadlineMillis = setup.automaticRetryDeadlineAt.toMillis(); + const foundationBefore = await readFoundationEvidence(setup.pair, 2); + const providerBefore = await readProviderEvidence(setup.pair); + const sendBefore = await readProviderSendEvidence(setup.pair); + + setTrustedNow(deadlineMillis - 1); + expectNewAttemptCandidate( + await recordInitialStripeReconciliationEvidence(args), + ); + expect(await readProviderReconciliationEvidence(setup.pair)).toEqual({ + evidence: { + exists: false, + data: undefined, + createTime: undefined, + updateTime: undefined, + }, + audit: { + exists: false, + data: undefined, + createTime: undefined, + updateTime: undefined, + }, + }); + + setTrustedNow(deadlineMillis); + const results = await Promise.all( + Array.from({ length: CONCURRENT_CALLS }, () => retryWholeOperationAfterUnavailable( + () => recordInitialStripeReconciliationEvidence(args), + retryEvidence, + )), + ); + for (const result of results) expectProviderReconciliationResult(result); + + const reconciliation = await readProviderReconciliationEvidence(setup.pair); + expect(setup.pair.providerReconciliationRef.path).toBe( + `checkoutRequests/${setup.pair.commandKeyHash}` + + '/providerAttempts/0000000001/reconciliationEvidence/0000000001', + ); + expect(setup.pair.providerReconciliationAuditRef.path).toBe( + `auditEvents/commerce_provider_reconciliation_${setup.pair.commandKeyHash}` + + '_0000000001_0000000001', + ); + expect(reconciliation.evidence.exists).toBe(true); + expect(reconciliation.audit.exists).toBe(true); + expect(Object.keys(reconciliation.evidence.data).sort()).toEqual([ + 'businessTransitionEvidence', + 'classification', + 'commandIdentityVersion', + 'commandKeyHash', + 'dispatchEvidence', + 'eventEvidence', + 'evidenceCompleteness', + 'evidenceRevision', + 'evidenceSource', + 'idempotencyEvidence', + 'observedFenceEpoch', + 'observedLeaseExpiresAt', + 'paymentEvidence', + 'planBinding', + 'provider', + 'providerAttempt', + 'providerObjectEvidence', + 'providerPlanCommitment', + 'providerPlanSchemaVersion', + 'providerReconciliationEvidenceSchemaVersion', + 'providerSendEvidenceCommitment', + 'providerSendEvidenceSchemaVersion', + 'reconciliationPolicySchemaVersion', + 'recordedAt', + 'responseEvidence', + 'searchEvidence', + 'state', + ].sort()); + expect(reconciliation.evidence.data).toMatchObject({ + providerReconciliationEvidenceSchemaVersion: 1, + reconciliationPolicySchemaVersion: 1, + providerPlanSchemaVersion: 1, + providerSendEvidenceSchemaVersion: 1, + commandIdentityVersion: 1, + commandKeyHash: setup.pair.commandKeyHash, + providerAttempt: 1, + provider: 'stripe', + evidenceRevision: 1, + providerPlanCommitment: expect.stringMatching(/^[0-9a-f]{64}$/), + providerSendEvidenceCommitment: expect.stringMatching(/^[0-9a-f]{64}$/), + classification: 'new_attempt_candidate', + state: 'requires_persistence_and_authorization', + ...dispatchNeverBeganCandidate(), + observedFenceEpoch: 1, + }); + expect(reconciliation.evidence.data.recordedAt.toMillis()).toBe(deadlineMillis); + expect(reconciliation.evidence.data.observedLeaseExpiresAt.toMillis()) + .toBe(BASE_NOW_MILLIS + LEASE_DURATION_MILLIS); + expect(reconciliation.audit.data).toMatchObject({ + providerReconciliationAuditSchemaVersion: 1, + providerReconciliationEvidenceSchemaVersion: 1, + aggregateType: 'commerce_provider_reconciliation', + commandKeyHash: setup.pair.commandKeyHash, + providerAttempt: 1, + evidenceRevision: 1, + eventType: 'provider_reconciliation_candidate_recorded', + provider: 'stripe', + environment: 'test', + stripeMode: 'test', + providerOperation: STRIPE_OPERATION, + providerPlanCommitment: reconciliation.evidence.data.providerPlanCommitment, + providerSendEvidenceCommitment: ( + reconciliation.evidence.data.providerSendEvidenceCommitment + ), + reconciliationPolicySchemaVersion: 1, + classification: 'new_attempt_candidate', + observedFenceEpoch: 1, + reconciliationEvidenceCommitment: expect.stringMatching(/^[0-9a-f]{64}$/), + }); + expect(reconciliation.audit.data.occurredAt.toMillis()).toBe(deadlineMillis); + expect(reconciliation.audit.data.observedLeaseExpiresAt.toMillis()) + .toBe(BASE_NOW_MILLIS + LEASE_DURATION_MILLIS); + expect(await readFoundationEvidence(setup.pair, 2)).toEqual(foundationBefore); + expect(await readProviderEvidence(setup.pair)).toEqual(providerBefore); + expect(await readProviderSendEvidence(setup.pair)).toEqual(sendBefore); + expect((await matchingAudits(setup.pair)).size).toBe(5); + const rendered = JSON.stringify({ results, reconciliation }); + expect(rendered).not.toContain(STRIPE_ACCOUNT_ID); + expect(rendered).not.toContain('provider-reconciliation-concurrent'); + expect(rendered).not.toContain(setup.leaseId); + }); + + test('12-v-12 valid candidates persist one winner and conflict without overwrite', async () => { + const setup = await setupProviderReconciliationFoundation( + COMMAND_IDS.providerReconciliationConflict, + 'provider-reconciliation-conflict', + 331, + ); + const firstArgs = reconciliationArgs(setup.args, dispatchNeverBeganCandidate()); + const secondArgs = reconciliationArgs(setup.args, expiredAttemptCandidate()); + setTrustedNow(setup.automaticRetryDeadlineAt.toMillis()); + + const settled = await Promise.allSettled([ + ...Array.from({ length: 12 }, () => retryWholeOperationAfterUnavailable( + () => recordInitialStripeReconciliationEvidence(firstArgs), + retryEvidence, + )), + ...Array.from({ length: 12 }, () => retryWholeOperationAfterUnavailable( + () => recordInitialStripeReconciliationEvidence(secondArgs), + retryEvidence, + )), + ]); + const fulfilled = settled.filter(({ status }) => status === 'fulfilled'); + const rejected = settled.filter(({ status }) => status === 'rejected'); + expect(fulfilled).toHaveLength(12); + for (const result of fulfilled) expectProviderReconciliationResult(result.value); + expect(rejected).toHaveLength(12); + for (const result of rejected) expectJournalError(result.reason, 'command_conflict'); + + const firstHalfWon = settled.slice(0, 12) + .every(({ status }) => status === 'fulfilled'); + const secondHalfWon = settled.slice(12) + .every(({ status }) => status === 'fulfilled'); + expect(firstHalfWon).not.toBe(secondHalfWon); + const winningArgs = firstHalfWon ? firstArgs : secondArgs; + const losingArgs = firstHalfWon ? secondArgs : firstArgs; + const winnerBeforeProbes = await readProviderReconciliationEvidence(setup.pair); + expectProviderReconciliationResult( + await recordInitialStripeReconciliationEvidence(winningArgs), + ); + await expect(recordInitialStripeReconciliationEvidence(losingArgs)) + .rejects.toMatchObject({ + code: 'commerce_command_journal_error', + reason: 'command_conflict', + }); + expect(await readProviderReconciliationEvidence(setup.pair)) + .toEqual(winnerBeforeProbes); + expect((await matchingAudits(setup.pair)).size).toBe(5); + }); + + test('a retry after a lost reconciliation response is exact and read-only', async () => { + const setup = await setupProviderReconciliationFoundation( + COMMAND_IDS.providerReconciliationLostResponse, + 'provider-reconciliation-lost-response', + 332, + ); + const args = reconciliationArgs(setup.args, expiredAttemptCandidate()); + setTrustedNow(setup.automaticRetryDeadlineAt.toMillis()); + expectProviderReconciliationResult( + await recordInitialStripeReconciliationEvidence(args), + ); + const reconciliationBefore = await readProviderReconciliationEvidence(setup.pair); + const foundationBefore = await readFoundationEvidence(setup.pair, 2); + const providerBefore = await readProviderEvidence(setup.pair); + const sendBefore = await readProviderSendEvidence(setup.pair); + + const retries = await Promise.all(Array.from( + { length: 8 }, + () => recordInitialStripeReconciliationEvidence(args), + )); + for (const result of retries) expectProviderReconciliationResult(result); + expect(await readProviderReconciliationEvidence(setup.pair)) + .toEqual(reconciliationBefore); + expect(await readFoundationEvidence(setup.pair, 2)).toEqual(foundationBefore); + expect(await readProviderEvidence(setup.pair)).toEqual(providerBefore); + expect(await readProviderSendEvidence(setup.pair)).toEqual(sendBefore); + expect((await matchingAudits(setup.pair)).size).toBe(5); + }); + + test('a takeover stays blocked while active and persists at its expiry equality', async () => { + const setup = await setupProviderReconciliationFoundation( + COMMAND_IDS.providerReconciliationTakeover, + 'provider-reconciliation-takeover', + 333, + ); + const deadlineMillis = setup.automaticRetryDeadlineAt.toMillis(); + const takeoverLeaseId = deterministicLeaseId(334); + setTrustedNow(deadlineMillis); + expectLeaseResult(await acquireCommerceCommandLease(acquireArgs( + db, + COMMAND_IDS.providerReconciliationTakeover, + setup.commandPayload, + takeoverLeaseId, + )), 2); + const args = reconciliationArgs(setup.args, dispatchNeverBeganCandidate()); + const takeoverExpiryMillis = deadlineMillis + LEASE_DURATION_MILLIS; + + setTrustedNow(takeoverExpiryMillis - 1); + expectNewAttemptCandidate( + await recordInitialStripeReconciliationEvidence(args), + ); + expect((await readProviderReconciliationEvidence(setup.pair)).evidence.exists) + .toBe(false); + + setTrustedNow(takeoverExpiryMillis); + expectProviderReconciliationResult( + await recordInitialStripeReconciliationEvidence(args), + ); + const reconciliation = await readProviderReconciliationEvidence(setup.pair); + expect(reconciliation.evidence.data.observedFenceEpoch).toBe(2); + expect(reconciliation.evidence.data.observedLeaseExpiresAt.toMillis()) + .toBe(takeoverExpiryMillis); + expect(reconciliation.evidence.data.recordedAt.toMillis()).toBe(takeoverExpiryMillis); + expect(reconciliation.audit.data.observedFenceEpoch).toBe(2); + expect(reconciliation.audit.data.observedLeaseExpiresAt.toMillis()) + .toBe(takeoverExpiryMillis); + expect((await matchingAudits(setup.pair)).size).toBe(6); + }); + + test('an injected commit failure leaves neither reconciliation partner', async () => { + const setup = await setupProviderReconciliationFoundation( + COMMAND_IDS.providerReconciliationCommitFailure, + 'provider-reconciliation-commit-failure', + 335, + ); + const args = reconciliationArgs(setup.args, dispatchNeverBeganCandidate()); + setTrustedNow(setup.automaticRetryDeadlineAt.toMillis()); + const reconciliationBefore = await readProviderReconciliationEvidence(setup.pair); + const foundationBefore = await readFoundationEvidence(setup.pair, 2); + const providerBefore = await readProviderEvidence(setup.pair); + const sendBefore = await readProviderSendEvidence(setup.pair); + const auditsBefore = await matchingAudits(setup.pair); + const commitFailure = jest.spyOn(FirestoreTransaction.prototype, 'commit') + .mockRejectedValueOnce(new Error('synthetic reconciliation commit failure')); + let commitCalls = 0; + + try { + const error = await recordInitialStripeReconciliationEvidence(args).then( + () => null, + (reason) => reason, + ); + expectJournalError(error, 'journal_unavailable'); + commitCalls = commitFailure.mock.calls.length; + } finally { + commitFailure.mockRestore(); + } + expect(commitCalls).toBe(1); + expect(await readProviderReconciliationEvidence(setup.pair)) + .toEqual(reconciliationBefore); + expect(await readFoundationEvidence(setup.pair, 2)).toEqual(foundationBefore); + expect(await readProviderEvidence(setup.pair)).toEqual(providerBefore); + expect(await readProviderSendEvidence(setup.pair)).toEqual(sendBefore); + expect((await matchingAudits(setup.pair)).size).toBe(auditsBefore.size); + expect(reconciliationBefore.evidence.exists).toBe(false); + expect(reconciliationBefore.audit.exists).toBe(false); + }); + + test('both reconciliation orphan directions fail closed without repair', async () => { + const setup = await setupProviderReconciliationFoundation( + COMMAND_IDS.providerReconciliationOrphan, + 'provider-reconciliation-orphan', + 336, + ); + const args = reconciliationArgs(setup.args, dispatchNeverBeganCandidate()); + setTrustedNow(setup.automaticRetryDeadlineAt.toMillis()); + await setup.pair.providerReconciliationAuditRef.create({ + providerReconciliationAuditSchemaVersion: 1, + syntheticOrphan: true, + }); + const auditOrphanBefore = await readProviderReconciliationEvidence(setup.pair); + await expect(recordInitialStripeReconciliationEvidence(args)).rejects.toMatchObject({ + code: 'commerce_command_journal_error', + reason: 'journal_record_invalid', + }); + expect(await readProviderReconciliationEvidence(setup.pair)) + .toEqual(auditOrphanBefore); + + await setup.pair.providerReconciliationAuditRef.delete(); + expectProviderReconciliationResult( + await recordInitialStripeReconciliationEvidence(args), + ); + await setup.pair.providerReconciliationAuditRef.delete(); + const recordOrphanBefore = await readProviderReconciliationEvidence(setup.pair); + await expect(recordInitialStripeReconciliationEvidence(args)).rejects.toMatchObject({ + code: 'commerce_command_journal_error', + reason: 'journal_record_invalid', + }); + expect(await readProviderReconciliationEvidence(setup.pair)) + .toEqual(recordOrphanBefore); + expect(recordOrphanBefore.evidence.exists).toBe(true); + expect(recordOrphanBefore.audit.exists).toBe(false); + }); + + test('a malformed C2 foundation blocks reconciliation without creating a pair', async () => { + const setup = await setupProviderReconciliationFoundation( + COMMAND_IDS.providerReconciliationMalformedSend, + 'provider-reconciliation-malformed-send', + 337, + ); + const args = reconciliationArgs(setup.args, expiredAttemptCandidate()); + const validSend = await setup.pair.providerSendRef.get(); + await setup.pair.providerSendRef.set({ + ...validSend.data(), + providerSendEvidenceSchemaVersion: 2, + }); + setTrustedNow(setup.automaticRetryDeadlineAt.toMillis()); + const malformedSendBefore = await readProviderSendEvidence(setup.pair); + const reconciliationBefore = await readProviderReconciliationEvidence(setup.pair); + const auditsBefore = await matchingAudits(setup.pair); + + await expect(recordInitialStripeReconciliationEvidence(args)).rejects.toMatchObject({ + code: 'commerce_command_journal_error', + reason: 'journal_record_invalid', + }); + expect(await readProviderSendEvidence(setup.pair)).toEqual(malformedSendBefore); + expect(await readProviderReconciliationEvidence(setup.pair)) + .toEqual(reconciliationBefore); + expect((await matchingAudits(setup.pair)).size).toBe(auditsBefore.size); + expect(reconciliationBefore.evidence.exists).toBe(false); + expect(reconciliationBefore.audit.exists).toBe(false); + }); }); diff --git a/functions/commerceCommandJournal.js b/functions/commerceCommandJournal.js index 1cbb7fc0..b3f38dd2 100644 --- a/functions/commerceCommandJournal.js +++ b/functions/commerceCommandJournal.js @@ -9,6 +9,11 @@ const { createPayloadFingerprint, createStripeIdempotencyKey, } = require('./commerceCommandIdentity'); +const { + reconciliationPolicySchemaVersion, + CommerceProviderReconciliationError, + classifyInitialStripeReconciliation, +} = require('./commerceProviderReconciliation'); const journalSchemaVersion = 1; const auditSchemaVersion = 1; @@ -18,6 +23,8 @@ const providerPlanSchemaVersion = 1; const providerPlanAuditSchemaVersion = 1; const providerSendEvidenceSchemaVersion = 1; const providerSendAuditSchemaVersion = 1; +const providerReconciliationEvidenceSchemaVersion = 1; +const providerReconciliationAuditSchemaVersion = 1; const MAXIMUM_ENDPOINT_SCHEMA_VERSION = 1000000; const MAXIMUM_LIFECYCLE_NUMBER = 9999999999; const LEASE_DURATION_SECONDS = 60; @@ -31,6 +38,8 @@ const PROVIDER_ATTEMPTS_COLLECTION = 'providerAttempts'; const INITIAL_PROVIDER_ATTEMPT_DOCUMENT = '0000000001'; const SEND_EVIDENCE_COLLECTION = 'sendEvidence'; const INITIAL_SEND_EVIDENCE_DOCUMENT = 'first'; +const RECONCILIATION_EVIDENCE_COLLECTION = 'reconciliationEvidence'; +const INITIAL_RECONCILIATION_EVIDENCE_DOCUMENT = '0000000001'; const COMMAND_STATE = 'registered'; const COMMAND_REVISION = 1; const INITIAL_PROVIDER_ATTEMPT = 1; @@ -49,6 +58,10 @@ const HASH_DOMAINS = Object.freeze({ stripeParameters: 'mprc.command-provider-plan-stripe-parameters.v1', stripeIdempotencyKey: 'mprc.command-provider-plan-stripe-idempotency-key.v1', providerPlan: 'mprc.command-provider-plan-complete.v1', + providerSendEvidence: 'mprc.command-provider-send-evidence-complete.v1', + providerReconciliationEvidence: ( + 'mprc.command-provider-reconciliation-evidence-complete.v1' + ), }); const ENVIRONMENTS = new Set(['local', 'test', 'staging', 'production']); const STRIPE_MODES = new Set(['test', 'live']); @@ -90,6 +103,32 @@ const BIND_PROVIDER_PLAN_INPUT_FIELDS = Object.freeze([ 'providerOperation', 'providerParameters', ]); +const RECORD_PROVIDER_RECONCILIATION_INPUT_FIELDS = Object.freeze([ + ...INPUT_FIELDS, + 'stripeAccountId', + 'stripeMode', + 'stripeApiVersion', + 'endpointPath', + 'providerOperation', + 'providerParameters', + 'reconciliationEvidence', +]); +const RECONCILIATION_EVIDENCE_INPUT_FIELDS = Object.freeze([ + 'reconciliationPolicySchemaVersion', + 'provider', + 'providerAttempt', + 'planBinding', + 'evidenceSource', + 'evidenceCompleteness', + 'dispatchEvidence', + 'responseEvidence', + 'idempotencyEvidence', + 'providerObjectEvidence', + 'paymentEvidence', + 'eventEvidence', + 'searchEvidence', + 'businessTransitionEvidence', +]); const CALLER_SCOPE_FIELDS = Object.freeze(['kind', 'value']); const COMMAND_FIELDS = Object.freeze([ 'journalSchemaVersion', @@ -204,6 +243,56 @@ const PROVIDER_SEND_AUDIT_FIELDS = Object.freeze([ 'automaticRetryDeadlineAt', 'occurredAt', ]); +const PROVIDER_RECONCILIATION_FIELDS = Object.freeze([ + 'providerReconciliationEvidenceSchemaVersion', + 'reconciliationPolicySchemaVersion', + 'providerPlanSchemaVersion', + 'providerSendEvidenceSchemaVersion', + 'commandIdentityVersion', + 'commandKeyHash', + 'providerAttempt', + 'provider', + 'evidenceRevision', + 'providerPlanCommitment', + 'providerSendEvidenceCommitment', + 'classification', + 'state', + 'planBinding', + 'evidenceSource', + 'evidenceCompleteness', + 'dispatchEvidence', + 'responseEvidence', + 'idempotencyEvidence', + 'providerObjectEvidence', + 'paymentEvidence', + 'eventEvidence', + 'searchEvidence', + 'businessTransitionEvidence', + 'observedFenceEpoch', + 'observedLeaseExpiresAt', + 'recordedAt', +]); +const PROVIDER_RECONCILIATION_AUDIT_FIELDS = Object.freeze([ + 'providerReconciliationAuditSchemaVersion', + 'providerReconciliationEvidenceSchemaVersion', + 'aggregateType', + 'commandKeyHash', + 'providerAttempt', + 'evidenceRevision', + 'eventType', + 'provider', + 'environment', + 'stripeMode', + 'providerOperation', + 'providerPlanCommitment', + 'providerSendEvidenceCommitment', + 'reconciliationPolicySchemaVersion', + 'classification', + 'observedFenceEpoch', + 'observedLeaseExpiresAt', + 'reconciliationEvidenceCommitment', + 'occurredAt', +]); const ERROR_MESSAGES = Object.freeze({ invalid_command_input: 'Commerce command journal input is invalid.', @@ -523,6 +612,100 @@ function createCompleteProviderPlanCommitment(plan, boundAt) { ]); } +function createCompleteProviderSendEvidenceCommitment(providerSend) { + if (providerSend.timeUnknown + || providerSend.prePostRecordedAt === null + || providerSend.automaticRetryDeadlineAt === null) { + reject('journal_record_invalid'); + } + const record = providerSend.record; + const audit = providerSend.audit; + return digest(HASH_DOMAINS.providerSendEvidence, [ + ['version', '1'], + ['providerSendEvidenceSchemaVersion', String(record.providerSendEvidenceSchemaVersion)], + ['providerPlanSchemaVersion', String(record.providerPlanSchemaVersion)], + ['commandIdentityVersion', String(record.commandIdentityVersion)], + ['commandKeyHash', record.commandKeyHash], + ['providerAttempt', String(record.providerAttempt)], + ['provider', record.provider], + ['providerPlanCommitment', record.providerPlanCommitment], + ['prePostFenceEpoch', String(record.prePostFenceEpoch)], + ['prePostRecordedAtSeconds', String(providerSend.prePostRecordedAt.seconds)], + ['prePostRecordedAtNanoseconds', String(providerSend.prePostRecordedAt.nanoseconds)], + ['automaticRetryDeadlineAtSeconds', String(providerSend.automaticRetryDeadlineAt.seconds)], + [ + 'automaticRetryDeadlineAtNanoseconds', + String(providerSend.automaticRetryDeadlineAt.nanoseconds), + ], + ['providerSendAuditSchemaVersion', String(audit.providerSendAuditSchemaVersion)], + ['auditAggregateType', audit.aggregateType], + ['auditCommandKeyHash', audit.commandKeyHash], + ['auditProviderAttempt', String(audit.providerAttempt)], + ['auditEventType', audit.eventType], + ['auditProvider', audit.provider], + ['auditEnvironment', audit.environment], + ['auditStripeMode', audit.stripeMode], + ['auditProviderOperation', audit.providerOperation], + ['auditProviderPlanCommitment', audit.providerPlanCommitment], + ['auditPrePostFenceEpoch', String(audit.prePostFenceEpoch)], + ['auditOccurredAtSeconds', String(providerSend.prePostRecordedAt.seconds)], + ['auditOccurredAtNanoseconds', String(providerSend.prePostRecordedAt.nanoseconds)], + [ + 'auditAutomaticRetryDeadlineAtSeconds', + String(providerSend.automaticRetryDeadlineAt.seconds), + ], + [ + 'auditAutomaticRetryDeadlineAtNanoseconds', + String(providerSend.automaticRetryDeadlineAt.nanoseconds), + ], + ]); +} + +function createCompleteProviderReconciliationEvidenceCommitment( + record, + observedLeaseExpiresAt, + recordedAt, +) { + if (observedLeaseExpiresAt === null || recordedAt === null) { + reject('journal_record_invalid'); + } + return digest(HASH_DOMAINS.providerReconciliationEvidence, [ + ['version', '1'], + [ + 'providerReconciliationEvidenceSchemaVersion', + String(record.providerReconciliationEvidenceSchemaVersion), + ], + ['reconciliationPolicySchemaVersion', String(record.reconciliationPolicySchemaVersion)], + ['providerPlanSchemaVersion', String(record.providerPlanSchemaVersion)], + ['providerSendEvidenceSchemaVersion', String(record.providerSendEvidenceSchemaVersion)], + ['commandIdentityVersion', String(record.commandIdentityVersion)], + ['commandKeyHash', record.commandKeyHash], + ['providerAttempt', String(record.providerAttempt)], + ['provider', record.provider], + ['evidenceRevision', String(record.evidenceRevision)], + ['providerPlanCommitment', record.providerPlanCommitment], + ['providerSendEvidenceCommitment', record.providerSendEvidenceCommitment], + ['classification', record.classification], + ['state', record.state], + ['planBinding', record.planBinding], + ['evidenceSource', record.evidenceSource], + ['evidenceCompleteness', record.evidenceCompleteness], + ['dispatchEvidence', record.dispatchEvidence], + ['responseEvidence', record.responseEvidence], + ['idempotencyEvidence', record.idempotencyEvidence], + ['providerObjectEvidence', record.providerObjectEvidence], + ['paymentEvidence', record.paymentEvidence], + ['eventEvidence', record.eventEvidence], + ['searchEvidence', record.searchEvidence], + ['businessTransitionEvidence', record.businessTransitionEvidence], + ['observedFenceEpoch', String(record.observedFenceEpoch)], + ['observedLeaseExpiresAtSeconds', String(observedLeaseExpiresAt.seconds)], + ['observedLeaseExpiresAtNanoseconds', String(observedLeaseExpiresAt.nanoseconds)], + ['recordedAtSeconds', String(recordedAt.seconds)], + ['recordedAtNanoseconds', String(recordedAt.nanoseconds)], + ]); +} + function validStripeAccountId(value) { return typeof value === 'string' && SAFE_STRIPE_ACCOUNT_ID.test(value); } @@ -777,6 +960,16 @@ function providerSendAuditDocumentId(commandKeyHash) { return `commerce_provider_send_${commandKeyHash}_${INITIAL_PROVIDER_ATTEMPT_DOCUMENT}`; } +function providerReconciliationAuditDocumentId(commandKeyHash) { + if (typeof commandKeyHash !== 'string' || !LOWERCASE_SHA256.test(commandKeyHash)) { + reject('journal_record_invalid'); + } + return ( + `commerce_provider_reconciliation_${commandKeyHash}` + + `_${INITIAL_PROVIDER_ATTEMPT_DOCUMENT}_0000000001` + ); +} + function prepareIdentity(input, fields) { let values; let commandKey; @@ -1037,6 +1230,141 @@ function prepareProviderSendOperation(input) { }); } +function prepareProviderReconciliationOperation(input) { + const identity = prepareIdentity(input, RECORD_PROVIDER_RECONCILIATION_INPUT_FIELDS); + const { values } = identity; + + if (!validStripeAccountId(values.stripeAccountId) + || !validStripeModeForEnvironment(values.stripeMode, values.environment) + || !validStripeApiVersion(values.stripeApiVersion) + || !validStripeOperationEndpoint(values.providerOperation, values.endpointPath)) { + reject('invalid_command_input'); + } + + let canonicalParametersFingerprint; + let stripeIdempotencyKey; + let reconciliationEvidence; + let reconciliationClassification; + try { + canonicalParametersFingerprint = createPayloadFingerprint({ + commandType: PROVIDER_PARAMETERS_COMMAND_TYPE, + payload: values.providerParameters, + }).payloadFingerprint; + stripeIdempotencyKey = createStripeIdempotencyKey({ + stripeMode: values.stripeMode, + environment: values.environment, + providerOperation: values.providerOperation, + commandKeyHash: identity.commandKeyHash, + providerAttempt: INITIAL_PROVIDER_ATTEMPT, + }).stripeIdempotencyKey; + const evidenceValues = readExactOwnDataObject( + values.reconciliationEvidence, + RECONCILIATION_EVIDENCE_INPUT_FIELDS, + 'invalid_command_input', + ); + reconciliationEvidence = Object.freeze(Object.fromEntries( + RECONCILIATION_EVIDENCE_INPUT_FIELDS.map((field) => [field, evidenceValues[field]]), + )); + reconciliationClassification = classifyInitialStripeReconciliation( + reconciliationEvidence, + ); + } catch (error) { + if (error instanceof CommerceCommandJournalError) throw error; + if (error instanceof CommandIdentityError + || error instanceof CommerceProviderReconciliationError) { + reject('invalid_command_input'); + } + reject('journal_unavailable'); + } + + const stripeAccountFingerprint = createProviderCommitment( + HASH_DOMAINS.stripeAccount, + identity.commandKeyHash, + 'stripeAccountId', + values.stripeAccountId, + ); + const parametersFingerprint = createProviderCommitment( + HASH_DOMAINS.stripeParameters, + identity.commandKeyHash, + 'canonicalParametersFingerprint', + canonicalParametersFingerprint, + ); + const idempotencyKeyFingerprint = createProviderCommitment( + HASH_DOMAINS.stripeIdempotencyKey, + identity.commandKeyHash, + 'stripeIdempotencyKey', + stripeIdempotencyKey, + ); + const providerPlanRecord = Object.freeze({ + environment: values.environment, + providerOperation: values.providerOperation, + stripeMode: values.stripeMode, + stripeAccountFingerprint, + stripeApiVersion: values.stripeApiVersion, + endpointPath: values.endpointPath, + parametersFingerprint, + idempotencyKeyFingerprint, + }); + + let commandRef; + let registrationAuditRef; + let lifecycleRef; + let providerPlanRef; + let providerPlanAuditRef; + let providerSendRef; + let providerSendAuditRef; + let providerReconciliationRef; + let providerReconciliationAuditRef; + try { + commandRef = values.db.collection(COMMAND_COLLECTION).doc(identity.commandKeyHash); + registrationAuditRef = values.db.collection(AUDIT_COLLECTION).doc( + auditDocumentId(identity.commandKeyHash, COMMAND_REVISION), + ); + lifecycleRef = commandRef.collection(LIFECYCLE_COLLECTION).doc(LIFECYCLE_DOCUMENT); + providerPlanRef = commandRef.collection(PROVIDER_ATTEMPTS_COLLECTION).doc( + INITIAL_PROVIDER_ATTEMPT_DOCUMENT, + ); + providerPlanAuditRef = values.db.collection(AUDIT_COLLECTION).doc( + providerPlanAuditDocumentId(identity.commandKeyHash), + ); + providerSendRef = providerPlanRef + .collection(SEND_EVIDENCE_COLLECTION) + .doc(INITIAL_SEND_EVIDENCE_DOCUMENT); + providerSendAuditRef = values.db.collection(AUDIT_COLLECTION).doc( + providerSendAuditDocumentId(identity.commandKeyHash), + ); + providerReconciliationRef = providerPlanRef + .collection(RECONCILIATION_EVIDENCE_COLLECTION) + .doc(INITIAL_RECONCILIATION_EVIDENCE_DOCUMENT); + providerReconciliationAuditRef = values.db.collection(AUDIT_COLLECTION).doc( + providerReconciliationAuditDocumentId(identity.commandKeyHash), + ); + } catch (error) { + if (error instanceof CommerceCommandJournalError) throw error; + reject('journal_unavailable'); + } + + const occurredAt = captureTrustedTimestamp(); + return Object.freeze({ + ...identity, + db: values.db, + occurredAt, + occurredAtParts: readTimestamp(occurredAt), + providerPlanRecord, + reconciliationEvidence, + reconciliationClassification, + commandRef, + registrationAuditRef, + lifecycleRef, + providerPlanRef, + providerPlanAuditRef, + providerSendRef, + providerSendAuditRef, + providerReconciliationRef, + providerReconciliationAuditRef, + }); +} + function createProviderSendPair(prepared, providerPlan) { const providerPlanCommitment = createCompleteProviderPlanCommitment( providerPlan.record, @@ -1358,6 +1686,22 @@ function validateProviderLeaseChronology(leaseAudit, predecessorAudit, lifecycle } } +function validateProviderLeaseAgainstCurrent(leaseAudit, lifecycle) { + if (leaseAudit.fenceEpoch > lifecycle.record.fenceEpoch) { + reject('journal_record_invalid'); + } + if (leaseAudit.fenceEpoch === lifecycle.record.fenceEpoch) { + if (!timestampsEqual(leaseAudit.occurredAt, lifecycle.leaseAcquiredAt) + || !timestampsEqual(leaseAudit.leaseExpiresAt, lifecycle.leaseExpiresAt)) { + reject('journal_record_invalid'); + } + return; + } + if (compareTimestamps(leaseAudit.leaseExpiresAt, lifecycle.leaseAcquiredAt) > 0) { + reject('journal_record_invalid'); + } +} + function validateProviderPlanPair( plan, audit, @@ -1429,14 +1773,7 @@ function validateProviderPlanPair( reject('journal_record_invalid'); } - if (plan.boundFenceEpoch === lifecycle.record.fenceEpoch) { - if (!timestampsEqual(bindingAudit.occurredAt, lifecycle.leaseAcquiredAt) - || !timestampsEqual(bindingAudit.leaseExpiresAt, lifecycle.leaseExpiresAt)) { - reject('journal_record_invalid'); - } - } else if (compareTimestamps(bindingAudit.leaseExpiresAt, lifecycle.leaseAcquiredAt) > 0) { - reject('journal_record_invalid'); - } + validateProviderLeaseAgainstCurrent(bindingAudit, lifecycle); const expected = prepared.providerPlanRecord; if (plan.providerOperation !== expected.providerOperation @@ -1452,7 +1789,8 @@ function validateProviderPlanPair( return Object.freeze({ record: plan, boundAt }); } -async function readProviderPlanContext(transaction, prepared) { +async function readProviderPlanContext(transaction, prepared, options = {}) { + const requireActiveLease = options.requireActiveLease !== false; const commandSnapshot = await transaction.get(prepared.commandRef); const registrationAuditSnapshot = await transaction.get(prepared.registrationAuditRef); const lifecycleSnapshot = await transaction.get(prepared.lifecycleRef); @@ -1515,9 +1853,14 @@ async function readProviderPlanContext(transaction, prepared) { reject('journal_record_invalid'); } validateLifecycleAudit(currentAudit.value, lifecycle, prepared.expected); - const mayCreate = validateActiveProviderPlanLease(prepared, lifecycle); + const mayCreate = requireActiveLease + ? validateActiveProviderPlanLease(prepared, lifecycle) + : false; + const currentLeaseAuditDocument = lifecycle.record.state === 'leased' + ? currentAudit + : await readRequiredLifecycleAudit(lifecycle.record.fenceEpoch + 1); const currentLeaseAudit = validateProviderLeaseAudit( - currentAudit.value, + currentLeaseAuditDocument.value, lifecycle.record.fenceEpoch, prepared, ); @@ -1536,6 +1879,7 @@ async function readProviderPlanContext(transaction, prepared) { // budget. Earlier server-only append history remains a trust boundary; a // recursive scan would make transaction reads grow without a bound. validateProviderLeaseChronology(currentLeaseAudit, currentPredecessorAudit, lifecycle); + validateProviderLeaseAgainstCurrent(currentLeaseAudit, lifecycle); if (providerPlanDocument.exists !== providerPlanAudit.exists) { reject('journal_record_invalid'); @@ -1675,8 +2019,8 @@ function parseProviderSendPair(recordValue, auditValue, prepared, context) { }); } -async function readProviderSendContext(transaction, prepared) { - const context = await readProviderPlanContext(transaction, prepared); +async function readProviderSendContext(transaction, prepared, options = {}) { + const context = await readProviderPlanContext(transaction, prepared, options); if (context.providerPlan === null) reject('journal_record_invalid'); const providerSendSnapshot = await transaction.get(prepared.providerSendRef); @@ -1721,6 +2065,7 @@ async function readProviderSendContext(transaction, prepared) { bindingPredecessorAudit, context.lifecycle, ); + validateProviderLeaseAgainstCurrent(bindingAudit, context.lifecycle); if (providerSend.timeUnknown) { return Object.freeze({ ...context, providerSend }); } @@ -1743,6 +2088,249 @@ async function readProviderSendContext(transaction, prepared) { return Object.freeze({ ...context, providerSend }); } +function createProviderReconciliationPair(prepared, context) { + const observedLeaseExpiresAt = copyTimestamp(context.lifecycle.leaseExpiresAt); + const providerPlanCommitment = createCompleteProviderPlanCommitment( + context.providerPlan.record, + context.providerPlan.boundAt, + ); + const providerSendEvidenceCommitment = createCompleteProviderSendEvidenceCommitment( + context.providerSend, + ); + const evidence = prepared.reconciliationEvidence; + const classification = prepared.reconciliationClassification; + const record = Object.freeze({ + providerReconciliationEvidenceSchemaVersion, + reconciliationPolicySchemaVersion, + providerPlanSchemaVersion, + providerSendEvidenceSchemaVersion, + commandIdentityVersion, + commandKeyHash: prepared.commandKeyHash, + providerAttempt: INITIAL_PROVIDER_ATTEMPT, + provider: 'stripe', + evidenceRevision: 1, + providerPlanCommitment, + providerSendEvidenceCommitment, + classification: classification.classification, + state: classification.state, + planBinding: evidence.planBinding, + evidenceSource: evidence.evidenceSource, + evidenceCompleteness: evidence.evidenceCompleteness, + dispatchEvidence: evidence.dispatchEvidence, + responseEvidence: evidence.responseEvidence, + idempotencyEvidence: evidence.idempotencyEvidence, + providerObjectEvidence: evidence.providerObjectEvidence, + paymentEvidence: evidence.paymentEvidence, + eventEvidence: evidence.eventEvidence, + searchEvidence: evidence.searchEvidence, + businessTransitionEvidence: evidence.businessTransitionEvidence, + observedFenceEpoch: context.lifecycle.record.fenceEpoch, + observedLeaseExpiresAt, + recordedAt: prepared.occurredAt, + }); + const reconciliationEvidenceCommitment = ( + createCompleteProviderReconciliationEvidenceCommitment( + record, + readTimestamp(observedLeaseExpiresAt), + prepared.occurredAtParts, + ) + ); + const audit = Object.freeze({ + providerReconciliationAuditSchemaVersion, + providerReconciliationEvidenceSchemaVersion, + aggregateType: 'commerce_provider_reconciliation', + commandKeyHash: prepared.commandKeyHash, + providerAttempt: INITIAL_PROVIDER_ATTEMPT, + evidenceRevision: 1, + eventType: 'provider_reconciliation_candidate_recorded', + provider: 'stripe', + environment: context.providerPlan.record.environment, + stripeMode: context.providerPlan.record.stripeMode, + providerOperation: context.providerPlan.record.providerOperation, + providerPlanCommitment, + providerSendEvidenceCommitment, + reconciliationPolicySchemaVersion, + classification: classification.classification, + observedFenceEpoch: context.lifecycle.record.fenceEpoch, + observedLeaseExpiresAt, + reconciliationEvidenceCommitment, + occurredAt: prepared.occurredAt, + }); + return Object.freeze({ record, audit }); +} + +function classifyStoredProviderReconciliation(record) { + const evidence = Object.freeze(Object.fromEntries( + RECONCILIATION_EVIDENCE_INPUT_FIELDS.map((field) => [field, record[field]]), + )); + let classification; + try { + classification = classifyInitialStripeReconciliation(evidence); + } catch (error) { + if (error instanceof CommerceProviderReconciliationError) { + reject('journal_record_invalid'); + } + if (error instanceof CommerceCommandJournalError) throw error; + reject('journal_unavailable'); + } + if (classification.classification !== 'new_attempt_candidate' + || classification.state !== 'requires_persistence_and_authorization') { + reject('journal_record_invalid'); + } + return classification; +} + +async function parseProviderReconciliationPair( + recordValue, + auditValue, + prepared, + context, +) { + const record = readExactOwnDataObject( + recordValue, + PROVIDER_RECONCILIATION_FIELDS, + 'journal_record_invalid', + ); + const audit = readExactOwnDataObject( + auditValue, + PROVIDER_RECONCILIATION_AUDIT_FIELDS, + 'journal_record_invalid', + ); + const recordedAt = readTimestamp(record.recordedAt); + const observedLeaseExpiresAt = readTimestamp(record.observedLeaseExpiresAt); + const auditOccurredAt = readTimestamp(audit.occurredAt); + const auditObservedLeaseExpiresAt = readTimestamp(audit.observedLeaseExpiresAt); + const expectedProviderPlanCommitment = createCompleteProviderPlanCommitment( + context.providerPlan.record, + context.providerPlan.boundAt, + ); + const expectedProviderSendEvidenceCommitment = ( + createCompleteProviderSendEvidenceCommitment(context.providerSend) + ); + + classifyStoredProviderReconciliation(record); + if (record.providerReconciliationEvidenceSchemaVersion + !== providerReconciliationEvidenceSchemaVersion + || record.reconciliationPolicySchemaVersion !== reconciliationPolicySchemaVersion + || record.providerPlanSchemaVersion !== providerPlanSchemaVersion + || record.providerSendEvidenceSchemaVersion !== providerSendEvidenceSchemaVersion + || record.commandIdentityVersion !== commandIdentityVersion + || record.commandKeyHash !== prepared.commandKeyHash + || record.providerAttempt !== INITIAL_PROVIDER_ATTEMPT + || record.provider !== 'stripe' + || record.evidenceRevision !== 1 + || record.providerPlanCommitment !== expectedProviderPlanCommitment + || record.providerSendEvidenceCommitment !== expectedProviderSendEvidenceCommitment + || record.classification !== 'new_attempt_candidate' + || record.state !== 'requires_persistence_and_authorization' + || !validLifecycleNumber(record.observedFenceEpoch) + || record.observedFenceEpoch < context.providerSend.record.prePostFenceEpoch + || record.observedFenceEpoch > context.lifecycle.record.fenceEpoch + || recordedAt === null + || observedLeaseExpiresAt === null + || auditOccurredAt === null + || auditObservedLeaseExpiresAt === null + || !timestampsEqual(recordedAt, auditOccurredAt) + || !timestampsEqual(observedLeaseExpiresAt, auditObservedLeaseExpiresAt) + || compareTimestamps(recordedAt, context.providerSend.automaticRetryDeadlineAt) < 0 + || compareTimestamps(recordedAt, observedLeaseExpiresAt) < 0 + || audit.providerReconciliationAuditSchemaVersion + !== providerReconciliationAuditSchemaVersion + || audit.providerReconciliationEvidenceSchemaVersion + !== providerReconciliationEvidenceSchemaVersion + || audit.aggregateType !== 'commerce_provider_reconciliation' + || audit.commandKeyHash !== record.commandKeyHash + || audit.providerAttempt !== record.providerAttempt + || audit.evidenceRevision !== record.evidenceRevision + || audit.eventType !== 'provider_reconciliation_candidate_recorded' + || audit.provider !== record.provider + || audit.environment !== context.providerPlan.record.environment + || audit.stripeMode !== context.providerPlan.record.stripeMode + || audit.providerOperation !== context.providerPlan.record.providerOperation + || audit.providerPlanCommitment !== record.providerPlanCommitment + || audit.providerSendEvidenceCommitment !== record.providerSendEvidenceCommitment + || audit.reconciliationPolicySchemaVersion !== record.reconciliationPolicySchemaVersion + || audit.classification !== record.classification + || audit.observedFenceEpoch !== record.observedFenceEpoch + || typeof audit.reconciliationEvidenceCommitment !== 'string' + || !LOWERCASE_SHA256.test(audit.reconciliationEvidenceCommitment) + || audit.reconciliationEvidenceCommitment + !== createCompleteProviderReconciliationEvidenceCommitment( + record, + observedLeaseExpiresAt, + recordedAt, + )) { + reject('journal_record_invalid'); + } + + const observedLeaseAuditDocument = await context.readRequiredLifecycleAudit( + record.observedFenceEpoch + 1, + ); + const observedLeaseAudit = validateProviderLeaseAudit( + observedLeaseAuditDocument.value, + record.observedFenceEpoch, + prepared, + ); + let predecessorLeaseAudit = null; + if (record.observedFenceEpoch > 1) { + const predecessorDocument = await context.readRequiredLifecycleAudit( + record.observedFenceEpoch, + ); + predecessorLeaseAudit = validateProviderLeaseAudit( + predecessorDocument.value, + record.observedFenceEpoch - 1, + prepared, + ); + } + validateProviderLeaseChronology( + observedLeaseAudit, + predecessorLeaseAudit, + context.lifecycle, + ); + validateProviderLeaseAgainstCurrent(observedLeaseAudit, context.lifecycle); + if (!timestampsEqual(observedLeaseExpiresAt, observedLeaseAudit.leaseExpiresAt)) { + reject('journal_record_invalid'); + } + + for (const field of RECONCILIATION_EVIDENCE_INPUT_FIELDS) { + if (record[field] !== prepared.reconciliationEvidence[field]) { + reject('command_conflict'); + } + } + return Object.freeze({ record, audit, recordedAt, observedLeaseExpiresAt }); +} + +async function readProviderReconciliationContext(transaction, prepared) { + const context = await readProviderSendContext( + transaction, + prepared, + { requireActiveLease: false }, + ); + if (context.providerSend === null || context.providerSend.timeUnknown) { + reject('journal_record_invalid'); + } + + const reconciliationSnapshot = await transaction.get(prepared.providerReconciliationRef); + const reconciliationAuditSnapshot = await transaction.get( + prepared.providerReconciliationAuditRef, + ); + const reconciliationDocument = readSnapshot(reconciliationSnapshot); + const reconciliationAudit = readSnapshot(reconciliationAuditSnapshot); + if (reconciliationDocument.exists !== reconciliationAudit.exists) { + reject('journal_record_invalid'); + } + if (!reconciliationDocument.exists) { + return Object.freeze({ ...context, providerReconciliation: null }); + } + const providerReconciliation = await parseProviderReconciliationPair( + reconciliationDocument.value, + reconciliationAudit.value, + prepared, + context, + ); + return Object.freeze({ ...context, providerReconciliation }); +} + function validateActiveProviderPlanLease(prepared, lifecycle) { if (lifecycle.record.state !== 'leased' || lifecycle.record.leaseOwnerFingerprint !== prepared.leaseOwnerFingerprint @@ -1878,6 +2466,79 @@ async function recordInitialStripeSendEvidence(input) { } } +const PROVIDER_RECONCILIATION_PERSISTED = Object.freeze({ + journalSchemaVersion, + providerReconciliationEvidenceSchemaVersion, + reconciliationPolicySchemaVersion, + outcome: 'reconciliation_candidate_persisted', + state: 'requires_separate_authorization', +}); + +async function recordInitialStripeReconciliationEvidence(input) { + const prepared = prepareProviderReconciliationOperation(input); + const permittedTransactionResults = new Set(); + const permitResult = (value, recordedAt = null) => { + const permitted = Object.freeze({ value, recordedAt }); + permittedTransactionResults.add(permitted); + return permitted; + }; + + try { + const result = await prepared.db.runTransaction(async (transaction) => { + const context = await readProviderReconciliationContext(transaction, prepared); + if (context.providerReconciliation !== null) { + return permitResult( + PROVIDER_RECONCILIATION_PERSISTED, + context.providerReconciliation.recordedAt, + ); + } + if (prepared.reconciliationClassification.classification + !== 'new_attempt_candidate' + || prepared.reconciliationClassification.state + !== 'requires_persistence_and_authorization' + || compareTimestamps( + prepared.occurredAtParts, + context.providerSend.automaticRetryDeadlineAt, + ) < 0 + || context.lifecycle.record.state !== 'leased' + || compareTimestamps( + prepared.occurredAtParts, + context.lifecycle.leaseExpiresAt, + ) < 0) { + return permitResult(prepared.reconciliationClassification); + } + + const pair = createProviderReconciliationPair(prepared, context); + transaction.create(prepared.providerReconciliationRef, pair.record); + transaction.create(prepared.providerReconciliationAuditRef, pair.audit); + return permitResult( + PROVIDER_RECONCILIATION_PERSISTED, + prepared.occurredAtParts, + ); + }, TRANSACTION_OPTIONS); + + if (!permittedTransactionResults.has(result) + || (result.value !== PROVIDER_RECONCILIATION_PERSISTED + && result.value !== prepared.reconciliationClassification)) { + reject('journal_unavailable'); + } + if (result.value === PROVIDER_RECONCILIATION_PERSISTED) { + const returnedAt = readTimestamp(captureTrustedTimestamp()); + if (result.recordedAt === null + || returnedAt === null + || compareTimestamps(returnedAt, result.recordedAt) < 0) { + reject('journal_record_invalid'); + } + } else if (result.recordedAt !== null) { + reject('journal_unavailable'); + } + return result.value; + } catch (error) { + if (error instanceof CommerceCommandJournalError) throw error; + reject('journal_unavailable'); + } +} + const REGISTERED_NEW = Object.freeze({ journalSchemaVersion, outcome: 'registered_new', @@ -2100,12 +2761,14 @@ Object.freeze(CommerceCommandJournalError); module.exports = Object.freeze({ journalSchemaVersion, lifecycleSchemaVersion, + providerReconciliationEvidenceSchemaVersion, providerSendEvidenceSchemaVersion, CommerceCommandJournalError, acquireCommerceCommandLease, bindInitialStripeProviderPlan, completeCommerceCommand, failCommerceCommand, + recordInitialStripeReconciliationEvidence, recordInitialStripeSendEvidence, registerCommerceCommand, }); diff --git a/functions/commerceCommandJournal.test.js b/functions/commerceCommandJournal.test.js index ec73ff7e..1e8a219b 100644 --- a/functions/commerceCommandJournal.test.js +++ b/functions/commerceCommandJournal.test.js @@ -1,5 +1,6 @@ const fs = require('node:fs'); const path = require('node:path'); +const { createHash } = require('node:crypto'); const { inspect } = require('node:util'); const { Timestamp } = require('firebase-admin/firestore'); @@ -12,12 +13,14 @@ const { const { journalSchemaVersion, lifecycleSchemaVersion, + providerReconciliationEvidenceSchemaVersion, providerSendEvidenceSchemaVersion, CommerceCommandJournalError, acquireCommerceCommandLease, bindInitialStripeProviderPlan, completeCommerceCommand, failCommerceCommand, + recordInitialStripeReconciliationEvidence, recordInitialStripeSendEvidence, registerCommerceCommand, } = require('./commerceCommandJournal'); @@ -27,6 +30,7 @@ const OTHER_COMMAND_ID = '123e4567-e89b-42d3-a456-426614174000'; const LEASE_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; const OTHER_LEASE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; const THIRD_LEASE_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; +const FOURTH_LEASE_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'; const TERMINAL_REFERENCE_FINGERPRINT = 'd'.repeat(64); const OTHER_TERMINAL_REFERENCE_FINGERPRINT = 'e'.repeat(64); const STRIPE_ACCOUNT_ID = 'acct_1SyntheticTest000000000001'; @@ -43,6 +47,64 @@ const RAW_CALLER = 'private-runner@example.test'; const HOSTILE_CANARY = 'private-runner@example.test/token?secret=do-not-copy'; const NOW_MILLIS = 1800000000123; const PROVIDER_SEND_RETRY_WINDOW_MILLIS = 23 * 60 * 60 * 1000; +const HASH_MAGIC = 'mprc-commerce-command-journal-sha256'; + +function updateTestDigest(hash, value) { + const bytes = Buffer.from(value, 'utf8'); + const length = Buffer.allocUnsafe(4); + length.writeUInt32BE(bytes.length, 0); + hash.update(length); + hash.update(bytes); +} + +function testDigest(domain, fields) { + const hash = createHash('sha256'); + updateTestDigest(hash, HASH_MAGIC); + updateTestDigest(hash, domain); + for (const [name, value] of fields) { + updateTestDigest(hash, name); + updateTestDigest(hash, value); + } + return hash.digest('hex'); +} + +function reconciliationEvidenceCommitment(record) { + return testDigest('mprc.command-provider-reconciliation-evidence-complete.v1', [ + ['version', '1'], + [ + 'providerReconciliationEvidenceSchemaVersion', + String(record.providerReconciliationEvidenceSchemaVersion), + ], + ['reconciliationPolicySchemaVersion', String(record.reconciliationPolicySchemaVersion)], + ['providerPlanSchemaVersion', String(record.providerPlanSchemaVersion)], + ['providerSendEvidenceSchemaVersion', String(record.providerSendEvidenceSchemaVersion)], + ['commandIdentityVersion', String(record.commandIdentityVersion)], + ['commandKeyHash', record.commandKeyHash], + ['providerAttempt', String(record.providerAttempt)], + ['provider', record.provider], + ['evidenceRevision', String(record.evidenceRevision)], + ['providerPlanCommitment', record.providerPlanCommitment], + ['providerSendEvidenceCommitment', record.providerSendEvidenceCommitment], + ['classification', record.classification], + ['state', record.state], + ['planBinding', record.planBinding], + ['evidenceSource', record.evidenceSource], + ['evidenceCompleteness', record.evidenceCompleteness], + ['dispatchEvidence', record.dispatchEvidence], + ['responseEvidence', record.responseEvidence], + ['idempotencyEvidence', record.idempotencyEvidence], + ['providerObjectEvidence', record.providerObjectEvidence], + ['paymentEvidence', record.paymentEvidence], + ['eventEvidence', record.eventEvidence], + ['searchEvidence', record.searchEvidence], + ['businessTransitionEvidence', record.businessTransitionEvidence], + ['observedFenceEpoch', String(record.observedFenceEpoch)], + ['observedLeaseExpiresAtSeconds', String(record.observedLeaseExpiresAt.seconds)], + ['observedLeaseExpiresAtNanoseconds', String(record.observedLeaseExpiresAt.nanoseconds)], + ['recordedAtSeconds', String(record.recordedAt.seconds)], + ['recordedAtNanoseconds', String(record.recordedAt.nanoseconds)], + ]); +} function frozenRecord(entries = []) { const value = Object.create(null); @@ -102,9 +164,10 @@ function cloneStore(store) { } function createMockDb(seed = [], options = {}) { - const store = new Map(seed); + const store = options.sharedStore || new Map(seed); const callbackRuns = []; const collectionCalls = []; + let rejectAfterCommit = options.rejectAfterCommit; const db = { collection: jest.fn((collectionName) => { collectionCalls.push(collectionName); @@ -119,6 +182,7 @@ function createMockDb(seed = [], options = {}) { let finalWrites = []; for (let index = 0; index < numberOfRuns; index += 1) { + if (options.beforeCallbackRun) await options.beforeCallbackRun(index); const readView = cloneStore(store); const writes = []; const transaction = Object.freeze({ @@ -142,6 +206,11 @@ function createMockDb(seed = [], options = {}) { } } for (const write of finalWrites) store.set(write.ref.path, write.value); + if (rejectAfterCommit) { + const error = rejectAfterCommit; + rejectAfterCommit = null; + throw error; + } if (Object.prototype.hasOwnProperty.call(options, 'returnedValue')) { return options.returnedValue; } @@ -256,6 +325,40 @@ function providerPlanInput(db, overrides = {}) { }; } +function candidateReconciliationEvidence(overrides = {}) { + return { + reconciliationPolicySchemaVersion: 1, + provider: 'stripe', + providerAttempt: 1, + planBinding: 'exact', + evidenceSource: 'trusted_dispatch_history', + evidenceCompleteness: 'complete', + dispatchEvidence: 'execution_never_began', + responseEvidence: 'none', + idempotencyEvidence: 'not_relied_upon', + providerObjectEvidence: 'none', + paymentEvidence: 'none', + eventEvidence: 'none', + searchEvidence: 'none', + businessTransitionEvidence: 'same_operation_eligible', + ...overrides, + }; +} + +function providerReconciliationInput(db, overrides = {}) { + return { + ...validInput(db), + stripeAccountId: STRIPE_ACCOUNT_ID, + stripeMode: 'test', + stripeApiVersion: STRIPE_API_VERSION, + endpointPath: STRIPE_ENDPOINT_PATH, + providerOperation: PROVIDER_OPERATION, + providerParameters: validProviderParameters(), + reconciliationEvidence: candidateReconciliationEvidence(), + ...overrides, + }; +} + function lifecyclePaths(input, revision = 2) { const identity = expectedIdentity(input); return Object.freeze({ @@ -292,6 +395,20 @@ function providerSendPaths(input) { }); } +function providerReconciliationPaths(input) { + const paths = providerSendPaths(input); + return Object.freeze({ + ...paths, + providerReconciliationPath: ( + `${paths.providerPlanPath}/reconciliationEvidence/0000000001` + ), + providerReconciliationAuditPath: documentPath( + 'auditEvents', + `commerce_provider_reconciliation_${paths.commandKeyHash}_0000000001_0000000001`, + ), + }); +} + function captureError(callback) { return Promise.resolve() .then(callback) @@ -2799,6 +2916,819 @@ describe('pre-POST Stripe send evidence and retry cutoff', () => { }); }); +describe('immutable initial Stripe reconciliation evidence', () => { + let nowMillis; + + beforeEach(() => { + nowMillis = NOW_MILLIS; + jest.spyOn(Timestamp, 'now') + .mockImplementation(() => Timestamp.fromMillis(nowMillis)); + }); + + afterEach(() => jest.restoreAllMocks()); + + async function createSendFoundation(mock) { + await registerCommerceCommand(validInput(mock.db)); + nowMillis += 1000; + await acquireCommerceCommandLease(leaseInput(mock.db)); + nowMillis += 1000; + await bindInitialStripeProviderPlan(providerPlanInput(mock.db)); + nowMillis += 1000; + await recordInitialStripeSendEvidence(providerPlanInput(mock.db)); + } + + function moveToSendCutoff(mock, input = providerReconciliationInput(mock.db)) { + const paths = providerReconciliationPaths(input); + nowMillis = mock.store.get(paths.providerSendPath).automaticRetryDeadlineAt.toMillis(); + return paths; + } + + test('creates one candidate pair at the C2 cutoff with no active lease and preserves prior bytes', async () => { + const mock = createMockDb(); + await createSendFoundation(mock); + const input = providerReconciliationInput(mock.db); + const paths = providerReconciliationPaths(input); + const before = cloneStore(mock.store); + nowMillis = mock.store.get(paths.providerSendPath).automaticRetryDeadlineAt.toMillis(); + + const result = await recordInitialStripeReconciliationEvidence(input); + + expect(providerReconciliationEvidenceSchemaVersion).toBe(1); + expect(result).toEqual({ + journalSchemaVersion: 1, + providerReconciliationEvidenceSchemaVersion: 1, + reconciliationPolicySchemaVersion: 1, + outcome: 'reconciliation_candidate_persisted', + state: 'requires_separate_authorization', + }); + expect(Object.isFrozen(result)).toBe(true); + const transactionRun = mock.callbackRuns.at(-1); + expect(transactionRun.transaction.get).toHaveBeenCalledTimes(10); + expect(transactionRun.transaction.create).toHaveBeenCalledTimes(2); + expect(Math.max(...transactionRun.transaction.get.mock.invocationCallOrder)) + .toBeLessThan(Math.min(...transactionRun.transaction.create.mock.invocationCallOrder)); + expect(transactionRun.writes.map((write) => write.ref.path)).toEqual([ + paths.providerReconciliationPath, + paths.providerReconciliationAuditPath, + ]); + + const record = mock.store.get(paths.providerReconciliationPath); + const audit = mock.store.get(paths.providerReconciliationAuditPath); + expect(record).toEqual({ + providerReconciliationEvidenceSchemaVersion: 1, + reconciliationPolicySchemaVersion: 1, + providerPlanSchemaVersion: 1, + providerSendEvidenceSchemaVersion: 1, + commandIdentityVersion: 1, + commandKeyHash: paths.commandKeyHash, + providerAttempt: 1, + provider: 'stripe', + evidenceRevision: 1, + providerPlanCommitment: expect.stringMatching(/^[0-9a-f]{64}$/), + providerSendEvidenceCommitment: expect.stringMatching(/^[0-9a-f]{64}$/), + classification: 'new_attempt_candidate', + state: 'requires_persistence_and_authorization', + planBinding: 'exact', + evidenceSource: 'trusted_dispatch_history', + evidenceCompleteness: 'complete', + dispatchEvidence: 'execution_never_began', + responseEvidence: 'none', + idempotencyEvidence: 'not_relied_upon', + providerObjectEvidence: 'none', + paymentEvidence: 'none', + eventEvidence: 'none', + searchEvidence: 'none', + businessTransitionEvidence: 'same_operation_eligible', + observedFenceEpoch: 1, + observedLeaseExpiresAt: expect.any(Timestamp), + recordedAt: expect.any(Timestamp), + }); + expect(audit).toEqual({ + providerReconciliationAuditSchemaVersion: 1, + providerReconciliationEvidenceSchemaVersion: 1, + aggregateType: 'commerce_provider_reconciliation', + commandKeyHash: paths.commandKeyHash, + providerAttempt: 1, + evidenceRevision: 1, + eventType: 'provider_reconciliation_candidate_recorded', + provider: 'stripe', + environment: 'test', + stripeMode: 'test', + providerOperation: PROVIDER_OPERATION, + providerPlanCommitment: record.providerPlanCommitment, + providerSendEvidenceCommitment: record.providerSendEvidenceCommitment, + reconciliationPolicySchemaVersion: 1, + classification: 'new_attempt_candidate', + observedFenceEpoch: 1, + observedLeaseExpiresAt: record.observedLeaseExpiresAt, + reconciliationEvidenceCommitment: expect.stringMatching(/^[0-9a-f]{64}$/), + occurredAt: expect.any(Timestamp), + }); + expect(record.recordedAt).toBe(audit.occurredAt); + expect(record.observedLeaseExpiresAt).toBe(audit.observedLeaseExpiresAt); + expect(record.recordedAt.toMillis()).toBe( + before.get(paths.providerSendPath).automaticRetryDeadlineAt.toMillis(), + ); + expect(Object.isFrozen(record)).toBe(true); + expect(Object.isFrozen(audit)).toBe(true); + for (const [path_, value] of before) expect(mock.store.get(path_)).toBe(value); + }); + + test('transaction retries snapshot evidence once and create the same pair after every read', async () => { + const foundation = createMockDb(); + await createSendFoundation(foundation); + moveToSendCutoff(foundation); + const evidence = candidateReconciliationEvidence(); + const mock = createMockDb([...foundation.store], { + callbackRuns: 4, + beforeCallbackRun: async (index) => { + if (index === 1) evidence.dispatchEvidence = 'timeout'; + }, + }); + const input = providerReconciliationInput(mock.db, { + reconciliationEvidence: evidence, + }); + Timestamp.now.mockClear(); + + const result = await recordInitialStripeReconciliationEvidence(input); + + expect(result.outcome).toBe('reconciliation_candidate_persisted'); + expect(Timestamp.now).toHaveBeenCalledTimes(2); + expect(mock.callbackRuns).toHaveLength(4); + for (const { transaction, writes } of mock.callbackRuns) { + expect(transaction.get).toHaveBeenCalledTimes(10); + expect(transaction.create).toHaveBeenCalledTimes(2); + expect(writes).toHaveLength(2); + expect(writes[0].value.dispatchEvidence).toBe('execution_never_began'); + } + const recordWrites = mock.callbackRuns.map(({ writes }) => writes[0].value); + const auditWrites = mock.callbackRuns.map(({ writes }) => writes[1].value); + expect(recordWrites.every((value) => JSON.stringify(value) === JSON.stringify(recordWrites[0]))) + .toBe(true); + expect(auditWrites.every((value) => JSON.stringify(value) === JSON.stringify(auditWrites[0]))) + .toBe(true); + }); + + test('an earlier-started call accepts the exact pair committed by a later-started call', async () => { + const foundation = createMockDb(); + await createSendFoundation(foundation); + const paths = moveToSendCutoff(foundation); + const cutoff = foundation.store.get( + paths.providerSendPath, + ).automaticRetryDeadlineAt.toMillis(); + let releaseEarlier; + let markEarlierStarted; + const earlierBlocked = new Promise((resolve) => { releaseEarlier = resolve; }); + const earlierStarted = new Promise((resolve) => { markEarlierStarted = resolve; }); + const earlier = createMockDb([], { + sharedStore: foundation.store, + beforeCallbackRun: async () => { + markEarlierStarted(); + await earlierBlocked; + }, + }); + const later = createMockDb([], { sharedStore: foundation.store }); + for (const offset of [0, 1, 2, 3]) { + Timestamp.now.mockImplementationOnce(() => Timestamp.fromMillis(cutoff + offset)); + } + Timestamp.now.mockClear(); + + const earlierPromise = recordInitialStripeReconciliationEvidence( + providerReconciliationInput(earlier.db), + ); + await earlierStarted; + let laterResult; + try { + laterResult = await recordInitialStripeReconciliationEvidence( + providerReconciliationInput(later.db), + ); + } finally { + releaseEarlier(); + } + const earlierResult = await earlierPromise; + + expect(laterResult).toMatchObject({ outcome: 'reconciliation_candidate_persisted' }); + expect(earlierResult).toEqual(laterResult); + expect(Timestamp.now).toHaveBeenCalledTimes(4); + expect(later.callbackRuns[0].writes).toHaveLength(2); + expect(earlier.callbackRuns[0].writes).toEqual([]); + expect(foundation.store.get(paths.providerReconciliationPath).recordedAt.toMillis()) + .toBe(cutoff + 1); + }); + + test('waits for both the C2 cutoff and current lease expiry without persisting', async () => { + const mock = createMockDb(); + await createSendFoundation(mock); + const input = providerReconciliationInput(mock.db); + const paths = providerReconciliationPaths(input); + const cutoff = mock.store.get(paths.providerSendPath).automaticRetryDeadlineAt.toMillis(); + + nowMillis += 1; + await expect(recordInitialStripeReconciliationEvidence(input)).resolves.toEqual({ + reconciliationPolicySchemaVersion: 1, + classification: 'new_attempt_candidate', + state: 'requires_persistence_and_authorization', + }); + expect(mock.store.has(paths.providerReconciliationPath)).toBe(false); + + nowMillis = cutoff - 30000; + await acquireCommerceCommandLease(leaseInput(mock.db, { leaseId: OTHER_LEASE_ID })); + nowMillis = cutoff; + const activeResult = await recordInitialStripeReconciliationEvidence(input); + expect(activeResult.classification).toBe('new_attempt_candidate'); + expect(mock.store.has(paths.providerReconciliationPath)).toBe(false); + expect(mock.store.get(paths.lifecyclePath).fenceEpoch).toBe(2); + + nowMillis = cutoff + 30000; + const persisted = await recordInitialStripeReconciliationEvidence(input); + expect(persisted.outcome).toBe('reconciliation_candidate_persisted'); + expect(mock.store.get(paths.providerReconciliationPath)).toMatchObject({ + observedFenceEpoch: 2, + observedLeaseExpiresAt: Timestamp.fromMillis(cutoff + 30000), + }); + }); + + test('uses nanosecond precision at the C2 deadline and lease-expiry boundaries', async () => { + const mock = createMockDb(); + await createSendFoundation(mock); + const input = providerReconciliationInput(mock.db); + const paths = providerReconciliationPaths(input); + const deadline = mock.store.get(paths.providerSendPath).automaticRetryDeadlineAt; + const oneNanosecondBefore = deadline.nanoseconds === 0 + ? new Timestamp(deadline.seconds - 1, 999999999) + : new Timestamp(deadline.seconds, deadline.nanoseconds - 1); + Timestamp.now.mockImplementationOnce(() => oneNanosecondBefore); + + expect(await recordInitialStripeReconciliationEvidence(input)).toEqual({ + reconciliationPolicySchemaVersion: 1, + classification: 'new_attempt_candidate', + state: 'requires_persistence_and_authorization', + }); + expect(mock.store.has(paths.providerReconciliationPath)).toBe(false); + + Timestamp.now.mockImplementationOnce(() => new Timestamp( + deadline.seconds, + deadline.nanoseconds, + )); + Timestamp.now.mockImplementationOnce(() => new Timestamp( + deadline.seconds, + deadline.nanoseconds, + )); + expect(await recordInitialStripeReconciliationEvidence(input)).toMatchObject({ + outcome: 'reconciliation_candidate_persisted', + }); + expect(mock.store.get(paths.providerReconciliationPath).recordedAt).toEqual(deadline); + + nowMillis = NOW_MILLIS; + const leaseMock = createMockDb(); + await createSendFoundation(leaseMock); + const leaseReconciliationInput = providerReconciliationInput(leaseMock.db); + const leasePaths = providerReconciliationPaths(leaseReconciliationInput); + const leaseDeadline = leaseMock.store.get( + leasePaths.providerSendPath, + ).automaticRetryDeadlineAt; + nowMillis = leaseDeadline.toMillis(); + await acquireCommerceCommandLease(leaseInput(leaseMock.db, { + leaseId: OTHER_LEASE_ID, + })); + const leaseExpiry = leaseMock.store.get(leasePaths.lifecyclePath).leaseExpiresAt; + const oneNanosecondBeforeExpiry = leaseExpiry.nanoseconds === 0 + ? new Timestamp(leaseExpiry.seconds - 1, 999999999) + : new Timestamp(leaseExpiry.seconds, leaseExpiry.nanoseconds - 1); + Timestamp.now.mockImplementationOnce(() => oneNanosecondBeforeExpiry); + expect(await recordInitialStripeReconciliationEvidence( + leaseReconciliationInput, + )).toEqual({ + reconciliationPolicySchemaVersion: 1, + classification: 'new_attempt_candidate', + state: 'requires_persistence_and_authorization', + }); + expect(leaseMock.store.has(leasePaths.providerReconciliationPath)).toBe(false); + + Timestamp.now.mockImplementationOnce(() => new Timestamp( + leaseExpiry.seconds, + leaseExpiry.nanoseconds, + )); + Timestamp.now.mockImplementationOnce(() => new Timestamp( + leaseExpiry.seconds, + leaseExpiry.nanoseconds, + )); + expect(await recordInitialStripeReconciliationEvidence( + leaseReconciliationInput, + )).toMatchObject({ + outcome: 'reconciliation_candidate_persisted', + }); + expect(leaseMock.store.get(leasePaths.providerReconciliationPath)).toMatchObject({ + observedFenceEpoch: 2, + observedLeaseExpiresAt: leaseExpiry, + }); + }); + + test('terminal lifecycle without a C3B pair returns the unchanged candidate and writes nothing', async () => { + const mock = createMockDb(); + await createSendFoundation(mock); + const input = providerReconciliationInput(mock.db); + const paths = providerReconciliationPaths(input); + nowMillis += 1; + await completeCommerceCommand(completionInput(mock.db)); + moveToSendCutoff(mock, input); + const runsBefore = mock.callbackRuns.length; + + const result = await recordInitialStripeReconciliationEvidence(input); + + expect(result).toEqual({ + reconciliationPolicySchemaVersion: 1, + classification: 'new_attempt_candidate', + state: 'requires_persistence_and_authorization', + }); + expect(mock.store.has(paths.providerReconciliationPath)).toBe(false); + expect(mock.store.has(paths.providerReconciliationAuditPath)).toBe(false); + expect(mock.callbackRuns.slice(runsBefore).at(-1).writes).toEqual([]); + }); + + test('an exact C3B pair remains read-only after a takeover reaches terminal state', async () => { + const mock = createMockDb(); + await createSendFoundation(mock); + const input = providerReconciliationInput(mock.db); + const paths = moveToSendCutoff(mock, input); + await recordInitialStripeReconciliationEvidence(input); + const recordBefore = mock.store.get(paths.providerReconciliationPath); + const auditBefore = mock.store.get(paths.providerReconciliationAuditPath); + + await acquireCommerceCommandLease(leaseInput(mock.db, { leaseId: OTHER_LEASE_ID })); + nowMillis += 1; + await completeCommerceCommand(completionInput(mock.db, { + leaseId: OTHER_LEASE_ID, + expectedFenceEpoch: 2, + })); + nowMillis += 1; + + expect(await recordInitialStripeReconciliationEvidence(input)).toMatchObject({ + outcome: 'reconciliation_candidate_persisted', + state: 'requires_separate_authorization', + }); + expect(mock.callbackRuns.at(-1).writes).toEqual([]); + expect(mock.store.get(paths.providerReconciliationPath)).toBe(recordBefore); + expect(mock.store.get(paths.providerReconciliationAuditPath)).toBe(auditBefore); + + const terminalLeaseAuditPath = lifecyclePaths(input, 3).lifecycleAuditPath; + const terminalLeaseAudit = mock.store.get(terminalLeaseAuditPath); + mock.store.set(terminalLeaseAuditPath, { + ...terminalLeaseAudit, + occurredAt: Timestamp.fromMillis(terminalLeaseAudit.occurredAt.toMillis() + 1), + leaseExpiresAt: Timestamp.fromMillis(terminalLeaseAudit.leaseExpiresAt.toMillis() + 1), + }); + const beforeMalformedRetry = cloneStore(mock.store); + nowMillis += 1; + await expectSafeError( + () => recordInitialStripeReconciliationEvidence(input), + 'journal_record_invalid', + ); + expect(mock.store).toEqual(beforeMalformedRetry); + }); + + test.each([ + [ + 'existing attempt', + candidateReconciliationEvidence({ + evidenceSource: 'verified_provider_object', + dispatchEvidence: 'execution_started', + responseEvidence: 'accepted', + providerObjectEvidence: 'exact_open', + paymentEvidence: 'unpaid', + searchEvidence: 'exact_lookup_complete', + businessTransitionEvidence: 'ineligible', + }), + 'existing_attempt_found', + 'do_not_advance', + ], + [ + 'unsafe evidence', + candidateReconciliationEvidence({ dispatchEvidence: 'timeout' }), + 'reconciliation_required', + 'requires_reconciliation', + ], + ])('returns unchanged C3A %s classification and writes nothing', async ( + _label, + reconciliationEvidence, + classification, + state, + ) => { + const mock = createMockDb(); + await createSendFoundation(mock); + const paths = moveToSendCutoff(mock); + const runsBefore = mock.callbackRuns.length; + + const result = await recordInitialStripeReconciliationEvidence( + providerReconciliationInput(mock.db, { reconciliationEvidence }), + ); + + expect(result).toEqual({ + reconciliationPolicySchemaVersion: 1, + classification, + state, + }); + expect(Object.isFrozen(result)).toBe(true); + expect(mock.store.has(paths.providerReconciliationPath)).toBe(false); + const [{ transaction, writes }] = mock.callbackRuns.slice(runsBefore); + expect(transaction.create).not.toHaveBeenCalled(); + expect(writes).toEqual([]); + }); + + test('exact and canonical retries are read-only while any valid change conflicts', async () => { + const mock = createMockDb(); + await createSendFoundation(mock); + const input = providerReconciliationInput(mock.db); + const paths = moveToSendCutoff(mock, input); + await recordInitialStripeReconciliationEvidence(input); + const recordBefore = mock.store.get(paths.providerReconciliationPath); + const auditBefore = mock.store.get(paths.providerReconciliationAuditPath); + const storeSize = mock.store.size; + + nowMillis += 1; + const exact = await recordInitialStripeReconciliationEvidence(input); + const canonical = await recordInitialStripeReconciliationEvidence( + providerReconciliationInput(mock.db, { + providerParameters: validProviderParameters([ + ['synthetic_reference', HOSTILE_CANARY], + ['currency', 'usd'], + ['amount_total', 2500], + ]), + }), + ); + expect(exact).toBe(canonical); + expect(exact.outcome).toBe('reconciliation_candidate_persisted'); + for (const { transaction, writes } of mock.callbackRuns.slice(-2)) { + expect(transaction.create).not.toHaveBeenCalled(); + expect(writes).toEqual([]); + } + + await expectSafeError( + () => recordInitialStripeReconciliationEvidence(providerReconciliationInput(mock.db, { + reconciliationEvidence: candidateReconciliationEvidence({ + dispatchEvidence: 'timeout', + }), + })), + 'command_conflict', + ); + await expectSafeError( + () => recordInitialStripeReconciliationEvidence(providerReconciliationInput(mock.db, { + providerParameters: validProviderParameters([ + ['amount_total', 2600], + ['currency', 'usd'], + ['synthetic_reference', HOSTILE_CANARY], + ]), + })), + 'command_conflict', + ); + expect(mock.store.size).toBe(storeSize); + expect(mock.store.get(paths.providerReconciliationPath)).toBe(recordBefore); + expect(mock.store.get(paths.providerReconciliationAuditPath)).toBe(auditBefore); + }); + + test('rejects C2 and C3B orphans or malformed partners without repair', async () => { + const foundation = createMockDb(); + await createSendFoundation(foundation); + const baseInput = providerReconciliationInput(foundation.db); + const paths = moveToSendCutoff(foundation, baseInput); + const foundationSeed = [...foundation.store]; + + const missingSendAudit = new Map(foundationSeed); + missingSendAudit.delete(paths.providerSendAuditPath); + const unreadableSendTime = new Map(foundationSeed); + unreadableSendTime.set(paths.providerSendPath, { + ...unreadableSendTime.get(paths.providerSendPath), + automaticRetryDeadlineAt: null, + }); + for (const seed of [missingSendAudit, unreadableSendTime]) { + const mock = createMockDb([...seed]); + await expectSafeError( + () => recordInitialStripeReconciliationEvidence(providerReconciliationInput(mock.db)), + 'journal_record_invalid', + ); + expect(mock.store.has(paths.providerReconciliationPath)).toBe(false); + expect(mock.store.has(paths.providerReconciliationAuditPath)).toBe(false); + } + + await recordInitialStripeReconciliationEvidence(baseInput); + const complete = cloneStore(foundation.store); + const record = complete.get(paths.providerReconciliationPath); + const audit = complete.get(paths.providerReconciliationAuditPath); + const variants = []; + const missingAudit = cloneStore(complete); + missingAudit.delete(paths.providerReconciliationAuditPath); + variants.push(missingAudit); + const missingRecord = cloneStore(complete); + missingRecord.delete(paths.providerReconciliationPath); + variants.push(missingRecord); + const extraRecord = cloneStore(complete); + extraRecord.set(paths.providerReconciliationPath, { ...record, extra: HOSTILE_CANARY }); + variants.push(extraRecord); + const badAudit = cloneStore(complete); + badAudit.set(paths.providerReconciliationAuditPath, { + ...audit, + reconciliationEvidenceCommitment: 'a'.repeat(64), + }); + variants.push(badAudit); + const futureRecord = cloneStore(complete); + futureRecord.set(paths.providerReconciliationPath, { + ...record, + providerReconciliationEvidenceSchemaVersion: 2, + }); + variants.push(futureRecord); + const futureAudit = cloneStore(complete); + futureAudit.set(paths.providerReconciliationAuditPath, { + ...audit, + providerReconciliationAuditSchemaVersion: 2, + }); + variants.push(futureAudit); + + for (const seed of variants) { + const mock = createMockDb([...seed]); + const before = cloneStore(mock.store); + await expectSafeError( + () => recordInitialStripeReconciliationEvidence(providerReconciliationInput(mock.db)), + 'journal_record_invalid', + ); + expect(mock.store).toEqual(before); + } + }); + + test('rejects a recommitted C3B pair whose observed fence predates C2', async () => { + const mock = createMockDb(); + await registerCommerceCommand(validInput(mock.db)); + nowMillis += 1000; + await acquireCommerceCommandLease(leaseInput(mock.db)); + nowMillis = NOW_MILLIS + 61000; + await acquireCommerceCommandLease(leaseInput(mock.db, { leaseId: OTHER_LEASE_ID })); + nowMillis += 1000; + const secondFencePlan = providerPlanInput(mock.db, { + leaseId: OTHER_LEASE_ID, + expectedFenceEpoch: 2, + }); + await bindInitialStripeProviderPlan(secondFencePlan); + nowMillis += 1000; + await recordInitialStripeSendEvidence(secondFencePlan); + const input = providerReconciliationInput(mock.db); + const paths = moveToSendCutoff(mock, input); + await recordInitialStripeReconciliationEvidence(input); + + const firstLeaseAudit = mock.store.get(lifecyclePaths(input, 2).lifecycleAuditPath); + const originalRecord = mock.store.get(paths.providerReconciliationPath); + const originalAudit = mock.store.get(paths.providerReconciliationAuditPath); + expect(mock.store.get(paths.providerSendPath).prePostFenceEpoch).toBe(2); + expect(originalRecord.observedFenceEpoch).toBe(2); + const forgedRecord = { + ...originalRecord, + observedFenceEpoch: 1, + observedLeaseExpiresAt: firstLeaseAudit.leaseExpiresAt, + }; + const forgedAudit = { + ...originalAudit, + observedFenceEpoch: 1, + observedLeaseExpiresAt: firstLeaseAudit.leaseExpiresAt, + reconciliationEvidenceCommitment: reconciliationEvidenceCommitment(forgedRecord), + }; + mock.store.set(paths.providerReconciliationPath, forgedRecord); + mock.store.set(paths.providerReconciliationAuditPath, forgedAudit); + nowMillis += 1; + const before = cloneStore(mock.store); + + await expectSafeError( + () => recordInitialStripeReconciliationEvidence(input), + 'journal_record_invalid', + ); + expect(mock.store).toEqual(before); + }); + + test('rejects historical C2 lease evidence that overlaps a newer current fence', async () => { + const mock = createMockDb(); + await registerCommerceCommand(validInput(mock.db)); + nowMillis += 1000; + await acquireCommerceCommandLease(leaseInput(mock.db)); + nowMillis += 1000; + await bindInitialStripeProviderPlan(providerPlanInput(mock.db)); + const input = providerReconciliationInput(mock.db); + const paths = providerReconciliationPaths(input); + + nowMillis = mock.store.get(paths.lifecyclePath).leaseExpiresAt.toMillis(); + await acquireCommerceCommandLease(leaseInput(mock.db, { leaseId: OTHER_LEASE_ID })); + nowMillis += 1000; + await recordInitialStripeSendEvidence(providerPlanInput(mock.db, { + leaseId: OTHER_LEASE_ID, + expectedFenceEpoch: 2, + })); + nowMillis = mock.store.get(paths.lifecyclePath).leaseExpiresAt.toMillis(); + await acquireCommerceCommandLease(leaseInput(mock.db, { leaseId: THIRD_LEASE_ID })); + nowMillis = mock.store.get(paths.lifecyclePath).leaseExpiresAt.toMillis(); + await acquireCommerceCommandLease(leaseInput(mock.db, { leaseId: FOURTH_LEASE_ID })); + const currentLeaseAcquiredAt = mock.store.get(paths.lifecyclePath).leaseAcquiredAt; + const impossibleFenceTwoAcquiredAt = Timestamp.fromMillis( + currentLeaseAcquiredAt.toMillis() + 1000, + ); + const impossibleFenceTwoExpiresAt = Timestamp.fromMillis( + impossibleFenceTwoAcquiredAt.toMillis() + 60000, + ); + const impossibleSendAt = Timestamp.fromMillis( + impossibleFenceTwoAcquiredAt.toMillis() + 1, + ); + const impossibleDeadline = Timestamp.fromMillis( + impossibleSendAt.toMillis() + PROVIDER_SEND_RETRY_WINDOW_MILLIS, + ); + const fenceTwoAuditPath = lifecyclePaths(input, 3).lifecycleAuditPath; + mock.store.set(fenceTwoAuditPath, { + ...mock.store.get(fenceTwoAuditPath), + leaseExpiresAt: impossibleFenceTwoExpiresAt, + occurredAt: impossibleFenceTwoAcquiredAt, + }); + mock.store.set(paths.providerSendPath, { + ...mock.store.get(paths.providerSendPath), + prePostRecordedAt: impossibleSendAt, + automaticRetryDeadlineAt: impossibleDeadline, + }); + mock.store.set(paths.providerSendAuditPath, { + ...mock.store.get(paths.providerSendAuditPath), + automaticRetryDeadlineAt: impossibleDeadline, + occurredAt: impossibleSendAt, + }); + nowMillis = impossibleDeadline.toMillis(); + const before = cloneStore(mock.store); + + await expectSafeError( + () => recordInitialStripeReconciliationEvidence(input), + 'journal_record_invalid', + ); + expect(mock.store).toEqual(before); + expect(mock.store.has(paths.providerReconciliationPath)).toBe(false); + }); + + test('rejects historical C3B lease evidence that overlaps a newer current fence', async () => { + const mock = createMockDb(); + await createSendFoundation(mock); + const input = providerReconciliationInput(mock.db); + const paths = moveToSendCutoff(mock, input); + await acquireCommerceCommandLease(leaseInput(mock.db, { leaseId: OTHER_LEASE_ID })); + nowMillis = mock.store.get(paths.lifecyclePath).leaseExpiresAt.toMillis(); + await recordInitialStripeReconciliationEvidence(input); + await acquireCommerceCommandLease(leaseInput(mock.db, { leaseId: THIRD_LEASE_ID })); + nowMillis = mock.store.get(paths.lifecyclePath).leaseExpiresAt.toMillis(); + await acquireCommerceCommandLease(leaseInput(mock.db, { leaseId: FOURTH_LEASE_ID })); + const currentLeaseAcquiredAt = mock.store.get(paths.lifecyclePath).leaseAcquiredAt; + const impossibleFenceTwoAcquiredAt = Timestamp.fromMillis( + currentLeaseAcquiredAt.toMillis() + 1000, + ); + const impossibleFenceTwoExpiresAt = Timestamp.fromMillis( + impossibleFenceTwoAcquiredAt.toMillis() + 60000, + ); + const impossibleRecordedAt = Timestamp.fromMillis( + impossibleFenceTwoExpiresAt.toMillis() + 1, + ); + const fenceTwoAuditPath = lifecyclePaths(input, 3).lifecycleAuditPath; + mock.store.set(fenceTwoAuditPath, { + ...mock.store.get(fenceTwoAuditPath), + leaseExpiresAt: impossibleFenceTwoExpiresAt, + occurredAt: impossibleFenceTwoAcquiredAt, + }); + const forgedRecord = { + ...mock.store.get(paths.providerReconciliationPath), + observedLeaseExpiresAt: impossibleFenceTwoExpiresAt, + recordedAt: impossibleRecordedAt, + }; + const forgedAudit = { + ...mock.store.get(paths.providerReconciliationAuditPath), + observedLeaseExpiresAt: impossibleFenceTwoExpiresAt, + occurredAt: impossibleRecordedAt, + reconciliationEvidenceCommitment: reconciliationEvidenceCommitment(forgedRecord), + }; + mock.store.set(paths.providerReconciliationPath, forgedRecord); + mock.store.set(paths.providerReconciliationAuditPath, forgedAudit); + nowMillis = impossibleRecordedAt.toMillis(); + const before = cloneStore(mock.store); + + await expectSafeError( + () => recordInitialStripeReconciliationEvidence(input), + 'journal_record_invalid', + ); + expect(mock.store).toEqual(before); + }); + + test('rejects hostile roots and evidence descriptors before Firestore', async () => { + const mock = createMockDb(); + const getter = jest.fn(() => HOSTILE_CANARY); + const accessorEvidence = candidateReconciliationEvidence(); + Object.defineProperty(accessorEvidence, 'dispatchEvidence', { + enumerable: true, + get: getter, + }); + const callsBefore = mock.db.runTransaction.mock.calls.length; + const badInputs = [ + { ...providerReconciliationInput(mock.db), providerAttempt: 2 }, + { ...providerReconciliationInput(mock.db), shouldAdvance: true }, + providerReconciliationInput(mock.db, { reconciliationEvidence: accessorEvidence }), + providerReconciliationInput(mock.db, { + reconciliationEvidence: new Proxy(candidateReconciliationEvidence(), {}), + }), + providerReconciliationInput(mock.db, { + reconciliationEvidence: candidateReconciliationEvidence({ providerAttempt: 2 }), + }), + ]; + for (const badInput of badInputs) { + await expectSafeError( + () => recordInitialStripeReconciliationEvidence(badInput), + 'invalid_command_input', + ); + } + expect(getter).not.toHaveBeenCalled(); + expect(mock.db.runTransaction).toHaveBeenCalledTimes(callsBefore); + expect(mock.store.size).toBe(0); + }); + + test('pre-commit failure is atomic and lost acknowledgement recovers read-only', async () => { + const foundation = createMockDb(); + await createSendFoundation(foundation); + const paths = moveToSendCutoff(foundation); + const commitFailure = createMockDb([...foundation.store], { + rejectCommit: new Error(`${HOSTILE_CANARY}/${STRIPE_ACCOUNT_ID}`), + }); + const before = cloneStore(commitFailure.store); + await expectSafeError( + () => recordInitialStripeReconciliationEvidence( + providerReconciliationInput(commitFailure.db), + ), + 'journal_unavailable', + [STRIPE_ACCOUNT_ID], + ); + expect(commitFailure.store).toEqual(before); + + const lostAcknowledgement = createMockDb([...foundation.store], { + rejectAfterCommit: new Error(`${HOSTILE_CANARY}/${STRIPE_ACCOUNT_ID}`), + }); + await expectSafeError( + () => recordInitialStripeReconciliationEvidence( + providerReconciliationInput(lostAcknowledgement.db), + ), + 'journal_unavailable', + [STRIPE_ACCOUNT_ID], + ); + expect(lostAcknowledgement.store.has(paths.providerReconciliationPath)).toBe(true); + expect(lostAcknowledgement.store.has(paths.providerReconciliationAuditPath)).toBe(true); + nowMillis += 1; + await expect(recordInitialStripeReconciliationEvidence( + providerReconciliationInput(lostAcknowledgement.db), + )).resolves.toMatchObject({ outcome: 'reconciliation_candidate_persisted' }); + expect(lostAcknowledgement.callbackRuns.at(-1).writes).toEqual([]); + }); + + test('future persisted evidence and forged transaction output fail closed', async () => { + const mock = createMockDb(); + await createSendFoundation(mock); + const input = providerReconciliationInput(mock.db); + const paths = moveToSendCutoff(mock, input); + await recordInitialStripeReconciliationEvidence(input); + const recordedAt = mock.store.get(paths.providerReconciliationPath).recordedAt.toMillis(); + nowMillis = recordedAt - 1; + await expectSafeError( + () => recordInitialStripeReconciliationEvidence(input), + 'journal_record_invalid', + ); + + nowMillis = recordedAt + 1; + const forged = createMockDb([...mock.store], { + returnedValue: Object.freeze({ value: HOSTILE_CANARY }), + }); + await expectSafeError( + () => recordInitialStripeReconciliationEvidence( + providerReconciliationInput(forged.db), + ), + 'journal_unavailable', + ); + }); + + test('stores and returns no raw identifiers, parameters, keys, or attempt-2 control', async () => { + const mock = createMockDb(); + await createSendFoundation(mock); + const input = providerReconciliationInput(mock.db); + const paths = moveToSendCutoff(mock, input); + + const result = await recordInitialStripeReconciliationEvidence(input); + const rendered = JSON.stringify({ + result, + record: mock.store.get(paths.providerReconciliationPath), + audit: mock.store.get(paths.providerReconciliationAuditPath), + }); + + for (const forbidden of [ + HOSTILE_CANARY, + RAW_CALLER, + COMMAND_ID, + STRIPE_ACCOUNT_ID, + '0000000002', + 'shouldAdvance', + 'shouldExecute', + ]) expect(rendered).not.toContain(forbidden); + }); +}); + describe('exact existing identity and partner validation', () => { beforeEach(() => { jest.spyOn(Timestamp, 'now').mockImplementation(() => Timestamp.fromMillis(NOW_MILLIS + 5000)); @@ -3050,7 +3980,7 @@ describe('fixed input and infrastructure failure boundary', () => { }); describe('closed source and export boundary', () => { - test('exports only the unused journal APIs and the narrow pre-send evidence gate', () => { + test('exports only the unused journal APIs and narrow evidence gates', () => { const api = require('./commerceCommandJournal'); expect(journalSchemaVersion).toBe(1); expect(lifecycleSchemaVersion).toBe(1); @@ -3063,7 +3993,9 @@ describe('closed source and export boundary', () => { 'failCommerceCommand', 'journalSchemaVersion', 'lifecycleSchemaVersion', + 'providerReconciliationEvidenceSchemaVersion', 'providerSendEvidenceSchemaVersion', + 'recordInitialStripeReconciliationEvidence', 'recordInitialStripeSendEvidence', 'registerCommerceCommand', ]); @@ -3071,7 +4003,7 @@ describe('closed source and export boundary', () => { expect(Object.isFrozen(CommerceCommandJournalError)).toBe(true); expect(Object.isFrozen(CommerceCommandJournalError.prototype)).toBe(true); expect(Object.keys(api).join(' ')).not.toMatch( - /execute|renew|release|reconcile|replay|advance|result|outcome/i, + /execute|renew|release|replay|advance|result|outcome/i, ); }); @@ -3083,6 +4015,7 @@ describe('closed source and export boundary', () => { const imports = importMatches.map((match) => match[2]); expect(imports.sort()).toEqual([ './commerceCommandIdentity', + './commerceProviderReconciliation', 'firebase-admin/firestore', 'node:crypto', 'node:util', @@ -3106,6 +4039,13 @@ describe('closed source and export boundary', () => { expect(source).not.toMatch( /Math\.random|randomBytes|randomFill|randomInt|randomUUID|getRandomValues|process\.env|node:fs|node:http|node:https|child_process/, ); + for (const forbidden of [ + 'providerAttempts/0000000002', + 'providerAttempt: 2', + 'providerAttempt = 2', + 'shouldAdvance', + 'shouldExecute', + ]) expect(source).not.toContain(forbidden); expect(source).toContain('maxAttempts: 10'); expect((source.match(/Timestamp\.now\(\)/g) || [])).toHaveLength(1); expect(source.indexOf('Timestamp.now()')).toBeLessThan(source.indexOf('.runTransaction(')); diff --git a/functions/commerceProviderReconciliation.test.js b/functions/commerceProviderReconciliation.test.js index 293761f8..3838f98d 100644 --- a/functions/commerceProviderReconciliation.test.js +++ b/functions/commerceProviderReconciliation.test.js @@ -477,15 +477,21 @@ describe('immutability, redaction, and zero-side-effect boundary', () => { } }); - test('no runtime or index entry point adopts the inert policy module', () => { + test('only the unused journal persistence boundary adopts the policy module', () => { const files = fs.readdirSync(__dirname) .filter((fileName) => fileName.endsWith('.js')) - .filter((fileName) => fileName !== 'commerceProviderReconciliation.js') - .filter((fileName) => fileName !== 'commerceProviderReconciliation.test.js'); + .filter((fileName) => !fileName.endsWith('.test.js')) + .filter((fileName) => fileName !== 'commerceProviderReconciliation.js'); + const importers = []; for (const fileName of files) { const source = fs.readFileSync(path.join(__dirname, fileName), 'utf8'); - expect({ fileName, importsPolicy: source.includes('commerceProviderReconciliation') }) - .toEqual({ fileName, importsPolicy: false }); + if (source.includes('commerceProviderReconciliation')) { + importers.push(fileName); + } } + expect(importers).toEqual(['commerceCommandJournal.js']); + const indexSource = fs.readFileSync(path.join(__dirname, 'index.js'), 'utf8'); + expect(indexSource).not.toContain('commerceCommandJournal'); + expect(indexSource).not.toContain('commerceProviderReconciliation'); }); }); diff --git a/tests/firestore-rules/shop-and-locked.test.js b/tests/firestore-rules/shop-and-locked.test.js index c6d1a262..7959ab6a 100644 --- a/tests/firestore-rules/shop-and-locked.test.js +++ b/tests/firestore-rules/shop-and-locked.test.js @@ -470,6 +470,69 @@ describe('server-only operational collections', () => { }); }); + describe.each([ + ['anonymous', undefined], + ['member', { uid: 'member-1', role: 'member', emailVerified: true }], + ['browser admin', { uid: 'admin-1', role: 'admin', emailVerified: true }], + ])('checkoutRequests provider reconciliation evidence — %s', (_label, auth) => { + const existingHash = 'a'.repeat(64); + const newHash = 'b'.repeat(64); + const existingPath = `checkoutRequests/${existingHash}/providerAttempts/0000000001/` + + 'reconciliationEvidence/0000000001'; + const newPath = `checkoutRequests/${newHash}/providerAttempts/0000000001/` + + 'reconciliationEvidence/0000000001'; + const collectionPath = `checkoutRequests/${existingHash}/providerAttempts/0000000001/` + + 'reconciliationEvidence'; + const existingAuditPath = 'auditEvents/' + + `commerce_provider_reconciliation_${existingHash}_0000000001_0000000001`; + const newAuditPath = 'auditEvents/' + + `commerce_provider_reconciliation_${newHash}_0000000001_0000000001`; + const evidence = { + providerReconciliationEvidenceSchemaVersion: 1, + providerAttempt: 1, + provider: 'stripe', + evidenceRevision: 1, + classification: 'reconciliation_candidate', + }; + const audit = { + providerReconciliationAuditSchemaVersion: 1, + commandKeyHash: existingHash, + providerAttempt: 1, + evidenceRevision: 1, + eventType: 'provider_reconciliation_candidate_recorded', + }; + + test('CANNOT read, create, update, or delete reconciliation evidence', async () => { + await seed(existingPath, evidence); + const client = await db(auth); + + await assertFails(client.doc(existingPath).get()); + await assertFails(client.doc(newPath).set(evidence)); + await assertFails(client.doc(existingPath).update({ evidenceRevision: 2 })); + await assertFails(client.doc(existingPath).delete()); + }); + + test('CANNOT list reconciliation evidence by collection or collection group', async () => { + await seed(existingPath, evidence); + const client = await db(auth); + + await assertFails(client.collection(collectionPath).get()); + await assertFails(client.collectionGroup('reconciliationEvidence').get()); + }); + + test('CANNOT read or write the matching reconciliation audit event', async () => { + await seed(existingAuditPath, audit); + const client = await db(auth); + + await assertFails(client.doc(existingAuditPath).get()); + await assertFails(client.doc(newAuditPath).set({ ...audit, commandKeyHash: newHash })); + await assertFails(client.doc(existingAuditPath).update({ evidenceRevision: 2 })); + await assertFails(client.doc(existingAuditPath).delete()); + await assertFails(client.collection('auditEvents').get()); + await assertFails(client.collectionGroup('auditEvents').get()); + }); + }); + describe.each([ ['promoCodes/PROMO1', 'promoCodes/PROMO2', { discountPercent: 10 }], ['ratelimits/checkout_ip__1.2.3.4', 'ratelimits/checkout_ip__5.6.7.8', { count: 5 }],