diff --git a/GITHUB_ISSUES.md b/GITHUB_ISSUES.md index eeb1085..2c83473 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 complete in [#157](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/157); race request/event-field adoption tracked in PAY-001B1 [#219](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/219); snapshots and remaining B/C/D work 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); PAY-002B2C3B immutable candidate persistence tracked in [#206](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/206); PAY-002B2C3C fresh-lease later-attempt authorization tracked in [#226](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/226); PAY-002B2C4A immutable authorized attempt-2 plan tracked in [#232](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/232); pre-send/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); PAY-002B2C3C fresh-lease later-attempt authorization tracked in [#226](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/226); PAY-002B2C4A immutable authorized attempt-2 plan tracked in [#232](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/232); PAY-002B2C4B attempt-2 pre-send evidence tracked in [#238](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/238); result/reconciliation/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 | @@ -639,7 +639,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). PAY-002B2C3B immutable reconciliation-candidate persistence source/tests are tracked in live [#206](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/206). PAY-002B2C3C fresh-lease later-attempt authorization source/tests are tracked in live [#226](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/226). PAY-002B2C4A immutable authorized attempt-2 plan source/tests are tracked in live [#232](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/232). Attempt-2 pre-send controls, 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). PAY-002B2C3C fresh-lease later-attempt authorization source/tests are tracked in live [#226](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/226). PAY-002B2C4A immutable authorized attempt-2 plan source/tests are tracked in live [#232](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/232). PAY-002B2C4B attempt-2 pre-send source/tests are tracked in live [#238](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/238). Attempt-2 result/reconciliation, runtime adoption, real legacy inventory/migration, and checkout sagas remain open. **Depends on:** CONFIG-001 and PAY-001 ### Current atomic boundary @@ -660,7 +660,7 @@ C3A is not a reconciler or authorization boundary. It stores nothing, calls noth 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. The Checkout Session parts of provider-calling PAY-002C/D also require C4A plan binding and the future C4B pre-send gate; C3C alone does not make those runtime children ready. C4A is limited to `checkout_session_create`. PAY-002D catalog objects, PAY-004 Session expiry, PAY-005 refunds, and provider-calling admin commands each require separately reviewed operation-specific plan, pre-send, result, and reconciliation boundaries. PAY-003B, RACE-001B, and MERCH-001B retain their own listed state and runtime dependencies. +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. The Checkout Session parts of provider-calling PAY-002C/D also require C4A plan binding and the C4B/#238 pre-send gate; C3C alone does not make those runtime children ready. C4A and C4B are limited to `checkout_session_create`. PAY-002D catalog objects, PAY-004 Session expiry, PAY-005 refunds, and provider-calling admin commands each require separately reviewed operation-specific plan, pre-send, result, and reconciliation boundaries. PAY-003B, RACE-001B, and MERCH-001B retain their own listed state and runtime dependencies. PAY-002B2C3C [#226](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/226) owns only the unused immutable attempt-2 authorization record/audit pair. `authorizeNextStripeProviderAttempt` validates the exact B1/B2/C1/C2/C3B chain, repeats the complete safe C3A tuple check, and accepts only the matching `retry_same_operation` or `replace_expired_unpaid` transition plus an opaque lowercase SHA-256 record commitment. The current holder/fence must be active, later than C3B's observed fence, and its deterministic lease audit must prove acquisition at or after C3B persistence with valid predecessor chronology. The opaque commitment is command-bound before storage but is not current business-state proof; a future trusted runtime must derive it from a server-side business-record transaction. @@ -668,9 +668,17 @@ The deterministic nested record and `commerce_provider_authorization` audit bind PAY-002B2C4A [#232](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/232) owns only the unused immutable attempt-2 provider-plan record/audit pair. `bindAuthorizedStripeProviderPlan` revalidates the exact B1/B2/C1/C2/C3B/C3C chain and current active lease before creating `providerAttempts/0000000002` with `auditEvents/commerce_provider_attempt_{commandKeyHash}_0000000002`, or creating neither. The current holder/fence must match; trusted time must be at or after lease acquisition and authorization and strictly before lease expiry. -Version 1 is equality-only. The attempt-2 account, mode, API version, operation, endpoint, and canonical parameters must equal attempt 1. Only the internally derived attempt-2 key commitment, current binding fence/time, attempt number, and C3C authorization provenance may differ. The fixed `provider_plan_bound` or `provider_plan_existing` result says only `requires_pre_send_evidence`; it exposes no sensitive or send/execute value. Exact retry and later valid lease observation are read-only. Changed valid input conflicts; orphaned, malformed, future, impossible-chronology, or foundation-mismatched partners fail closed without repair. C4A creates no send evidence, calls no provider, changes no business record, and has no runtime/index import. PAY-002B2C4B must separately add attempt-2 pre-send evidence. +Version 1 is equality-only. The attempt-2 account, mode, API version, operation, endpoint, and canonical parameters must equal attempt 1. Only the internally derived attempt-2 key commitment, current binding fence/time, attempt number, and C3C authorization provenance may differ. The fixed `provider_plan_bound` or `provider_plan_existing` result says only `requires_pre_send_evidence`; it exposes no sensitive or send/execute value. Exact retry and later valid lease observation are read-only. Changed valid input conflicts; orphaned, malformed, future, impossible-chronology, or foundation-mismatched partners fail closed without repair. C4A creates no send evidence, calls no provider, changes no business record, and has no runtime/index import. PAY-002B2C4B [#238](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/238) owns the separate attempt-2 pre-send evidence. -B2A/B2B/C1/C2/C3B/C3C/C4A add no TTL. Deleting paired plan, send, reconciliation, or authorization evidence 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, #206, #226, and #232 expand that same file. Hosted results remain synthetic source proof only. The current foundations, including C3A/C3B/C3C/C4A, are unused: they add no endpoint/index export, Firebase deployment, Stripe/provider configuration, production read/write, website change, or officer behavior. +PAY-002B2C4B [#238](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/238) owns only the unused attempt-2 pre-POST record/audit pair. `recordAuthorizedStripeSendEvidence` revalidates the exact B1-through-C4A chain and current active lease before creating `providerAttempts/0000000002/sendEvidence/first` with `auditEvents/commerce_provider_send_{commandKeyHash}_0000000002`, or creating neither. The accepted operation is only `checkout_session_create` at `POST /v1/checkout/sessions`. + +C4B preserves the existing version-1 plan-commitment bytes. A separate `providerPlanCommitmentSchemaVersion: 2` digest covers every authorized C4A plan field, its nanosecond binding time, and both C4A authorization-provenance fields. Both C4B partners carry that commitment and provenance. The marker uses one prepared trusted time and an immutable deadline exactly 23 hours later. Exact retry and later valid lease observation never extend it. + +After the transaction, a fresh trusted-time check returns fixed `send_permitted` / `pre_send_recorded` only while time has not rolled back and remains strictly before both the transaction-captured current lease expiry and stored deadline. Equality, later time, rollback, or unreadable paired time returns fixed `reconciliation_required` / `provider_outcome_unknown`; stale lease input fails as `lease_stale`. Changed input conflicts, and malformed/orphan/future/detached pairs fail closed. C4B never creates attempt `3`, calls Stripe, writes business state, replays a result, or enters an endpoint/index. Product/Price creation, Session expiry, refunds, and privileged provider operations require separate operation-specific boundaries. + +C4B alone makes no complete runtime child ready. PAY-002C still waits for PAY-001B2 and RACE-001B. PAY-002D still waits for PAY-001C, MERCH-001B, and separate Product/Price boundaries. PAY-003B and later cancellation, refund, and provider-admin work retain their listed dependencies. + +B2A/B2B/C1/C2/C3B/C3C/C4A/C4B add no TTL. Deleting paired plan, send, reconciliation, or authorization evidence 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, #206, #226, #232, and #238 expand that same file. Hosted results remain synthetic source proof only. The current foundations, including C3A/C3B/C3C/C4A/C4B, 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 72d009c..c3c4ade 100644 --- a/GITHUB_ISSUE_SLICES.md +++ b/GITHUB_ISSUE_SLICES.md @@ -77,8 +77,9 @@ An agent-ready child has one observable outcome, one trust boundary, explicit de | 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 | source/tests tracked in live [#226](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/226); PAY-002B2C3B/#206; unused/no runtime or deploy | Under a fresh later-fence lease, validate the exact persisted C3B pair and matching closed transition before atomically creating one immutable attempt-2 authorization/audit pair. Command-bind the opaque transition commitment and attempt-2 key fingerprint. The fixed result still requires plan binding and cannot send, call Stripe, or change a business record. | Exact never-began and expired/unpaid mappings; unsafe-evidence/stale-lease/chronology denial; atomic pair, retry, lost-ack, orphan/malformed/future, redaction, browser-denial, and 24-way identical plus 12-v-12 conflict emulator tests. | | PAY-002B2C4A | source/tests tracked in live [#232](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/232); PAY-002B2C3C/#226 and CI-001B3/#167; unused/no runtime or deploy | Under the exact C3C authorization and current active lease, atomically bind one immutable attempt-2 plan/audit pair. Version 1 preserves attempt-1 account, mode, API version, operation, endpoint, and canonical parameters. Only the internally derived attempt-2 key commitment, current fence/time, attempt number, and C3C provenance may differ. The fixed result requires separate C4B pre-send evidence. | Mock plus #167-hosted emulator equality/drift, lease-time, atomic-pair, exact-retry, later-lease observation, conflict, commit/lost-ack, orphan/malformed/future, complete prior-byte immutability, redaction, no-call/adoption, and browser-denial tests. | -| PAY-002C | proposed; PAY-001B2, future PAY-002B2C4B, 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, future PAY-002B2C4B for Checkout Sessions, separate operation-specific Product/Price plan and pre-send gates, 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. | +| PAY-002B2C4B | source/tests tracked in live [#238](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/238); PAY-002B2C4A/#232 and CI-001B3/#167; unused/no runtime or deploy | Revalidate B1-C4A and the current lease before atomically recording attempt-2 Checkout Session pre-send evidence/audit. Preserve v1 bytes; bind every authorized C4A plan field and provenance with commitment version 2. Use one immutable 23-hour deadline and a second trusted-time check; fixed permission is retry-safety only. No Stripe call, result, attempt `3`, or business write. | Golden v1/v2 commitment tests; mock plus #167-hosted emulator transition/concurrency/conflict/later-lease/nanosecond/rollback/commit/lost-ack/malformed/provenance/immutability/redaction/no-call tests; explicit browser denial. | +| PAY-002C | proposed; PAY-001B2, PAY-002B2C4B/#238, 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-002B2C4B/#238 for Checkout Sessions, separate operation-specific Product/Price plan and pre-send gates, 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. | ## Webhooks, capacity, inventory, and cancellation diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index f85c9ff..74a60ba 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. 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. PAY-002B2C3C source/tests are tracked in live [#226](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/226). Under a fresh later-fence lease, the unused journal accepts only the matching closed transition for an exact persisted safe candidate and creates one immutable attempt-2 authorization/audit pair. Its command-bound transition commitment is not business-state proof, and its fixed result still requires plan binding; no send or provider call is authorized. PAY-002B2C4A source/tests are tracked in live [#232](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/232). Under the exact C3C authorization and current active lease, the unused journal creates one immutable attempt-2 plan/audit pair. Version 1 preserves the attempt-1 account, mode, API version, operation, endpoint, and canonical parameters; its fixed result still requires separate C4B pre-send evidence. 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, #206, #226, and #232 expand. Attempt-2 pre-send controls 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. PAY-002B2C3C source/tests are tracked in live [#226](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/226). Under a fresh later-fence lease, the unused journal accepts only the matching closed transition for an exact persisted safe candidate and creates one immutable attempt-2 authorization/audit pair. Its command-bound transition commitment is not business-state proof, and its fixed result still requires plan binding; no send or provider call is authorized. PAY-002B2C4A source/tests are tracked in live [#232](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/232). Under the exact C3C authorization and current active lease, the unused journal creates one immutable attempt-2 plan/audit pair. Version 1 preserves the attempt-1 account, mode, API version, operation, endpoint, and canonical parameters; its fixed result still requires separate C4B pre-send evidence. PAY-002B2C4B source/tests are tracked in live [#238](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/238). The unused journal binds the complete authorized C4A plan with a separate version-2 commitment, creates one attempt-2 pre-send marker/audit with a fixed 23-hour deadline, and returns fixed `send_permitted` only after a second trusted-time check remains before both the captured lease expiry and deadline. It makes no Stripe call and stores no result. 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, #206, #226, #232, and #238 expand. Attempt-2 result/reconciliation 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) merged at `a880177`, proving the exact verified-email second gate for role-based Firestore access while keeping UID-bound safe profile recovery. AUTH-001C [#209](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/209) merged at `85751dac`, proving the same second gate at current Functions role consumers. AUTH-001D1 [#213](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/213) tracks the matching fail-closed browser role projection; browser state remains guidance rather than authority. Firebase/website deployment is unproven, and verification-mirror refresh, revocation/audit, authoritative membership, capabilities/MFA/recent-auth, safe roster export, 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 complete in [#157](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/157) as the shared validation/logging foundation. PAY-001B1 [#219](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/219) adds the compatible active-field/volunteer-tier browser projection plus exact bounded request, opaque-ID/callback, and selected event-answer validation in the current race endpoint; PAY-001B2 still owns immutable field/price/waiver snapshots, compatibility, and protected staging proof. 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-002B2C3C [#226](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/226) owns only the fresh-lease immutable attempt-2 authorization/audit pair. PAY-002B2C4A [#232](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/232) owns only the unused immutable attempt-2 plan/audit pair under the exact C3C authorization and current lease; version 1 preserves the attempt-1 provider plan fields and still requires separate C4B pre-send evidence. PAY-001C/D still require endpoint adoption; PAY-002C/D must add persistence-first sagas and trusted business-transition integration; PAY-003B must adopt the shared reducer. No current endpoint imports these PAY-002 foundations, and none of #169/#173/#182/#184/#206/#226/#232 deploys or configures Firebase/Stripe. +6. **PAY-001/PAY-002:** PAY-001A source/tests are complete in [#157](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/157) as the shared validation/logging foundation. PAY-001B1 [#219](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/219) adds the compatible active-field/volunteer-tier browser projection plus exact bounded request, opaque-ID/callback, and selected event-answer validation in the current race endpoint; PAY-001B2 still owns immutable field/price/waiver snapshots, compatibility, and protected staging proof. 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-002B2C3C [#226](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/226) owns only the fresh-lease immutable attempt-2 authorization/audit pair. PAY-002B2C4A [#232](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/232) owns only the unused immutable attempt-2 plan/audit pair under the exact C3C authorization and current lease; version 1 preserves the attempt-1 provider plan fields and still requires separate C4B pre-send evidence. PAY-002B2C4B [#238](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/238) owns only the unused attempt-2 pre-send marker/audit, complete authorized-plan commitment version 2, fixed 23-hour deadline, and post-transaction lease/deadline clock gate for Checkout Session creation. PAY-001C/D still require endpoint adoption; PAY-002C/D must add persistence-first sagas and trusted business-transition integration; PAY-003B must adopt the shared reducer. No current endpoint imports these PAY-002 foundations, and none of #169/#173/#182/#184/#206/#226/#232/#238 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/OPERATIONS_RUNBOOK.md b/OPERATIONS_RUNBOOK.md index 8a73270..62127db 100644 --- a/OPERATIONS_RUNBOOK.md +++ b/OPERATIONS_RUNBOOK.md @@ -392,7 +392,7 @@ flowchart LR G -- "No" --> S["Stop; no write"] G -- "Yes" --> P["Write immutable attempt-2 plan + audit"] P --> R["Stop at requires_pre_send_evidence"] - R -. "No Stripe call" .-> C4B["Future C4B pre-send gate"] + R -. "No Stripe call" .-> C4B["C4B attempt-2 pre-send gate"] ``` Text alternative: the exact C3C authorization and current active lease can create only an immutable attempt-2 Checkout Session plan; C4B must still record pre-send evidence before that Session request. @@ -415,7 +415,52 @@ Expected proof: both allowed C3C transitions bind the same constrained attempt-2 Stop if the emulator names a non-demo project, any provider credential is present, a network/provider call occurs, an attempt-2 send-evidence document appears, an earlier record changes, or any browser role can access the plan or audit. -This is source and synthetic-test evidence only. It does not prove Firebase deployment, Stripe/provider configuration, production data, website publication, `runmprc.com`, or live behavior. Officer impact is none because no officer action or live surface changes. C4B pre-send controls and PAY-002C/D/PAY-003B runtime adoption remain open. Never test this boundary with a real registration, order, Stripe object, customer, or member. +This is source and synthetic-test evidence only. It does not prove Firebase deployment, Stripe/provider configuration, production data, website publication, `runmprc.com`, or live behavior. Officer impact is none because no officer action or live surface changes. C4B source/tests are tracked in [#238](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/238); PAY-002C/D/PAY-003B runtime adoption remains open. Never test this boundary with a real registration, order, Stripe object, customer, or member. + +### Authorized Stripe attempt-2 pre-send evidence — SOURCE ONLY, UNUSED + +PAY-002B2C4B [#238](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/238) adds one server-only attempt-2 pre-send marker after the C4A plan. No endpoint or Functions index imports it. It cannot call Stripe, create attempt `3`, change a registration/order/hold, return a Checkout URL, or send a response to a member. + +```text +checkoutRequests/{commandKeyHash}/providerAttempts/0000000002/sendEvidence/first +auditEvents/commerce_provider_send_{commandKeyHash}_0000000002 +``` + +```mermaid +flowchart TD + P["Exact C4A attempt-2 plan + audit"] --> G{"Exact chain and active lease?"} + L["Current holder and fence"] --> G + G -- "No" --> S["Stop; no write"] + G -- "Yes" --> E["Create or read immutable pre-send pair"] + E --> T["Second trusted-time check"] + T -- "Before lease expiry and 23-hour deadline" --> Permit["send_permitted"] + T -- "Equality, later, or rollback" --> Reconcile["reconciliation_required"] + Permit -. "No Stripe call here" .-> Future["Future trusted runtime"] +``` + +Text alternative: the exact attempt-2 Checkout Session plan and current lease may create one immutable pre-send pair; permission is returned only after a second clock check stays before both limits, and this source never contacts Stripe. + +C4B leaves the existing version-1 plan-commitment bytes unchanged. It uses `providerPlanCommitmentSchemaVersion: 2` to cover every C4A plan field, its nanosecond binding time, and both authorization-provenance fields. The marker time is captured once before the transaction. Its deadline is exactly 23 hours later and cannot move on retry. + +The fixed permitted result contains only schema versions, `send_permitted`, and `pre_send_recorded`. The fixed ambiguous result contains only schema versions, `reconciliation_required`, and `provider_outcome_unknown`. Neither result proves caller authority, current business state, Stripe-account control, request execution, or a provider result. This boundary is only for `checkout_session_create` at `POST /v1/checkout/sessions`; Product/Price creation, Session expiry, refunds, and privileged provider actions need separate reviewed boundaries. + +Run only with Node 20, Java 17, repository lockfiles, and synthetic demo data: + +```bash +npm --prefix functions run test:run -- --runInBand commerceCommandJournal.test.js +REQUIRE_COMMERCE_COMMAND_JOURNAL_EMULATOR=1 \ + npx --no-install firebase emulators:exec \ + --project demo-pay002b2-test \ + --only firestore \ + "npm --prefix functions run test:run -- --runInBand commerceCommandJournal.emulator.test.js" +npm run test:rules +``` + +Expected proof: both allowed C3C transitions reach the same C4A/C4B shape; the version-2 golden commitment covers the full authorized plan while version-1 bytes stay unchanged; exact concurrency creates one pair; a later valid lease observes the same deadline; and equality, expiry, rollback, changed provenance, malformed partners, commit failure, or browser access never permits a send. All earlier journal records must remain byte-identical. + +Stop if the emulator names a non-demo project, any provider credential is present, a network/provider call occurs, the deadline changes on retry, attempt `3` appears, a business record changes, version-1 commitment bytes change, an earlier record changes, or any browser role can access the marker or audit. + +This is source and synthetic-test evidence only. Record these states separately: source changed; tests passed; code merged; website published; `runmprc.com` verified; Firebase deployed; Stripe/provider configured; production data changed; production behavior verified. #238 does not itself prove any deployment, provider setting, live behavior, or officer action. PAY-002C/D/PAY-003B still need trusted business, result, reconciliation, and runtime work. Never test this boundary with a real registration, order, Stripe object, customer, or member. ### Target payment integration suite diff --git a/SECURITY.md b/SECURITY.md index c768677..93b9e1e 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; 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; PAY-002B2C3C [#226](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/226) supplies only an unused fresh-lease authorization record/audit for one later attempt; PAY-002B2C4A [#232](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/232) supplies only the unused immutable attempt-2 plan/audit under that exact authorization and a current active lease. None authorizes a send. C3C's fixed result requires the separate C4A plan, and C4A's fixed result still requires separate C4B pre-send evidence; 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. PAY-002B2C3C is tracked in live [#226](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/226) for the deterministic later-attempt authorization/audit pair bound to that exact candidate, a matching closed transition commitment, and a fresh active lease. PAY-002B2C4A is tracked in live [#232](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/232) for the deterministic attempt-2 plan/audit pair bound to the exact C3C authorization and current active lease. | Hashes and commitments are pseudonymous server-only equality evidence—not anonymization, caller authorization, configured-account proof, current business-state proof, durable response 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. C3C accepts only the two complete safe tuples under a later current lease; its opaque transition commitment is command-bound but is not proof of current business state. Its fixed `requires_plan_binding` result creates no attempt-2 plan, send marker, or provider call. C4A requires that exact authorization plus a current active lease and preserves attempt-1 account/mode/API/operation/endpoint/parameters in its immutable attempt-2 plan. Its fixed `requires_pre_send_evidence` result creates no send marker or provider call. Only the Checkout Session parts of PAY-002C/D may adopt this C4A and future C4B. Product/Price creation, Session expiry, refunds in PAY-005, and other provider operations need separate operation-specific plan, pre-send, result, and reconciliation boundaries. All runtime adoption also requires trusted business transactions, 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; PAY-002B2C3C [#226](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/226) supplies only an unused fresh-lease authorization record/audit for one later attempt; PAY-002B2C4A [#232](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/232) supplies only the unused immutable attempt-2 plan/audit under that exact authorization and a current active lease. PAY-002B2C4B [#238](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/238) supplies only the unused attempt-2 pre-send pair, a complete authorized-plan commitment version 2, fixed 23-hour deadline, and post-transaction clock gate. C4B's fixed `send_permitted` is retry-safety classification only: it makes no Stripe request, records no result, and grants no caller or business authority; 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. PAY-002B2C3C is tracked in live [#226](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/226) for the deterministic later-attempt authorization/audit pair bound to that exact candidate, a matching closed transition commitment, and a fresh active lease. PAY-002B2C4A is tracked in live [#232](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/232) for the deterministic attempt-2 plan/audit pair bound to the exact C3C authorization and current active lease. PAY-002B2C4B is tracked in live [#238](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/238) for its atomic pre-send pair, explicit version-2 commitment over every C4A field and authorization provenance, immutable 23-hour deadline, and second trusted-time check. | Hashes and commitments are pseudonymous server-only equality evidence—not anonymization, caller authorization, configured-account proof, current business-state proof, durable response 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. C3C accepts only the two complete safe tuples under a later current lease; its opaque transition commitment is command-bound but is not proof of current business state. Its fixed `requires_plan_binding` result creates no attempt-2 plan, send marker, or provider call. C4A requires that exact authorization plus a current active lease and preserves attempt-1 account/mode/API/operation/endpoint/parameters in its immutable attempt-2 plan. Its fixed `requires_pre_send_evidence` result creates no send marker or provider call. C4B revalidates the complete chain and current lease, then returns fixed `send_permitted` only before both the captured lease expiry and stored deadline; equality, later time, rollback, or unreadable time reconciles. It creates no provider call, attempt `3`, business write, or result. Only the Checkout Session parts of PAY-002C/D may adopt C4A/C4B. Product/Price creation, Session expiry, refunds in PAY-005, and other provider operations need separate operation-specific plan, pre-send, result, and reconciliation boundaries. All runtime adoption also requires trusted business transactions, 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 and RISK-018 | PAY-001A is complete in [#157](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/157). PAY-001B1 is tracked in live [#219](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/219): the website sends only the active field set and omits a volunteer tier; the race callable then adopts an exact bounded root/runner parser before Firestore and matches answers to the selected bounded server event/volunteer field schema before rate limits, role/capacity work, token creation, writes, or Stripe. Unknown fields, malformed prototypes/accessors/proxies, unsafe Unicode, oversized values, invalid participant/volunteer/tier combinations, non-literal waiver acceptance, malformed selected definitions, duplicate keys/options, missing/unknown/wrong-type answers, and invalid select choices return one fixed non-identifying failure. Opaque event IDs remain byte-identical and are encoded before callback URL use. | The browser projection is compatibility hygiene, never authority. This is source/test evidence, not deployment or production protection. PAY-001B2 still must persist immutable event-field, price, and waiver snapshots and prove compatibility in protected staging. PAY-001C/D must adopt exact merchandise and lookup/refund/admin schemas. Request IDs, persistence-first records, capacity holds, provider idempotency, provider configuration, and live behavior remain open. Safe validation/log primitives do not replace approved privacy purpose, retention, access, monitoring, or provider controls. | @@ -117,7 +117,7 @@ These entries are implementation evidence, not a production risk-acceptance deci | Part of RISK-014 | SUPPLY-001D1 [#212](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/212) uses an exact Functions-only override to resolve the sole installed `form-data` node from 2.5.5 to the patched 2.5.6 release without changing the direct Firebase Admin, Firebase Functions, or Stripe versions. The exact lock change updates that node's registry URL, integrity, and `hasown` constraint. A clean Node 20 production audit falls from 9 findings (1 high) to 8 moderate findings (0 high or critical). A focused test requires one exact installed/locked resolution and uses only synthetic multipart names to prove carriage return, line feed, and quote characters cannot create an extra header. | The remaining 8 moderate Functions findings are not accepted as safe. They stay in the Firebase Admin dependency chain and require separate compatibility work rather than a forced audit upgrade. No application source directly imports `form-data`; this source/test result changes no endpoint or business logic, does not deploy Firebase or the website, and does not change provider or production data. | | 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, named blocking lint step, and clean-checkout step. Exact PR run `29291402007` and post-merge run `29291515653` passed every GitHub job. The checker now scans 106 frontend files; CI-001B4A [#227](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/227) and CI-001B4B [#239](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/239) each retire exactly one reviewed finding. | The reviewable lint baseline deliberately records 123 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, #206, #226, and #232 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, named blocking lint step, and clean-checkout step. Exact PR run `29291402007` and post-merge run `29291515653` passed every GitHub job. The checker now scans 106 frontend files; CI-001B4A [#227](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/227) and CI-001B4B [#239](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/239) each retire exactly one reviewed finding. | The reviewable lint baseline deliberately records 123 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, #206, #226, #232, and #238 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 3b6b0d2..4100f09 100644 --- a/STRIPE_COMMERCE_DESIGN.md +++ b/STRIPE_COMMERCE_DESIGN.md @@ -237,7 +237,7 @@ Launch with quantity `1` unless cart and multi-line inventory semantics are expl } ``` -Within PAY-002B2C2's conservative stored safe-send window, returning the same request reuses the exact B2C1 plan, parameters, and Stripe idempotency key. After that window—or when first-send time is unknown—the server must stop automatic POST retries. PAY-002B2C3A can classify only already-verified closed evidence; C3B must persist that proof, C3C must separately authorize/version a later attempt, C4A must bind its immutable plan, and future C4B must record separate pre-send evidence before returning a new Session. If a Session is proven expired, a versioned new attempt can be created against the same business record only under an explicit verified transition. +Within PAY-002B2C2's conservative stored safe-send window, returning the same request reuses the exact B2C1 plan, parameters, and Stripe idempotency key. After that window—or when first-send time is unknown—the server must stop automatic POST retries. PAY-002B2C3A can classify only already-verified closed evidence; C3B must persist that proof, C3C must separately authorize/version a later attempt, C4A must bind its immutable plan, and C4B [#238](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/238) must record separate pre-send evidence before returning a new Session. If a Session is proven expired, a versioned new attempt can be created against the same business record only under an explicit verified transition. ### Pure command identity and provider-key contract (PAY-002B1) @@ -414,7 +414,7 @@ flowchart LR G -- "No" --> Stop["Fail closed; write nothing"] G -- "Yes" --> P["Create immutable attempt-2 plan + audit"] P --> R["requires_pre_send_evidence"] - R -. "No send marker or Stripe call" .-> C4B["Future PAY-002B2C4B"] + R -. "No send marker or Stripe call" .-> C4B["C4B attempt-2 pre-send evidence"] ``` Text alternative: the exact saved C3C authorization and current active lease may bind one immutable attempt-2 Checkout Session plan; a separate C4B pre-send boundary is still required before that Session request. @@ -425,9 +425,46 @@ The transaction revalidates B1 through C3C before reading both attempt-2 partner The fixed results are `provider_plan_bound` or `provider_plan_existing`, each with `requires_pre_send_evidence`. They expose no attempt, path, key, hash, commitment, fence, timestamp, evidence, account, parameter, or send/execute flag. The stored pair contains no raw account, parameters, key, identity, business/provider object ID, money, URL, response, secret, or personal data. -#232 adds no attempt-2 send evidence, Stripe/network call, business write, response replay, endpoint/index import, provider configuration, migration, production-data action, deployment, website change, live behavior, or officer task. PAY-002B2C4B must separately record and freshness-check pre-send evidence before any attempt-2 Checkout Session request can be considered. PAY-002C/D and PAY-003B still own trusted business-state and runtime adoption. +#232 adds no attempt-2 send evidence, Stripe/network call, business write, response replay, endpoint/index import, provider configuration, migration, production-data action, deployment, website change, live behavior, or officer task. PAY-002B2C4B [#238](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/238) owns the separate pre-send record and freshness check described below. PAY-002C/D and PAY-003B still own trusted business-state and runtime adoption. -This version-1 C4A boundary is specific to `checkout_session_create` at `/v1/checkout/sessions`. A future C4B can complete only that Checkout Session path. Stripe Product or Price creation, Session expiry, refunds, and other provider operations each need their own reviewed operation mapping, immutable plan, pre-send, result, and reconciliation boundaries; C3C or this C4A must not be reused as general provider-call permission. +This version-1 C4A boundary is specific to `checkout_session_create` at `/v1/checkout/sessions`. C4B can complete only that Checkout Session pre-send path. Stripe Product or Price creation, Session expiry, refunds, and other provider operations each need their own reviewed operation mapping, immutable plan, pre-send, result, and reconciliation boundaries; C3C, C4A, or C4B must not be reused as general provider-call permission. + +### Attempt-2 pre-POST send-evidence target (PAY-002B2C4B) + +PAY-002B2C4B source/tests are tracked in live [#238](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/238). The unused `recordAuthorizedStripeSendEvidence` API revalidates the complete B1-through-C4A chain and the exact current active lease. It may create only this atomic pair, or neither record: + +```text +checkoutRequests/{commandKeyHash}/providerAttempts/0000000002/sendEvidence/first +auditEvents/commerce_provider_send_{commandKeyHash}_0000000002 +``` + +```mermaid +flowchart TD + P["Exact C4A attempt-2 plan + audit"] --> V{"Complete chain and current lease valid?"} + L["Current active holder and fence"] --> V + V -- "No" --> Stop["Stop; write nothing"] + V -- "Yes; pair missing" --> W["Create pre-send marker + audit atomically"] + V -- "Yes; exact pair exists" --> Read["Read only"] + W --> Clock["Second trusted-time check"] + Read --> Clock + Clock -- "Before lease expiry and 23-hour deadline" --> Permit["send_permitted / pre_send_recorded"] + Clock -- "Equality, later time, or rollback" --> Reconcile["reconciliation_required"] + Permit -. "No Stripe call in C4B" .-> Runtime["Future trusted runtime beside POST"] +``` + +Text alternative: the exact attempt-2 Checkout Session plan and current active lease may create one immutable pre-send pair; a second clock check must still be before both captured limits, and this source makes no Stripe request. + +C4B does not change the existing version-1 complete-plan commitment. That byte contract protects attempt-1 C2/C3 evidence and does not include the two C4A authorization-provenance fields. The attempt-2 send record and audit use their own schema version 2 and a separate `providerPlanCommitmentSchemaVersion: 2` commitment. It command-binds every authorized C4A plan field, its nanosecond binding time, `providerAttemptAuthorizationSchemaVersion`, and `providerAttemptAuthorizationCommitment`. Both C4B partners carry the version-2 plan commitment and the validated authorization provenance. A coherent plan replacement therefore cannot detach the pre-send pair from the authorization that allowed attempt `2`. The audit event is `provider_pre_send_recorded`, never “sent” or “completed.” + +The marker time is captured once before the transaction and reused if Firestore reruns the callback. Its immutable automatic-retry deadline is exactly 23 hours later. The transaction validates every B1-through-C4A partner, both C4B partners, the exact holder/fence, `leaseAcquiredAt <= marker time < leaseExpiresAt`, and `C4A boundAt <= marker time` before a write. An exact existing pair is read-only. A later valid lease may observe it only inside the original deadline; retry never extends that deadline. + +After the transaction, a fresh trusted time is captured without re-reading lifecycle state. `send_permitted` is returned only when that time has not rolled back and remains strictly before both the lease expiry captured by the transaction and the persisted deadline. Lease/deadline equality, later time, rollback, or missing/unreadable paired time returns the fixed `reconciliation_required` classification without repair. A stale holder/fence fails as `lease_stale`; changed valid input conflicts; orphaned, extra-field, future-version, malformed, detached-provenance, or commitment-mismatched partners fail closed. Commit failure creates neither partner, and a retry after a lost acknowledgement may only observe the exact pair. + +The fixed permitted output contains only schema versions, `send_permitted`, and `pre_send_recorded`. The fixed ambiguous output contains only schema versions, `reconciliation_required`, and `provider_outcome_unknown`. These are retry-safety classifications, not caller authorization, current business-state proof, Stripe-account control, proof that a request began, or a provider result. The pair stores no raw account, parameters, key, caller, UUID, transition value, provider/business/member ID, money, URL, response, secret, or personal data. + +#238 is limited to `checkout_session_create` with `POST /v1/checkout/sessions`. It cannot create attempt `3`, call Stripe/network code, write a registration/order/hold, replay a result, or enter an endpoint/Functions index. Attempt-2 result and reconciliation work remains separate. Product/Price creation, Session expiry, refunds, and privileged provider operations still need their own operation-specific plan, pre-send, result, and reconciliation boundaries. + +This is source and synthetic-test design evidence only. Source changed, tests passed, code merged, Firebase deployed, Stripe configured, production data changed, website published, `runmprc.com` verified, and live behavior verified are separate states. #238 does not by itself prove any deployment, provider setting, production behavior, or officer action. ## 7. Persistence-first checkout saga @@ -447,7 +484,7 @@ External Stripe calls cannot be part of a Firestore transaction. Use this sequen 6. Store Session ID, URL, expiry, and attempt state. 7. Return the URL. 8. If Stripe definitively rejects creation, run a compensating transaction that marks the attempt failed and releases the hold once. -9. If the function loses its response after Stripe creates the Session, PAY-002B2C2 retries the exact B2C1 plan/key only inside its stored safe-send window. After the deadline—or when first-send time is unknown—it stops POSTing. C3A alone cannot retrieve or trust provider facts; C3B/C3C must persist verified evidence and authorize a later generation, C4A must bind its plan, and future C4B must separately record fresh pre-send evidence before completing step 6. +9. If the function loses its response after Stripe creates the Session, PAY-002B2C2 retries the exact B2C1 plan/key only inside its stored safe-send window. After the deadline—or when first-send time is unknown—it stops POSTing. C3A alone cannot retrieve or trust provider facts; C3B/C3C must persist verified evidence and authorize a later generation, C4A must bind its plan, and C4B/#238 must separately record fresh pre-send evidence before completing step 6. Create the local business record before calling Stripe. That lets a very fast webhook resolve metadata directly and eliminates the current record-not-found race. diff --git a/SYSTEM_DESIGN.md b/SYSTEM_DESIGN.md index b45e33d..d6c2097 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -298,7 +298,8 @@ All four slices are source boundaries until the exact Rules, Functions, and webs | `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. | | `checkoutRequests/{commandKeyHash}/providerAttempts/0000000001/reconciliationEvidence/0000000001/nextAttemptAuthorizations/0000000002` | PAY-002B2C3C/#226 immutable server-only authorization for one later logical provider attempt. It requires the exact C3B pair, a matching closed transition commitment, and a fresh active lease. It is unused and grants no plan or send permission. | | `checkoutRequests/{commandKeyHash}/providerAttempts/0000000002` | PAY-002B2C4A/#232 immutable server-only attempt-2 plan. It requires the exact C3C authorization pair and current active lease. Version 1 preserves the attempt-1 account/mode/API/operation/endpoint/parameters and grants no 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`; C3C pairs later-attempt authorization with `commerce_provider_authorization_{commandKeyHash}_0000000001_0000000001_0000000002`; C4A binds the second plan with `commerce_provider_attempt_{commandKeyHash}_0000000002`. | +| `checkoutRequests/{commandKeyHash}/providerAttempts/0000000002/sendEvidence/first` | PAY-002B2C4B/#238 immutable server-only attempt-2 pre-POST marker. It binds the complete authorized C4A plan with commitment version 2, the current fence/time, and a fixed 23-hour 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 attempt-1 pre-send marker with `commerce_provider_send_{commandKeyHash}_0000000001`; C3B pairs candidate evidence with `commerce_provider_reconciliation_{commandKeyHash}_0000000001_0000000001`; C3C pairs later-attempt authorization with `commerce_provider_authorization_{commandKeyHash}_0000000001_0000000001_0000000002`; C4A binds the second plan with `commerce_provider_attempt_{commandKeyHash}_0000000002`; C4B pairs its pre-send marker with `commerce_provider_send_{commandKeyHash}_0000000002`. | | `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 | @@ -340,17 +341,21 @@ PAY-002B2C3C source/tests are tracked in live [#226](https://github.com/Run-MPRC ```mermaid flowchart TD C3B["Persisted exact candidate + audit"] --> Gate{"Complete safe tuple?"} - T["Matching closed transition commitment"] --> Gate + Transition["Matching closed transition commitment"] --> Gate L["Fresh active later-fence lease"] --> Gate Gate -- "No" --> Stop["No write"] Gate -- "Yes" --> A["Immutable attempt-2 authorization + audit"] A --> R["requires_plan_binding"] R --> P["C4A immutable attempt-2 plan + audit"] P --> S["requires_pre_send_evidence"] - S -. "No Stripe request" .-> F["Future C4B pre-send child"] + S --> E["C4B immutable pre-send marker + audit"] + E --> Clock["Second trusted-time check"] + Clock -- "Fresh" --> Permit["send_permitted"] + Clock -- "Expired or rollback" --> Reconcile["reconciliation_required"] + Permit -. "No Stripe request in this source" .-> F["Future trusted runtime"] ``` -Text alternative: one exact saved safe candidate, matching transition commitment, and fresh active lease create only a durable attempt-2 authorization; C4A may bind its immutable plan, but a separate C4B pre-send boundary is still required. +Text alternative: one exact saved safe candidate, matching transition, and fresh lease can authorize attempt 2; C4A may bind its plan, and C4B may record pre-send evidence only while a second clock check remains before the captured lease expiry and fixed deadline; none of these source steps calls Stripe. The record/audit pair binds the command identity; complete C1, C2, and C3B commitments; environment/mode/operation; attempts `1` and `2`; transition kind and command-bound transition-record commitment; a command-derived attempt-2 key fingerprint; the fresh lease fence; and one trusted time. It stores no raw key, account, parameters, identity, money, URL, response, free text, or personal data. The opaque transition commitment is not current business-state proof; a future runtime must derive it from a trusted business-record transaction. Exact retry and later-lease observation are read-only. Changed valid input conflicts. Orphaned, malformed, future, unsafe, or foundation-mismatched data fails closed without repair. @@ -358,9 +363,15 @@ The fixed result is `provider_attempt_authorized` with `requires_plan_binding`; PAY-002B2C4A source/tests are tracked in live [#232](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/232). The unused `bindAuthorizedStripeProviderPlan` API revalidates the exact B1-through-C3C chain and current active lease before atomically creating the immutable attempt-2 plan and its deterministic audit. Version 1 is equality-only: account, mode, API version, operation, endpoint, and canonical parameters equal attempt 1. Only the internally derived attempt-2 key commitment, current binding fence/time, attempt number, and C3C provenance may differ. -The fixed result is `provider_plan_bound` or `provider_plan_existing`, with `requires_pre_send_evidence`. Exact retry and later valid lease observation are read-only; changed valid input conflicts; malformed, orphaned, future, impossible-chronology, or foundation-mismatched partners fail closed. The pair stores no raw identity, account, parameters, key, money, URL, response, secret, or personal data. C4A creates no send evidence, changes no business record, calls no provider, and has no endpoint/index edge. PAY-002B2C4B must add the separate attempt-2 pre-send boundary before that Checkout Session request. Other Stripe operations require their own reviewed boundaries. +The fixed result is `provider_plan_bound` or `provider_plan_existing`, with `requires_pre_send_evidence`. Exact retry and later valid lease observation are read-only; changed valid input conflicts; malformed, orphaned, future, impossible-chronology, or foundation-mismatched partners fail closed. The pair stores no raw identity, account, parameters, key, money, URL, response, secret, or personal data. C4A creates no send evidence, changes no business record, calls no provider, and has no endpoint/index edge. PAY-002B2C4B [#238](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/238) owns the separate attempt-2 pre-send boundary below. Other Stripe operations require their own reviewed boundaries. -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, #206, #226, and #232 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-002B2C4B source/tests are tracked in live [#238](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/238). The unused `recordAuthorizedStripeSendEvidence` API revalidates B1 through C4A plus the current active holder/fence before creating the attempt-2 marker and deterministic audit, or neither. Both carry `providerPlanCommitmentSchemaVersion: 2`, a digest of every C4A plan field and nanosecond binding time including the authorization schema and commitment. Existing version-1 commitment bytes remain unchanged. + +The marker time is captured once before the transaction; its deadline is exactly 23 hours later and never moves on retry. After the transaction, a second trusted-time check does not re-read lifecycle state. Permission requires no rollback and a time strictly before both the captured current lease expiry and stored deadline. Equality, later time, rollback, or missing/unreadable paired time returns fixed `reconciliation_required`. Exact retry and a later valid lease observation are read-only inside the original deadline. The fixed permitted result is only `send_permitted` with `pre_send_recorded`; it does not prove caller authority, current business state, Stripe-account control, request execution, or a provider result. + +C4B stores no raw identity, account, parameters, key, transition value, money, URL, response, secret, or personal data. It is limited to `checkout_session_create` at `POST /v1/checkout/sessions`; it cannot create attempt `3`, call Stripe, write a business record, or enter an endpoint/index. Product/Price creation, Session expiry, refunds, and privileged provider actions require separate operation-specific plan, pre-send, result, and reconciliation boundaries. + +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, #206, #226, #232, and #238 expand that same suite. These are synthetic source checks only. Source change, tests, merge, Firebase deployment, Stripe configuration, production data, website publication, `runmprc.com` verification, and live behavior remain separate states. The current journal source remains unused and makes no endpoint, provider, production, website, or officer change. ## 7. Business invariants @@ -487,7 +498,7 @@ sequenceDiagram Text alternative: the website sends only the active signup fields; the server then accepts only a small exact request, preserves and safely encodes the event identity, and matches answers to the selected server fields; the target flow later reserves locally before Stripe, accepts payment only through the signed webhook, and returns only a sanitized result. -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, C4A must bind the immutable later plan, and future C4B must record its own pre-send evidence 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, C4A must bind the immutable later plan, and C4B/#238 must record its own pre-send evidence before a later POST. A scheduled job releases abandoned reservations and reconciles records that missed a webhook. ### 8.2 Free or volunteer registration @@ -510,7 +521,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, C3B persists only the exact candidate after the stored cutoff and current lease expiry, C3C separately authorizes a later generation, and C4A binds its immutable plan. Future C4B pre-send evidence remains required because authorization and planning are not provider-send permission. +- 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, C3C separately authorizes a later generation, C4A binds its immutable plan, and C4B/#238 records the separate attempt-2 pre-send pair and clock gate. None proves a provider result. - 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 c45fb1b..6f90865 100644 --- a/functions/commerceCommandJournal.emulator.test.js +++ b/functions/commerceCommandJournal.emulator.test.js @@ -17,6 +17,7 @@ const { bindInitialStripeProviderPlan, completeCommerceCommand, failCommerceCommand, + recordAuthorizedStripeSendEvidence, recordInitialStripeReconciliationEvidence, recordInitialStripeSendEvidence, registerCommerceCommand, @@ -98,6 +99,17 @@ const COMMAND_IDS = Object.freeze({ providerNextPlanLostResponse: 'a4ace024-7913-4bdf-8ace-68ace0247913', providerNextPlanCommitFailure: 'a5ace024-7913-4bdf-8ace-68ace0247913', providerNextPlanConflict: 'a6ace024-7913-4bdf-8ace-68ace0247913', + providerNextSendNeverBegan: 'b1ace024-7913-4bdf-8ace-68ace0247913', + providerNextSendExpired: 'b2ace024-7913-4bdf-8ace-68ace0247913', + providerNextSendConcurrent: 'b3ace024-7913-4bdf-8ace-68ace0247913', + providerNextSendConflict: 'b4ace024-7913-4bdf-8ace-68ace0247913', + providerNextSendLostResponse: 'b5ace024-7913-4bdf-8ace-68ace0247913', + providerNextSendBoundary: 'b6ace024-7913-4bdf-8ace-68ace0247913', + providerNextSendPostLease: 'b7ace024-7913-4bdf-8ace-68ace0247913', + providerNextSendPostRollback: 'b8ace024-7913-4bdf-8ace-68ace0247913', + providerNextSendReplacement: 'b9ace024-7913-4bdf-8ace-68ace0247913', + providerNextSendCommitFailure: 'baace024-7913-4bdf-8ace-68ace0247913', + providerNextSendOrphan: 'bbace024-7913-4bdf-8ace-68ace0247913', }); const BASE_NOW_MILLIS = 1800000000123; const LEASE_DURATION_MILLIS = 60000; @@ -397,6 +409,8 @@ describeWithEmulator('commerce command journal Firestore transaction', () => { .collection('sendEvidence').doc('first')); const providerSendAuditRef = trackRef(db.collection('auditEvents') .doc(`commerce_provider_send_${commandKeyHash}_0000000001`)); + const nextProviderSendAuditRef = trackRef(db.collection('auditEvents') + .doc(`commerce_provider_send_${commandKeyHash}_0000000002`)); const providerReconciliationRef = trackRef(providerPlanRef .collection('reconciliationEvidence').doc('0000000001')); const providerReconciliationAuditRef = trackRef(db.collection('auditEvents') @@ -424,6 +438,7 @@ describeWithEmulator('commerce command journal Firestore transaction', () => { providerSendRef, nextProviderSendRef, providerSendAuditRef, + nextProviderSendAuditRef, providerReconciliationRef, providerReconciliationAuditRef, providerAuthorizationRef, @@ -491,6 +506,17 @@ describeWithEmulator('commerce command journal Firestore transaction', () => { }; } + async function readNextProviderSendEvidence(pair) { + const [evidenceSnapshot, auditSnapshot] = await Promise.all([ + pair.nextProviderSendRef.get(), + pair.nextProviderSendAuditRef.get(), + ]); + return { + evidence: snapshotEvidence(evidenceSnapshot), + audit: snapshotEvidence(auditSnapshot), + }; + } + async function readProviderReconciliationEvidence(pair) { const [evidenceSnapshot, auditSnapshot] = await Promise.all([ pair.providerReconciliationRef.get(), @@ -548,6 +574,14 @@ describeWithEmulator('commerce command journal Firestore transaction', () => { }; } + async function readAuthorizedProviderPlanFoundationEvidence(pair) { + const [foundation, nextProvider] = await Promise.all([ + readAuthorizedProviderFoundationEvidence(pair), + readNextProviderEvidence(pair), + ]); + return { ...foundation, nextProvider }; + } + async function matchingAudits(pair) { return db.collection('auditEvents') .where('commandKeyHash', '==', pair.commandKeyHash) @@ -650,6 +684,20 @@ describeWithEmulator('commerce command journal Firestore transaction', () => { expect(Object.isFrozen(result)).toBe(true); } + function expectAuthorizedProviderSendResult(result, outcome) { + expect(result).toEqual({ + journalSchemaVersion: 1, + providerPlanSchemaVersion: 1, + providerPlanCommitmentSchemaVersion: 2, + providerSendEvidenceSchemaVersion: 2, + outcome, + state: outcome === 'send_permitted' + ? 'pre_send_recorded' + : 'provider_outcome_unknown', + }); + expect(Object.isFrozen(result)).toBe(true); + } + function expectNewAttemptCandidate(result) { expect(result).toEqual({ reconciliationPolicySchemaVersion: 1, @@ -770,6 +818,25 @@ describeWithEmulator('commerce command journal Firestore transaction', () => { return setup; } + async function setupBoundAuthorizedProviderPlan( + commandId, + reference, + leaseIndex, + reconciliationEvidence, + ) { + const setup = await setupAuthorizedProviderCandidate( + commandId, + reference, + leaseIndex, + reconciliationEvidence, + ); + expectAuthorizedProviderPlanResult( + await bindAuthorizedStripeProviderPlan(setup.authorizationArgs), + 'provider_plan_bound', + ); + return setup; + } + async function cleanupTrackedRefs() { if (!trackedRefs) return; const refs = [...trackedRefs.values()]; @@ -3632,4 +3699,578 @@ describeWithEmulator('commerce command journal Firestore transaction', () => { expect(nextProviderBefore.plan.exists).toBe(false); expect(nextProviderBefore.audit.exists).toBe(false); }); + + test.each([ + [ + 'execution-never-began authorization', + COMMAND_IDS.providerNextSendNeverBegan, + 'provider-next-send-never-began', + 380, + dispatchNeverBeganCandidate, + 'retry_same_operation', + ], + [ + 'expired-and-unpaid authorization', + COMMAND_IDS.providerNextSendExpired, + 'provider-next-send-expired', + 382, + expiredAttemptCandidate, + 'replace_expired_unpaid', + ], + ])('records attempt-2 pre-send evidence from %s without changing B1-C4A', async ( + _label, + commandId, + reference, + leaseIndex, + evidenceFactory, + expectedTransitionKind, + ) => { + const setup = await setupBoundAuthorizedProviderPlan( + commandId, + reference, + leaseIndex, + evidenceFactory(), + ); + const foundationBefore = await readAuthorizedProviderPlanFoundationEvidence( + setup.pair, + ); + const nextProvider = foundationBefore.nextProvider; + + expectAuthorizedProviderSendResult( + await recordAuthorizedStripeSendEvidence(setup.authorizationArgs), + 'send_permitted', + ); + + const send = await readNextProviderSendEvidence(setup.pair); + expect(setup.pair.nextProviderSendRef.path).toBe( + `checkoutRequests/${setup.pair.commandKeyHash}` + + '/providerAttempts/0000000002/sendEvidence/first', + ); + expect(setup.pair.nextProviderSendAuditRef.path).toBe( + `auditEvents/commerce_provider_send_${setup.pair.commandKeyHash}_0000000002`, + ); + expect(send.evidence.exists).toBe(true); + expect(send.audit.exists).toBe(true); + expect(send.evidence.createTime).toBe(send.evidence.updateTime); + expect(send.audit.createTime).toBe(send.audit.updateTime); + expect(Object.keys(send.evidence.data).sort()).toEqual([ + 'automaticRetryDeadlineAt', + 'commandIdentityVersion', + 'commandKeyHash', + 'prePostFenceEpoch', + 'prePostRecordedAt', + 'provider', + 'providerAttempt', + 'providerAttemptAuthorizationCommitment', + 'providerAttemptAuthorizationSchemaVersion', + 'providerPlanCommitment', + 'providerPlanCommitmentSchemaVersion', + 'providerPlanSchemaVersion', + 'providerSendEvidenceSchemaVersion', + ].sort()); + expect(send.evidence.data).toMatchObject({ + providerSendEvidenceSchemaVersion: 2, + providerPlanSchemaVersion: 1, + providerPlanCommitmentSchemaVersion: 2, + providerAttemptAuthorizationSchemaVersion: 1, + commandIdentityVersion: 1, + commandKeyHash: setup.pair.commandKeyHash, + providerAttempt: 2, + provider: 'stripe', + providerPlanCommitment: expect.stringMatching(/^[0-9a-f]{64}$/), + providerAttemptAuthorizationCommitment: ( + nextProvider.plan.data.providerAttemptAuthorizationCommitment + ), + prePostFenceEpoch: 2, + }); + expect(send.evidence.data.prePostRecordedAt.toMillis()) + .toBe(nextProvider.plan.data.boundAt.toMillis()); + expect(send.evidence.data.automaticRetryDeadlineAt.toMillis() + - send.evidence.data.prePostRecordedAt.toMillis()) + .toBe(PROVIDER_SEND_RETRY_WINDOW_MILLIS); + expect(send.evidence.data.automaticRetryDeadlineAt.toMillis()) + .toBeGreaterThan(setup.automaticRetryDeadlineAt.toMillis()); + expect(Object.keys(send.audit.data).sort()).toEqual([ + 'aggregateType', + 'automaticRetryDeadlineAt', + 'commandKeyHash', + 'environment', + 'eventType', + 'occurredAt', + 'prePostFenceEpoch', + 'provider', + 'providerAttempt', + 'providerAttemptAuthorizationCommitment', + 'providerAttemptAuthorizationSchemaVersion', + 'providerOperation', + 'providerPlanCommitment', + 'providerPlanCommitmentSchemaVersion', + 'providerPlanSchemaVersion', + 'providerSendAuditSchemaVersion', + 'providerSendEvidenceSchemaVersion', + 'stripeMode', + ].sort()); + expect(send.audit.data).toMatchObject({ + providerSendAuditSchemaVersion: 2, + providerSendEvidenceSchemaVersion: 2, + providerPlanSchemaVersion: 1, + providerPlanCommitmentSchemaVersion: 2, + providerAttemptAuthorizationSchemaVersion: 1, + aggregateType: 'commerce_provider_send', + commandKeyHash: setup.pair.commandKeyHash, + providerAttempt: 2, + eventType: 'provider_pre_send_recorded', + provider: 'stripe', + environment: 'test', + stripeMode: 'test', + providerOperation: STRIPE_OPERATION, + providerPlanCommitment: send.evidence.data.providerPlanCommitment, + providerAttemptAuthorizationCommitment: ( + send.evidence.data.providerAttemptAuthorizationCommitment + ), + prePostFenceEpoch: 2, + }); + expect(send.audit.data.occurredAt.toMillis()) + .toBe(send.evidence.data.prePostRecordedAt.toMillis()); + expect(send.audit.data.automaticRetryDeadlineAt.toMillis()) + .toBe(send.evidence.data.automaticRetryDeadlineAt.toMillis()); + expect(foundationBefore.authorization.authorization.data.transitionKind) + .toBe(expectedTransitionKind); + expect(await readAuthorizedProviderPlanFoundationEvidence(setup.pair)) + .toEqual(foundationBefore); + expect((await matchingAudits(setup.pair)).size).toBe(9); + + const rendered = JSON.stringify({ send }); + expect(rendered).not.toContain(STRIPE_ACCOUNT_ID); + expect(rendered).not.toContain(reference); + expect(rendered).not.toContain(setup.freshLeaseId); + expect(rendered).not.toContain(TRANSITION_RECORD_COMMITMENT); + }); + + test('24 identical attempt-2 pre-send calls create one immutable pair', async () => { + const setup = await setupBoundAuthorizedProviderPlan( + COMMAND_IDS.providerNextSendConcurrent, + 'provider-next-send-concurrent', + 384, + dispatchNeverBeganCandidate(), + ); + const foundationBefore = await readAuthorizedProviderPlanFoundationEvidence( + setup.pair, + ); + + const results = await Promise.all(Array.from( + { length: CONCURRENT_CALLS }, + () => retryWholeOperationAfterUnavailable( + () => recordAuthorizedStripeSendEvidence(setup.authorizationArgs), + retryEvidence, + ), + )); + for (const result of results) { + expectAuthorizedProviderSendResult(result, 'send_permitted'); + } + + const send = await readNextProviderSendEvidence(setup.pair); + expect(send.evidence.exists).toBe(true); + expect(send.audit.exists).toBe(true); + expect(send.evidence.createTime).toBe(send.evidence.updateTime); + expect(send.audit.createTime).toBe(send.audit.updateTime); + expect(await readAuthorizedProviderPlanFoundationEvidence(setup.pair)) + .toEqual(foundationBefore); + expect((await matchingAudits(setup.pair)).size).toBe(9); + }); + + test('12 exact and 12 conflicting calls preserve the established C4A winner', async () => { + const evidence = expiredAttemptCandidate(); + const setup = await setupPersistedProviderCandidate( + COMMAND_IDS.providerNextSendConflict, + 'provider-next-send-conflict', + 386, + evidence, + ); + const winningArgs = setup.authorizationArgs; + const losingArgs = providerAuthorizationArgs( + setup.args, + setup.freshLeaseId, + 2, + evidence, + 'replace_expired_unpaid', + OTHER_TRANSITION_RECORD_COMMITMENT, + ); + expectProviderAuthorizationResult( + await authorizeNextStripeProviderAttempt(winningArgs), + ); + expectAuthorizedProviderPlanResult( + await bindAuthorizedStripeProviderPlan(winningArgs), + 'provider_plan_bound', + ); + const foundationBefore = await readAuthorizedProviderPlanFoundationEvidence( + setup.pair, + ); + + const settled = await Promise.allSettled([ + ...Array.from({ length: 12 }, () => retryWholeOperationAfterUnavailable( + () => recordAuthorizedStripeSendEvidence(winningArgs), + retryEvidence, + )), + ...Array.from({ length: 12 }, () => retryWholeOperationAfterUnavailable( + () => recordAuthorizedStripeSendEvidence(losingArgs), + retryEvidence, + )), + ]); + const fulfilled = settled.filter(({ status }) => status === 'fulfilled'); + const rejected = settled.filter(({ status }) => status === 'rejected'); + expect(fulfilled).toHaveLength(12); + for (const result of fulfilled) { + expectAuthorizedProviderSendResult(result.value, 'send_permitted'); + } + expect(rejected).toHaveLength(12); + for (const result of rejected) expectJournalError(result.reason, 'command_conflict'); + + const sendBeforeProbes = await readNextProviderSendEvidence(setup.pair); + expect(sendBeforeProbes.evidence.exists).toBe(true); + expect(sendBeforeProbes.audit.exists).toBe(true); + expectAuthorizedProviderSendResult( + await recordAuthorizedStripeSendEvidence(winningArgs), + 'send_permitted', + ); + await expect(recordAuthorizedStripeSendEvidence(losingArgs)).rejects.toMatchObject({ + code: 'commerce_command_journal_error', + reason: 'command_conflict', + }); + expect(await readNextProviderSendEvidence(setup.pair)).toEqual(sendBeforeProbes); + expect(await readAuthorizedProviderPlanFoundationEvidence(setup.pair)) + .toEqual(foundationBefore); + expect((await matchingAudits(setup.pair)).size).toBe(9); + }); + + test('lost-response retries and a later valid lease reuse the original window', async () => { + const evidence = dispatchNeverBeganCandidate(); + const setup = await setupBoundAuthorizedProviderPlan( + COMMAND_IDS.providerNextSendLostResponse, + 'provider-next-send-lost-response', + 388, + evidence, + ); + expectAuthorizedProviderSendResult( + await recordAuthorizedStripeSendEvidence(setup.authorizationArgs), + 'send_permitted', + ); + const sendBefore = await readNextProviderSendEvidence(setup.pair); + + const retries = await Promise.all(Array.from( + { length: 8 }, + () => recordAuthorizedStripeSendEvidence(setup.authorizationArgs), + )); + for (const result of retries) { + expectAuthorizedProviderSendResult(result, 'send_permitted'); + } + expect(await readNextProviderSendEvidence(setup.pair)).toEqual(sendBefore); + + const currentLifecycle = await setup.pair.lifecycleRef.get(); + const laterLeaseId = deterministicLeaseId(390); + setTrustedNow(currentLifecycle.data().leaseExpiresAt.toMillis()); + expectLeaseResult(await acquireCommerceCommandLease(acquireArgs( + db, + COMMAND_IDS.providerNextSendLostResponse, + setup.commandPayload, + laterLeaseId, + )), 3); + const laterArgs = providerAuthorizationArgs( + setup.args, + laterLeaseId, + 3, + evidence, + setup.transitionKind, + ); + const foundationBefore = { + ...(await readAuthorizedProviderPlanFoundationEvidence(setup.pair)), + laterLeaseAudit: snapshotEvidence(await readAudit(setup.pair, 4)), + }; + expectAuthorizedProviderSendResult( + await recordAuthorizedStripeSendEvidence(laterArgs), + 'send_permitted', + ); + expect(await readNextProviderSendEvidence(setup.pair)).toEqual(sendBefore); + expect({ + ...(await readAuthorizedProviderPlanFoundationEvidence(setup.pair)), + laterLeaseAudit: snapshotEvidence(await readAudit(setup.pair, 4)), + }).toEqual(foundationBefore); + expect((await matchingAudits(setup.pair)).size).toBe(10); + }); + + test('attempt-2 deadline permits minus 1ns and reconciles at equality and later', async () => { + const evidence = dispatchNeverBeganCandidate(); + const setup = await setupBoundAuthorizedProviderPlan( + COMMAND_IDS.providerNextSendBoundary, + 'provider-next-send-boundary', + 392, + evidence, + ); + expectAuthorizedProviderSendResult( + await recordAuthorizedStripeSendEvidence(setup.authorizationArgs), + 'send_permitted', + ); + const sendBefore = await readNextProviderSendEvidence(setup.pair); + const deadline = sendBefore.evidence.data.automaticRetryDeadlineAt; + const takeoverLeaseId = deterministicLeaseId(394); + setTrustedNow(deadline.toMillis() - 1000); + expectLeaseResult(await acquireCommerceCommandLease(acquireArgs( + db, + COMMAND_IDS.providerNextSendBoundary, + setup.commandPayload, + takeoverLeaseId, + )), 3); + const takeoverArgs = providerAuthorizationArgs( + setup.args, + takeoverLeaseId, + 3, + evidence, + setup.transitionKind, + ); + + const immediatelyBeforeDeadline = new admin.firestore.Timestamp( + deadline._seconds, + deadline._nanoseconds - 1, + ); + timestampNow + .mockImplementationOnce(() => immediatelyBeforeDeadline) + .mockImplementationOnce(() => immediatelyBeforeDeadline); + expectAuthorizedProviderSendResult( + await recordAuthorizedStripeSendEvidence(takeoverArgs), + 'send_permitted', + ); + timestampNow.mockImplementationOnce(() => new admin.firestore.Timestamp( + deadline._seconds, + deadline._nanoseconds, + )); + expectAuthorizedProviderSendResult( + await recordAuthorizedStripeSendEvidence(takeoverArgs), + 'reconciliation_required', + ); + timestampNow.mockImplementationOnce(() => new admin.firestore.Timestamp( + deadline._seconds, + deadline._nanoseconds + 1, + )); + expectAuthorizedProviderSendResult( + await recordAuthorizedStripeSendEvidence(takeoverArgs), + 'reconciliation_required', + ); + expect(await readNextProviderSendEvidence(setup.pair)).toEqual(sendBefore); + expect((await matchingAudits(setup.pair)).size).toBe(10); + }); + + test.each([ + [ + 'lease-expiry equality', + COMMAND_IDS.providerNextSendPostLease, + 'provider-next-send-post-lease', + 396, + 'lease_expiry', + ], + [ + 'trusted-clock rollback', + COMMAND_IDS.providerNextSendPostRollback, + 'provider-next-send-post-rollback', + 398, + 'clock_rollback', + ], + ])('post-transaction %s commits evidence but returns reconciliation', async ( + _label, + commandId, + reference, + leaseIndex, + boundary, + ) => { + const setup = await setupBoundAuthorizedProviderPlan( + commandId, + reference, + leaseIndex, + dispatchNeverBeganCandidate(), + ); + const lifecycle = await setup.pair.lifecycleRef.get(); + const plan = await setup.pair.nextProviderPlanRef.get(); + const transactionTime = boundary === 'lease_expiry' + ? new admin.firestore.Timestamp( + lifecycle.data().leaseExpiresAt._seconds, + lifecycle.data().leaseExpiresAt._nanoseconds - 1, + ) + : plan.data().boundAt; + const returnedTime = boundary === 'lease_expiry' + ? lifecycle.data().leaseExpiresAt + : new admin.firestore.Timestamp( + transactionTime._seconds, + transactionTime._nanoseconds - 1, + ); + timestampNow + .mockImplementationOnce(() => transactionTime) + .mockImplementationOnce(() => returnedTime); + + expectAuthorizedProviderSendResult( + await recordAuthorizedStripeSendEvidence(setup.authorizationArgs), + 'reconciliation_required', + ); + const send = await readNextProviderSendEvidence(setup.pair); + expect(send.evidence.exists).toBe(true); + expect(send.audit.exists).toBe(true); + expect(send.evidence.data.prePostRecordedAt.toMillis()) + .toBe(transactionTime.toMillis()); + expect((await matchingAudits(setup.pair)).size).toBe(9); + }); + + test('v2 commitment rejects coherent plan replacement and detached provenance', async () => { + const setup = await setupBoundAuthorizedProviderPlan( + COMMAND_IDS.providerNextSendReplacement, + 'provider-next-send-replacement', + 400, + expiredAttemptCandidate(), + ); + expectAuthorizedProviderSendResult( + await recordAuthorizedStripeSendEvidence(setup.authorizationArgs), + 'send_permitted', + ); + const sendBefore = await readNextProviderSendEvidence(setup.pair); + const plan = await setup.pair.nextProviderPlanRef.get(); + const planAudit = await setup.pair.nextProviderAuditRef.get(); + const replacementTime = admin.firestore.Timestamp.fromMillis( + plan.data().boundAt.toMillis() + 1, + ); + await Promise.all([ + setup.pair.nextProviderPlanRef.delete(), + setup.pair.nextProviderAuditRef.delete(), + ]); + await Promise.all([ + setup.pair.nextProviderPlanRef.create({ + ...plan.data(), + boundAt: replacementTime, + }), + setup.pair.nextProviderAuditRef.create({ + ...planAudit.data(), + occurredAt: replacementTime, + }), + ]); + setTrustedNow(replacementTime.toMillis() + 1); + await expect(recordAuthorizedStripeSendEvidence(setup.authorizationArgs)) + .rejects.toMatchObject({ + code: 'commerce_command_journal_error', + reason: 'journal_record_invalid', + }); + expect(await readNextProviderSendEvidence(setup.pair)).toEqual(sendBefore); + + await Promise.all([ + setup.pair.nextProviderPlanRef.delete(), + setup.pair.nextProviderAuditRef.delete(), + ]); + await Promise.all([ + setup.pair.nextProviderPlanRef.create(plan.data()), + setup.pair.nextProviderAuditRef.create(planAudit.data()), + ]); + const validSendRecord = await setup.pair.nextProviderSendRef.get(); + const validSendAudit = await setup.pair.nextProviderSendAuditRef.get(); + await Promise.all([ + setup.pair.nextProviderSendRef.set({ + ...validSendRecord.data(), + providerAttemptAuthorizationCommitment: OTHER_TRANSITION_RECORD_COMMITMENT, + }), + setup.pair.nextProviderSendAuditRef.set({ + ...validSendAudit.data(), + providerAttemptAuthorizationCommitment: OTHER_TRANSITION_RECORD_COMMITMENT, + }), + ]); + const detachedBefore = await readNextProviderSendEvidence(setup.pair); + await expect(recordAuthorizedStripeSendEvidence(setup.authorizationArgs)) + .rejects.toMatchObject({ + code: 'commerce_command_journal_error', + reason: 'journal_record_invalid', + }); + expect(await readNextProviderSendEvidence(setup.pair)).toEqual(detachedBefore); + }); + + test('an injected commit failure leaves neither attempt-2 send partner', async () => { + const setup = await setupBoundAuthorizedProviderPlan( + COMMAND_IDS.providerNextSendCommitFailure, + 'provider-next-send-commit-failure', + 402, + dispatchNeverBeganCandidate(), + ); + const foundationBefore = await readAuthorizedProviderPlanFoundationEvidence( + setup.pair, + ); + const sendBefore = await readNextProviderSendEvidence(setup.pair); + const auditsBefore = await matchingAudits(setup.pair); + const commitFailure = jest.spyOn(FirestoreTransaction.prototype, 'commit') + .mockRejectedValueOnce(new Error('synthetic attempt-2 send commit failure')); + let commitCalls = 0; + + try { + const error = await recordAuthorizedStripeSendEvidence( + setup.authorizationArgs, + ).then( + () => null, + (reason) => reason, + ); + expectJournalError(error, 'journal_unavailable'); + commitCalls = commitFailure.mock.calls.length; + } finally { + commitFailure.mockRestore(); + } + expect(commitCalls).toBe(1); + expect(await readNextProviderSendEvidence(setup.pair)).toEqual(sendBefore); + expect(await readAuthorizedProviderPlanFoundationEvidence(setup.pair)) + .toEqual(foundationBefore); + expect((await matchingAudits(setup.pair)).size).toBe(auditsBefore.size); + expect(sendBefore.evidence.exists).toBe(false); + expect(sendBefore.audit.exists).toBe(false); + }); + + test('attempt-2 send orphans fail closed and missing time reconciles', async () => { + const setup = await setupBoundAuthorizedProviderPlan( + COMMAND_IDS.providerNextSendOrphan, + 'provider-next-send-orphan', + 404, + expiredAttemptCandidate(), + ); + await setup.pair.nextProviderSendAuditRef.create({ + providerSendAuditSchemaVersion: 2, + syntheticOrphan: true, + }); + const auditOrphanBefore = await readNextProviderSendEvidence(setup.pair); + await expect(recordAuthorizedStripeSendEvidence(setup.authorizationArgs)) + .rejects.toMatchObject({ + code: 'commerce_command_journal_error', + reason: 'journal_record_invalid', + }); + expect(await readNextProviderSendEvidence(setup.pair)).toEqual(auditOrphanBefore); + + await setup.pair.nextProviderSendAuditRef.delete(); + expectAuthorizedProviderSendResult( + await recordAuthorizedStripeSendEvidence(setup.authorizationArgs), + 'send_permitted', + ); + const validEvidence = await setup.pair.nextProviderSendRef.get(); + const validAudit = await setup.pair.nextProviderSendAuditRef.get(); + await setup.pair.nextProviderSendAuditRef.delete(); + const recordOrphanBefore = await readNextProviderSendEvidence(setup.pair); + await expect(recordAuthorizedStripeSendEvidence(setup.authorizationArgs)) + .rejects.toMatchObject({ + code: 'commerce_command_journal_error', + reason: 'journal_record_invalid', + }); + expect(await readNextProviderSendEvidence(setup.pair)).toEqual(recordOrphanBefore); + + await setup.pair.nextProviderSendAuditRef.create(validAudit.data()); + const evidenceWithoutTime = { ...validEvidence.data() }; + const auditWithoutTime = { ...validAudit.data() }; + delete evidenceWithoutTime.prePostRecordedAt; + delete auditWithoutTime.occurredAt; + await Promise.all([ + setup.pair.nextProviderSendRef.set(evidenceWithoutTime), + setup.pair.nextProviderSendAuditRef.set(auditWithoutTime), + ]); + const unknownBefore = await readNextProviderSendEvidence(setup.pair); + expectAuthorizedProviderSendResult( + await recordAuthorizedStripeSendEvidence(setup.authorizationArgs), + 'reconciliation_required', + ); + expect(await readNextProviderSendEvidence(setup.pair)).toEqual(unknownBefore); + }); }); diff --git a/functions/commerceCommandJournal.js b/functions/commerceCommandJournal.js index 5137cb8..8e2710b 100644 --- a/functions/commerceCommandJournal.js +++ b/functions/commerceCommandJournal.js @@ -23,6 +23,9 @@ const providerPlanSchemaVersion = 1; const providerPlanAuditSchemaVersion = 1; const providerSendEvidenceSchemaVersion = 1; const providerSendAuditSchemaVersion = 1; +const authorizedProviderPlanCommitmentSchemaVersion = 2; +const authorizedProviderSendEvidenceSchemaVersion = 2; +const authorizedProviderSendAuditSchemaVersion = 2; const providerReconciliationEvidenceSchemaVersion = 1; const providerReconciliationAuditSchemaVersion = 1; const providerAttemptAuthorizationSchemaVersion = 1; @@ -63,6 +66,7 @@ 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', + authorizedProviderPlan: 'mprc.command-authorized-provider-plan-complete.v2', providerSendEvidence: 'mprc.command-provider-send-evidence-complete.v1', providerReconciliationEvidence: ( 'mprc.command-provider-reconciliation-evidence-complete.v1' @@ -269,6 +273,41 @@ const PROVIDER_SEND_AUDIT_FIELDS = Object.freeze([ 'automaticRetryDeadlineAt', 'occurredAt', ]); +const AUTHORIZED_PROVIDER_SEND_FIELDS = Object.freeze([ + 'providerSendEvidenceSchemaVersion', + 'providerPlanSchemaVersion', + 'providerPlanCommitmentSchemaVersion', + 'providerAttemptAuthorizationSchemaVersion', + 'commandIdentityVersion', + 'commandKeyHash', + 'providerAttempt', + 'provider', + 'providerPlanCommitment', + 'providerAttemptAuthorizationCommitment', + 'prePostFenceEpoch', + 'prePostRecordedAt', + 'automaticRetryDeadlineAt', +]); +const AUTHORIZED_PROVIDER_SEND_AUDIT_FIELDS = Object.freeze([ + 'providerSendAuditSchemaVersion', + 'providerSendEvidenceSchemaVersion', + 'providerPlanSchemaVersion', + 'providerPlanCommitmentSchemaVersion', + 'providerAttemptAuthorizationSchemaVersion', + 'aggregateType', + 'commandKeyHash', + 'providerAttempt', + 'eventType', + 'provider', + 'environment', + 'stripeMode', + 'providerOperation', + 'providerPlanCommitment', + 'providerAttemptAuthorizationCommitment', + 'prePostFenceEpoch', + 'automaticRetryDeadlineAt', + 'occurredAt', +]); const PROVIDER_RECONCILIATION_FIELDS = Object.freeze([ 'providerReconciliationEvidenceSchemaVersion', 'reconciliationPolicySchemaVersion', @@ -570,6 +609,23 @@ function captureTrustedTimestamp() { return timestamp; } +function capturePostTransactionTimestampParts() { + let timestamp; + try { + timestamp = Timestamp.now(); + } catch { + return null; + } + const parts = readTimestamp(timestamp); + if (parts === null) return null; + try { + Object.freeze(timestamp); + } catch { + return null; + } + return parts; +} + function addTimestampSeconds(timestamp, seconds) { const parts = readTimestamp(timestamp); if (!Number.isSafeInteger(seconds) @@ -687,6 +743,70 @@ function createCompleteProviderPlanCommitment(plan, boundAt) { ]); } +function createCompleteAuthorizedProviderPlanCommitment(providerPlan) { + const record = providerPlan.record; + const audit = providerPlan.audit; + const boundAt = providerPlan.boundAt; + const auditOccurredAt = readTimestamp(audit.occurredAt); + if (boundAt === null + || auditOccurredAt === null + || !timestampsEqual(boundAt, auditOccurredAt)) { + reject('journal_record_invalid'); + } + return digest(HASH_DOMAINS.authorizedProviderPlan, [ + ['version', String(authorizedProviderPlanCommitmentSchemaVersion)], + [ + 'providerPlanCommitmentSchemaVersion', + String(authorizedProviderPlanCommitmentSchemaVersion), + ], + ['providerPlanSchemaVersion', String(record.providerPlanSchemaVersion)], + ['commandIdentityVersion', String(record.commandIdentityVersion)], + ['commandKeyHash', record.commandKeyHash], + ['environment', record.environment], + ['provider', record.provider], + ['providerAttempt', String(record.providerAttempt)], + ['providerOperation', record.providerOperation], + ['stripeMode', record.stripeMode], + ['stripeAccountFingerprint', record.stripeAccountFingerprint], + ['stripeApiVersion', record.stripeApiVersion], + ['httpMethod', record.httpMethod], + ['endpointPath', record.endpointPath], + ['parametersFingerprint', record.parametersFingerprint], + ['idempotencyKeyFingerprint', record.idempotencyKeyFingerprint], + ['boundFenceEpoch', String(record.boundFenceEpoch)], + ['boundAtSeconds', String(boundAt.seconds)], + ['boundAtNanoseconds', String(boundAt.nanoseconds)], + [ + 'providerAttemptAuthorizationSchemaVersion', + String(record.providerAttemptAuthorizationSchemaVersion), + ], + [ + 'providerAttemptAuthorizationCommitment', + record.providerAttemptAuthorizationCommitment, + ], + ['auditProviderPlanAuditSchemaVersion', String(audit.providerPlanAuditSchemaVersion)], + ['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], + ['auditBoundFenceEpoch', String(audit.boundFenceEpoch)], + ['auditOccurredAtSeconds', String(auditOccurredAt.seconds)], + ['auditOccurredAtNanoseconds', String(auditOccurredAt.nanoseconds)], + [ + 'auditProviderAttemptAuthorizationSchemaVersion', + String(audit.providerAttemptAuthorizationSchemaVersion), + ], + [ + 'auditProviderAttemptAuthorizationCommitment', + audit.providerAttemptAuthorizationCommitment, + ], + ]); +} + function createCompleteProviderSendEvidenceCommitment(providerSend) { if (providerSend.timeUnknown || providerSend.prePostRecordedAt === null @@ -1117,11 +1237,19 @@ function providerPlanAuditDocumentId( return `commerce_provider_attempt_${commandKeyHash}_${attemptDocument}`; } -function providerSendAuditDocumentId(commandKeyHash) { +function providerSendAuditDocumentId( + commandKeyHash, + providerAttempt = INITIAL_PROVIDER_ATTEMPT, +) { if (typeof commandKeyHash !== 'string' || !LOWERCASE_SHA256.test(commandKeyHash)) { reject('journal_record_invalid'); } - return `commerce_provider_send_${commandKeyHash}_${INITIAL_PROVIDER_ATTEMPT_DOCUMENT}`; + if (providerAttempt !== INITIAL_PROVIDER_ATTEMPT + && providerAttempt !== NEXT_PROVIDER_ATTEMPT) { + reject('journal_record_invalid'); + } + const attemptDocument = String(providerAttempt).padStart(10, '0'); + return `commerce_provider_send_${commandKeyHash}_${attemptDocument}`; } function providerReconciliationAuditDocumentId(commandKeyHash) { @@ -1639,6 +1767,30 @@ function prepareNextProviderAttemptAuthorizationOperation(input) { }); } +function prepareAuthorizedProviderSendOperation(input) { + const prepared = prepareNextProviderAttemptAuthorizationOperation(input); + let authorizedProviderSendRef; + let authorizedProviderSendAuditRef; + try { + authorizedProviderSendRef = prepared.authorizedProviderPlanRef + .collection(SEND_EVIDENCE_COLLECTION) + .doc(INITIAL_SEND_EVIDENCE_DOCUMENT); + authorizedProviderSendAuditRef = prepared.db.collection(AUDIT_COLLECTION).doc( + providerSendAuditDocumentId(prepared.commandKeyHash, NEXT_PROVIDER_ATTEMPT), + ); + } catch (error) { + if (error instanceof CommerceCommandJournalError) throw error; + reject('journal_unavailable'); + } + const automaticRetryDeadlineAt = addProviderSendRetryWindow(prepared.occurredAt); + return Object.freeze({ + ...prepared, + authorizedProviderSendRef, + authorizedProviderSendAuditRef, + automaticRetryDeadlineAt, + }); +} + function createProviderSendPair(prepared, providerPlan) { const providerPlanCommitment = createCompleteProviderPlanCommitment( providerPlan.record, @@ -1675,6 +1827,52 @@ function createProviderSendPair(prepared, providerPlan) { return Object.freeze({ record, audit }); } +function createAuthorizedProviderSendPair(prepared, context) { + const providerPlan = context.authorizedProviderPlan; + const providerPlanCommitment = createCompleteAuthorizedProviderPlanCommitment( + providerPlan, + ); + const providerAttemptAuthorizationCommitment = ( + providerPlan.record.providerAttemptAuthorizationCommitment + ); + const record = Object.freeze({ + providerSendEvidenceSchemaVersion: authorizedProviderSendEvidenceSchemaVersion, + providerPlanSchemaVersion, + providerPlanCommitmentSchemaVersion: authorizedProviderPlanCommitmentSchemaVersion, + providerAttemptAuthorizationSchemaVersion, + commandIdentityVersion, + commandKeyHash: prepared.commandKeyHash, + providerAttempt: NEXT_PROVIDER_ATTEMPT, + provider: 'stripe', + providerPlanCommitment, + providerAttemptAuthorizationCommitment, + prePostFenceEpoch: prepared.expectedFenceEpoch, + prePostRecordedAt: prepared.occurredAt, + automaticRetryDeadlineAt: prepared.automaticRetryDeadlineAt, + }); + const audit = Object.freeze({ + providerSendAuditSchemaVersion: authorizedProviderSendAuditSchemaVersion, + providerSendEvidenceSchemaVersion: authorizedProviderSendEvidenceSchemaVersion, + providerPlanSchemaVersion, + providerPlanCommitmentSchemaVersion: authorizedProviderPlanCommitmentSchemaVersion, + providerAttemptAuthorizationSchemaVersion, + aggregateType: 'commerce_provider_send', + commandKeyHash: prepared.commandKeyHash, + providerAttempt: NEXT_PROVIDER_ATTEMPT, + eventType: 'provider_pre_send_recorded', + provider: 'stripe', + environment: providerPlan.record.environment, + stripeMode: providerPlan.record.stripeMode, + providerOperation: providerPlan.record.providerOperation, + providerPlanCommitment, + providerAttemptAuthorizationCommitment, + prePostFenceEpoch: prepared.expectedFenceEpoch, + automaticRetryDeadlineAt: prepared.automaticRetryDeadlineAt, + occurredAt: prepared.occurredAt, + }); + return Object.freeze({ record, audit }); +} + function readSnapshot(snapshot) { let exists; try { @@ -3011,6 +3209,168 @@ async function readAuthorizedProviderPlanContext(transaction, prepared) { return Object.freeze({ ...context, authorizedProviderPlan }); } +function parseAuthorizedProviderSendPair(recordValue, auditValue, prepared, context) { + const record = readExactObjectWithOptionalTimeFields( + recordValue, + AUTHORIZED_PROVIDER_SEND_FIELDS, + ['prePostRecordedAt', 'automaticRetryDeadlineAt'], + 'journal_record_invalid', + ); + const audit = readExactObjectWithOptionalTimeFields( + auditValue, + AUTHORIZED_PROVIDER_SEND_AUDIT_FIELDS, + ['occurredAt', 'automaticRetryDeadlineAt'], + 'journal_record_invalid', + ); + const providerPlan = context.authorizedProviderPlan; + const expectedProviderPlanCommitment = ( + createCompleteAuthorizedProviderPlanCommitment(providerPlan) + ); + const expectedAuthorizationCommitment = ( + providerPlan.record.providerAttemptAuthorizationCommitment + ); + + if (record.providerSendEvidenceSchemaVersion + !== authorizedProviderSendEvidenceSchemaVersion + || record.providerPlanSchemaVersion !== providerPlanSchemaVersion + || record.providerPlanCommitmentSchemaVersion + !== authorizedProviderPlanCommitmentSchemaVersion + || record.providerAttemptAuthorizationSchemaVersion + !== providerAttemptAuthorizationSchemaVersion + || record.commandIdentityVersion !== commandIdentityVersion + || record.commandKeyHash !== prepared.commandKeyHash + || record.providerAttempt !== NEXT_PROVIDER_ATTEMPT + || record.provider !== 'stripe' + || typeof record.providerPlanCommitment !== 'string' + || !LOWERCASE_SHA256.test(record.providerPlanCommitment) + || record.providerPlanCommitment !== expectedProviderPlanCommitment + || typeof record.providerAttemptAuthorizationCommitment !== 'string' + || !LOWERCASE_SHA256.test(record.providerAttemptAuthorizationCommitment) + || record.providerAttemptAuthorizationCommitment !== expectedAuthorizationCommitment + || !validLifecycleNumber(record.prePostFenceEpoch) + || record.prePostFenceEpoch < providerPlan.record.boundFenceEpoch + || record.prePostFenceEpoch > context.lifecycle.record.fenceEpoch + || audit.providerSendAuditSchemaVersion !== authorizedProviderSendAuditSchemaVersion + || audit.providerSendEvidenceSchemaVersion !== record.providerSendEvidenceSchemaVersion + || audit.providerPlanSchemaVersion !== record.providerPlanSchemaVersion + || audit.providerPlanCommitmentSchemaVersion + !== record.providerPlanCommitmentSchemaVersion + || audit.providerAttemptAuthorizationSchemaVersion + !== record.providerAttemptAuthorizationSchemaVersion + || audit.aggregateType !== 'commerce_provider_send' + || audit.commandKeyHash !== record.commandKeyHash + || audit.providerAttempt !== record.providerAttempt + || audit.eventType !== 'provider_pre_send_recorded' + || audit.provider !== record.provider + || audit.environment !== providerPlan.record.environment + || audit.stripeMode !== providerPlan.record.stripeMode + || audit.providerOperation !== providerPlan.record.providerOperation + || audit.providerPlanCommitment !== record.providerPlanCommitment + || audit.providerAttemptAuthorizationCommitment + !== record.providerAttemptAuthorizationCommitment + || audit.prePostFenceEpoch !== record.prePostFenceEpoch) { + reject('journal_record_invalid'); + } + + const prePostRecordedAt = readTimestamp(record.prePostRecordedAt); + const automaticRetryDeadlineAt = readTimestamp(record.automaticRetryDeadlineAt); + const auditOccurredAt = readTimestamp(audit.occurredAt); + const auditRetryDeadlineAt = readTimestamp(audit.automaticRetryDeadlineAt); + const timeUnknown = prePostRecordedAt === null + || automaticRetryDeadlineAt === null + || auditOccurredAt === null + || auditRetryDeadlineAt === null + || !timestampsEqual(prePostRecordedAt, auditOccurredAt) + || !timestampsEqual(automaticRetryDeadlineAt, auditRetryDeadlineAt) + || !timestampExactlySecondsAfter( + automaticRetryDeadlineAt, + prePostRecordedAt, + PROVIDER_SEND_RETRY_WINDOW_SECONDS, + ) + || compareTimestamps(prePostRecordedAt, providerPlan.boundAt) < 0; + if (timeUnknown) { + return Object.freeze({ + record, + audit, + timeUnknown: true, + prePostRecordedAt: null, + automaticRetryDeadlineAt: null, + }); + } + return Object.freeze({ + record, + audit, + timeUnknown: false, + prePostRecordedAt, + automaticRetryDeadlineAt, + }); +} + +async function readAuthorizedProviderSendContext(transaction, prepared) { + const context = await readAuthorizedProviderPlanContext(transaction, prepared); + if (context.authorizedProviderPlan === null) reject('journal_record_invalid'); + + const sendSnapshot = await transaction.get(prepared.authorizedProviderSendRef); + const sendAuditSnapshot = await transaction.get(prepared.authorizedProviderSendAuditRef); + const sendDocument = readSnapshot(sendSnapshot); + const sendAuditDocument = readSnapshot(sendAuditSnapshot); + if (sendDocument.exists !== sendAuditDocument.exists) { + reject('journal_record_invalid'); + } + if (!sendDocument.exists) { + return Object.freeze({ ...context, authorizedProviderSend: null }); + } + + const authorizedProviderSend = parseAuthorizedProviderSendPair( + sendDocument.value, + sendAuditDocument.value, + prepared, + context, + ); + const bindingAuditDocument = await context.readRequiredLifecycleAudit( + authorizedProviderSend.record.prePostFenceEpoch + 1, + ); + const bindingAudit = validateProviderLeaseAudit( + bindingAuditDocument.value, + authorizedProviderSend.record.prePostFenceEpoch, + prepared, + ); + let predecessorAudit = null; + if (authorizedProviderSend.record.prePostFenceEpoch > 1) { + const predecessorDocument = await context.readRequiredLifecycleAudit( + authorizedProviderSend.record.prePostFenceEpoch, + ); + predecessorAudit = validateProviderLeaseAudit( + predecessorDocument.value, + authorizedProviderSend.record.prePostFenceEpoch - 1, + prepared, + ); + } + validateProviderLeaseChronology(bindingAudit, predecessorAudit, context.lifecycle); + validateProviderLeaseAgainstCurrent(bindingAudit, context.lifecycle); + if (authorizedProviderSend.timeUnknown) { + return Object.freeze({ ...context, authorizedProviderSend }); + } + if (compareTimestamps( + authorizedProviderSend.prePostRecordedAt, + bindingAudit.occurredAt, + ) < 0 || compareTimestamps( + authorizedProviderSend.prePostRecordedAt, + bindingAudit.leaseExpiresAt, + ) >= 0) { + return Object.freeze({ + ...context, + authorizedProviderSend: Object.freeze({ + ...authorizedProviderSend, + timeUnknown: true, + prePostRecordedAt: null, + automaticRetryDeadlineAt: null, + }), + }); + } + return Object.freeze({ ...context, authorizedProviderSend }); +} + function validateActiveProviderPlanLease(prepared, lifecycle) { if (lifecycle.record.state !== 'leased' || lifecycle.record.leaseOwnerFingerprint !== prepared.leaseOwnerFingerprint @@ -3336,6 +3696,95 @@ async function bindAuthorizedStripeProviderPlan(input) { } } +const AUTHORIZED_PROVIDER_SEND_PERMITTED = Object.freeze({ + journalSchemaVersion, + providerPlanSchemaVersion, + providerPlanCommitmentSchemaVersion: authorizedProviderPlanCommitmentSchemaVersion, + providerSendEvidenceSchemaVersion: authorizedProviderSendEvidenceSchemaVersion, + outcome: 'send_permitted', + state: 'pre_send_recorded', +}); +const AUTHORIZED_PROVIDER_RECONCILIATION_REQUIRED = Object.freeze({ + journalSchemaVersion, + providerPlanSchemaVersion, + providerPlanCommitmentSchemaVersion: authorizedProviderPlanCommitmentSchemaVersion, + providerSendEvidenceSchemaVersion: authorizedProviderSendEvidenceSchemaVersion, + outcome: 'reconciliation_required', + state: 'provider_outcome_unknown', +}); + +async function recordAuthorizedStripeSendEvidence(input) { + const prepared = prepareAuthorizedProviderSendOperation(input); + const permittedTransactionResults = new Set(); + + try { + const result = await prepared.db.runTransaction(async (transaction) => { + const context = await readAuthorizedProviderSendContext(transaction, prepared); + if (!validateActiveProviderPlanLease(prepared, context.lifecycle)) { + reject('lease_stale'); + } + const createPermittedResult = (prePostRecordedAt, automaticRetryDeadlineAt) => { + const permitted = Object.freeze({ + transactionCheckedAt: prepared.occurredAtParts, + leaseExpiresAt: context.lifecycle.leaseExpiresAt, + planBoundAt: context.authorizedProviderPlan.boundAt, + prePostRecordedAt, + automaticRetryDeadlineAt, + }); + permittedTransactionResults.add(permitted); + return permitted; + }; + + if (context.authorizedProviderSend === null) { + if (compareTimestamps( + prepared.occurredAtParts, + context.authorizedProviderPlan.boundAt, + ) < 0) { + return AUTHORIZED_PROVIDER_RECONCILIATION_REQUIRED; + } + const pair = createAuthorizedProviderSendPair(prepared, context); + const deadline = readTimestamp(pair.record.automaticRetryDeadlineAt); + if (deadline === null) reject('journal_unavailable'); + transaction.create(prepared.authorizedProviderSendRef, pair.record); + transaction.create(prepared.authorizedProviderSendAuditRef, pair.audit); + return createPermittedResult(prepared.occurredAtParts, deadline); + } + + if (context.authorizedProviderSend.timeUnknown + || compareTimestamps( + prepared.occurredAtParts, + context.authorizedProviderSend.prePostRecordedAt, + ) < 0 + || compareTimestamps( + prepared.occurredAtParts, + context.authorizedProviderSend.automaticRetryDeadlineAt, + ) >= 0) { + return AUTHORIZED_PROVIDER_RECONCILIATION_REQUIRED; + } + return createPermittedResult( + context.authorizedProviderSend.prePostRecordedAt, + context.authorizedProviderSend.automaticRetryDeadlineAt, + ); + }, TRANSACTION_OPTIONS); + + if (result === AUTHORIZED_PROVIDER_RECONCILIATION_REQUIRED) return result; + if (!permittedTransactionResults.has(result)) reject('journal_unavailable'); + const returnedAt = capturePostTransactionTimestampParts(); + if (returnedAt === null + || compareTimestamps(returnedAt, result.transactionCheckedAt) < 0 + || compareTimestamps(returnedAt, result.planBoundAt) < 0 + || compareTimestamps(returnedAt, result.prePostRecordedAt) < 0 + || compareTimestamps(returnedAt, result.leaseExpiresAt) >= 0 + || compareTimestamps(returnedAt, result.automaticRetryDeadlineAt) >= 0) { + return AUTHORIZED_PROVIDER_RECONCILIATION_REQUIRED; + } + return AUTHORIZED_PROVIDER_SEND_PERMITTED; + } catch (error) { + if (error instanceof CommerceCommandJournalError) throw error; + reject('journal_unavailable'); + } +} + const REGISTERED_NEW = Object.freeze({ journalSchemaVersion, outcome: 'registered_new', @@ -3570,5 +4019,6 @@ module.exports = Object.freeze({ failCommerceCommand, recordInitialStripeReconciliationEvidence, recordInitialStripeSendEvidence, + recordAuthorizedStripeSendEvidence, registerCommerceCommand, }); diff --git a/functions/commerceCommandJournal.test.js b/functions/commerceCommandJournal.test.js index 9536247..ff960e3 100644 --- a/functions/commerceCommandJournal.test.js +++ b/functions/commerceCommandJournal.test.js @@ -23,6 +23,7 @@ const { bindInitialStripeProviderPlan, completeCommerceCommand, failCommerceCommand, + recordAuthorizedStripeSendEvidence, recordInitialStripeReconciliationEvidence, recordInitialStripeSendEvidence, registerCommerceCommand, @@ -111,6 +112,58 @@ function reconciliationEvidenceCommitment(record) { ]); } +function authorizedProviderPlanCommitment(record, audit) { + return testDigest('mprc.command-authorized-provider-plan-complete.v2', [ + ['version', '2'], + ['providerPlanCommitmentSchemaVersion', '2'], + ['providerPlanSchemaVersion', String(record.providerPlanSchemaVersion)], + ['commandIdentityVersion', String(record.commandIdentityVersion)], + ['commandKeyHash', record.commandKeyHash], + ['environment', record.environment], + ['provider', record.provider], + ['providerAttempt', String(record.providerAttempt)], + ['providerOperation', record.providerOperation], + ['stripeMode', record.stripeMode], + ['stripeAccountFingerprint', record.stripeAccountFingerprint], + ['stripeApiVersion', record.stripeApiVersion], + ['httpMethod', record.httpMethod], + ['endpointPath', record.endpointPath], + ['parametersFingerprint', record.parametersFingerprint], + ['idempotencyKeyFingerprint', record.idempotencyKeyFingerprint], + ['boundFenceEpoch', String(record.boundFenceEpoch)], + ['boundAtSeconds', String(record.boundAt.seconds)], + ['boundAtNanoseconds', String(record.boundAt.nanoseconds)], + [ + 'providerAttemptAuthorizationSchemaVersion', + String(record.providerAttemptAuthorizationSchemaVersion), + ], + [ + 'providerAttemptAuthorizationCommitment', + record.providerAttemptAuthorizationCommitment, + ], + ['auditProviderPlanAuditSchemaVersion', String(audit.providerPlanAuditSchemaVersion)], + ['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], + ['auditBoundFenceEpoch', String(audit.boundFenceEpoch)], + ['auditOccurredAtSeconds', String(audit.occurredAt.seconds)], + ['auditOccurredAtNanoseconds', String(audit.occurredAt.nanoseconds)], + [ + 'auditProviderAttemptAuthorizationSchemaVersion', + String(audit.providerAttemptAuthorizationSchemaVersion), + ], + [ + 'auditProviderAttemptAuthorizationCommitment', + audit.providerAttemptAuthorizationCommitment, + ], + ]); +} + function frozenRecord(entries = []) { const value = Object.create(null); for (const [key, child] of entries) value[key] = child; @@ -467,6 +520,10 @@ function authorizedProviderPlanPaths(input) { `commerce_provider_attempt_${paths.commandKeyHash}_0000000002`, ), authorizedProviderSendPath: `${providerPlanPath}/sendEvidence/first`, + authorizedProviderSendAuditPath: documentPath( + 'auditEvents', + `commerce_provider_send_${paths.commandKeyHash}_0000000002`, + ), }); } @@ -5002,6 +5059,849 @@ describe('fresh-lease authorization for one later Stripe provider attempt', () = expect(mock.store.size).toBe(0); }); }); + + describe('authorized attempt-2 pre-POST evidence', () => { + async function createAuthorizedSendFoundation(mock, options = {}) { + const foundation = await createAuthorizationFoundation(mock, options); + await authorizeNextStripeProviderAttempt(foundation.input); + await bindAuthorizedStripeProviderPlan(foundation.input); + return Object.freeze({ + input: foundation.input, + paths: authorizedProviderPlanPaths(foundation.input), + }); + } + + function expectAuthorizedSendResult(result, outcome) { + expect(result).toEqual({ + journalSchemaVersion: 1, + providerPlanSchemaVersion: 1, + providerPlanCommitmentSchemaVersion: 2, + providerSendEvidenceSchemaVersion: 2, + outcome, + state: outcome === 'send_permitted' + ? 'pre_send_recorded' + : 'provider_outcome_unknown', + }); + expect(Object.isFrozen(result)).toBe(true); + expect(Reflect.ownKeys(result).sort()).toEqual([ + 'journalSchemaVersion', + 'outcome', + 'providerPlanCommitmentSchemaVersion', + 'providerPlanSchemaVersion', + 'providerSendEvidenceSchemaVersion', + 'state', + ]); + } + + test.each([ + [ + 'never-began retry', + candidateReconciliationEvidence(), + 'retry_same_operation', + ], + [ + 'verified expired/unpaid replacement', + expiredReconciliationEvidence(), + 'replace_expired_unpaid', + ], + ])('atomically records one complete C4A-bound marker for the %s transition', async ( + _label, + reconciliationEvidence, + transitionKind, + ) => { + const mock = createMockDb(); + const { input, paths } = await createAuthorizedSendFoundation(mock, { + reconciliationEvidence, + transitionKind, + }); + const preserved = cloneStore(mock.store); + const plan = preserved.get(paths.authorizedProviderPlanPath); + const planAudit = preserved.get(paths.authorizedProviderPlanAuditPath); + const initialSend = preserved.get(paths.providerSendPath); + const initialSendAudit = preserved.get(paths.providerSendAuditPath); + + const result = await recordAuthorizedStripeSendEvidence(input); + + expectAuthorizedSendResult(result, 'send_permitted'); + const transactionRun = mock.callbackRuns.at(-1); + expect(transactionRun.transaction.get).toHaveBeenCalledTimes(17); + 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.authorizedProviderSendPath, + paths.authorizedProviderSendAuditPath, + ]); + + const record = mock.store.get(paths.authorizedProviderSendPath); + const audit = mock.store.get(paths.authorizedProviderSendAuditPath); + const completePlanCommitment = authorizedProviderPlanCommitment(plan, planAudit); + expect(record).toEqual({ + providerSendEvidenceSchemaVersion: 2, + providerPlanSchemaVersion: 1, + providerPlanCommitmentSchemaVersion: 2, + providerAttemptAuthorizationSchemaVersion: 1, + commandIdentityVersion: 1, + commandKeyHash: paths.commandKeyHash, + providerAttempt: 2, + provider: 'stripe', + providerPlanCommitment: completePlanCommitment, + providerAttemptAuthorizationCommitment: ( + plan.providerAttemptAuthorizationCommitment + ), + prePostFenceEpoch: 2, + prePostRecordedAt: expect.any(Timestamp), + automaticRetryDeadlineAt: expect.any(Timestamp), + }); + expect(audit).toEqual({ + providerSendAuditSchemaVersion: 2, + providerSendEvidenceSchemaVersion: 2, + providerPlanSchemaVersion: 1, + providerPlanCommitmentSchemaVersion: 2, + providerAttemptAuthorizationSchemaVersion: 1, + aggregateType: 'commerce_provider_send', + commandKeyHash: paths.commandKeyHash, + providerAttempt: 2, + eventType: 'provider_pre_send_recorded', + provider: 'stripe', + environment: 'test', + stripeMode: 'test', + providerOperation: PROVIDER_OPERATION, + providerPlanCommitment: completePlanCommitment, + providerAttemptAuthorizationCommitment: ( + plan.providerAttemptAuthorizationCommitment + ), + prePostFenceEpoch: 2, + automaticRetryDeadlineAt: expect.any(Timestamp), + occurredAt: expect.any(Timestamp), + }); + expect(record.prePostRecordedAt).toBe(audit.occurredAt); + expect(record.automaticRetryDeadlineAt).toBe(audit.automaticRetryDeadlineAt); + expect(record.prePostRecordedAt.toMillis()).toBe(nowMillis); + expect(record.prePostRecordedAt.toMillis()).toBeGreaterThanOrEqual( + plan.boundAt.toMillis(), + ); + expect(record.automaticRetryDeadlineAt.toMillis() + - record.prePostRecordedAt.toMillis()).toBe(PROVIDER_SEND_RETRY_WINDOW_MILLIS); + expect(Object.isFrozen(record)).toBe(true); + expect(Object.isFrozen(audit)).toBe(true); + expect(mock.store.size).toBe(preserved.size + 2); + for (const [path_, value] of preserved) expect(mock.store.get(path_)).toBe(value); + expect(mock.store.get(paths.providerSendPath)).toBe(initialSend); + expect(mock.store.get(paths.providerSendAuditPath)).toBe(initialSendAudit); + expect(mock.store.has(`${paths.commandPath}/providerAttempts/0000000003`)).toBe(false); + + const rawAttemptTwoKey = createStripeIdempotencyKey({ + stripeMode: input.stripeMode, + environment: input.environment, + providerOperation: input.providerOperation, + commandKeyHash: paths.commandKeyHash, + providerAttempt: 2, + }).stripeIdempotencyKey; + const rendered = JSON.stringify({ result, record, audit }); + for (const forbidden of [ + STRIPE_ACCOUNT_ID, + rawAttemptTwoKey, + HOSTILE_CANARY, + RAW_CALLER, + COMMAND_ID, + OTHER_LEASE_ID, + TRANSITION_RECORD_COMMITMENT, + ]) expect(rendered).not.toContain(forbidden); + for (const value of [result, record, audit]) { + expect(value).not.toHaveProperty('shouldSend'); + expect(value).not.toHaveProperty('shouldExecute'); + expect(value).not.toHaveProperty('executeProvider'); + expect(value).not.toHaveProperty('response'); + } + }); + + test('uses the independent v2 golden digest while leaving the v1 C2 pair unchanged', async () => { + const mock = createMockDb(); + const { input, paths } = await createAuthorizedSendFoundation(mock); + const initialRecord = mock.store.get(paths.providerSendPath); + const initialAudit = mock.store.get(paths.providerSendAuditPath); + const plan = mock.store.get(paths.authorizedProviderPlanPath); + const planAudit = mock.store.get(paths.authorizedProviderPlanAuditPath); + const expectedCommitment = authorizedProviderPlanCommitment(plan, planAudit); + + await recordAuthorizedStripeSendEvidence(input); + + expect(expectedCommitment).toBe( + 'eda65ceed6d371418e0bc661853e54c243c580ddf60bc1806c5fd60409791c74', + ); + expect(mock.store.get(paths.authorizedProviderSendPath).providerPlanCommitment) + .toBe(expectedCommitment); + expect(mock.store.get(paths.authorizedProviderSendAuditPath).providerPlanCommitment) + .toBe(expectedCommitment); + expect(mock.store.get(paths.providerSendPath)).toBe(initialRecord); + expect(mock.store.get(paths.providerSendAuditPath)).toBe(initialAudit); + expect(initialRecord).toMatchObject({ + providerSendEvidenceSchemaVersion: 1, + providerPlanSchemaVersion: 1, + providerAttempt: 1, + }); + expect(initialRecord).not.toHaveProperty('providerPlanCommitmentSchemaVersion'); + expect(initialRecord).not.toHaveProperty('providerAttemptAuthorizationCommitment'); + }); + + test('exact, canonically equivalent, and later-lease retries are read-only', async () => { + const mock = createMockDb(); + const { input, paths } = await createAuthorizedSendFoundation(mock); + await recordAuthorizedStripeSendEvidence(input); + const markerBefore = mock.store.get(paths.authorizedProviderSendPath); + const auditBefore = mock.store.get(paths.authorizedProviderSendAuditPath); + const planBefore = mock.store.get(paths.authorizedProviderPlanPath); + const planAuditBefore = mock.store.get(paths.authorizedProviderPlanAuditPath); + + nowMillis += 1; + expectAuthorizedSendResult( + await recordAuthorizedStripeSendEvidence(input), + 'send_permitted', + ); + expect(mock.callbackRuns.at(-1).writes).toEqual([]); + expectAuthorizedSendResult( + await recordAuthorizedStripeSendEvidence({ + ...input, + providerParameters: validProviderParameters([ + ['synthetic_reference', HOSTILE_CANARY], + ['currency', 'usd'], + ['amount_total', 2500], + ]), + }), + 'send_permitted', + ); + expect(mock.callbackRuns.at(-1).writes).toEqual([]); + + nowMillis = mock.store.get(paths.lifecyclePath).leaseExpiresAt.toMillis(); + await acquireCommerceCommandLease(leaseInput(mock.db, { leaseId: THIRD_LEASE_ID })); + nowMillis += 1; + expectAuthorizedSendResult( + await recordAuthorizedStripeSendEvidence({ + ...input, + leaseId: THIRD_LEASE_ID, + expectedFenceEpoch: 3, + }), + 'send_permitted', + ); + expect(mock.callbackRuns.at(-1).writes).toEqual([]); + expect(mock.store.get(paths.authorizedProviderSendPath)).toBe(markerBefore); + expect(mock.store.get(paths.authorizedProviderSendAuditPath)).toBe(auditBefore); + expect(mock.store.get(paths.authorizedProviderPlanPath)).toBe(planBefore); + expect(mock.store.get(paths.authorizedProviderPlanAuditPath)).toBe(planAuditBefore); + }); + + test.each([ + ['account', { stripeAccountId: OTHER_STRIPE_ACCOUNT_ID }, 'command_conflict'], + ['API version', { stripeApiVersion: '2025-07-01.basil' }, 'command_conflict'], + ['parameters', { + providerParameters: validProviderParameters([ + ['amount_total', 2600], + ['currency', 'usd'], + ['synthetic_reference', HOSTILE_CANARY], + ]), + }, 'command_conflict'], + ['authorization', { + transitionAuthorization: { + kind: 'retry_same_operation', + recordCommitment: OTHER_TRANSITION_RECORD_COMMITMENT, + }, + }, 'command_conflict'], + ['mode', { stripeMode: 'live' }, 'invalid_command_input'], + ['endpoint', { endpointPath: '/v1/payment_intents' }, 'invalid_command_input'], + ['operation', { + providerOperation: 'payment_intent_create', + endpointPath: '/v1/payment_intents', + }, 'invalid_command_input'], + ['environment', { environment: 'staging' }, 'command_not_registered'], + ])('rejects %s drift without creating or changing C4B evidence', async ( + _label, + change, + reason, + ) => { + const mock = createMockDb(); + const { input, paths } = await createAuthorizedSendFoundation(mock); + const before = cloneStore(mock.store); + + await expectSafeError( + () => recordAuthorizedStripeSendEvidence({ ...input, ...change }), + reason, + [STRIPE_ACCOUNT_ID, OTHER_STRIPE_ACCOUNT_ID], + ); + expect(mock.store).toEqual(before); + expect(mock.store.has(paths.authorizedProviderSendPath)).toBe(false); + expect(mock.store.has(paths.authorizedProviderSendAuditPath)).toBe(false); + }); + + test('transaction retries reuse one trusted time, deadline, and complete pair', async () => { + const foundation = createMockDb(); + const ready = await createAuthorizedSendFoundation(foundation); + const mock = createMockDb([...foundation.store], { callbackRuns: 4 }); + const input = { ...ready.input, db: mock.db }; + Timestamp.now.mockClear(); + + expectAuthorizedSendResult( + await recordAuthorizedStripeSendEvidence(input), + 'send_permitted', + ); + + expect(mock.callbackRuns).toHaveLength(4); + for (const { transaction, writes } of mock.callbackRuns) { + expect(transaction.get).toHaveBeenCalledTimes(17); + expect(transaction.create).toHaveBeenCalledTimes(2); + expect(writes).toHaveLength(2); + expect(writes[0].value.prePostRecordedAt).toEqual(Timestamp.fromMillis(nowMillis)); + expect(writes[0].value.automaticRetryDeadlineAt.toMillis()) + .toBe(nowMillis + PROVIDER_SEND_RETRY_WINDOW_MILLIS); + expect(writes[1].value.occurredAt).toBe(writes[0].value.prePostRecordedAt); + expect(writes[1].value.automaticRetryDeadlineAt) + .toBe(writes[0].value.automaticRetryDeadlineAt); + } + const records = mock.callbackRuns.map(({ writes }) => writes[0].value); + const audits = mock.callbackRuns.map(({ writes }) => writes[1].value); + expect(records.every((value) => JSON.stringify(value) === JSON.stringify(records[0]))) + .toBe(true); + expect(audits.every((value) => JSON.stringify(value) === JSON.stringify(audits[0]))) + .toBe(true); + expect(Timestamp.now).toHaveBeenCalledTimes(2); + }); + + test('post-transaction lease equality downgrades a committed marker before send', async () => { + const mock = createMockDb(); + const { input, paths } = await createAuthorizedSendFoundation(mock); + const checkedAt = Timestamp.fromMillis(nowMillis); + const leaseExpiry = mock.store.get(paths.lifecyclePath).leaseExpiresAt; + Timestamp.now + .mockImplementationOnce(() => checkedAt) + .mockImplementationOnce(() => leaseExpiry); + + expectAuthorizedSendResult( + await recordAuthorizedStripeSendEvidence(input), + 'reconciliation_required', + ); + expect(mock.store.has(paths.authorizedProviderSendPath)).toBe(true); + expect(mock.store.has(paths.authorizedProviderSendAuditPath)).toBe(true); + }); + + test('post-transaction deadline equality and clock rollback downgrade retries', async () => { + const mock = createMockDb(); + const { input, paths } = await createAuthorizedSendFoundation(mock); + await recordAuthorizedStripeSendEvidence(input); + const marker = mock.store.get(paths.authorizedProviderSendPath); + const deadline = marker.automaticRetryDeadlineAt; + const immediatelyBeforeDeadline = new Timestamp( + deadline.seconds, + deadline.nanoseconds - 1, + ); + const beforeMarker = new Timestamp( + marker.prePostRecordedAt.seconds, + marker.prePostRecordedAt.nanoseconds === 0 + ? 0 + : marker.prePostRecordedAt.nanoseconds - 1, + ); + + nowMillis = deadline.toMillis() - 1000; + await acquireCommerceCommandLease(leaseInput(mock.db, { leaseId: THIRD_LEASE_ID })); + const takeoverInput = { + ...input, + leaseId: THIRD_LEASE_ID, + expectedFenceEpoch: 3, + }; + const before = cloneStore(mock.store); + + Timestamp.now + .mockImplementationOnce(() => immediatelyBeforeDeadline) + .mockImplementationOnce(() => deadline); + expectAuthorizedSendResult( + await recordAuthorizedStripeSendEvidence(takeoverInput), + 'reconciliation_required', + ); + + Timestamp.now + .mockImplementationOnce(() => immediatelyBeforeDeadline) + .mockImplementationOnce(() => beforeMarker); + expectAuthorizedSendResult( + await recordAuthorizedStripeSendEvidence(takeoverInput), + 'reconciliation_required', + ); + expect(mock.store).toEqual(before); + }); + + test('unknown or hostile post-transaction time always downgrades to reconciliation', async () => { + const mock = createMockDb(); + const { input, paths } = await createAuthorizedSendFoundation(mock); + const markerTime = Timestamp.fromMillis(nowMillis); + + Timestamp.now + .mockImplementationOnce(() => markerTime) + .mockImplementationOnce(() => { + throw new Error(HOSTILE_CANARY); + }); + expectAuthorizedSendResult( + await recordAuthorizedStripeSendEvidence(input), + 'reconciliation_required', + ); + expect(mock.store.has(paths.authorizedProviderSendPath)).toBe(true); + expect(mock.store.has(paths.authorizedProviderSendAuditPath)).toBe(true); + + const before = cloneStore(mock.store); + Timestamp.now + .mockImplementationOnce(() => markerTime) + .mockImplementationOnce(() => ({ + _seconds: markerTime.seconds, + _nanoseconds: markerTime.nanoseconds, + })); + expectAuthorizedSendResult( + await recordAuthorizedStripeSendEvidence(input), + 'reconciliation_required', + ); + + const hostileTimestamp = new Proxy(markerTime, {}); + Timestamp.now + .mockImplementationOnce(() => markerTime) + .mockImplementationOnce(() => hostileTimestamp); + expectAuthorizedSendResult( + await recordAuthorizedStripeSendEvidence(input), + 'reconciliation_required', + ); + expect(mock.store).toEqual(before); + }); + + test('permits only strictly before the attempt-2 23-hour deadline at nanosecond precision', async () => { + const mock = createMockDb(); + const { input, paths } = await createAuthorizedSendFoundation(mock); + await recordAuthorizedStripeSendEvidence(input); + const marker = mock.store.get(paths.authorizedProviderSendPath); + const deadline = marker.automaticRetryDeadlineAt; + const immediatelyBeforeDeadline = new Timestamp( + deadline.seconds, + deadline.nanoseconds - 1, + ); + + nowMillis = deadline.toMillis() - 1000; + await acquireCommerceCommandLease(leaseInput(mock.db, { leaseId: THIRD_LEASE_ID })); + const takeoverInput = { + ...input, + leaseId: THIRD_LEASE_ID, + expectedFenceEpoch: 3, + }; + const before = cloneStore(mock.store); + + Timestamp.now + .mockImplementationOnce(() => immediatelyBeforeDeadline) + .mockImplementationOnce(() => immediatelyBeforeDeadline); + expectAuthorizedSendResult( + await recordAuthorizedStripeSendEvidence(takeoverInput), + 'send_permitted', + ); + Timestamp.now.mockImplementationOnce(() => deadline); + expectAuthorizedSendResult( + await recordAuthorizedStripeSendEvidence(takeoverInput), + 'reconciliation_required', + ); + Timestamp.now.mockImplementationOnce(() => new Timestamp( + deadline.seconds, + deadline.nanoseconds + 1, + )); + expectAuthorizedSendResult( + await recordAuthorizedStripeSendEvidence(takeoverInput), + 'reconciliation_required', + ); + expect(mock.store).toEqual(before); + }); + + test('lease-expiry equality and terminal lifecycle never create or reveal C4B evidence', async () => { + const mock = createMockDb(); + const { input, paths } = await createAuthorizedSendFoundation(mock); + const ready = cloneStore(mock.store); + nowMillis = mock.store.get(paths.lifecyclePath).leaseExpiresAt.toMillis(); + await expectSafeError( + () => recordAuthorizedStripeSendEvidence(input), + 'lease_stale', + ); + expect(mock.store).toEqual(ready); + + nowMillis = NOW_MILLIS; + const terminal = createMockDb(); + const terminalFoundation = await createAuthorizedSendFoundation(terminal); + nowMillis += 1; + await completeCommerceCommand(completionInput(terminal.db, { + leaseId: OTHER_LEASE_ID, + expectedFenceEpoch: 2, + })); + const terminalBefore = cloneStore(terminal.store); + await expectSafeError( + () => recordAuthorizedStripeSendEvidence(terminalFoundation.input), + 'lease_stale', + ); + expect(terminal.store).toEqual(terminalBefore); + expect(terminal.store.has( + terminalFoundation.paths.authorizedProviderSendPath, + )).toBe(false); + }); + + test('a retry-window timestamp overflow fails before Firestore', async () => { + const mock = createMockDb(); + Timestamp.now.mockImplementationOnce(() => new Timestamp(253402300790, 0)); + await expectSafeError( + () => recordAuthorizedStripeSendEvidence(providerAuthorizationInput(mock.db)), + 'journal_unavailable', + ); + expect(mock.db.runTransaction).not.toHaveBeenCalled(); + expect(mock.store.size).toBe(0); + }); + + test('requires every B1-C4A partner and never repairs a missing foundation', async () => { + const foundation = createMockDb(); + const { input, paths } = await createAuthorizedSendFoundation(foundation); + const complete = cloneStore(foundation.store); + const requiredPaths = [ + paths.commandPath, + paths.auditPath, + paths.lifecyclePath, + paths.providerPlanPath, + paths.providerPlanAuditPath, + paths.providerSendPath, + paths.providerSendAuditPath, + paths.providerReconciliationPath, + paths.providerReconciliationAuditPath, + paths.providerAuthorizationPath, + paths.providerAuthorizationAuditPath, + paths.authorizedProviderPlanPath, + paths.authorizedProviderPlanAuditPath, + lifecyclePaths(input, 3).lifecycleAuditPath, + ]; + + for (const path_ of requiredPaths) { + const seed = cloneStore(complete); + seed.delete(path_); + const mock = createMockDb([...seed]); + const before = cloneStore(mock.store); + await expectSafeError( + () => recordAuthorizedStripeSendEvidence({ ...input, db: mock.db }), + 'journal_record_invalid', + ); + expect(mock.store).toEqual(before); + expect(mock.store.has(paths.authorizedProviderSendPath)).toBe(false); + expect(mock.store.has(paths.authorizedProviderSendAuditPath)).toBe(false); + } + }); + + test('orphaned, malformed, future, or provenance-detached C4B pairs are never repaired', async () => { + const foundation = createMockDb(); + const { input, paths } = await createAuthorizedSendFoundation(foundation); + await recordAuthorizedStripeSendEvidence(input); + const complete = cloneStore(foundation.store); + const record = complete.get(paths.authorizedProviderSendPath); + const audit = complete.get(paths.authorizedProviderSendAuditPath); + const variants = []; + + const missingRecord = cloneStore(complete); + missingRecord.delete(paths.authorizedProviderSendPath); + variants.push(missingRecord); + const missingAudit = cloneStore(complete); + missingAudit.delete(paths.authorizedProviderSendAuditPath); + variants.push(missingAudit); + const extraRecord = cloneStore(complete); + extraRecord.set(paths.authorizedProviderSendPath, Object.freeze({ + ...record, + extra: HOSTILE_CANARY, + })); + variants.push(extraRecord); + const futureRecord = cloneStore(complete); + futureRecord.set(paths.authorizedProviderSendPath, Object.freeze({ + ...record, + providerSendEvidenceSchemaVersion: 3, + })); + variants.push(futureRecord); + const futureAudit = cloneStore(complete); + futureAudit.set(paths.authorizedProviderSendAuditPath, Object.freeze({ + ...audit, + providerSendAuditSchemaVersion: 3, + })); + variants.push(futureAudit); + const wrongCommitment = cloneStore(complete); + wrongCommitment.set(paths.authorizedProviderSendPath, Object.freeze({ + ...record, + providerPlanCommitment: 'a'.repeat(64), + })); + wrongCommitment.set(paths.authorizedProviderSendAuditPath, Object.freeze({ + ...audit, + providerPlanCommitment: 'a'.repeat(64), + })); + variants.push(wrongCommitment); + const detached = cloneStore(complete); + detached.set(paths.authorizedProviderSendPath, Object.freeze({ + ...record, + providerAttemptAuthorizationCommitment: 'b'.repeat(64), + })); + detached.set(paths.authorizedProviderSendAuditPath, Object.freeze({ + ...audit, + providerAttemptAuthorizationCommitment: 'b'.repeat(64), + })); + variants.push(detached); + const wrongAttempt = cloneStore(complete); + wrongAttempt.set(paths.authorizedProviderSendPath, Object.freeze({ + ...record, + providerAttempt: 1, + })); + wrongAttempt.set(paths.authorizedProviderSendAuditPath, Object.freeze({ + ...audit, + providerAttempt: 1, + })); + variants.push(wrongAttempt); + + for (const seed of variants) { + const mock = createMockDb([...seed]); + const before = cloneStore(mock.store); + nowMillis += 1; + await expectSafeError( + () => recordAuthorizedStripeSendEvidence({ ...input, db: mock.db }), + 'journal_record_invalid', + ); + expect(mock.store).toEqual(before); + expect(mock.callbackRuns.at(-1).transaction.create).not.toHaveBeenCalled(); + } + }); + + test('missing, unreadable, mismatched, or future paired times reconcile without mutation', async () => { + const foundation = createMockDb(); + const { input, paths } = await createAuthorizedSendFoundation(foundation); + await recordAuthorizedStripeSendEvidence(input); + const complete = cloneStore(foundation.store); + const record = complete.get(paths.authorizedProviderSendPath); + const audit = complete.get(paths.authorizedProviderSendAuditPath); + const futureTime = Timestamp.fromMillis(record.prePostRecordedAt.toMillis() + 1000); + const futureDeadline = Timestamp.fromMillis( + record.automaticRetryDeadlineAt.toMillis() + 1000, + ); + const variants = [ + (() => { + const nextRecord = { ...record }; + const nextAudit = { ...audit }; + delete nextRecord.prePostRecordedAt; + delete nextAudit.occurredAt; + return [Object.freeze(nextRecord), Object.freeze(nextAudit)]; + })(), + [Object.freeze({ + ...record, + automaticRetryDeadlineAt: { _seconds: 1, _nanoseconds: 0 }, + }), audit], + [record, Object.freeze({ + ...audit, + occurredAt: Timestamp.fromMillis(record.prePostRecordedAt.toMillis() + 1), + })], + [Object.freeze({ + ...record, + prePostRecordedAt: futureTime, + automaticRetryDeadlineAt: futureDeadline, + }), Object.freeze({ + ...audit, + occurredAt: futureTime, + automaticRetryDeadlineAt: futureDeadline, + })], + ]; + + for (const [nextRecord, nextAudit] of variants) { + const seed = cloneStore(complete); + seed.set(paths.authorizedProviderSendPath, nextRecord); + seed.set(paths.authorizedProviderSendAuditPath, nextAudit); + const mock = createMockDb([...seed]); + const before = cloneStore(mock.store); + expectAuthorizedSendResult( + await recordAuthorizedStripeSendEvidence({ ...input, db: mock.db }), + 'reconciliation_required', + ); + expect(mock.store).toEqual(before); + expect(mock.callbackRuns.at(-1).writes).toEqual([]); + } + }); + + test('a self-consistent replacement C4A pair cannot reuse surviving C4B evidence', async () => { + const mock = createMockDb(); + const { input, paths } = await createAuthorizedSendFoundation(mock); + await recordAuthorizedStripeSendEvidence(input); + const markerBefore = mock.store.get(paths.authorizedProviderSendPath); + const markerAuditBefore = mock.store.get(paths.authorizedProviderSendAuditPath); + const plan = mock.store.get(paths.authorizedProviderPlanPath); + const planAudit = mock.store.get(paths.authorizedProviderPlanAuditPath); + const replacedAt = Timestamp.fromMillis(plan.boundAt.toMillis() + 1); + mock.store.set(paths.authorizedProviderPlanPath, Object.freeze({ + ...plan, + boundAt: replacedAt, + })); + mock.store.set(paths.authorizedProviderPlanAuditPath, Object.freeze({ + ...planAudit, + occurredAt: replacedAt, + })); + const before = cloneStore(mock.store); + nowMillis = replacedAt.toMillis() + 1; + + await expectSafeError( + () => recordAuthorizedStripeSendEvidence(input), + 'journal_record_invalid', + ); + expect(mock.store).toEqual(before); + expect(mock.store.get(paths.authorizedProviderSendPath)).toBe(markerBefore); + expect(mock.store.get(paths.authorizedProviderSendAuditPath)).toBe(markerAuditBefore); + }); + + test('wrong holder, wrong fence, and rolled-back time cannot create C4B evidence', async () => { + const mock = createMockDb(); + const { input, paths } = await createAuthorizedSendFoundation(mock); + const before = cloneStore(mock.store); + for (const change of [ + { leaseId: THIRD_LEASE_ID }, + { expectedFenceEpoch: 1 }, + { expectedFenceEpoch: 3 }, + { leaseId: THIRD_LEASE_ID, expectedFenceEpoch: 3 }, + ]) { + await expectSafeError( + () => recordAuthorizedStripeSendEvidence({ ...input, ...change }), + 'lease_stale', + ); + expect(mock.store).toEqual(before); + } + nowMillis = mock.store.get(paths.authorizedProviderPlanPath).boundAt.toMillis() - 1; + await expectSafeError( + () => recordAuthorizedStripeSendEvidence(input), + 'lease_stale', + ); + expect(mock.store).toEqual(before); + expect(mock.store.has(paths.authorizedProviderSendPath)).toBe(false); + }); + + test('commit failure is atomic, lost acknowledgement recovers, and forged output fails', async () => { + const foundation = createMockDb(); + const { input, paths } = await createAuthorizedSendFoundation(foundation); + + const commitFailure = createMockDb([...foundation.store], { + rejectCommit: new Error(`${HOSTILE_CANARY}/${STRIPE_ACCOUNT_ID}`), + }); + const commitBefore = cloneStore(commitFailure.store); + await expectSafeError( + () => recordAuthorizedStripeSendEvidence({ ...input, db: commitFailure.db }), + 'journal_unavailable', + [STRIPE_ACCOUNT_ID], + ); + expect(commitFailure.store).toEqual(commitBefore); + expect(commitFailure.store.has(paths.authorizedProviderSendPath)).toBe(false); + expect(commitFailure.store.has(paths.authorizedProviderSendAuditPath)).toBe(false); + + const lostAcknowledgement = createMockDb([...foundation.store], { + rejectAfterCommit: new Error(`${HOSTILE_CANARY}/${STRIPE_ACCOUNT_ID}`), + }); + await expectSafeError( + () => recordAuthorizedStripeSendEvidence({ ...input, db: lostAcknowledgement.db }), + 'journal_unavailable', + [STRIPE_ACCOUNT_ID], + ); + expect(lostAcknowledgement.store.has(paths.authorizedProviderSendPath)).toBe(true); + expect(lostAcknowledgement.store.has(paths.authorizedProviderSendAuditPath)).toBe(true); + nowMillis += 1; + expectAuthorizedSendResult( + await recordAuthorizedStripeSendEvidence({ + ...input, + db: lostAcknowledgement.db, + }), + 'send_permitted', + ); + expect(lostAcknowledgement.callbackRuns.at(-1).writes).toEqual([]); + + const forged = createMockDb([...foundation.store], { + returnedValue: Object.freeze({ + outcome: HOSTILE_CANARY, + shouldSend: true, + }), + }); + await expectSafeError( + () => recordAuthorizedStripeSendEvidence({ ...input, db: forged.db }), + 'journal_unavailable', + ); + expect(forged.store.has(paths.authorizedProviderSendPath)).toBe(true); + expect(forged.store.has(paths.authorizedProviderSendAuditPath)).toBe(true); + }); + + test('rejects caller-selected send, key, time, result, and shape controls before Firestore', async () => { + const mock = createMockDb(); + const base = providerAuthorizationInput(mock.db); + const missing = { ...base }; + delete missing.transitionAuthorization; + const inherited = Object.assign(Object.create({ inherited: true }), base); + const badInputs = [ + { ...base, providerAttempt: 2 }, + { ...base, stripeIdempotencyKey: HOSTILE_CANARY }, + { ...base, prePostRecordedAt: Timestamp.fromMillis(NOW_MILLIS) }, + { ...base, automaticRetryDeadlineAt: Timestamp.fromMillis(NOW_MILLIS) }, + { ...base, retryWindowSeconds: 23 * 60 * 60 }, + { ...base, providerPlanCommitment: 'a'.repeat(64) }, + { ...base, providerPlanCommitmentSchemaVersion: 2 }, + { ...base, providerSendEvidenceSchemaVersion: 2 }, + { ...base, shouldSend: true }, + { ...base, executeProvider: true }, + { ...base, providerOutcome: 'timeout' }, + { ...base, providerResponse: { id: HOSTILE_CANARY } }, + { ...base, providerHttpStatus: 500 }, + missing, + inherited, + new Proxy(base, {}), + ]; + + for (const badInput of badInputs) { + await expectSafeError( + () => recordAuthorizedStripeSendEvidence(badInput), + 'invalid_command_input', + ); + } + expect(mock.db.runTransaction).not.toHaveBeenCalled(); + expect(mock.store.size).toBe(0); + }); + + test('stored C4B accessors and proxies fail without invoking traps or mutating evidence', async () => { + const foundation = createMockDb(); + const { input, paths } = await createAuthorizedSendFoundation(foundation); + await recordAuthorizedStripeSendEvidence(input); + + let getterCalls = 0; + const accessor = { ...foundation.store.get(paths.authorizedProviderSendPath) }; + Object.defineProperty(accessor, 'provider', { + enumerable: true, + get() { + getterCalls += 1; + return HOSTILE_CANARY; + }, + }); + let mock = createMockDb([...foundation.store]); + mock.store.set(paths.authorizedProviderSendPath, accessor); + const accessorBefore = cloneStore(mock.store); + await expectSafeError( + () => recordAuthorizedStripeSendEvidence({ ...input, db: mock.db }), + 'journal_record_invalid', + ); + expect(getterCalls).toBe(0); + expect(mock.store).toEqual(accessorBefore); + + let trapCalls = 0; + const proxy = new Proxy( + foundation.store.get(paths.authorizedProviderSendAuditPath), + { + ownKeys() { + trapCalls += 1; + return []; + }, + }, + ); + mock = createMockDb([...foundation.store]); + mock.store.set(paths.authorizedProviderSendAuditPath, proxy); + const proxyBefore = cloneStore(mock.store); + await expectSafeError( + () => recordAuthorizedStripeSendEvidence({ ...input, db: mock.db }), + 'journal_record_invalid', + ); + expect(trapCalls).toBe(0); + expect(mock.store).toEqual(proxyBefore); + }); + }); }); describe('exact existing identity and partner validation', () => { @@ -5274,6 +6174,7 @@ describe('closed source and export boundary', () => { 'providerAttemptAuthorizationSchemaVersion', 'providerReconciliationEvidenceSchemaVersion', 'providerSendEvidenceSchemaVersion', + 'recordAuthorizedStripeSendEvidence', 'recordInitialStripeReconciliationEvidence', 'recordInitialStripeSendEvidence', 'registerCommerceCommand', @@ -5326,8 +6227,9 @@ describe('closed source and export boundary', () => { '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(')); + expect((source.match(/Timestamp\.now\(\)/g) || [])).toHaveLength(2); + expect(source.lastIndexOf('Timestamp.now()')) + .toBeLessThan(source.indexOf('.runTransaction(')); }); test('is not imported by the Functions index or any runtime module', () => { diff --git a/tests/firestore-rules/shop-and-locked.test.js b/tests/firestore-rules/shop-and-locked.test.js index c9615d1..97774b0 100644 --- a/tests/firestore-rules/shop-and-locked.test.js +++ b/tests/firestore-rules/shop-and-locked.test.js @@ -529,6 +529,79 @@ 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 authorized attempt-2 send evidence — %s', (_label, auth) => { + const existingHash = '8'.repeat(64); + const newHash = '9'.repeat(64); + const existingPath = `checkoutRequests/${existingHash}/providerAttempts/0000000002/` + + 'sendEvidence/first'; + const newPath = `checkoutRequests/${newHash}/providerAttempts/0000000002/` + + 'sendEvidence/first'; + const collectionPath = `checkoutRequests/${existingHash}/providerAttempts/0000000002/` + + 'sendEvidence'; + const existingAuditPath = 'auditEvents/' + + `commerce_provider_send_${existingHash}_0000000002`; + const newAuditPath = 'auditEvents/' + + `commerce_provider_send_${newHash}_0000000002`; + const evidence = { + providerSendEvidenceSchemaVersion: 2, + providerPlanCommitmentSchemaVersion: 2, + providerAttemptAuthorizationSchemaVersion: 1, + commandKeyHash: existingHash, + providerAttempt: 2, + provider: 'stripe', + providerPlanCommitment: 'c'.repeat(64), + providerAttemptAuthorizationCommitment: 'd'.repeat(64), + prePostFenceEpoch: 2, + }; + const audit = { + providerSendAuditSchemaVersion: 2, + providerPlanCommitmentSchemaVersion: 2, + providerAttemptAuthorizationSchemaVersion: 1, + commandKeyHash: existingHash, + providerAttempt: 2, + eventType: 'provider_pre_send_recorded', + providerPlanCommitment: 'c'.repeat(64), + providerAttemptAuthorizationCommitment: 'd'.repeat(64), + }; + + test( + 'CANNOT read, create, update, delete, list, or collection-group-list the marker', + async () => { + await seed(existingPath, evidence); + const client = await db(auth); + + await assertFails(client.doc(existingPath).get()); + await assertFails(client.doc(newPath).set({ + ...evidence, + commandKeyHash: newHash, + })); + await assertFails(client.doc(existingPath).update({ prePostFenceEpoch: 3 })); + await assertFails(client.doc(existingPath).delete()); + await assertFails(client.collection(collectionPath).get()); + await assertFails(client.collectionGroup('sendEvidence').get()); + }, + ); + + test('CANNOT read, create, update, delete, or list the deterministic audit', 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({ providerAttempt: 3 })); + await assertFails(client.doc(existingAuditPath).delete()); + await assertFails(client.collection('auditEvents').get()); + await assertFails(client.collectionGroup('auditEvents').get()); + }); + }); + describe.each([ ['anonymous', undefined], ['member', { uid: 'member-1', role: 'member', emailVerified: true }],