From f43237c2952e7ab334f1c2ad4d4b0ed6eb09cd69 Mon Sep 17 00:00:00 2001 From: Dave Liu <7david12liu@gmail.com> Date: Mon, 13 Jul 2026 15:01:53 -0700 Subject: [PATCH] feat: freeze Stripe reconciliation policy (#184) --- GITHUB_ISSUES.md | 10 +- GITHUB_ISSUE_SLICES.md | 20 +- IMPLEMENTATION_PLAN.md | 4 +- SECURITY.md | 4 +- STRIPE_COMMERCE_DESIGN.md | 26 +- SYSTEM_DESIGN.md | 10 +- functions/commerceProviderReconciliation.js | 289 +++++++++++ .../commerceProviderReconciliation.test.js | 491 ++++++++++++++++++ 8 files changed, 831 insertions(+), 23 deletions(-) create mode 100644 functions/commerceProviderReconciliation.js create mode 100644 functions/commerceProviderReconciliation.test.js diff --git a/GITHUB_ISSUES.md b/GITHUB_ISSUES.md index ff7a329e..9a94c3a8 100644 --- a/GITHUB_ISSUES.md +++ b/GITHUB_ISSUES.md @@ -62,7 +62,7 @@ Every issue inherits `AGENTS.md` and the definition of done in `IMPLEMENTATION_P | 10 | ABUSE-001 | Enforce native App Check and privacy-preserving abuse limits | P0 | L | partial: Enterprise browser source/tests tracked in [#159](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/159); provider config, native enforcement, rate limits, and live proof open | CI-001 baseline | | 11 | PAY-001 | Add strict request schemas and immutable monetary snapshots | P0 | L | partial: PAY-001A source/tests tracked in [#157](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/157); B/C/D and endpoint adoption open | ABUSE-001 interface agreed | | 12 | PROMO-001 | Disable unmodeled Stripe promotions until discounts are authoritative | P0 | S | source/tests complete under open GitHub issue #102; provider inventory and deployment remain owner action | PAY-001 for any future discount contract | -| 13 | PAY-002 | Implement idempotent payment commands and explicit state machines | P0 | L | partial: PAY-002A1 pure state source/tests tracked in [#161](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/161); PAY-002B1 pure command identity tracked in [#163](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/163); registered-only journal pair tracked in PAY-002B2A [#165](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/165); PAY-002B2B lease/fence source/tests tracked in [#169](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/169); PAY-002B2C1 immutable initial plan tracked in [#173](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/173); PAY-002B2C2 pre-send evidence/cutoff tracked in [#182](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/182); reconciliation/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); evidence persistence/authorization/runtime/migration/sagas open | CONFIG-001, PAY-001 | | 14 | PAY-003 | Build idempotent, async-aware Stripe webhook ingestion | P0 | L | PAY-003A source merged in #101; PAY-003B/C remain open; not deployed | CONFIG-001, PAY-001/PAY-002 target contract | | 15 | RACE-001 | Add transactional race-capacity reservations | P0 | L | proposed | PAY-002, PAY-003 event contract | | 16 | MERCH-001 | Add SKU variants and transactional inventory reservations | P0 | L | proposed | PAY-002, PAY-003 event contract | @@ -624,7 +624,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). Verified reconciliation, safe attempt advancement, runtime adoption, real legacy inventory/migration, and checkout sagas remain open. +**Status:** Partial. PAY-002A1 pure state source/tests are tracked in live [#161](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/161). PAY-002B1 pure command-identity source/tests are tracked in live [#163](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/163). PAY-002B2A registered-only journal source/tests are tracked in live [#165](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/165). PAY-002B2B lease/fence source and tests are tracked in live [#169](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/169). PAY-002B2C1 immutable initial-plan source/tests are tracked in live [#173](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/173). PAY-002B2C2 pre-POST evidence and retry-cutoff source/tests are tracked in live [#182](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/182). PAY-002B2C3A pure closed reconciliation-decision source/tests are tracked in live [#184](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/184). Verified evidence persistence, safe attempt advancement, runtime adoption, real legacy inventory/migration, and checkout sagas remain open. **Depends on:** CONFIG-001 and PAY-001 ### Current atomic boundary @@ -639,9 +639,11 @@ PAY-002B2C1 [#173](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/173) ow PAY-002B2C2 [#182](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/182) owns only the unused pre-POST marker, deterministic audit, and fixed 23-hour automatic-retry cutoff. It preserves B2A, B2B, and C1 records byte-for-byte. Under the exact current lease/fence and exact attempt-1 plan, `recordInitialStripeSendEvidence` creates `providerAttempts/0000000001/sendEvidence/first` plus `auditEvents/commerce_provider_send_{commandKeyHash}_0000000001`, or creates neither. Both bind a command-bound digest of every immutable C1 plan field. The first atomic creation, and an exact retry, return fixed `send_permitted` only when a post-transaction trusted-time check remains strictly before the transaction-validated lease's captured expiry and the persisted deadline. That second check does not re-read lifecycle state. Lease/deadline equality, later time, clock rollback, paired missing/unreadable time, or a changed/replaced C1 plan returns fixed `reconciliation_required` or fails closed without advancing. These classifications are retry-safety evidence only: they do not authorize a caller, prove the configured account, report a Stripe outcome, make a request, advance to attempt `2`, or replay a result. -PAY-002B2C3 owns verified reconciliation evidence and safe later-attempt authorization. PAY-002C/D, PAY-003B, RACE-001B, MERCH-001B, PAY-004, and PAY-005 depend on the completed B2C chain. +PAY-002B2C3A [#184](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/184) owns only the unused pure closed attempt-1 evidence policy. Its exact flat version-1 record contains closed evidence categories and no provider-object, business, member, account, metadata, amount, timestamp, URL, free-text, or raw-status-code identifier/payload value. Only the complete matching tuple for trusted proof that endpoint execution never began, or the complete matching tuple for an exact verified expired/unpaid Session with verified expiry and an explicitly eligible new logical generation, returns `new_attempt_candidate`. The complete matching exact-open or verified-success tuple returns `existing_attempt_found`; every single-field difference, timeout, lost connection, provider error, old/pruned key, missing/not-found object, empty/partial search, unknown/processing state, mismatch, or conflict returns `reconciliation_required`. All outputs are fixed/frozen and non-identifying. -B2A/B2B/C1/C2 add no TTL. Deleting both C2 partners would look like first use, so [#110](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/110) must approve retention and a server-only tombstone or equivalent durable duplicate barrier before any journal record is removed. CI-001B3 [#167](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/167) runs the exact demo-project/opt-in command-journal suite as a separate hosted release prerequisite; #169, #173, and #182 expand that same file. Hosted results remain synthetic source proof only. The current foundations are unused: they add no endpoint/index export, Firebase deployment, Stripe/provider configuration, production read/write, website change, or officer behavior. +C3A is not a reconciler or authorization boundary. It stores nothing, calls nothing, has no runtime/index import, cannot send/execute/advance or create attempt `2`, and does not replay a provider result. C3B must persist immutable paired verified evidence; C3C must separately authorize/version a later attempt only after that proof, an allowed business transition, and a fresh lease. PAY-002C/D, PAY-003B, RACE-001B, MERCH-001B, PAY-004, and PAY-005 depend on the completed B2C chain. + +B2A/B2B/C1/C2 add no TTL. Deleting both C2 partners would look like first use, so [#110](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/110) must approve retention and a server-only tombstone or equivalent durable duplicate barrier before any journal record is removed. CI-001B3 [#167](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/167) runs the exact demo-project/opt-in command-journal suite as a separate hosted release prerequisite; #169, #173, and #182 expand that same file. Hosted results remain synthetic source proof only. The current foundations, including C3A, are unused: they add no endpoint/index export, Firebase deployment, Stripe/provider configuration, production read/write, website change, or officer behavior. ### Problem diff --git a/GITHUB_ISSUE_SLICES.md b/GITHUB_ISSUE_SLICES.md index cb19d666..16748389 100644 --- a/GITHUB_ISSUE_SLICES.md +++ b/GITHUB_ISSUE_SLICES.md @@ -68,22 +68,24 @@ An agent-ready child has one observable outcome, one trust boundary, explicit de | PAY-002B2B | source/tests tracked in live [#169](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/169); PAY-002B2A/#165 and CI-001B3/#167; unused/no runtime or deploy | Preserve the immutable B2A root/audit and add separate `lifecycle/current` state with a fixed 60-second server lease, command-bound UUID-holder fingerprint, monotonic fence, terminal commitment/fixed final failure, and append-oriented transition audit. The lease grants no provider-send permission; the commitment is not result proof or replay. | Mock plus #167-hosted emulator races for acquire/recovery/busy/expiry/takeover/stale fence/terminal conflict, exact read-only retry, malformed partner, and immutable B2A records; explicit browser deny tests. | | PAY-002B2C1 | source/tests tracked in live [#173](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/173); PAY-002B2B/#169; unused/no runtime or deploy | While the exact current lease/fence is active, atomically bind one immutable attempt-1 Stripe account/mode/API-version/static `checkout_session_create` POST mapping/parameters/key plan and deterministic audit. Reject arbitrary or object-ID-bearing paths. Store only command-bound commitments; this is not account proof or send permission. | Mock plus #167-hosted emulator races for exact bind/recovery/conflict/expiry/takeover/malformed partners and byte-preserved B2A/B2B records; explicit browser-denial tests. | | PAY-002B2C2 | source/tests tracked in live [#182](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/182); PAY-002B2C1/#173 and CI-001B3/#167; unused/no runtime or deploy | Recompute the exact B1/C1 attempt-1 plan under the current lease/fence, then atomically create a separate pre-POST marker and deterministic audit. Bind both to a command-bound digest of every immutable C1 plan field. The persisted version-1 deadline is 23 hours after the marker. The first atomic creation, and an exact retry, return fixed `send_permitted` only after a post-transaction trusted-time check remains before the transaction-validated lease's captured expiry and deadline; this does not re-read lifecycle state. Equality, later time, rollback, missing/unreadable paired time, or plan replacement reconciles or fails closed. Never create attempt 2 or make a provider request. | Mock and #167-hosted emulator concurrency, post-transaction lease expiry, self-consistent plan replacement, commit-failure, lost-response, takeover, nanosecond cutoff, unknown-time, timeout/connection-loss/5xx ambiguity, conflict, malformed-partner, byte-preservation, redaction, static no-call/random, and explicit browser-denial tests. | -| PAY-002B2C3 | proposed; PAY-002B2C2/#182 and the merged PAY-003A/#101 evidence vocabulary | Append verified reconciliation evidence and permit a later attempt only after proven non-execution or an explicitly safe corrected/new logical operation. Keep every prior attempt immutable. PAY-003B may later consume this result; C3 does not wait on PAY-003B. | Closed evidence-reason matrix; unsafe timeout/5xx/old-key/missing-reference/partial-search advancement denied; synthetic provider/business reconciliation tests. | -| PAY-002C | proposed; PAY-001B, PAY-002B2C3, 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-002B2C3, 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-002B2C3A | source/tests tracked in live [#184](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/184); PAY-002B2C2/#182 and merged PAY-003A/#101 vocabulary; unused/no runtime or deploy | Define only the pure exact flat version-1 Stripe attempt-1 evidence classifier. Closed enums produce fixed frozen `existing_attempt_found`, `new_attempt_candidate`, or `reconciliation_required`; a candidate is never send/advance permission. Only verified never-began or exact verified expired/unpaid evidence can be a candidate. | Exhaust the closed matrix and every single-field mutation; timeout/connection/provider-error/old-key/missing-reference/not-found/empty-or-partial-search/unknown/conflict advancement denied; hostile shape/proxy/accessor/Unicode/redaction/no-side-effect/static no-adoption tests. | +| PAY-002B2C3B | proposed; PAY-002B2C3A/#184 | Append one immutable paired reconciliation evidence record/audit only after a trusted verifier produces an exact C3A input. Keep all prior command, lifecycle, plan, send, and provider-attempt evidence immutable. Do not authorize or create attempt `2`. | Mock plus emulator atomic-pair/exact-retry/orphan/conflict/concurrency/immutability/browser-denial tests; verifier inputs are synthetic and contain no production/provider data. | +| PAY-002B2C3C | proposed; PAY-002B2C3B | Under a fresh lease and allowed business transition, consume the persisted C3B result to authorize and version a later provider attempt. Never advance on candidate classification alone; keep every earlier attempt immutable. PAY-003B may later consume this result. | Safe never-began and expired/unpaid advancement tests plus timeout/5xx/old-key/missing-reference/partial-search/stale-lease/conflict denial and retry/concurrency invariants. | +| PAY-002C | proposed; PAY-001B, PAY-002B2C3C, RACE-001B | Convert race checkout to persistence-first record/hold plus deterministic Stripe Session saga. | One record/Session on retry at every failure boundary. | +| PAY-002D | proposed; PAY-001C, PAY-002B2C3C, MERCH-001B | Convert merchandise/catalog creation to persistence-first order/hold plus deterministic Stripe keys. | One order/Session/catalog object on retry; failure recovery tests. | ## Webhooks, capacity, inventory, and cancellation | ID | Status/dependency | Bounded deliverable | Close evidence | | --- | --- | --- | --- | | PAY-003A | source merged in [#101](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/101) via [PR #140](https://github.com/Run-MPRC/Run-MPRC.github.io/pull/140); not deployed | Review/land current ingress slice: signature/method, explicit livemode, payment mode, event ledger, reference resolution, money/adjustment checks, monotonic refunds, terminal guards, quarantine, generic errors, webhook-only secret. | Node 20 Functions lint and signed positive/negative/duplicate/out-of-order unit suite passed; protected deployment, Stripe destination rehearsal, and live verification remain open. | -| PAY-003B | proposed; PAY-002A1, PAY-002B2C3, PAY-002C/D, RACE-001B, MERCH-001B; PAY-002A2/#113 before any legacy/provider-path adoption | Inventory/validate the Stripe endpoint Event API version, move handling to strict versioned schemas/canonical reducers, and atomically finalize/release counters and outbox projections. Same-state Events must still apply monotonic amount/evidence changes after Event-ID dedupe. | Versioned/mismatch signed fixtures, emulator transactions for both domains, distinct duplicates, reorder, direct async failure, same-state higher partial-refund total, inquiry escalation, late win, and terminal cases. | +| PAY-003B | proposed; PAY-002A1, PAY-002B2C3C, PAY-002C/D, RACE-001B, MERCH-001B; PAY-002A2/#113 before any legacy/provider-path adoption | Inventory/validate the Stripe endpoint Event API version, move handling to strict versioned schemas/canonical reducers, and atomically finalize/release counters and outbox projections. Same-state Events must still apply monotonic amount/evidence changes after Event-ID dedupe. | Versioned/mismatch signed fixtures, emulator transactions for both domains, distinct duplicates, reorder, direct async failure, same-state higher partial-refund total, inquiry escalation, late win, and terminal cases. | | PAY-003C | owner_action; A/B, CI-001A | Add retry/quarantine worker/lease semantics, TTL/dead-letter monitoring, and Stripe CLI test-mode delivery rehearsal. | Redacted signed immediate/delayed/retry/rotation evidence and alerts. | | RACE-001A | proposed; PAY-002A1, PAY-002A2 for legacy/backfill adoption | Add capacity/hold schema, expiry/indexes, compatibility reader, and dry-run counter backfill. | Full/closed/legacy fixture report and idempotent rerun. | -| RACE-001B | proposed; RACE-001A, PAY-002B2C3 | Implement transaction-safe reserve/confirm/release across paid/free/comp/volunteer/admin paths. | Repeated concurrent last-seat, duplicate, expiry and nonnegative-counter tests. | +| RACE-001B | proposed; RACE-001A, PAY-002B2C3C | Implement transaction-safe reserve/confirm/release across paid/free/comp/volunteer/admin paths. | Repeated concurrent last-seat, duplicate, expiry and nonnegative-counter tests. | | MERCH-001A | proposed; PAY-001C, PAY-002A1, PAY-002A2 for legacy/backfill adoption | Add canonical SKU/variant and conventional `onHand`, `reserved`, `sold` schema plus catalog migration: `available = onHand - reserved`. | Legacy/size/color/zero-stock migration fixtures and invariant report. | -| MERCH-001B | proposed; A, PAY-002B2C3 | Implement deterministic multi-SKU reserve; on paid decrement `reserved` and `onHand`, increment `sold`; release only `reserved`; approved restock increments `onHand`. | Concurrent last-SKU/multi-line/retry/return tests; no negative stock. | -| PAY-004A | proposed; PAY-002B2C3, PAY-002C, RACE-001B | Implement one-record cancellation that expires Session/releases hold with already-paid/expired/network race recovery. | Retry and payment/cancel race tests; no resurrection. | +| MERCH-001B | proposed; A, PAY-002B2C3C | Implement deterministic multi-SKU reserve; on paid decrement `reserved` and `onHand`, increment `sold`; release only `reserved`; approved restock increments `onHand`. | Concurrent last-SKU/multi-line/retry/return tests; no negative stock. | +| PAY-004A | proposed; PAY-002B2C3C, PAY-002C, RACE-001B | Implement one-record cancellation that expires Session/releases hold with already-paid/expired/network race recovery. | Retry and payment/cancel race tests; no resurrection. | | PAY-004B | proposed; A, RACE/MERCH services | Implement bulk event/product closure as bounded idempotent jobs over active Sessions/holds. | Partial failure/resume/rerun report. | | PAY-004C | proposed; PAY-002C, PAY-003B | Replace late reusable Payment Links with one-off Checkout; inventory/deactivate/reconcile legacy links. | Retry/reuse/expiry/payment tests; owner performs Dashboard deactivation. | @@ -91,7 +93,7 @@ An agent-ready child has one observable outcome, one trust boundary, explicit de | ID | Status/dependency | Bounded deliverable | Close evidence | | --- | --- | --- | --- | -| PAY-005A | proposed; PAY-001D, PAY-002A1, PAY-002B2C3, PAY-003B | Add idempotent refund commands, remaining-balance/concurrency validation, stable Stripe keys, and event-finalized totals. | Same/conflicting/concurrent partial/full/timeout and repeated `partially_refunded` status with increasing confirmed total tests. | +| PAY-005A | proposed; PAY-001D, PAY-002A1, PAY-002B2C3C, PAY-003B | Add idempotent refund commands, remaining-balance/concurrency validation, stable Stripe keys, and event-finalized totals. | Same/conflicting/concurrent partial/full/timeout and repeated `partially_refunded` status with increasing confirmed total tests. | | PAY-005B | proposed; PAY-003B | Model per-dispute lifecycle for orders and registrations with finance alert references. | Duplicate/reordered warning lifecycle, inquiry escalation, created/updated/lost/won, rare lost-to-won late win, and `prevented` only under an explicitly validated upgraded webhook Event version. | | PAY-005C | proposed; A/B | Build read-only scheduled/operator reconciliation and mismatch/quarantine report. | Orphan/mismatch/refund/event/counter synthetic report. | | PAY-005D | proposed; C, AUTH-003B | Build separately authorized idempotent repair commands with reason and append audit. | Dry-run/apply/rerun and forbidden-capability tests. | @@ -100,7 +102,7 @@ An agent-ready child has one observable outcome, one trust boundary, explicit de | DATA-001A | proposed; PAY-002C/D | Replace confirmation query bearer tokens with verified Session/UID projection; hash/expire/scrub anonymous free-flow capabilities. | Cross-user/guess/replay/expiry/history/referrer tests. | | DATA-001B | blocked_owner_decision; LEGAL-001 | Define classification/purpose projections and remove PII from logs/monitoring/admin views. | Owner-approved matrix and projection/redaction snapshots. | | DATA-001C | blocked_owner_decision; B, RESILIENCE-001A | Add dry-run-first retention/minimization and authenticated access/deletion workflows. | Date/status/hold/cross-user/dry-run/apply/rerun tests. | -| ADMIN-001A | proposed; AUTH-003B, PAY-001A, PAY-002B2B | Build common privileged command middleware and append-only audit. Provider-calling commands additionally require PAY-002B2C3. | Capability/auth/schema/idempotency/audit tests. | +| ADMIN-001A | proposed; AUTH-003B, PAY-001A, PAY-002B2B | Build common privileged command middleware and append-only audit. Provider-calling commands additionally require PAY-002B2C3C. | Capability/auth/schema/idempotency/audit tests. | | ADMIN-001B | proposed; A, RACE-001A | Move event/registration configuration and lifecycle operations to scoped APIs. | Field/state/capability tests and UI parity. | | ADMIN-001C | proposed; A, MERCH-001A | Move catalog/order/inventory/fulfillment operations to scoped APIs. | Field/state/capability/inventory tests and UI parity. | | ADMIN-001D | proposed; A, DATA-001B | Add purpose-specific minimum check-in/emergency/finance/fulfillment exports with re-auth/reason/bounds/formula defense/audit. | Column/PII/large/formula/capability snapshots. | diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index e40894dd..309078f4 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. These classifications are not caller authorization, configured-account proof, provider execution/outcome evidence, or response replay. CI-001B3 [#167](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/167) runs the exact focused emulator file as a named hosted release prerequisite, which #169, #173, and #182 expand. B2C3 reconciliation/advancement 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. These classifications are not caller authorization, configured-account proof, provider execution/outcome retrieval, persistence, attempt advancement, or response replay. CI-001B3 [#167](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/167) runs the exact focused emulator file as a named hosted release prerequisite, which #169, #173, and #182 expand. C3B evidence persistence, C3C later-attempt authorization/versioning, and every runtime adopter remain open. Real migration, capacity, inventory, deployment, provider configuration, and production operations remain open. ### 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. Its Firebase deployment skipped, so backend-live status is unproven and the remaining AUTH-001 parent work stays open. CONFIG-001A merged through [#149](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/149) / PR #150 as `2c62b8dd`, but remains undeployed. CONFIG-001B1 is tracked in [#151](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/151) for fail-closed read-only admission; B2 retains the protected audited operator control, provider-object recovery, and drill. Follow the live issues and do not duplicate these slices. -6. **PAY-001/PAY-002:** PAY-001A source/tests are tracked in [#157](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/157) as the shared validation/logging foundation. PAY-002A1 is tracked in [#161](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/161) for pure separate state reducers and synthetic legacy classification. PAY-002B1 is tracked in [#163](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/163) for pure command/fingerprint/Stripe-key derivation. PAY-002B2A is tracked in [#165](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/165) for the immutable registered command/audit pair only. PAY-002B2B [#169](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/169) owns the separate unused lease/fence/terminal-commitment source and tests without send permission. PAY-002B2C1 [#173](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/173) owns only the unused lease-bound immutable initial plan and audit. PAY-002B2C2 [#182](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/182) owns only the unused separate pre-send marker/audit, exact-attempt retry classification, and persisted 23-hour cutoff. PAY-001B/C/D must still wire exact endpoint schemas/snapshots; B2C3 must add verified reconciliation and safe attempt advancement; PAY-002C/D must then add persistence-first sagas; PAY-003B must adopt the shared reducer. No current endpoint imports these PAY-002 foundations, and none of #169/#173/#182 deploys or configures Firebase/Stripe. +6. **PAY-001/PAY-002:** PAY-001A source/tests are tracked in [#157](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/157) as the shared validation/logging foundation. PAY-002A1 is tracked in [#161](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/161) for pure separate state reducers and synthetic legacy classification. PAY-002B1 is tracked in [#163](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/163) for pure command/fingerprint/Stripe-key derivation. PAY-002B2A is tracked in [#165](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/165) for the immutable registered command/audit pair only. PAY-002B2B [#169](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/169) owns the separate unused lease/fence/terminal-commitment source and tests without send permission. PAY-002B2C1 [#173](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/173) owns only the unused lease-bound immutable initial plan and audit. PAY-002B2C2 [#182](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/182) owns only the unused separate pre-send marker/audit, exact-attempt retry classification, and persisted 23-hour cutoff. PAY-002B2C3A [#184](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/184) owns only the unused pure closed reconciliation decision. PAY-001B/C/D must still wire exact endpoint schemas/snapshots; C3B/C3C must persist verified reconciliation evidence and separately authorize/version safe attempt advancement; PAY-002C/D must then add persistence-first sagas; PAY-003B must adopt the shared reducer. No current endpoint imports these PAY-002 foundations, and none of #169/#173/#182/#184 deploys or configures Firebase/Stripe. 7. **Then:** RACE-001 and MERCH-001 transactional reservations. Why this order: SAFETY-001 removes immediate accidental-production and callback hazards without schema impact. PAY-003 closes the most dangerous “unpaid looks paid” and replay gaps while establishing test fixtures. The subsequent trust-boundary and command/state work completes the surrounding architecture; no live launch is authorized by the partial webhook patch alone. diff --git a/SECURITY.md b/SECURITY.md index c79f0099..ceade2e2 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -95,8 +95,8 @@ These entries are implementation evidence, not a production risk-acceptance deci | Risks | Source result | Remaining closure evidence | | --- | --- | --- | -| 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; B2C3 must still add verified reconciliation and attempt advancement; 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. | Hashes and commitments are pseudonymous server-only equality evidence—not anonymization, authorization, configured-account proof, durable replay, or provider-execution proof. No endpoint imports the library. 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`. B2C3 must reconcile verified evidence and allow only safe attempt advancement. PAY-002C/D and PAY-005 must adopt the complete B2C chain with endpoint schemas and failure-boundary tests. Deployment, provider configuration, and live behavior remain unproven. | +| Foundation only for parts of RISK-011, RISK-020, RISK-021 | PAY-002A1 is tracked in live [#161](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/161). The source-only child defines versioned pure payment, registration, fulfillment, confirmed-refund, and per-dispute structural reducers; fixed cross-dimension review/rejection; and a deterministic synthetic legacy classifier/aggregate report with no IDs. Orders/registrations carry no singular canonical dispute state. Forbidden regressions fail closed, refunds/disputes do not overwrite payment state, and `paid_after_cancellation` remains paid plus cancelled plus review rather than becoming a payment enum. | This does not yet mitigate those risks in a runtime. An unchanged enum does not prove duplicate Event/command/evidence, authorization, amount monotonicity, provider state, or club policy. No endpoint imports the library and no business record is read/written or migrated. PAY-002B1 [#163](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/163) supplies pure command/attempt keys; PAY-002B2A [#165](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/165) supplies only unused registration/audit persistence; PAY-002B2B [#169](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/169) supplies unused lease/fence/terminal-commitment source and tests without provider permission; PAY-002B2C1 [#173](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/173) supplies only unused lease-bound attempt-1 plan commitments/audit; PAY-002B2C2 [#182](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/182) supplies only unused pre-send evidence/audit and the exact persisted retry cutoff; PAY-002B2C3A [#184](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/184) supplies only an unused pure closed evidence classifier with no persistence or authorization; C3B/C3C must still persist verified proof and authorize/version any later attempt; PAY-002C/D persistence-first checkout; PAY-003B Event/version/evidence adoption including same-state amount changes and separate dispute records; PAY-004/PAY-005 cancellation/refund operations; MERCH-002/LEGAL-001 return/policy gates; #113 and a later migration child real inventory/dry-run. Firebase/Stripe deployment and live behavior remain unproven. | +| Foundation only for retry/duplication parts of RISK-003 and RISK-020 | PAY-002B1 is tracked in live [#163](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/163). The pure source child defines a canonical UUID v4 boundary, a domain-separated caller/environment command hash, a command-type plus canonical frozen-payload fingerprint, and a deterministic environment/mode/operation/command/provider-attempt Stripe key. Invalid production/test or non-production/live pairs fail. Raw caller, UUID, operation, and payload values never appear in outputs. PAY-002B2C1 is tracked in live [#173](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/173) for an immutable lease-bound attempt-1 plan and deterministic audit whose account, canonical parameters, and B1 key are stored only as command-bound commitments. PAY-002B2C2 is tracked in live [#182](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/182) for a separate atomic pre-send marker/audit, a complete command-bound C1-plan digest in both partners, a post-transaction lease/deadline freshness check, and persisted 23-hour attempt-1 cutoff. PAY-002B2C3A is tracked in live [#184](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/184) for the pure exact attempt-1 reconciliation evidence policy. | Hashes and commitments are pseudonymous server-only equality evidence—not anonymization, authorization, configured-account proof, durable replay, or provider-execution proof. No endpoint imports these libraries. PAY-002B2A/#165 registers only a command/audit pair. PAY-002B2B/#169 adds a separate fixed-duration lease, holder fingerprint, monotonic fence, terminal commitment, and transition audit without provider permission. C1 does not permit a send. C2's fixed `send_permitted` is only a retry-safety classification under the exact lease/fence/plan; it is not caller authorization, configured-account proof, a Stripe call/outcome, or response replay. C2 makes no provider request and never advances attempt `1`. A changed/replaced plan fails its stored digest. Lease/deadline equality, later time, rollback, or missing/unreadable paired time returns `reconciliation_required`. C3A returns only fixed `existing_attempt_found`, `new_attempt_candidate`, or `reconciliation_required` results from closed enum evidence; a candidate is not send/advance permission. Timeout, connection loss, provider error, old/pruned key, missing/not-found object, partial/empty search, processing/unknown state, and conflict cannot become a candidate. C3B/C3C must persist verified evidence and separately authorize/version any later attempt. PAY-002C/D and PAY-005 must adopt the complete B2C chain with endpoint schemas and failure-boundary tests. Deployment, provider configuration, and live behavior remain unproven. | | Foundation only for registration/audit parts of RISK-003, RISK-020, and RISK-032 | PAY-002B2A is tracked in live [#165](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/165). The unused server-only transaction creates one immutable `checkoutRequests/{commandKeyHash}` registered record and deterministic revision-1 `auditEvents` partner atomically. Exact retries are read-only. Same-key type/schema/payload conflicts and corrupt, orphan, future, or unexpected stored pairs fail closed without repair. Environment/caller-scope changes derive different B1 keys; a stored mismatch under one key is corruption, not a cross-scope lookup. Fixed results expose no raw identity, UUID, payload, hash, path, or Firestore detail. CI-001B3 [#167](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/167) runs its dedicated exact demo-project/opt-in suite as a named hosted job and protected-release prerequisite. | Registration is neither authorization nor execution permission. #169 keeps the root and revision-1 audit immutable, places lifecycle state in the separate server-only subcollection, fails closed on malformed partners, and proves in synthetic tests that stale workers cannot finalize. The hosted emulator result is still synthetic source proof, not deployment or provider proof. No TTL is safe: [#110](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/110) must approve retention and a server-only tombstone or equivalent durable duplicate barrier before deletion. Source/tests/merge do not prove Firebase deployment, Stripe configuration, or live behavior. | | RISK-008 | ABUSE-001A1 is tracked in live [#159](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/159). The source-only slice replaces the browser reCAPTCHA v3 constructor with the pinned Firebase SDK's Enterprise provider and preserves local/test/capability-callback shutdown, missing-key behavior, token refresh, Analytics shutdown, and fixed redacted failure diagnostics. Synthetic tests use no real key or provider call. | No Enterprise key, allowed-domain policy, provider console setting, protected build variable, token exchange, staged metric, callable enforcement, website/Firebase publication, or live behavior is configured or proven. Release `29254280177` failed closed before build because the public site-key variable was absent. Complete #113/#133, ABUSE-001A2, #136/WEB-001, staged metrics, and provider readback before enforcement or a live claim. | | RISK-017 | PAY-001A is tracked in live [#157](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/157). The source slice adds dependency-free strict-root-object, root-budget, bounded string/array, integer-cents, fixed-currency, calendar-date, canonical-URL, conservative email, fixed-error, and closed-enum safe-log primitives. Synthetic hostile-input tests prove deep immutable copies and reject prototypes, accessors, and unknown configured root keys. The libraries contain no network, Firebase, Stripe, logger, clock, or random dependency and make no such call themselves; a supplied array item parser must be trusted, pure, and synchronous. | This library is not imported by an endpoint and is not deployed. PAY-001B/C/D must define exact race, merchandise, lookup, refund, admin, nested-object, and custom-field schemas; apply smaller business limits before every write/provider call; store immutable schema/price snapshots; and add endpoint no-side-effect tests. Safe-log primitives do not replace approved observability purpose, retention, access, transport, or provider controls. | diff --git a/STRIPE_COMMERCE_DESIGN.md b/STRIPE_COMMERCE_DESIGN.md index a627885e..65f168fb 100644 --- a/STRIPE_COMMERCE_DESIGN.md +++ b/STRIPE_COMMERCE_DESIGN.md @@ -226,7 +226,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-002B2C3 must reconcile stored/provider/webhook evidence before returning a Session or allowing an explicit new attempt. 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 and C3C must separately authorize/version any later attempt 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) @@ -310,12 +310,32 @@ If both partners are absent, the exact current holder may create them atomically #182 remains unused source. It adds no endpoint/index import, provider adapter/call, Firebase deployment, Stripe account/key/domain configuration, production read/write, website change, or live behavior. Its mock, Firestore emulator, and browser-denial tests use synthetic demo-only data. A green test does not prove Firebase or Stripe configuration. +### Closed reconciliation-decision target (PAY-002B2C3A) + +PAY-002B2C3A source/tests are tracked in live [#184](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/184). This first C3 child is a pure unused policy, not a reconciler. It accepts one exact flat version-1 record for provider `stripe` and attempt `1`. Every other field is a closed enum describing exact-plan binding, evidence source/completeness, dispatch/response/idempotency evidence, Checkout Session/payment/event/search evidence, and the business-transition gate. It accepts no provider or business identifier, key, account, metadata, amount, timestamp, URL, free text, or raw status code. + +Each positive row below is shorthand for the module's complete exact enum tuple across all of those fields. Every single-field difference—including a different source, completeness, dispatch/response/idempotency category, event/search fact, or business-transition gate—falls to `reconciliation_required`, except that the exact successful tuple accepts either `paid` or `no_payment_required`. + +| Complete verified evidence | Frozen classification | Fixed state | Meaning | +| --- | --- | --- | --- | +| Trusted dispatch history proves endpoint execution never began; exact plan; same logical operation explicitly eligible | `new_attempt_candidate` | `requires_persistence_and_authorization` | Attempt 1 stays bound; no provider request began and no later send is authorized | +| Exact attempt-1 Session is `expired` and `unpaid`; exact plan; verified expiry; new logical generation explicitly eligible | `new_attempt_candidate` | `requires_persistence_and_authorization` | Attempt 1 stays immutable and terminal; no later generation or send is authorized | +| Exact attempt-1 Session is `open` and `unpaid` | `existing_attempt_found` | `do_not_advance` | Preserve the existing attempt; this is not a result replay | +| Exact attempt-1 Session is complete and `paid` or `no_payment_required`, with verified success evidence | `existing_attempt_found` | `do_not_advance` | Preserve the existing success; the caller still receives no provider result | +| Every other valid combination | `reconciliation_required` | `requires_reconciliation` | Stop; do not advance, even when one provider fact is known | + +The final row includes timeout, connection loss, conflict, external-dependency failure, rate limit, provider server failure, old/pruned key, missing reference, a not-found result without an exact known object, empty/partial search, processing/unknown Session state, incomplete evidence, and every mismatch/conflict. Those facts may be useful to a later verifier but never prove non-execution by themselves. Stripe documents that idempotency results can be pruned after at least 24 hours and that a reused pruned key can start a new request; C2 therefore stops at 23 hours and C3A never uses key age as advancement proof. + +All three results are frozen and contain only the policy version, classification, and state. Invalid or hostile input produces one fixed non-identifying error. The module has no Firebase, Stripe, network, logger, environment, clock, random, filesystem, persistence, or runtime/index edge. It cannot return send/execute/advance permission, create an attempt-2 plan/key/document, replay a response, or apply a business transition. + +This child does not complete PAY-002B2C3. C3B must append immutable paired reconciliation evidence only after a trusted verifier can produce it. C3C must require that persisted proof, an allowed business transition, and a fresh lease before it can authorize and version a later provider attempt. Runtime adoption, Firebase/Stripe deployment, provider configuration, production data, and live behavior remain unchanged. + ## 7. Persistence-first checkout saga External Stripe calls cannot be part of a Firestore transaction. Use this sequence: 1. Validate the request and read server-controlled catalog/event state. -2. Derive the PAY-002B1 command key and payload fingerprint; PAY-002B2A must register and compare them, PAY-002B2B must provide the current fence, PAY-002B2C1 must bind the immutable initial plan, and B2C2/B2C3 must complete their send/reconciliation gates before any provider call. +2. Derive the PAY-002B1 command key and payload fingerprint; PAY-002B2A must register and compare them, PAY-002B2B must provide the current fence, PAY-002B2C1 must bind the immutable initial plan, B2C2 must record/freshness-check pre-send evidence, and C3A/C3B/C3C must classify, persist, and authorize verified reconciliation evidence before any later provider generation. 3. In one Firestore transaction: - Reuse a matching prior request or reject a conflicting reuse. - Lock/read the event capacity counter or SKU variant. @@ -328,7 +348,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; B2C3 reconciles stored provider references plus verified webhook/metadata 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 any later generation 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 765b2248..7d293660 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -278,6 +278,10 @@ These commitments are pseudonymous equality evidence, not authorization, configu The fixed C2 result is narrow retry-safety evidence. It is not caller authorization, configured-account proof, a claim that Stripe received the POST, a provider outcome, or response replay. Timeout, connection loss, `5xx`, a missing object reference, and incomplete search are deliberately not C2 inputs and cannot manufacture attempt `2`. B2C3 remains responsible for verified reconciliation evidence and safe later-attempt authorization. #173/#182 have no runtime/index import, Stripe/network call, Firebase deployment, provider configuration, production data, website, or live/officer effect. +PAY-002B2C3A source/tests are tracked in live [#184](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/184). The unused pure policy accepts only one exact flat version-1 Stripe attempt-1 evidence record made from closed enums. It returns one of three frozen non-identifying results: `existing_attempt_found` with `do_not_advance`, `new_attempt_candidate` with `requires_persistence_and_authorization`, or `reconciliation_required` with `requires_reconciliation`. Only the matching complete tuple for trusted proof that endpoint execution never began, or the matching complete tuple for an exact verified expired/unpaid Session plus verified expiry and an explicitly eligible new logical generation, can be a candidate. The matching complete exact-open or verified-success tuple identifies an existing attempt. Every single-field difference, timeout, lost connection, provider error category, old/pruned key, missing reference, not-found result, empty/partial search, processing/unknown state, mismatch, or conflict requires reconciliation. + +C3A classification is not authorization or provider truth. The module accepts no IDs, keys, accounts, metadata, money, timestamps, business/member values, free text, URLs, or caller-selected status codes. It stores nothing, calls nothing, is imported by no runtime entry point, and cannot create attempt `2`. C3B must later persist immutable paired verified evidence; C3C must separately authorize and version any later attempt only after that proof, an allowed business transition, and a fresh lease. No Firebase/Stripe deployment, provider configuration, production read/write, website, or officer behavior changes in C3A. + CI-001B3 [#167](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/167) runs the exact opt-in command-journal emulator suite as a named hosted release prerequisite; #169, #173, and #182 expand that same suite. These are synthetic source checks only. The current journal source remains unused and makes no endpoint, Firebase deployment, Stripe/provider configuration, production data, website, or live/officer change. ## 7. Business invariants @@ -359,7 +363,7 @@ sequenceDiagram R->>W: Submit identity, waiver acceptance, request ID W->>F: Callable request + Auth/App Check when available F->>D: Transaction: validate event snapshot, reserve capacity, create pending registration - F->>S: Create Checkout Session with stable idempotency key and business metadata + F->>S: Create Session after command gate; later generation also needs reconciliation gate F->>D: Attach Session ID and expiry F-->>W: Stripe-hosted Checkout URL W->>S: Redirect to Checkout @@ -372,7 +376,7 @@ sequenceDiagram F-->>W: Confirmed, processing, failed, or support-required state ``` -If Stripe Session creation fails, the function releases the reservation in a compensating transaction. If the function crashes after Stripe creates the Session, PAY-002B2C2 retries only the exact B2C1 plan/key inside its stored safe-send window. After that deadline—or when first-send time is unknown—it stops POSTing, and B2C3 reconciles stored/provider/webhook evidence because Stripe may have pruned the idempotency key. 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 but cannot retrieve, store, authorize, or advance anything; C3B/C3C must complete those gates before a later POST. A scheduled job releases abandoned reservations and reconciles records that missed a webhook. ### 8.2 Free or volunteer registration @@ -395,7 +399,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, and B2C3 reconciles because Stripe may have pruned the old key. +- A stable PAY-002B1 Stripe key identifies one logical provider generation. A lease takeover or HTTP retry is not a new generation. PAY-002B2C1 binds the immutable attempt-1 plan; B2C2 may retry only that exact plan/key inside its conservative stored send window; after that window or with unknown send time, it stops automatic POST. C3A classifies only closed verified evidence, while C3B/C3C must persist proof and separately authorize any later generation because Stripe may have pruned the old key. - The business record stores the saga step and external ID. - A compensating transaction releases a reservation after a known failure. - A webhook advances successful external state. diff --git a/functions/commerceProviderReconciliation.js b/functions/commerceProviderReconciliation.js new file mode 100644 index 00000000..a58b6a1c --- /dev/null +++ b/functions/commerceProviderReconciliation.js @@ -0,0 +1,289 @@ +const { types: { isProxy } } = require('node:util'); + +const reconciliationPolicySchemaVersion = 1; +const RECONCILIATION_ERROR_MESSAGE = 'Commerce provider reconciliation evidence is invalid.'; + +const EXPECTED_FIELDS = Object.freeze([ + 'reconciliationPolicySchemaVersion', + 'provider', + 'providerAttempt', + 'planBinding', + 'evidenceSource', + 'evidenceCompleteness', + 'dispatchEvidence', + 'responseEvidence', + 'idempotencyEvidence', + 'providerObjectEvidence', + 'paymentEvidence', + 'eventEvidence', + 'searchEvidence', + 'businessTransitionEvidence', +]); + +const RECONCILIATION_POLICY_ENUMS = Object.freeze({ + planBinding: Object.freeze(['exact', 'missing', 'conflicting']), + evidenceSource: Object.freeze([ + 'trusted_dispatch_history', + 'verified_provider_object', + 'verified_provider_and_event', + 'unverified_or_missing', + ]), + evidenceCompleteness: Object.freeze(['complete', 'partial', 'conflicting']), + dispatchEvidence: Object.freeze([ + 'execution_never_began', + 'execution_started', + 'timeout', + 'connection_lost', + 'unknown', + 'not_observed', + 'conflicting', + ]), + responseEvidence: Object.freeze([ + 'none', + 'accepted', + 'conflict', + 'external_dependency_failure', + 'rate_limited', + 'server_failure', + 'other_failure', + 'unknown', + 'conflicting', + ]), + idempotencyEvidence: Object.freeze([ + 'not_relied_upon', + 'active_exact', + 'old_or_pruned', + 'unknown', + 'conflicting', + ]), + providerObjectEvidence: Object.freeze([ + 'none', + 'exact_open', + 'exact_complete', + 'exact_expired', + 'exact_unknown', + 'missing_reference', + 'not_found', + 'conflicting', + ]), + paymentEvidence: Object.freeze([ + 'none', + 'unpaid', + 'paid', + 'no_payment_required', + 'processing', + 'unknown', + 'conflicting', + ]), + eventEvidence: Object.freeze([ + 'none', + 'verified_success', + 'verified_expiry', + 'partial', + 'unknown', + 'conflicting', + ]), + searchEvidence: Object.freeze([ + 'none', + 'exact_lookup_complete', + 'empty', + 'partial', + 'conflicting', + ]), + businessTransitionEvidence: Object.freeze([ + 'same_operation_eligible', + 'new_generation_eligible', + 'already_succeeded', + 'ineligible', + 'unknown', + 'conflicting', + ]), +}); + +const ENUM_SETS = Object.freeze(Object.fromEntries( + Object.entries(RECONCILIATION_POLICY_ENUMS) + .map(([field, values]) => [field, new Set(values)]), +)); + +const RESULTS = Object.freeze({ + existingAttemptFound: Object.freeze({ + reconciliationPolicySchemaVersion, + classification: 'existing_attempt_found', + state: 'do_not_advance', + }), + newAttemptCandidate: Object.freeze({ + reconciliationPolicySchemaVersion, + classification: 'new_attempt_candidate', + state: 'requires_persistence_and_authorization', + }), + reconciliationRequired: Object.freeze({ + reconciliationPolicySchemaVersion, + classification: 'reconciliation_required', + state: 'requires_reconciliation', + }), +}); + +class CommerceProviderReconciliationError extends Error { + constructor() { + super(RECONCILIATION_ERROR_MESSAGE); + Object.defineProperty(this, 'name', { + value: 'CommerceProviderReconciliationError', + enumerable: false, + writable: false, + configurable: false, + }); + Object.defineProperty(this, 'message', { + value: RECONCILIATION_ERROR_MESSAGE, + enumerable: false, + writable: false, + configurable: false, + }); + Object.defineProperty(this, 'code', { + value: 'invalid_reconciliation_evidence', + enumerable: false, + writable: false, + configurable: false, + }); + Error.captureStackTrace?.(this, CommerceProviderReconciliationError); + Object.freeze(this); + } +} + +function fail() { + throw new CommerceProviderReconciliationError(); +} + +function readExactEvidence(value) { + if (value === null || typeof value !== 'object' || isProxy(value)) fail(); + + let prototype; + let keys; + try { + prototype = Object.getPrototypeOf(value); + keys = Reflect.ownKeys(value); + } catch { + fail(); + } + + if (prototype !== Object.prototype || keys.length !== EXPECTED_FIELDS.length) fail(); + + const keySet = new Set(); + for (const key of keys) { + if (typeof key !== 'string' || keySet.has(key)) fail(); + keySet.add(key); + } + if (EXPECTED_FIELDS.some((field) => !keySet.has(field))) fail(); + + for (const key in value) { + if (!Object.prototype.hasOwnProperty.call(value, key)) fail(); + } + + const evidence = Object.create(null); + for (const field of EXPECTED_FIELDS) { + let descriptor; + try { + descriptor = Object.getOwnPropertyDescriptor(value, field); + } catch { + fail(); + } + if (!descriptor + || descriptor.enumerable !== true + || !Object.prototype.hasOwnProperty.call(descriptor, 'value') + || descriptor.get !== undefined + || descriptor.set !== undefined) { + fail(); + } + evidence[field] = descriptor.value; + } + + if (evidence.reconciliationPolicySchemaVersion !== reconciliationPolicySchemaVersion + || evidence.provider !== 'stripe' + || evidence.providerAttempt !== 1) { + fail(); + } + + for (const [field, allowedValues] of Object.entries(ENUM_SETS)) { + if (!allowedValues.has(evidence[field])) fail(); + } + + return evidence; +} + +function isDispatchNeverBeganCandidate(evidence) { + return evidence.planBinding === 'exact' + && evidence.evidenceSource === 'trusted_dispatch_history' + && evidence.evidenceCompleteness === 'complete' + && evidence.dispatchEvidence === 'execution_never_began' + && evidence.responseEvidence === 'none' + && evidence.idempotencyEvidence === 'not_relied_upon' + && evidence.providerObjectEvidence === 'none' + && evidence.paymentEvidence === 'none' + && evidence.eventEvidence === 'none' + && evidence.searchEvidence === 'none' + && evidence.businessTransitionEvidence === 'same_operation_eligible'; +} + +function isExpiredAttemptCandidate(evidence) { + return evidence.planBinding === 'exact' + && evidence.evidenceSource === 'verified_provider_and_event' + && evidence.evidenceCompleteness === 'complete' + && evidence.dispatchEvidence === 'execution_started' + && evidence.responseEvidence === 'accepted' + && evidence.idempotencyEvidence === 'not_relied_upon' + && evidence.providerObjectEvidence === 'exact_expired' + && evidence.paymentEvidence === 'unpaid' + && evidence.eventEvidence === 'verified_expiry' + && evidence.searchEvidence === 'exact_lookup_complete' + && evidence.businessTransitionEvidence === 'new_generation_eligible'; +} + +function isExistingOpenAttempt(evidence) { + return evidence.planBinding === 'exact' + && evidence.evidenceSource === 'verified_provider_object' + && evidence.evidenceCompleteness === 'complete' + && evidence.dispatchEvidence === 'execution_started' + && evidence.responseEvidence === 'accepted' + && evidence.idempotencyEvidence === 'not_relied_upon' + && evidence.providerObjectEvidence === 'exact_open' + && evidence.paymentEvidence === 'unpaid' + && evidence.eventEvidence === 'none' + && evidence.searchEvidence === 'exact_lookup_complete' + && evidence.businessTransitionEvidence === 'ineligible'; +} + +function isExistingSuccessfulAttempt(evidence) { + return evidence.planBinding === 'exact' + && evidence.evidenceSource === 'verified_provider_and_event' + && evidence.evidenceCompleteness === 'complete' + && evidence.dispatchEvidence === 'execution_started' + && evidence.responseEvidence === 'accepted' + && evidence.idempotencyEvidence === 'not_relied_upon' + && evidence.providerObjectEvidence === 'exact_complete' + && (evidence.paymentEvidence === 'paid' + || evidence.paymentEvidence === 'no_payment_required') + && evidence.eventEvidence === 'verified_success' + && evidence.searchEvidence === 'exact_lookup_complete' + && evidence.businessTransitionEvidence === 'already_succeeded'; +} + +function classifyInitialStripeReconciliation(input) { + const evidence = readExactEvidence(input); + + if (isExistingOpenAttempt(evidence) || isExistingSuccessfulAttempt(evidence)) { + return RESULTS.existingAttemptFound; + } + if (isDispatchNeverBeganCandidate(evidence) || isExpiredAttemptCandidate(evidence)) { + return RESULTS.newAttemptCandidate; + } + return RESULTS.reconciliationRequired; +} + +Object.freeze(CommerceProviderReconciliationError.prototype); +Object.freeze(CommerceProviderReconciliationError); + +module.exports = Object.freeze({ + reconciliationPolicySchemaVersion, + RECONCILIATION_POLICY_ENUMS, + CommerceProviderReconciliationError, + classifyInitialStripeReconciliation, +}); diff --git a/functions/commerceProviderReconciliation.test.js b/functions/commerceProviderReconciliation.test.js new file mode 100644 index 00000000..293761f8 --- /dev/null +++ b/functions/commerceProviderReconciliation.test.js @@ -0,0 +1,491 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const { inspect } = require('node:util'); + +const { + reconciliationPolicySchemaVersion, + RECONCILIATION_POLICY_ENUMS, + CommerceProviderReconciliationError, + classifyInitialStripeReconciliation, +} = require('./commerceProviderReconciliation'); + +const HOSTILE_CANARY = 'private-runner@example.test/token?secret=do-not-copy'; + +function evidence(overrides = {}) { + return { + reconciliationPolicySchemaVersion: 1, + provider: 'stripe', + providerAttempt: 1, + planBinding: 'exact', + evidenceSource: 'trusted_dispatch_history', + evidenceCompleteness: 'complete', + dispatchEvidence: 'execution_never_began', + responseEvidence: 'none', + idempotencyEvidence: 'not_relied_upon', + providerObjectEvidence: 'none', + paymentEvidence: 'none', + eventEvidence: 'none', + searchEvidence: 'none', + businessTransitionEvidence: 'same_operation_eligible', + ...overrides, + }; +} + +function expiredEvidence(overrides = {}) { + return evidence({ + evidenceSource: 'verified_provider_and_event', + dispatchEvidence: 'execution_started', + responseEvidence: 'accepted', + providerObjectEvidence: 'exact_expired', + paymentEvidence: 'unpaid', + eventEvidence: 'verified_expiry', + searchEvidence: 'exact_lookup_complete', + businessTransitionEvidence: 'new_generation_eligible', + ...overrides, + }); +} + +function openEvidence(overrides = {}) { + return evidence({ + evidenceSource: 'verified_provider_object', + dispatchEvidence: 'execution_started', + responseEvidence: 'accepted', + providerObjectEvidence: 'exact_open', + paymentEvidence: 'unpaid', + searchEvidence: 'exact_lookup_complete', + businessTransitionEvidence: 'ineligible', + ...overrides, + }); +} + +function successfulEvidence(paymentEvidence = 'paid', overrides = {}) { + return evidence({ + evidenceSource: 'verified_provider_and_event', + dispatchEvidence: 'execution_started', + responseEvidence: 'accepted', + providerObjectEvidence: 'exact_complete', + paymentEvidence, + eventEvidence: 'verified_success', + searchEvidence: 'exact_lookup_complete', + businessTransitionEvidence: 'already_succeeded', + ...overrides, + }); +} + +function captureError(callback) { + try { + callback(); + } catch (error) { + return error; + } + throw new Error('Expected callback to throw'); +} + +function expectSafeError(callback, rawValue = HOSTILE_CANARY) { + const error = captureError(callback); + expect(error).toBeInstanceOf(CommerceProviderReconciliationError); + expect(error).toMatchObject({ + name: 'CommerceProviderReconciliationError', + code: 'invalid_reconciliation_evidence', + message: 'Commerce provider reconciliation evidence is invalid.', + }); + expect(Object.isFrozen(error)).toBe(true); + const rendered = [ + error.message, + String(error), + JSON.stringify(error), + inspect(error), + error.stack, + ].join('\n'); + if (rawValue) expect(rendered).not.toContain(rawValue); + expect(rendered).not.toContain(HOSTILE_CANARY); + return error; +} + +function expectResult(input, classification, state) { + const result = classifyInitialStripeReconciliation(input); + expect(result).toEqual({ + reconciliationPolicySchemaVersion: 1, + classification, + state, + }); + expect(Object.isFrozen(result)).toBe(true); + expect(JSON.stringify(result)).not.toContain(HOSTILE_CANARY); + return result; +} + +describe('closed reconciliation classifications', () => { + test('exports one frozen versioned API and frozen closed enum catalog', () => { + const api = require('./commerceProviderReconciliation'); + expect(reconciliationPolicySchemaVersion).toBe(1); + expect(Object.isFrozen(api)).toBe(true); + expect(Object.isFrozen(RECONCILIATION_POLICY_ENUMS)).toBe(true); + for (const values of Object.values(RECONCILIATION_POLICY_ENUMS)) { + expect(Object.isFrozen(values)).toBe(true); + } + expect(Object.isFrozen(CommerceProviderReconciliationError)).toBe(true); + expect(Object.isFrozen(CommerceProviderReconciliationError.prototype)).toBe(true); + }); + + test('classifies only complete trusted never-dispatched evidence as a same-operation candidate', () => { + const input = evidence(); + const before = JSON.stringify(input); + const first = expectResult( + input, + 'new_attempt_candidate', + 'requires_persistence_and_authorization', + ); + const second = classifyInitialStripeReconciliation(evidence()); + expect(second).toBe(first); + expect(JSON.stringify(input)).toBe(before); + }); + + test('classifies only exact verified expired and unpaid evidence as a new-generation candidate', () => { + expectResult( + expiredEvidence(), + 'new_attempt_candidate', + 'requires_persistence_and_authorization', + ); + }); + + test('finds an exact open attempt and never treats it as reusable or advanceable', () => { + expectResult(openEvidence(), 'existing_attempt_found', 'do_not_advance'); + }); + + test.each(['paid', 'no_payment_required'])( + 'finds an exact successful attempt with %s evidence and does not advance', + (paymentEvidence) => { + expectResult( + successfulEvidence(paymentEvidence), + 'existing_attempt_found', + 'do_not_advance', + ); + }, + ); + + test('returns the same frozen reconciliation result for every otherwise valid unsafe matrix row', () => { + const unsafe = [ + evidence({ dispatchEvidence: 'timeout' }), + evidence({ dispatchEvidence: 'connection_lost' }), + evidence({ dispatchEvidence: 'unknown' }), + evidence({ responseEvidence: 'conflict' }), + evidence({ responseEvidence: 'external_dependency_failure' }), + evidence({ responseEvidence: 'rate_limited' }), + evidence({ responseEvidence: 'server_failure' }), + evidence({ idempotencyEvidence: 'old_or_pruned' }), + evidence({ idempotencyEvidence: 'unknown' }), + evidence({ providerObjectEvidence: 'missing_reference' }), + evidence({ + evidenceSource: 'verified_provider_object', + dispatchEvidence: 'unknown', + responseEvidence: 'unknown', + providerObjectEvidence: 'not_found', + searchEvidence: 'exact_lookup_complete', + }), + evidence({ searchEvidence: 'empty' }), + evidence({ searchEvidence: 'partial' }), + evidence({ evidenceCompleteness: 'partial' }), + evidence({ evidenceCompleteness: 'conflicting' }), + evidence({ planBinding: 'missing' }), + evidence({ planBinding: 'conflicting' }), + expiredEvidence({ paymentEvidence: 'processing' }), + expiredEvidence({ providerObjectEvidence: 'exact_unknown' }), + successfulEvidence('processing'), + successfulEvidence('unpaid'), + successfulEvidence('paid', { eventEvidence: 'partial' }), + openEvidence({ providerObjectEvidence: 'conflicting' }), + openEvidence({ paymentEvidence: 'unknown' }), + evidence({ evidenceSource: 'unverified_or_missing' }), + evidence({ businessTransitionEvidence: 'unknown' }), + evidence({ businessTransitionEvidence: 'conflicting' }), + ]; + + const results = unsafe.map((input) => expectResult( + input, + 'reconciliation_required', + 'requires_reconciliation', + )); + expect(new Set(results).size).toBe(1); + expect(new Set(results.map((result) => JSON.stringify(result))).size).toBe(1); + }); + + test('one changed evidence dimension removes each candidate classification', () => { + const candidates = [evidence(), expiredEvidence()]; + for (const baseline of candidates) { + for (const [field, values] of Object.entries(RECONCILIATION_POLICY_ENUMS)) { + for (const value of values) { + if (value === baseline[field]) continue; + expectResult( + { ...baseline, [field]: value }, + 'reconciliation_required', + 'requires_reconciliation', + ); + } + } + } + }); + + test('one changed evidence dimension removes each existing-attempt classification', () => { + const existing = [ + openEvidence(), + successfulEvidence('paid'), + successfulEvidence('no_payment_required'), + ]; + for (const baseline of existing) { + for (const [field, values] of Object.entries(RECONCILIATION_POLICY_ENUMS)) { + for (const value of values) { + if (value === baseline[field]) continue; + const alternateSuccessfulPayment = baseline.providerObjectEvidence === 'exact_complete' + && field === 'paymentEvidence' + && ['paid', 'no_payment_required'].includes(value); + if (alternateSuccessfulPayment) { + expectResult( + { ...baseline, [field]: value }, + 'existing_attempt_found', + 'do_not_advance', + ); + continue; + } + expectResult( + { ...baseline, [field]: value }, + 'reconciliation_required', + 'requires_reconciliation', + ); + } + } + } + }); + + test('a classification is not send, execution, advancement, persistence, or replay authorization', () => { + for (const input of [evidence(), expiredEvidence(), openEvidence(), successfulEvidence()]) { + const result = classifyInitialStripeReconciliation(input); + expect(Object.keys(result)).toEqual([ + 'reconciliationPolicySchemaVersion', + 'classification', + 'state', + ]); + for (const forbidden of [ + 'send_permitted', + 'shouldExecute', + 'shouldAdvance', + 'providerAttempt', + 'attempt2', + 'result', + 'response', + 'providerObject', + ]) { + expect(result).not.toHaveProperty(forbidden); + } + } + }); +}); + +describe('hostile and malformed evidence fails with one fixed error', () => { + test.each([ + undefined, + null, + true, + 1, + 'stripe', + [], + new Date(0), + new Number(1), + ])('rejects a non-plain root case %#', (input) => { + expectSafeError(() => classifyInitialStripeReconciliation(input)); + }); + + test('rejects every missing field and every extra string or symbol field', () => { + for (const field of Object.keys(evidence())) { + const input = evidence(); + delete input[field]; + expectSafeError(() => classifyInitialStripeReconciliation(input)); + } + + expectSafeError(() => classifyInitialStripeReconciliation({ + ...evidence(), + [HOSTILE_CANARY]: 'extra', + })); + const symbolInput = evidence(); + symbolInput[Symbol(HOSTILE_CANARY)] = 'extra'; + expectSafeError(() => classifyInitialStripeReconciliation(symbolInput)); + for (const field of ['__proto__', 'constructor', 'prototype']) { + const input = evidence(); + Object.defineProperty(input, field, { + value: HOSTILE_CANARY, + enumerable: true, + configurable: true, + }); + expectSafeError(() => classifyInitialStripeReconciliation(input)); + } + }); + + test('rejects inherited, custom, null, and polluted prototypes', () => { + const inherited = Object.create(evidence()); + expectSafeError(() => classifyInitialStripeReconciliation(inherited)); + + const custom = Object.assign(Object.create({ inherited: HOSTILE_CANARY }), evidence()); + expectSafeError(() => classifyInitialStripeReconciliation(custom)); + + const nullPrototype = Object.assign(Object.create(null), evidence()); + expectSafeError(() => classifyInitialStripeReconciliation(nullPrototype)); + + Object.defineProperty(Object.prototype, 'temporaryReconciliationPollution', { + value: HOSTILE_CANARY, + enumerable: true, + configurable: true, + }); + try { + expectSafeError(() => classifyInitialStripeReconciliation(evidence())); + } finally { + delete Object.prototype.temporaryReconciliationPollution; + } + }); + + test('rejects accessors without invoking them', () => { + let getterCalls = 0; + const input = evidence(); + Object.defineProperty(input, 'provider', { + enumerable: true, + configurable: true, + get() { + getterCalls += 1; + throw new Error(HOSTILE_CANARY); + }, + }); + expectSafeError(() => classifyInitialStripeReconciliation(input)); + expect(getterCalls).toBe(0); + }); + + test('rejects proxies before invoking any hostile trap', () => { + let trapCalls = 0; + const handler = new Proxy({}, { + get() { + return () => { + trapCalls += 1; + throw new Error(HOSTILE_CANARY); + }; + }, + }); + const input = new Proxy(evidence(), handler); + expectSafeError(() => classifyInitialStripeReconciliation(input)); + expect(trapCalls).toBe(0); + }); + + test('rejects non-enumerable data fields without reading through them', () => { + const input = evidence(); + Object.defineProperty(input, 'provider', { + value: 'stripe', + enumerable: false, + configurable: true, + }); + expectSafeError(() => classifyInitialStripeReconciliation(input)); + }); + + test.each([ + ['reconciliationPolicySchemaVersion', 2], + ['reconciliationPolicySchemaVersion', '1'], + ['provider', 'STRIPE'], + ['provider', 'str\uD800ipe'], + ['providerAttempt', 0], + ['providerAttempt', 2], + ['providerAttempt', '1'], + ['planBinding', 'exact '], + ['evidenceSource', 'future_source'], + ['evidenceCompleteness', HOSTILE_CANARY], + ['dispatchEvidence', 'http_500'], + ['responseEvidence', 500], + ['idempotencyEvidence', { state: 'active_exact' }], + ['providerObjectEvidence', 'cs_test_private'], + ['paymentEvidence', 'PAID'], + ['eventEvidence', 'evt_private'], + ['searchEvidence', false], + ['businessTransitionEvidence', Symbol(HOSTILE_CANARY)], + ])('rejects an unknown/future/non-enum value for %s', (field, value) => { + expectSafeError( + () => classifyInitialStripeReconciliation(evidence({ [field]: value })), + typeof value === 'string' && value.length >= 5 ? value : HOSTILE_CANARY, + ); + }); + + test('all validation errors are byte-equivalent and never contain supplied input', () => { + const errors = [ + captureError(() => classifyInitialStripeReconciliation(null)), + captureError(() => classifyInitialStripeReconciliation(evidence({ + evidenceSource: HOSTILE_CANARY, + }))), + captureError(() => classifyInitialStripeReconciliation({ + ...evidence(), + extra: HOSTILE_CANARY, + })), + ]; + const publicShapes = errors.map((error) => JSON.stringify({ + name: error.name, + code: error.code, + message: error.message, + enumerable: JSON.stringify(error), + })); + expect(new Set(publicShapes).size).toBe(1); + expect(publicShapes[0]).not.toContain(HOSTILE_CANARY); + }); +}); + +describe('immutability, redaction, and zero-side-effect boundary', () => { + test('callers cannot mutate canonical outputs, enum arrays, or error fields', () => { + const result = classifyInitialStripeReconciliation(evidence()); + expect(() => { result.classification = HOSTILE_CANARY; }).toThrow(TypeError); + expect(() => { result.extra = HOSTILE_CANARY; }).toThrow(TypeError); + expect(() => { RECONCILIATION_POLICY_ENUMS.planBinding.push(HOSTILE_CANARY); }) + .toThrow(TypeError); + + const error = captureError(() => classifyInitialStripeReconciliation(null)); + expect(() => { error.message = HOSTILE_CANARY; }).toThrow(TypeError); + expect(() => { error.code = HOSTILE_CANARY; }).toThrow(TypeError); + }); + + test('source imports only the proxy detector and contains no forbidden side-effect primitive', () => { + const source = fs.readFileSync( + path.join(__dirname, 'commerceProviderReconciliation.js'), + 'utf8', + ); + const requires = [...source.matchAll(/require\((['"])([^'"]+)\1\)/g)] + .map((match) => match[2]); + expect(requires).toEqual(['node:util']); + for (const forbidden of [ + 'firebase', + "require('stripe')", + 'process.env', + 'console.', + 'Math.random', + 'Date.', + 'new Date', + 'fetch(', + 'node:http', + 'node:https', + 'node:net', + 'node:dns', + 'node:fs', + 'child_process', + 'import(', + 'providerAttempts/0000000002', + 'send_permitted', + 'shouldExecute', + 'shouldAdvance', + ]) { + expect(source).not.toContain(forbidden); + } + }); + + test('no runtime or index entry point adopts the inert policy module', () => { + const files = fs.readdirSync(__dirname) + .filter((fileName) => fileName.endsWith('.js')) + .filter((fileName) => fileName !== 'commerceProviderReconciliation.js') + .filter((fileName) => fileName !== 'commerceProviderReconciliation.test.js'); + for (const fileName of files) { + const source = fs.readFileSync(path.join(__dirname, fileName), 'utf8'); + expect({ fileName, importsPolicy: source.includes('commerceProviderReconciliation') }) + .toEqual({ fileName, importsPolicy: false }); + } + }); +});