diff --git a/GITHUB_ISSUES.md b/GITHUB_ISSUES.md index a9d6207a..b3ffc6a7 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); 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); provider/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,16 +624,18 @@ 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). Leases, provider-plan recovery, 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). Provider-plan recovery, runtime adoption, real legacy inventory/migration, and checkout sagas remain open. **Depends on:** CONFIG-001 and PAY-001 ### Current atomic boundary PAY-002A1 defines only a dependency-free version-1 target: separate payment, registration, fulfillment, confirmed-refund, and per-dispute reducers; fixed cross-dimension validation; and a synthetic legacy classifier/redacted aggregate report. Orders/registrations have no canonical singular `disputeStatus`; each dispute is validated separately against paid payment context. Operational `fulfilled`, `transferred`, and `cancelled` legacy values require separate compatible payment evidence rather than manufacturing payment state. It does not import Firebase/Stripe, expose a Function, change the webhook, write a compatibility field, enumerate a real record, or deploy. -The next boundaries remain separate. A later inventory/migration child must use #113-approved data categories and a dry-run before writes. PAY-002B1 [#163](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/163) owns only pure caller-scoped command keys, payload fingerprints, and deterministic Stripe keys with closed environment/mode pairing. A provider attempt is one logical generation, not an HTTP retry. PAY-002B2A [#165](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/165) owns only the atomic `checkoutRequests/{commandKeyHash}` registered record plus deterministic revision-1 `auditEvents` partner. Exact existing pairs are read-only; same-key command-type/schema/payload conflicts and malformed pairs reject. Environment and caller scope derive different B1 keys; there is no cross-scope UUID query. B2A adds no lease, execute/send permission, result replay, provider plan/attempt, or reconciliation. PAY-002B2B owns leases/fencing, stale-worker rejection, terminal pointers/fixed final failure, and transition audit without provider-send permission. PAY-002B2C owns immutable provider-plan binding, safe-send cutoff, ambiguous outcomes, reconciliation, and safe provider-attempt advancement. PAY-002C/D, PAY-003B, RACE-001B, MERCH-001B, PAY-004, and PAY-005 depend on B2C. +The next boundaries remain separate. A later inventory/migration child must use #113-approved data categories and a dry-run before writes. PAY-002B1 [#163](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/163) owns only pure caller-scoped command keys, payload fingerprints, and deterministic Stripe keys with closed environment/mode pairing. A provider attempt is one logical generation, not an HTTP retry. PAY-002B2A [#165](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/165) owns only the atomic `checkoutRequests/{commandKeyHash}` registered record plus deterministic revision-1 `auditEvents` partner. Exact existing pairs are read-only; same-key command-type/schema/payload conflicts and malformed pairs reject. Environment and caller scope derive different B1 keys; there is no cross-scope UUID query. B2A adds no lease, execute/send permission, result replay, provider plan/attempt, or reconciliation. -B2A adds no TTL. Deletion could re-enable an old command, 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 pair is removed. CI-001B3 [#167](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/167) runs #165's exact demo-project/opt-in transaction suite as a separate hosted job and requires it in protected-release CI evidence. That hosted result remains synthetic source proof only. No #165 endpoint/index export, Firebase deployment, Stripe/provider configuration, production read/write, or officer behavior exists. +PAY-002B2B [#169](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/169) owns the unused lease/fence source and tests. It preserves those B2A records exactly and stores mutable state separately at `checkoutRequests/{commandKeyHash}/lifecycle/current`. A fixed 60-second server lease fingerprints a trusted UUID v4 holder; a monotonic fence rejects stale or expired workers; each real change appends one deterministic audit. Terminal success stores only a command-bound server-only commitment, not a business/Stripe result, action proof, or replay value. The lease does not authorize the caller or permit a provider request. PAY-002B2C still owns immutable provider-plan binding, safe-send cutoff, ambiguous outcomes, reconciliation, and safe provider-attempt advancement. PAY-002C/D, PAY-003B, RACE-001B, MERCH-001B, PAY-004, and PAY-005 depend on B2C. + +B2A/B2B add no TTL. Deletion could re-enable an old command, 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 expands that same file for lease and terminal races. Hosted results remain synthetic source proof only. #169's source is unused: it adds 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 607b4ca3..40adb011 100644 --- a/GITHUB_ISSUE_SLICES.md +++ b/GITHUB_ISSUE_SLICES.md @@ -65,7 +65,7 @@ An agent-ready child has one observable outcome, one trust boundary, explicit de | PAY-002A2 | proposed; PAY-002A1, #113 owner-approved inventory | Use an owner-approved private read-only inventory for any real legacy/provider references (no mutation/export or copying into tests/issues), define one context-aware compatibility derivation, and use only synthetic emulator fixtures for the dry-run/idempotent migration plan. | Redacted counts/reasons for every approved fixture; repeat synthetic dry-run is identical; no production write until separately approved. | | PAY-002B1 | source/tests tracked in live [#163](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/163); PAY-002A1; no journal/runtime/deploy | Define the pure caller-scoped command-key, UUID/payload-fingerprint, and deterministic Stripe-key contract. The command key excludes command type so B2 can reject cross-operation ID reuse; environment/mode pairs fail closed; provider attempt means one logical generation and survives lease/HTTP retry. | Canonical UUID, golden length-framed digests, environment/mode and dimension separation, hostile frozen payload, key-length/raw-value absence, immutability, and no-side-effect tests. | | PAY-002B2A | source/tests tracked in live [#165](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/165); PAY-002B1; unused/no runtime or deploy | Atomically create one immutable `registered` command and deterministic revision-1 audit partner, return a fixed read-only exact-existing classification, and reject same-key type/schema/payload conflicts or malformed pairs. Environment/caller changes derive another B1 key; stored mismatches are corruption. No lease, execute/send permission, result replay, provider, or reconciliation API. | Mocked transaction plus dedicated opt-in demo-emulator concurrent pair/conflict/orphan/failure tests; CI-001B3 [#167](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/167) adds the exact suite as a hosted release prerequisite. | -| PAY-002B2B | proposed; PAY-002B2A | Add leases, monotonic fence epochs, stale-worker rejection, terminal server-only pointers/fixed final failure, and append-oriented transition audit without provider-send permission. | Concurrent lease/takeover/stale fence, terminal replay-pointer, fixed-failure, and transaction-retry emulator tests. | +| 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-002B2C | proposed; PAY-002B2B | Bind an immutable Stripe account/sandbox/API-version/endpoint/operation/mode/parameters plan, attempt/key, and pre-POST first-send evidence; stop old/unknown automatic POST, record ambiguity, reconcile verified evidence, and advance only a proven-safe logical attempt. | Exact-plan/key reuse, timeout/5xx/unknown-send cutoff, conflicting-plan, old-key, reconciliation, and safe/unsafe attempt-advance emulator/provider-fixture tests. | | PAY-002C | proposed; PAY-001B, PAY-002B2C, 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-002B2C, MERCH-001B | Convert merchandise/catalog creation to persistence-first order/hold plus deterministic Stripe keys. | One order/Session/catalog object on retry; failure recovery tests. | diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 172b5168..aa12b9be 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. B2A grants no authorization, lease, execute/send permission, result replay, provider attempt, or reconciliation; B2B/C and every runtime adopter remain open. Its concurrency proof uses a dedicated opt-in local emulator project; current hosted Functions CI does not run that opt-in suite, and the standard differently named demo run safely skips it. 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. A lease is not authorization or provider-send permission, and its terminal commitment is not business/provider proof or result 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 expands. B2C 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 registered command/audit pair only. PAY-001B/C/D must still wire exact endpoint schemas/snapshots; PAY-002B2B must add leases/fences/terminal pointers without send permission; PAY-002B2C must bind the provider plan and safe-send/reconciliation lifecycle; 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 #165 does not deploy or configure 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-001B/C/D must still wire exact endpoint schemas/snapshots; PAY-002B2C must bind the provider plan and safe-send/reconciliation lifecycle; 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 #169 does not deploy or configure 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 def4f834..c26e343a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -95,9 +95,9 @@ 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/C must add lease/fence/terminal and provider-plan/recovery behavior; 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. | Hashes are pseudonymous server-only identifiers—not anonymization—and equality is neither authorization nor durable replay proof. No endpoint imports the library. PAY-002B2A/#165 registers only a command/audit pair; it does not add a lease, provider plan, send permission, or result replay. PAY-002B2B must add leases/fencing and terminal pointers; PAY-002B2C must preserve ambiguous provider attempts/keys, stop old/unknown-send-time automatic POST retries, reconcile evidence, and allow only safe attempt advancement. PAY-002C/D and PAY-005 must adopt B2C 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) adds its dedicated exact demo-project/opt-in suite as a named hosted job and protected-release prerequisite. | Registration is neither authorization nor execution permission. There is no endpoint/index export, lease/fence, provider send, result replay, Stripe object, attempt transition, or reconciliation. 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. | +| 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-002B2C must still add provider-plan/recovery behavior; 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. | Hashes are pseudonymous server-only identifiers—not anonymization—and equality is neither authorization nor durable replay proof. No endpoint imports the library. PAY-002B2A/#165 registers only a command/audit pair. PAY-002B2B source/tests are tracked in live [#169](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/169) for a separate fixed-duration lease, holder fingerprint, monotonic fence, terminal commitment, and transition audit. The source does not add authorization, provider-send permission, result proof, or replay. PAY-002B2C must preserve ambiguous provider attempts/keys, stop old/unknown-send-time automatic POST retries, reconcile evidence, and allow only safe attempt advancement. PAY-002C/D and PAY-005 must adopt B2C 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. | | RISK-001, RISK-002, RISK-034 | PAY-003A [#101](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/101) source merged as `87bd1210575882cdd06214bcb98ed38ce00f71c2`, adding durable Stripe Event deduplication, transactional transition/processed marking, paid/amount/currency/Checkout-mode/reference-shape and explicitly configured livemode checks, async outcomes, monotonic refund/terminal guards, quarantine, generic signature failures, and a webhook-secret-only binding. | Firebase was not deployed. Complete CONFIG/PAY-001/PAY-002 contracts, metadata schema allowlist/migration, canonical reducers/reservations/outbox, retry/dead-letter/TTL/alerts, emulator integration, Stripe test-mode rehearsal, protected deploy, and reconcile. | @@ -110,7 +110,7 @@ These entries are implementation evidence, not a production risk-acceptance deci | Part of RISK-014 | SUPPLY-001A [#127](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/127) removes the unused direct `gpxparser` dependency and 50 lockfile entries without changing any retained package version, source URL, or integrity hash. A fresh Node 20 install lowers the root production audit from 33 findings (3 critical, 9 high) to 22 (1 critical, 8 high), and the optimized production asset hashes remain identical. | Remediate or time-bound the remaining root and Functions findings through the separately tested router, Firebase, Stripe, build-system, and scanning slices; do not use a forced audit upgrade. | | 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) adds one lockfile-bound Node 20/Java 17 `Commerce command journal emulator` job using exact demo project `demo-pay002b2-test`, Firestore only, and explicit opt-in. Static negative tests require that exact job at both protected-release CI rechecks. | Record exact pull-request and post-merge hosted evidence for #167. A green demo-emulator job proves synthetic source behavior only; it does not deploy Firebase or prove Stripe/live behavior. Make lint non-mutating/fail-closed, configure and 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. Static negative tests require that exact job at both protected-release CI rechecks. | #169 expands the same focused suite for lease/takeover/stale-fence/terminal races. A green demo-emulator job proves synthetic source behavior only; it does not deploy Firebase or prove Stripe/live behavior. Make lint non-mutating/fail-closed, configure and 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 result after provider success or failure, an immediate repeat guard, and the same browser wait. Synthetic tests use mocked calls and canary values only. | Publish and verify each exact frontend revision separately. #118 profile source is merged but live behavior is unproven. Keep delivery, Spam, DNS, templates, and private Firebase email-enumeration-protection readback under #119. Identical browser copy does not prove the direct Firebase API suppresses account-state differences. An accepted verification request does not prove delivery, and browser cooldowns can reset; they are not server abuse protection. Incoming action-code recovery remains AUTH-MAIL-002C2 under #120. | diff --git a/STRIPE_COMMERCE_DESIGN.md b/STRIPE_COMMERCE_DESIGN.md index 776ac468..41a23461 100644 --- a/STRIPE_COMMERCE_DESIGN.md +++ b/STRIPE_COMMERCE_DESIGN.md @@ -238,7 +238,7 @@ PAY-002B1 is tracked in live [#163](https://github.com/Run-MPRC/Run-MPRC.github. Version 1 supports only the closed caller scopes `firebase_uid`, `anonymous_principal`, and the fixed `internal_system` principal. It does not support multi-tenant Firebase Auth. A future multi-tenant version must bind the verified tenant identity into the command key rather than treating a UID alone as globally unique. -A Firestore worker-lease takeover is not a new provider attempt. Here, provider attempt means one logical provider generation, not an HTTP retry count. A network error, timeout, Stripe `5xx`, or unknown outcome never advances it. Before a conservative stored first-send deadline, retry uses the exact same parameters, attempt, and key. Stripe may prune a key after at least 24 hours; after the safe deadline—or when first-send time is unknown—PAY-002B2C must stop automatic POST retries and reconcile stored provider references plus verified webhook/metadata or authorized operator evidence. A later provider attempt is allowed only after separately proven non-execution or an explicitly safe corrected/new logical operation. PAY-002B2A owns only first registration, PAY-002B2B owns lease/fence and terminal-pointer state without provider-send permission, and PAY-002B2C owns the immutable provider plan, safe-send boundary, ambiguous outcomes, and reconciliation. +A Firestore worker-lease takeover is not a new provider attempt. Here, provider attempt means one logical provider generation, not an HTTP retry count. A network error, timeout, Stripe `5xx`, or unknown outcome never advances it. Before a conservative stored first-send deadline, retry uses the exact same parameters, attempt, and key. Stripe may prune a key after at least 24 hours; after the safe deadline—or when first-send time is unknown—PAY-002B2C must stop automatic POST retries and reconcile stored provider references plus verified webhook/metadata or authorized operator evidence. A later provider attempt is allowed only after separately proven non-execution or an explicitly safe corrected/new logical operation. PAY-002B2A owns only first registration, PAY-002B2B owns lease/fence and terminal-commitment state without provider-send permission, and PAY-002B2C owns the immutable provider plan, safe-send boundary, ambiguous outcomes, and reconciliation. Command hashes and fingerprints are pseudonymous server-only identifiers, not anonymization. They must not be returned to browsers or placed in logs, URLs, analytics, screenshots, or public evidence. This source contract performs no authorization, Firestore/Stripe call, clock/random operation, deployment, or provider configuration. No endpoint imports it yet, so it does not currently prevent a duplicate payment operation. @@ -264,7 +264,17 @@ Neither document stores the raw caller, command UUID, payload, personal data, St B2A adds no TTL. Deleting the pair can make an old command appear new. Before any later retention job removes it, the owner-approved policy in [#110](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/110) must define a server-only tombstone or equivalent durable duplicate barrier that preserves conflict detection without raw identity or payload data. Until then, deletion is not safe. -The concurrency proof for #165 runs only against its dedicated local demo Firestore emulator. Current hosted Functions CI neither starts that exact demo project nor sets the suite's explicit opt-in flag, so hosted unit CI does not gate the transactional emulator cases. The repository's standard differently named demo project safely skips this isolated suite. Source, tests, review, merge, and hosted CI are separate from Firebase deployment, Stripe configuration, and live behavior. +CI-001B3 [#167](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/167) runs this exact opt-in demo Firestore suite in a separate named hosted job and requires that job in protected-release source checks. The standard differently named demo run still skips the isolated suite. This is synthetic source proof only. It does not deploy Firebase, configure Stripe, or prove live behavior. + +### Lease, fence, and terminal commitment target (PAY-002B2B) + +PAY-002B2B source/tests are tracked in live [#169](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/169). They preserve the B2A root and revision-1 audit exactly. Mutable state is stored separately at `checkoutRequests/{commandKeyHash}/lifecycle/current`, so an old registration can still be compared byte-for-byte and a malformed or orphan lifecycle fails closed. + +The target uses a fixed 60-second server lease. Trusted server code supplies one canonical lowercase UUID v4 for each worker attempt, but the journal stores only a command-bound SHA-256 fingerprint. The raw lease ID is never stored, returned, or logged. The first holder gets fence `1`. An exact same-holder retry before expiry is read-only, another active holder gets a fixed busy result with no fence or expiry, and an expired takeover increments the fence. A stale holder, stale fence, or expired lease cannot finish the command. + +The only terminal states are `succeeded` and `failed_final`. Success stores a write-once command-bound commitment to a later server-only business result; final failure stores no reason. The commitment is not a business or Stripe result, proof that an action happened, or a value that can replay a response. Every real lifecycle change appends one deterministic audit event. Recovery reads, busy observations, exact terminal retries, and rejected conflicts append nothing. + +This lease is concurrency evidence only. It grants no authorization, inventory or capacity claim, price approval, execute/send permission, provider plan, safe POST retry, reconciliation decision, or result replay. PAY-002B2C and the domain-specific PAY-002C/D work remain required before any Stripe call can claim retry safety. #169's source is unused; no endpoint adopts it, and no Firebase deployment, Stripe/provider configuration, production read/write, website change, or live behavior is claimed. ## 7. Persistence-first checkout saga diff --git a/SYSTEM_DESIGN.md b/SYSTEM_DESIGN.md index ba154623..27704300 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -245,8 +245,9 @@ The first release may continue using `admin` in the UI, but server endpoints and | Path or field | Purpose | | --- | --- | | `stripeEvents/{stripeEventId}` | Durable, non-PII webhook inbox/deduplication record with processing status and business reference | -| `checkoutRequests/{commandKeyHash}` | Server-only command journal. PAY-002B2A/#165 adds only an immutable `registered` revision-1 record; PAY-002B2B/C must add lease/fence, terminal-pointer, provider-plan, safe-send, and reconciliation state. | -| `auditEvents/{eventId}` or bounded per-record audit subcollections | Append-oriented operational and security audit trail. B2A's first event is exactly `auditEvents/commerce_command_{commandKeyHash}_0000000001`. | +| `checkoutRequests/{commandKeyHash}` | Immutable server-only command registration. PAY-002B2A/#165 owns the exact `registered` revision-1 record. | +| `checkoutRequests/{commandKeyHash}/lifecycle/current` | PAY-002B2B/#169 server-only lease, monotonic fence, and terminal commitment source. It is unused and is not provider-send permission. | +| `auditEvents/{eventId}` or bounded per-record audit subcollections | Append-oriented operational and security audit trail. B2A's first event is exactly `auditEvents/commerce_command_{commandKeyHash}_0000000001`; B2B targets one deterministic event for each real lifecycle change. | | `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 | @@ -265,7 +266,11 @@ PAY-002B1 is tracked in live [#163](https://github.com/Run-MPRC/Run-MPRC.github. PAY-002B2A is tracked in live [#165](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/165). Its unused server-only transaction writes the exact `checkoutRequests/{commandKeyHash}` registered record and `auditEvents/commerce_command_{commandKeyHash}_0000000001` event together with one trusted Timestamp, or writes neither. Exact retries are read-only; command-type, endpoint-version, or payload mismatch under the same B1 key conflicts; corrupt or incomplete pairs fail closed without repair. Environment and caller scope are already bound into the B1 document ID, so a different scope creates a different record rather than a cross-scope conflict. The fixed result grants no authorization or execute/send permission and contains no hash, path, raw identity, UUID, or payload. -B2A has no endpoint/index export, lease, fence, terminal pointer, result replay, provider plan/key/object, Stripe call, provider attempt transition, safe-send clock, or reconciliation behavior. PAY-002B2B owns lease/fence and terminal-pointer state without provider-send permission. PAY-002B2C owns immutable provider-plan binding, pre-send evidence, conservative retry cutoff, ambiguous outcome, verified reconciliation, and safe attempt advancement. No TTL is safe yet: [#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 a command pair can be deleted. The #165 concurrent transaction proof is dedicated local-emulator evidence; hosted Functions CI does not start its exact demo project or set its explicit opt-in flag, and the standard differently named demo run safely skips it. None of this deploys Firebase, configures Stripe, or changes live/officer behavior. +B2A has no endpoint/index export, lease, fence, terminal commitment, result replay, provider plan/key/object, Stripe call, provider attempt transition, safe-send clock, or reconciliation behavior. PAY-002B2B source/tests are tracked in live [#169](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/169). They keep the B2A root and revision-1 audit immutable and store mutable state at `checkoutRequests/{commandKeyHash}/lifecycle/current`. The source uses a fixed 60-second server lease, a command-bound fingerprint of a trusted UUID v4 holder, and a monotonic fence so stale or expired workers cannot finish. Each real lease or terminal change gets one deterministic audit event. + +B2B terminal success stores only a command-bound commitment to a later server-only business result. That commitment is not the result itself, proof that Stripe or Firestore work happened, or response replay. The lease/fence is concurrency evidence, not authorization or provider-send permission. PAY-002B2C still owns immutable provider-plan binding, pre-send evidence, conservative retry cutoff, ambiguous outcome, verified reconciliation, and safe attempt advancement. No TTL is safe yet: [#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 a command pair can be deleted. + +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 expands that same suite. These are synthetic source checks only. #169's source remains unused and makes no endpoint, Firebase deployment, Stripe/provider configuration, production data, website, or live/officer change. ## 7. Business invariants diff --git a/firestore.rules b/firestore.rules index 8991eecb..7fc2ce07 100644 --- a/firestore.rules +++ b/firestore.rules @@ -441,6 +441,13 @@ service cloud.firestore { match /checkoutRequests/{requestId} { allow read, write: if false; + + // Firestore subcollections do not inherit their parent document's rule. + // Keep every command lifecycle and any future nested command record at + // this server-only boundary, including from browser-admin clients. + match /{nestedDocument=**} { + allow read, write: if false; + } } match /auditEvents/{auditEventId} { diff --git a/functions/commerceCommandJournal.emulator.test.js b/functions/commerceCommandJournal.emulator.test.js index a4328e6e..37b558f0 100644 --- a/functions/commerceCommandJournal.emulator.test.js +++ b/functions/commerceCommandJournal.emulator.test.js @@ -6,6 +6,9 @@ const { } = require('./commerceCommandIdentity'); const { CommerceCommandJournalError, + acquireCommerceCommandLease, + completeCommerceCommand, + failCommerceCommand, registerCommerceCommand, } = require('./commerceCommandJournal'); @@ -17,7 +20,7 @@ const describeWithEmulator = process.env[JOURNAL_EMULATOR_OPT_IN] === '1' ? describe : describe.skip; -jest.setTimeout(30000); +jest.setTimeout(60000); const COMMAND_IDS = Object.freeze({ identical: '11111111-1111-4111-8111-111111111111', @@ -26,7 +29,25 @@ const COMMAND_IDS = Object.freeze({ lostResponse: '44444444-4444-4444-8444-444444444444', malformed: '55555555-5555-4555-8555-555555555555', future: '66666666-6666-4666-8666-666666666666', + distinctLeaseRace: '77777777-7777-4777-8777-777777777777', + sameLeaseRace: '88888888-8888-4888-8888-888888888888', + takeoverRace: '99999999-9999-4999-8999-999999999999', + terminalRace: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + commitmentRace: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + terminalReplay: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + finalFailureReplay: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', + preseedLifecycleAudit: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee', + malformedLifecycle: 'ffffffff-ffff-4fff-8fff-ffffffffffff', + orphanLifecycle: '01234567-89ab-4cde-8fab-0123456789ab', + preseedTerminalAudit: '12345678-9abc-4def-8abc-123456789abc', + missingCurrentAudit: '23456789-abcd-4efa-8bcd-23456789abcd', + malformedLifecycleShape: '3456789a-bcde-4fab-8cde-3456789abcde', + malformedCurrentAudit: '456789ab-cdef-4abc-8def-456789abcdef', }); +const BASE_NOW_MILLIS = 1800000000123; +const LEASE_DURATION_MILLIS = 60000; +const TERMINAL_REFERENCE_FINGERPRINT = 'd'.repeat(64); +const OTHER_TERMINAL_REFERENCE_FINGERPRINT = 'e'.repeat(64); function frozenRecord(entries) { const record = Object.create(null); @@ -57,6 +78,33 @@ function commandArgs(db, commandId, commandPayload) { }; } +function deterministicLeaseId(index) { + return `10000000-0000-4000-8000-${index.toString(16).padStart(12, '0')}`; +} + +function acquireArgs(db, commandId, commandPayload, leaseId) { + return { + ...commandArgs(db, commandId, commandPayload), + leaseId, + }; +} + +function completeArgs(db, commandId, commandPayload, leaseId, expectedFenceEpoch, + terminalReferenceFingerprint = TERMINAL_REFERENCE_FINGERPRINT) { + return { + ...acquireArgs(db, commandId, commandPayload, leaseId), + expectedFenceEpoch, + terminalReferenceFingerprint, + }; +} + +function failArgs(db, commandId, commandPayload, leaseId, expectedFenceEpoch) { + return { + ...acquireArgs(db, commandId, commandPayload, leaseId), + expectedFenceEpoch, + }; +} + function assertExactSafeEmulator() { if (process.env[JOURNAL_EMULATOR_OPT_IN] !== '1') { throw new Error('The commerce command journal emulator opt-in is required.'); @@ -130,15 +178,28 @@ describeWithEmulator('commerce command journal Firestore transaction', () => { let app; let db; let trackedRefs = new Map(); + let nowMillis; + let timestampNow; + + function trackRef(ref) { + trackedRefs.set(ref.path, ref); + return ref; + } + + function auditRefFor(commandKeyHash, revision) { + return trackRef(db.collection('auditEvents') + .doc(`commerce_command_${commandKeyHash}_${String(revision).padStart(10, '0')}`)); + } function pairFor(args) { const { commandKeyHash } = commandIdentity(args); - const commandRef = db.collection('checkoutRequests').doc(commandKeyHash); - const auditRef = db.collection('auditEvents') - .doc(`commerce_command_${commandKeyHash}_0000000001`); - trackedRefs.set(commandRef.path, commandRef); - trackedRefs.set(auditRef.path, auditRef); - return { commandKeyHash, commandRef, auditRef }; + const commandRef = trackRef(db.collection('checkoutRequests').doc(commandKeyHash)); + const lifecycleRef = trackRef(commandRef.collection('lifecycle').doc('current')); + const auditRef = auditRefFor(commandKeyHash, 1); + // Track every revision this focused suite can create before a concurrent + // assertion runs, so even an early failure cannot leak synthetic audits. + for (const revision of [2, 3, 4]) auditRefFor(commandKeyHash, revision); + return { commandKeyHash, commandRef, lifecycleRef, auditRef }; } async function readPair(pair) { @@ -149,6 +210,73 @@ describeWithEmulator('commerce command journal Firestore transaction', () => { return { commandSnapshot, auditSnapshot }; } + async function readLifecycle(pair) { + return pair.lifecycleRef.get(); + } + + async function readAudit(pair, revision) { + return auditRefFor(pair.commandKeyHash, revision).get(); + } + + async function readLifecycleEvidence(pair, revision) { + const [lifecycleSnapshot, auditSnapshot] = await Promise.all([ + readLifecycle(pair), + readAudit(pair, revision), + ]); + return { + lifecycle: snapshotEvidence(lifecycleSnapshot), + audit: snapshotEvidence(auditSnapshot), + }; + } + + async function matchingAudits(pair) { + return db.collection('auditEvents') + .where('commandKeyHash', '==', pair.commandKeyHash) + .get(); + } + + function setTrustedNow(millis) { + nowMillis = millis; + } + + function expectLeaseResult(result, fenceEpoch) { + expect(result).toEqual({ + journalSchemaVersion: 1, + lifecycleSchemaVersion: 1, + outcome: 'lease_acquired', + state: 'leased', + fenceEpoch, + leaseExpiresAt: { + seconds: expect.any(Number), + nanoseconds: expect.any(Number), + }, + }); + expect(Object.isFrozen(result)).toBe(true); + expect(Object.isFrozen(result.leaseExpiresAt)).toBe(true); + } + + function expectBusyResult(result) { + expect(result).toEqual({ + journalSchemaVersion: 1, + lifecycleSchemaVersion: 1, + outcome: 'lease_busy', + state: 'leased', + }); + expect(Object.isFrozen(result)).toBe(true); + expect(result).not.toHaveProperty('fenceEpoch'); + expect(result).not.toHaveProperty('leaseExpiresAt'); + } + + function expectTerminalResult(result, state) { + expect(result).toEqual({ + journalSchemaVersion: 1, + lifecycleSchemaVersion: 1, + outcome: state === 'succeeded' ? 'terminal_succeeded' : 'terminal_failed_final', + state, + }); + expect(Object.isFrozen(result)).toBe(true); + } + async function cleanupTrackedRefs() { if (!trackedRefs) return; const refs = [...trackedRefs.values()]; @@ -168,9 +296,13 @@ describeWithEmulator('commerce command journal Firestore transaction', () => { beforeEach(() => { trackedRefs = new Map(); + nowMillis = BASE_NOW_MILLIS; + timestampNow = jest.spyOn(admin.firestore.Timestamp, 'now') + .mockImplementation(() => admin.firestore.Timestamp.fromMillis(nowMillis)); }); afterEach(async () => { + timestampNow.mockRestore(); await cleanupTrackedRefs(); }); @@ -350,4 +482,585 @@ describeWithEmulator('commerce command journal Firestore transaction', () => { expect(snapshotEvidence(afterAttempt.auditSnapshot)) .toEqual(snapshotEvidence(future.auditSnapshot)); }); + + test('24 distinct holders race to one first lease and preserve registration bytes', async () => { + const commandPayload = payload('distinct-lease-race'); + const registrationArgs = commandArgs( + db, + COMMAND_IDS.distinctLeaseRace, + commandPayload, + ); + const pair = pairFor(registrationArgs); + await registerCommerceCommand(registrationArgs); + const registrationBefore = await readPair(pair); + + const leaseArgs = Array.from({ length: CONCURRENT_CALLS }, (_, index) => acquireArgs( + db, + COMMAND_IDS.distinctLeaseRace, + commandPayload, + deterministicLeaseId(index + 1), + )); + const results = await Promise.all(leaseArgs.map(acquireCommerceCommandLease)); + const acquired = results.filter((result) => result.outcome === 'lease_acquired'); + const busy = results.filter((result) => result.outcome === 'lease_busy'); + + expect(acquired).toHaveLength(1); + expect(busy).toHaveLength(CONCURRENT_CALLS - 1); + expectLeaseResult(acquired[0], 1); + for (const result of busy) expectBusyResult(result); + + const lifecycle = await readLifecycle(pair); + const lifecycleAudit = await readAudit(pair, 2); + expect(lifecycle.data()).toMatchObject({ + lifecycleSchemaVersion: 1, + commandKeyHash: pair.commandKeyHash, + state: 'leased', + commandRevision: 2, + fenceEpoch: 1, + terminalCommitmentKind: null, + terminalCommitmentHash: null, + }); + expect(lifecycle.data().leaseAcquiredAt.toMillis()).toBe(BASE_NOW_MILLIS); + expect(lifecycle.data().leaseExpiresAt.toMillis()) + .toBe(BASE_NOW_MILLIS + LEASE_DURATION_MILLIS); + expect(lifecycleAudit.data()).toMatchObject({ + auditSchemaVersion: 2, + commandKeyHash: pair.commandKeyHash, + commandRevision: 2, + eventType: 'command_lease_acquired', + fromState: 'registered', + toState: 'leased', + fenceEpoch: 1, + }); + expect((await matchingAudits(pair)).size).toBe(2); + + const registrationAfter = await readPair(pair); + expect(snapshotEvidence(registrationAfter.commandSnapshot)) + .toEqual(snapshotEvidence(registrationBefore.commandSnapshot)); + expect(snapshotEvidence(registrationAfter.auditSnapshot)) + .toEqual(snapshotEvidence(registrationBefore.auditSnapshot)); + await expect(registerCommerceCommand(registrationArgs)).resolves.toEqual({ + journalSchemaVersion: 1, + outcome: 'registered_existing', + state: 'registered', + }); + }); + + test('24 same-holder lost-response calls recover one identical lease without another write', async () => { + const commandPayload = payload('same-holder-recovery'); + const registrationArgs = commandArgs(db, COMMAND_IDS.sameLeaseRace, commandPayload); + const args = acquireArgs( + db, + COMMAND_IDS.sameLeaseRace, + commandPayload, + deterministicLeaseId(50), + ); + const pair = pairFor(args); + await registerCommerceCommand(registrationArgs); + + const results = await Promise.all( + Array.from({ length: CONCURRENT_CALLS }, () => acquireCommerceCommandLease(args)), + ); + for (const result of results) { + expectLeaseResult(result, 1); + expect(result).toEqual(results[0]); + } + + const beforeRecovery = await readLifecycleEvidence(pair, 2); + const recovered = await Promise.all( + Array.from({ length: 8 }, () => acquireCommerceCommandLease(args)), + ); + expect(recovered).toEqual(Array.from({ length: 8 }, () => results[0])); + const afterRecovery = await readLifecycleEvidence(pair, 2); + expect(afterRecovery).toEqual(beforeRecovery); + expect((await matchingAudits(pair)).size).toBe(2); + }); + + test('an expired 24-holder race advances one fence and rejects the old holder and fence', async () => { + const commandPayload = payload('expired-takeover'); + const registrationArgs = commandArgs(db, COMMAND_IDS.takeoverRace, commandPayload); + const oldLeaseId = deterministicLeaseId(60); + const oldLeaseArgs = acquireArgs( + db, + COMMAND_IDS.takeoverRace, + commandPayload, + oldLeaseId, + ); + const pair = pairFor(oldLeaseArgs); + await registerCommerceCommand(registrationArgs); + const firstLease = await acquireCommerceCommandLease(oldLeaseArgs); + expectLeaseResult(firstLease, 1); + + setTrustedNow(BASE_NOW_MILLIS + LEASE_DURATION_MILLIS); + const contenders = Array.from({ length: CONCURRENT_CALLS }, (_, index) => ({ + leaseId: deterministicLeaseId(index + 100), + args: acquireArgs( + db, + COMMAND_IDS.takeoverRace, + commandPayload, + deterministicLeaseId(index + 100), + ), + })); + const results = await Promise.all( + contenders.map(({ args }) => acquireCommerceCommandLease(args)), + ); + const winningIndexes = results + .map((result, index) => ({ result, index })) + .filter(({ result }) => result.outcome === 'lease_acquired'); + expect(winningIndexes).toHaveLength(1); + expectLeaseResult(winningIndexes[0].result, 2); + for (const result of results.filter((value) => value.outcome === 'lease_busy')) { + expectBusyResult(result); + } + expect(results.filter((result) => result.outcome === 'lease_busy')) + .toHaveLength(CONCURRENT_CALLS - 1); + + const lifecycle = await readLifecycle(pair); + expect(lifecycle.data()).toMatchObject({ + state: 'leased', + commandRevision: 3, + fenceEpoch: 2, + }); + const takeoverAudit = await readAudit(pair, 3); + expect(takeoverAudit.data()).toMatchObject({ + commandRevision: 3, + eventType: 'command_lease_taken_over', + fromState: 'leased', + toState: 'leased', + fenceEpoch: 2, + }); + expect((await matchingAudits(pair)).size).toBe(3); + + const beforeStaleFinish = await readLifecycleEvidence(pair, 3); + const staleAttempts = await Promise.allSettled([ + completeCommerceCommand(completeArgs( + db, + COMMAND_IDS.takeoverRace, + commandPayload, + oldLeaseId, + 1, + )), + failCommerceCommand(failArgs( + db, + COMMAND_IDS.takeoverRace, + commandPayload, + oldLeaseId, + 1, + )), + ]); + expect(staleAttempts).toHaveLength(2); + for (const result of staleAttempts) { + expect(result.status).toBe('rejected'); + expectJournalError(result.reason, 'lease_stale'); + } + expect(await readLifecycleEvidence(pair, 3)).toEqual(beforeStaleFinish); + expect((await matchingAudits(pair)).size).toBe(3); + }); + + test('concurrent complete-versus-final-fail commits exactly one terminal outcome', async () => { + const commandPayload = payload('terminal-outcome-race'); + const leaseId = deterministicLeaseId(200); + const registrationArgs = commandArgs(db, COMMAND_IDS.terminalRace, commandPayload); + const leaseArgs = acquireArgs( + db, + COMMAND_IDS.terminalRace, + commandPayload, + leaseId, + ); + const pair = pairFor(leaseArgs); + await registerCommerceCommand(registrationArgs); + await acquireCommerceCommandLease(leaseArgs); + setTrustedNow(BASE_NOW_MILLIS + 1); + + const settled = await Promise.allSettled([ + completeCommerceCommand(completeArgs( + db, + COMMAND_IDS.terminalRace, + commandPayload, + leaseId, + 1, + )), + failCommerceCommand(failArgs( + db, + COMMAND_IDS.terminalRace, + commandPayload, + leaseId, + 1, + )), + ]); + const fulfilled = settled.filter((result) => result.status === 'fulfilled'); + const rejected = settled.filter((result) => result.status === 'rejected'); + expect(fulfilled).toHaveLength(1); + expect(rejected).toHaveLength(1); + expect(['succeeded', 'failed_final']).toContain(fulfilled[0].value.state); + expectTerminalResult(fulfilled[0].value, fulfilled[0].value.state); + expectJournalError(rejected[0].reason, 'terminal_conflict'); + + const lifecycle = await readLifecycle(pair); + const terminalAudit = await readAudit(pair, 3); + expect(lifecycle.data()).toMatchObject({ + state: fulfilled[0].value.state, + commandRevision: 3, + fenceEpoch: 1, + }); + expect(terminalAudit.data()).toMatchObject({ + commandRevision: 3, + eventType: fulfilled[0].value.state === 'succeeded' + ? 'command_succeeded' + : 'command_failed_final', + fromState: 'leased', + toState: fulfilled[0].value.state, + fenceEpoch: 1, + }); + expect((await matchingAudits(pair)).size).toBe(3); + expect((await readAudit(pair, 4)).exists).toBe(false); + }); + + test('distinct success commitments race to one immutable terminal commitment', async () => { + const commandPayload = payload('terminal-commitment-race'); + const leaseId = deterministicLeaseId(210); + const registrationArgs = commandArgs(db, COMMAND_IDS.commitmentRace, commandPayload); + const leaseArgs = acquireArgs( + db, + COMMAND_IDS.commitmentRace, + commandPayload, + leaseId, + ); + const pair = pairFor(leaseArgs); + await registerCommerceCommand(registrationArgs); + await acquireCommerceCommandLease(leaseArgs); + setTrustedNow(BASE_NOW_MILLIS + 1); + + const settled = await Promise.allSettled([ + completeCommerceCommand(completeArgs( + db, + COMMAND_IDS.commitmentRace, + commandPayload, + leaseId, + 1, + TERMINAL_REFERENCE_FINGERPRINT, + )), + completeCommerceCommand(completeArgs( + db, + COMMAND_IDS.commitmentRace, + commandPayload, + leaseId, + 1, + OTHER_TERMINAL_REFERENCE_FINGERPRINT, + )), + ]); + const fulfilled = settled.filter((result) => result.status === 'fulfilled'); + const rejected = settled.filter((result) => result.status === 'rejected'); + expect(fulfilled).toHaveLength(1); + expect(rejected).toHaveLength(1); + expectTerminalResult(fulfilled[0].value, 'succeeded'); + expectJournalError(rejected[0].reason, 'terminal_conflict'); + + const lifecycle = await readLifecycle(pair); + const terminalAudit = await readAudit(pair, 3); + expect(lifecycle.data()).toMatchObject({ + state: 'succeeded', + commandRevision: 3, + fenceEpoch: 1, + terminalCommitmentKind: 'business_record_digest', + terminalCommitmentHash: expect.stringMatching(/^[0-9a-f]{64}$/), + }); + expect(lifecycle.data().terminalCommitmentHash) + .not.toBe(TERMINAL_REFERENCE_FINGERPRINT); + expect(lifecycle.data().terminalCommitmentHash) + .not.toBe(OTHER_TERMINAL_REFERENCE_FINGERPRINT); + expect(terminalAudit.data()).not.toHaveProperty('terminalCommitmentKind'); + expect(terminalAudit.data()).not.toHaveProperty('terminalCommitmentHash'); + expect(JSON.stringify(terminalAudit.data())).not.toContain(TERMINAL_REFERENCE_FINGERPRINT); + expect(JSON.stringify(terminalAudit.data())) + .not.toContain(OTHER_TERMINAL_REFERENCE_FINGERPRINT); + expect((await matchingAudits(pair)).size).toBe(3); + }); + + test.each([ + { + name: 'successful', + commandId: COMMAND_IDS.terminalReplay, + state: 'succeeded', + finalize: completeCommerceCommand, + finalizeArgs: completeArgs, + }, + { + name: 'final-failure', + commandId: COMMAND_IDS.finalFailureReplay, + state: 'failed_final', + finalize: failCommerceCommand, + finalizeArgs: failArgs, + }, + ])('$name terminal retry, later acquire, and registration are read-only', async ({ + commandId, + state, + finalize, + finalizeArgs, + }) => { + setTrustedNow(BASE_NOW_MILLIS); + const commandPayload = payload(`terminal-replay-${state}`); + const leaseId = deterministicLeaseId(state === 'succeeded' ? 220 : 221); + const registrationArgs = commandArgs(db, commandId, commandPayload); + const leaseArgs = acquireArgs(db, commandId, commandPayload, leaseId); + const pair = pairFor(leaseArgs); + await registerCommerceCommand(registrationArgs); + const registrationBefore = await readPair(pair); + await acquireCommerceCommandLease(leaseArgs); + setTrustedNow(BASE_NOW_MILLIS + 1); + + const terminalInput = finalizeArgs( + db, + commandId, + commandPayload, + leaseId, + 1, + ); + expectTerminalResult(await finalize(terminalInput), state); + const terminalBeforeReplay = await readLifecycleEvidence(pair, 3); + const registrationAtTerminal = await readPair(pair); + expect(snapshotEvidence(registrationAtTerminal.commandSnapshot)) + .toEqual(snapshotEvidence(registrationBefore.commandSnapshot)); + expect(snapshotEvidence(registrationAtTerminal.auditSnapshot)) + .toEqual(snapshotEvidence(registrationBefore.auditSnapshot)); + + setTrustedNow(BASE_NOW_MILLIS + (LEASE_DURATION_MILLIS * 2)); + expectTerminalResult(await finalize(terminalInput), state); + expectTerminalResult(await acquireCommerceCommandLease(acquireArgs( + db, + commandId, + commandPayload, + deterministicLeaseId(state === 'succeeded' ? 222 : 223), + )), state); + await expect(registerCommerceCommand(registrationArgs)).resolves.toEqual({ + journalSchemaVersion: 1, + outcome: 'registered_existing', + state: 'registered', + }); + + expect(await readLifecycleEvidence(pair, 3)).toEqual(terminalBeforeReplay); + expect((await matchingAudits(pair)).size).toBe(3); + expect((await readAudit(pair, 4)).exists).toBe(false); + const registrationAfterReplay = await readPair(pair); + expect(snapshotEvidence(registrationAfterReplay.commandSnapshot)) + .toEqual(snapshotEvidence(registrationBefore.commandSnapshot)); + expect(snapshotEvidence(registrationAfterReplay.auditSnapshot)) + .toEqual(snapshotEvidence(registrationBefore.auditSnapshot)); + }); + + test('preseeded next audits block first lease and terminal completion atomically', async () => { + const firstPayload = payload('preseed-first-lifecycle-audit'); + const firstRegistration = commandArgs( + db, + COMMAND_IDS.preseedLifecycleAudit, + firstPayload, + ); + const firstLease = acquireArgs( + db, + COMMAND_IDS.preseedLifecycleAudit, + firstPayload, + deterministicLeaseId(230), + ); + const firstPair = pairFor(firstLease); + await registerCommerceCommand(firstRegistration); + const firstRegistrationBefore = await readPair(firstPair); + const preseededRevisionTwo = auditRefFor(firstPair.commandKeyHash, 2); + await preseededRevisionTwo.create({ syntheticPreseed: 'revision-two' }); + const revisionTwoBefore = await preseededRevisionTwo.get(); + + await expect(acquireCommerceCommandLease(firstLease)).rejects.toMatchObject({ + code: 'commerce_command_journal_error', + reason: 'journal_record_invalid', + }); + expect((await readLifecycle(firstPair)).exists).toBe(false); + expect(snapshotEvidence(await preseededRevisionTwo.get())) + .toEqual(snapshotEvidence(revisionTwoBefore)); + const firstRegistrationAfter = await readPair(firstPair); + expect(snapshotEvidence(firstRegistrationAfter.commandSnapshot)) + .toEqual(snapshotEvidence(firstRegistrationBefore.commandSnapshot)); + expect(snapshotEvidence(firstRegistrationAfter.auditSnapshot)) + .toEqual(snapshotEvidence(firstRegistrationBefore.auditSnapshot)); + + const terminalPayload = payload('preseed-terminal-audit'); + const terminalRegistration = commandArgs( + db, + COMMAND_IDS.preseedTerminalAudit, + terminalPayload, + ); + const terminalLeaseId = deterministicLeaseId(231); + const terminalLease = acquireArgs( + db, + COMMAND_IDS.preseedTerminalAudit, + terminalPayload, + terminalLeaseId, + ); + const terminalPair = pairFor(terminalLease); + await registerCommerceCommand(terminalRegistration); + await acquireCommerceCommandLease(terminalLease); + const lifecycleBefore = await readLifecycleEvidence(terminalPair, 2); + const preseededRevisionThree = auditRefFor(terminalPair.commandKeyHash, 3); + await preseededRevisionThree.create({ syntheticPreseed: 'revision-three' }); + const revisionThreeBefore = await preseededRevisionThree.get(); + setTrustedNow(BASE_NOW_MILLIS + 1); + + await expect(completeCommerceCommand(completeArgs( + db, + COMMAND_IDS.preseedTerminalAudit, + terminalPayload, + terminalLeaseId, + 1, + ))).rejects.toMatchObject({ + code: 'commerce_command_journal_error', + reason: 'journal_record_invalid', + }); + expect(await readLifecycleEvidence(terminalPair, 2)).toEqual(lifecycleBefore); + expect(snapshotEvidence(await preseededRevisionThree.get())) + .toEqual(snapshotEvidence(revisionThreeBefore)); + }); + + test('orphan, future, malformed, and unpaired lifecycle states reject without repair', async () => { + const orphanPayload = payload('orphan-lifecycle'); + const orphanArgs = acquireArgs( + db, + COMMAND_IDS.orphanLifecycle, + orphanPayload, + deterministicLeaseId(240), + ); + const orphanPair = pairFor(orphanArgs); + await orphanPair.lifecycleRef.create({ syntheticOrphan: true }); + const orphanBefore = await orphanPair.lifecycleRef.get(); + await expect(acquireCommerceCommandLease(orphanArgs)).rejects.toMatchObject({ + code: 'commerce_command_journal_error', + reason: 'journal_record_invalid', + }); + expect(snapshotEvidence(await orphanPair.lifecycleRef.get())) + .toEqual(snapshotEvidence(orphanBefore)); + expect((await readPair(orphanPair)).commandSnapshot.exists).toBe(false); + expect((await readPair(orphanPair)).auditSnapshot.exists).toBe(false); + + const futurePayload = payload('future-lifecycle'); + const futureRegistration = commandArgs( + db, + COMMAND_IDS.malformedLifecycle, + futurePayload, + ); + const futureArgs = acquireArgs( + db, + COMMAND_IDS.malformedLifecycle, + futurePayload, + deterministicLeaseId(241), + ); + const futurePair = pairFor(futureArgs); + await registerCommerceCommand(futureRegistration); + await futurePair.lifecycleRef.create({ + lifecycleSchemaVersion: 2, + syntheticFuture: true, + }); + const futureBefore = await futurePair.lifecycleRef.get(); + await expect(acquireCommerceCommandLease(futureArgs)).rejects.toMatchObject({ + code: 'commerce_command_journal_error', + reason: 'journal_record_invalid', + }); + expect(snapshotEvidence(await futurePair.lifecycleRef.get())) + .toEqual(snapshotEvidence(futureBefore)); + expect((await readAudit(futurePair, 2)).exists).toBe(false); + + const malformedPayload = payload('malformed-lifecycle-shape'); + const malformedRegistration = commandArgs( + db, + COMMAND_IDS.malformedLifecycleShape, + malformedPayload, + ); + const malformedArgs = acquireArgs( + db, + COMMAND_IDS.malformedLifecycleShape, + malformedPayload, + deterministicLeaseId(243), + ); + const malformedPair = pairFor(malformedArgs); + await registerCommerceCommand(malformedRegistration); + await acquireCommerceCommandLease(malformedArgs); + const validMalformedLifecycle = await readLifecycle(malformedPair); + const malformedAuditBefore = await readAudit(malformedPair, 2); + await malformedPair.lifecycleRef.set({ + ...validMalformedLifecycle.data(), + commandRevision: 4, + }); + const malformedLifecycleBefore = await readLifecycle(malformedPair); + + await expect(acquireCommerceCommandLease(malformedArgs)).rejects.toMatchObject({ + code: 'commerce_command_journal_error', + reason: 'journal_record_invalid', + }); + expect(snapshotEvidence(await readLifecycle(malformedPair))) + .toEqual(snapshotEvidence(malformedLifecycleBefore)); + expect(snapshotEvidence(await readAudit(malformedPair, 2))) + .toEqual(snapshotEvidence(malformedAuditBefore)); + expect((await readAudit(malformedPair, 3)).exists).toBe(false); + + const malformedAuditPayload = payload('malformed-current-audit'); + const malformedAuditRegistration = commandArgs( + db, + COMMAND_IDS.malformedCurrentAudit, + malformedAuditPayload, + ); + const malformedAuditArgs = acquireArgs( + db, + COMMAND_IDS.malformedCurrentAudit, + malformedAuditPayload, + deterministicLeaseId(244), + ); + const malformedAuditPair = pairFor(malformedAuditArgs); + await registerCommerceCommand(malformedAuditRegistration); + await acquireCommerceCommandLease(malformedAuditArgs); + const lifecycleBeforeMalformedAudit = await readLifecycle(malformedAuditPair); + const currentMalformedAuditRef = auditRefFor(malformedAuditPair.commandKeyHash, 2); + const validCurrentAudit = await currentMalformedAuditRef.get(); + await currentMalformedAuditRef.set({ + ...validCurrentAudit.data(), + eventType: 'command_succeeded', + }); + const currentMalformedAuditBefore = await currentMalformedAuditRef.get(); + + await expect(acquireCommerceCommandLease(malformedAuditArgs)).rejects.toMatchObject({ + code: 'commerce_command_journal_error', + reason: 'journal_record_invalid', + }); + expect(snapshotEvidence(await readLifecycle(malformedAuditPair))) + .toEqual(snapshotEvidence(lifecycleBeforeMalformedAudit)); + expect(snapshotEvidence(await currentMalformedAuditRef.get())) + .toEqual(snapshotEvidence(currentMalformedAuditBefore)); + expect((await readAudit(malformedAuditPair, 3)).exists).toBe(false); + + const missingAuditPayload = payload('missing-current-audit'); + const missingAuditRegistration = commandArgs( + db, + COMMAND_IDS.missingCurrentAudit, + missingAuditPayload, + ); + const missingAuditArgs = acquireArgs( + db, + COMMAND_IDS.missingCurrentAudit, + missingAuditPayload, + deterministicLeaseId(242), + ); + const missingAuditPair = pairFor(missingAuditArgs); + await registerCommerceCommand(missingAuditRegistration); + await acquireCommerceCommandLease(missingAuditArgs); + const lifecycleWithPartner = await readLifecycle(missingAuditPair); + const currentAuditRef = auditRefFor(missingAuditPair.commandKeyHash, 2); + await currentAuditRef.delete(); + const lifecycleWithoutPartner = await readLifecycle(missingAuditPair); + expect(snapshotEvidence(lifecycleWithoutPartner)) + .toEqual(snapshotEvidence(lifecycleWithPartner)); + + await expect(acquireCommerceCommandLease(missingAuditArgs)).rejects.toMatchObject({ + code: 'commerce_command_journal_error', + reason: 'journal_record_invalid', + }); + expect(snapshotEvidence(await readLifecycle(missingAuditPair))) + .toEqual(snapshotEvidence(lifecycleWithoutPartner)); + expect((await currentAuditRef.get()).exists).toBe(false); + expect((await readAudit(missingAuditPair, 3)).exists).toBe(false); + }); }); diff --git a/functions/commerceCommandJournal.js b/functions/commerceCommandJournal.js index 33dda228..d90abf63 100644 --- a/functions/commerceCommandJournal.js +++ b/functions/commerceCommandJournal.js @@ -1,3 +1,4 @@ +const { createHash } = require('node:crypto'); const { types: { isProxy } } = require('node:util'); const { Timestamp } = require('firebase-admin/firestore'); @@ -10,13 +11,27 @@ const { const journalSchemaVersion = 1; const auditSchemaVersion = 1; +const lifecycleSchemaVersion = 1; +const lifecycleAuditSchemaVersion = 2; const MAXIMUM_ENDPOINT_SCHEMA_VERSION = 1000000; +const MAXIMUM_LIFECYCLE_NUMBER = 9999999999; +const LEASE_DURATION_SECONDS = 60; +const TRANSACTION_OPTIONS = Object.freeze({ maxAttempts: 10 }); const COMMAND_COLLECTION = 'checkoutRequests'; const AUDIT_COLLECTION = 'auditEvents'; +const LIFECYCLE_COLLECTION = 'lifecycle'; +const LIFECYCLE_DOCUMENT = 'current'; const COMMAND_STATE = 'registered'; const COMMAND_REVISION = 1; const LOWERCASE_SHA256 = /^[0-9a-f]{64}$/; +const CANONICAL_UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; const SAFE_COMMAND_TYPE = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/; +const LIFECYCLE_STATES = new Set(['leased', 'succeeded', 'failed_final']); +const HASH_MAGIC = 'mprc-commerce-command-journal-sha256'; +const HASH_DOMAINS = Object.freeze({ + leaseOwner: 'mprc.command-lease-owner.v1', + terminalCommitment: 'mprc.command-terminal-commitment.v1', +}); const ENVIRONMENTS = new Set(['local', 'test', 'staging', 'production']); const CALLER_SCOPE_KINDS = new Set([ 'firebase_uid', @@ -33,6 +48,16 @@ const INPUT_FIELDS = Object.freeze([ 'endpointSchemaVersion', 'payload', ]); +const ACQUIRE_INPUT_FIELDS = Object.freeze([...INPUT_FIELDS, 'leaseId']); +const COMPLETE_INPUT_FIELDS = Object.freeze([ + ...ACQUIRE_INPUT_FIELDS, + 'expectedFenceEpoch', + 'terminalReferenceFingerprint', +]); +const FAIL_INPUT_FIELDS = Object.freeze([ + ...ACQUIRE_INPUT_FIELDS, + 'expectedFenceEpoch', +]); const CALLER_SCOPE_FIELDS = Object.freeze(['kind', 'value']); const COMMAND_FIELDS = Object.freeze([ 'journalSchemaVersion', @@ -60,12 +85,44 @@ const AUDIT_FIELDS = Object.freeze([ 'commandType', 'occurredAt', ]); +const LIFECYCLE_FIELDS = Object.freeze([ + 'lifecycleSchemaVersion', + 'commandKeyHash', + 'state', + 'commandRevision', + 'fenceEpoch', + 'leaseOwnerFingerprint', + 'leaseAcquiredAt', + 'leaseExpiresAt', + 'createdAt', + 'updatedAt', + 'terminalCommitmentKind', + 'terminalCommitmentHash', +]); +const LIFECYCLE_AUDIT_FIELDS = Object.freeze([ + 'auditSchemaVersion', + 'aggregateType', + 'commandKeyHash', + 'commandRevision', + 'eventType', + 'fromState', + 'toState', + 'environment', + 'callerScopeKind', + 'commandType', + 'fenceEpoch', + 'leaseExpiresAt', + 'occurredAt', +]); const ERROR_MESSAGES = Object.freeze({ - invalid_command_input: 'Commerce command registration input is invalid.', - command_conflict: 'Commerce command registration conflicts with an existing command.', - journal_record_invalid: 'Commerce command registration record is invalid.', - journal_unavailable: 'Commerce command registration is unavailable.', + invalid_command_input: 'Commerce command journal input is invalid.', + command_conflict: 'Commerce command conflicts with an existing command.', + command_not_registered: 'Commerce command is not registered.', + journal_record_invalid: 'Commerce command journal record is invalid.', + lease_stale: 'Commerce command lease is no longer current.', + terminal_conflict: 'Commerce command terminal state conflicts with this request.', + journal_unavailable: 'Commerce command journal is unavailable.', }); class CommerceCommandJournalError extends Error { @@ -179,6 +236,115 @@ function timestampsEqual(first, second) { && first.nanoseconds === second.nanoseconds; } +function compareTimestamps(first, second) { + if (first.seconds !== second.seconds) return first.seconds < second.seconds ? -1 : 1; + if (first.nanoseconds !== second.nanoseconds) { + return first.nanoseconds < second.nanoseconds ? -1 : 1; + } + return 0; +} + +function timestampExactlySecondsAfter(later, earlier, seconds) { + return later.seconds === earlier.seconds + seconds + && later.nanoseconds === earlier.nanoseconds; +} + +function captureTrustedTimestamp() { + let timestamp; + try { + timestamp = Timestamp.now(); + } catch { + reject('journal_unavailable'); + } + if (readTimestamp(timestamp) === null) reject('journal_unavailable'); + try { + Object.freeze(timestamp); + } catch { + reject('journal_unavailable'); + } + return timestamp; +} + +function addLeaseDuration(timestamp) { + const parts = readTimestamp(timestamp); + if (parts === null || parts.seconds > 253402300799 - LEASE_DURATION_SECONDS) { + reject('journal_unavailable'); + } + let expiresAt; + try { + expiresAt = new Timestamp( + parts.seconds + LEASE_DURATION_SECONDS, + parts.nanoseconds, + ); + Object.freeze(expiresAt); + } catch { + reject('journal_unavailable'); + } + return expiresAt; +} + +function copyTimestamp(parts) { + let timestamp; + try { + timestamp = new Timestamp(parts.seconds, parts.nanoseconds); + Object.freeze(timestamp); + } catch { + reject('journal_unavailable'); + } + return timestamp; +} + +function unsignedLength(length) { + const buffer = Buffer.allocUnsafe(4); + buffer.writeUInt32BE(length, 0); + return buffer; +} + +function updateLengthFramed(hash, value) { + const bytes = Buffer.from(value, 'utf8'); + hash.update(unsignedLength(bytes.length)); + hash.update(bytes); +} + +function digest(domain, fields) { + try { + const hash = createHash('sha256'); + updateLengthFramed(hash, HASH_MAGIC); + updateLengthFramed(hash, domain); + for (const [name, value] of fields) { + updateLengthFramed(hash, name); + updateLengthFramed(hash, value); + } + return hash.digest('hex'); + } catch (error) { + if (error instanceof CommerceCommandJournalError) throw error; + reject('journal_unavailable'); + } +} + +function createLeaseOwnerFingerprint(commandKeyHash, leaseId) { + return digest(HASH_DOMAINS.leaseOwner, [ + ['version', '1'], + ['commandKeyHash', commandKeyHash], + ['leaseId', leaseId], + ]); +} + +function createTerminalCommitment(commandKeyHash, terminalReferenceFingerprint) { + return digest(HASH_DOMAINS.terminalCommitment, [ + ['version', '1'], + ['commandKeyHash', commandKeyHash], + ['kind', 'business_record_digest'], + ['terminalReferenceFingerprint', terminalReferenceFingerprint], + ]); +} + +function validLifecycleNumber(value) { + return Number.isSafeInteger(value) + && value >= 1 + && value <= MAXIMUM_LIFECYCLE_NUMBER; +} + function validEndpointSchemaVersion(value) { return Number.isSafeInteger(value) && value >= 1 @@ -261,45 +427,135 @@ function validateExistingPair(commandValue, auditValue, expected) { || command.payloadFingerprint !== expected.payloadFingerprint) { reject('command_conflict'); } + return Object.freeze({ createdAt }); } -function readSnapshot(snapshot) { - let exists; - try { - exists = snapshot.exists; - } catch { - reject('journal_unavailable'); +function parseLifecycle(value, expected) { + const record = readExactOwnDataObject( + value, + LIFECYCLE_FIELDS, + 'journal_record_invalid', + ); + const createdAt = readTimestamp(record.createdAt); + const updatedAt = readTimestamp(record.updatedAt); + const leaseAcquiredAt = readTimestamp(record.leaseAcquiredAt); + const leaseExpiresAt = readTimestamp(record.leaseExpiresAt); + + if (record.lifecycleSchemaVersion !== lifecycleSchemaVersion + || record.commandKeyHash !== expected.commandKeyHash + || typeof record.state !== 'string' + || !LIFECYCLE_STATES.has(record.state) + || !validLifecycleNumber(record.commandRevision) + || record.commandRevision < 2 + || !validLifecycleNumber(record.fenceEpoch) + || typeof record.leaseOwnerFingerprint !== 'string' + || !LOWERCASE_SHA256.test(record.leaseOwnerFingerprint) + || createdAt === null + || updatedAt === null + || leaseAcquiredAt === null + || leaseExpiresAt === null + || compareTimestamps(createdAt, leaseAcquiredAt) > 0 + || !timestampExactlySecondsAfter( + leaseExpiresAt, + leaseAcquiredAt, + LEASE_DURATION_SECONDS, + )) { + reject('journal_record_invalid'); } - if (exists === false) return Object.freeze({ exists: false, value: null }); - if (exists !== true) reject('journal_unavailable'); - let value; - try { - value = snapshot.data(); - } catch { - reject('journal_unavailable'); + if (record.fenceEpoch === 1) { + if (!timestampsEqual(createdAt, leaseAcquiredAt)) reject('journal_record_invalid'); + } else if (compareTimestamps(createdAt, leaseAcquiredAt) >= 0) { + reject('journal_record_invalid'); } - return Object.freeze({ exists: true, value }); + + if (record.state === 'leased') { + if (record.commandRevision !== record.fenceEpoch + 1 + || !timestampsEqual(updatedAt, leaseAcquiredAt) + || record.terminalCommitmentKind !== null + || record.terminalCommitmentHash !== null) { + reject('journal_record_invalid'); + } + } else { + if (record.commandRevision !== record.fenceEpoch + 2 + || compareTimestamps(updatedAt, leaseAcquiredAt) < 0 + || compareTimestamps(updatedAt, leaseExpiresAt) >= 0) { + reject('journal_record_invalid'); + } + if (record.state === 'succeeded') { + if (record.terminalCommitmentKind !== 'business_record_digest' + || typeof record.terminalCommitmentHash !== 'string' + || !LOWERCASE_SHA256.test(record.terminalCommitmentHash)) { + reject('journal_record_invalid'); + } + } else if (record.terminalCommitmentKind !== null + || record.terminalCommitmentHash !== null) { + reject('journal_record_invalid'); + } + } + + return Object.freeze({ + record, + createdAt, + updatedAt, + leaseAcquiredAt, + leaseExpiresAt, + }); } -const REGISTERED_NEW = Object.freeze({ - journalSchemaVersion, - outcome: 'registered_new', - state: COMMAND_STATE, -}); -const REGISTERED_EXISTING = Object.freeze({ - journalSchemaVersion, - outcome: 'registered_existing', - state: COMMAND_STATE, -}); +function validateLifecycleAudit(value, lifecycle, expected) { + const audit = readExactOwnDataObject( + value, + LIFECYCLE_AUDIT_FIELDS, + 'journal_record_invalid', + ); + const leaseExpiresAt = readTimestamp(audit.leaseExpiresAt); + const occurredAt = readTimestamp(audit.occurredAt); -async function registerCommerceCommand(input) { + let expectedEvent; + let expectedFromState; + if (lifecycle.record.state === 'leased') { + expectedEvent = lifecycle.record.fenceEpoch === 1 + ? 'command_lease_acquired' + : 'command_lease_taken_over'; + expectedFromState = lifecycle.record.fenceEpoch === 1 ? 'registered' : 'leased'; + } else if (lifecycle.record.state === 'succeeded') { + expectedEvent = 'command_succeeded'; + expectedFromState = 'leased'; + } else { + expectedEvent = 'command_failed_final'; + expectedFromState = 'leased'; + } + + if (audit.auditSchemaVersion !== lifecycleAuditSchemaVersion + || audit.aggregateType !== 'commerce_command' + || audit.commandKeyHash !== expected.commandKeyHash + || audit.commandRevision !== lifecycle.record.commandRevision + || audit.eventType !== expectedEvent + || audit.fromState !== expectedFromState + || audit.toState !== lifecycle.record.state + || audit.environment !== expected.environment + || audit.callerScopeKind !== expected.callerScopeKind + || audit.commandType !== expected.commandType + || audit.fenceEpoch !== lifecycle.record.fenceEpoch + || !timestampsEqual(leaseExpiresAt, lifecycle.leaseExpiresAt) + || !timestampsEqual(occurredAt, lifecycle.updatedAt)) { + reject('journal_record_invalid'); + } +} + +function auditDocumentId(commandKeyHash, revision) { + if (!validLifecycleNumber(revision)) reject('journal_record_invalid'); + return `commerce_command_${commandKeyHash}_${String(revision).padStart(10, '0')}`; +} + +function prepareIdentity(input, fields) { let values; let commandKey; let fingerprint; let callerScopeKind; try { - values = readExactOwnDataObject(input, INPUT_FIELDS, 'invalid_command_input'); + values = readExactOwnDataObject(input, fields, 'invalid_command_input'); if (!validEndpointSchemaVersion(values.endpointSchemaVersion)) { reject('invalid_command_input'); } @@ -324,16 +580,327 @@ async function registerCommerceCommand(input) { reject('journal_unavailable'); } + return Object.freeze({ + values, + commandKeyHash: commandKey.commandKeyHash, + callerScopeKind, + expected: Object.freeze({ + commandKeyHash: commandKey.commandKeyHash, + endpointSchemaVersion: values.endpointSchemaVersion, + environment: values.environment, + callerScopeKind, + commandType: values.commandType, + payloadFingerprint: fingerprint.payloadFingerprint, + }), + }); +} + +function prepareLifecycleOperation(input, fields, operation) { + const identity = prepareIdentity(input, fields); + const { values } = identity; + + if (typeof values.leaseId !== 'string' + || !CANONICAL_UUID_V4.test(values.leaseId) + || values.leaseId === values.commandId) { + reject('invalid_command_input'); + } + if (operation !== 'acquire' && !validLifecycleNumber(values.expectedFenceEpoch)) { + reject('invalid_command_input'); + } + if (operation === 'complete' + && (typeof values.terminalReferenceFingerprint !== 'string' + || !LOWERCASE_SHA256.test(values.terminalReferenceFingerprint))) { + reject('invalid_command_input'); + } + + let commandRef; + let registrationAuditRef; + let lifecycleRef; + try { + commandRef = values.db.collection(COMMAND_COLLECTION).doc(identity.commandKeyHash); + registrationAuditRef = values.db.collection(AUDIT_COLLECTION).doc( + auditDocumentId(identity.commandKeyHash, COMMAND_REVISION), + ); + lifecycleRef = commandRef.collection(LIFECYCLE_COLLECTION).doc(LIFECYCLE_DOCUMENT); + } catch (error) { + if (error instanceof CommerceCommandJournalError) throw error; + reject('journal_unavailable'); + } + + const occurredAt = captureTrustedTimestamp(); + const leaseOwnerFingerprint = createLeaseOwnerFingerprint( + identity.commandKeyHash, + values.leaseId, + ); + const terminalCommitmentHash = operation === 'complete' + ? createTerminalCommitment( + identity.commandKeyHash, + values.terminalReferenceFingerprint, + ) + : null; + + return Object.freeze({ + ...identity, + commandRef, + registrationAuditRef, + lifecycleRef, + occurredAt, + occurredAtParts: readTimestamp(occurredAt), + proposedLeaseExpiresAt: operation === 'acquire' ? addLeaseDuration(occurredAt) : null, + leaseOwnerFingerprint, + terminalCommitmentHash, + }); +} + +function readSnapshot(snapshot) { + let exists; + try { + exists = snapshot.exists; + } catch { + reject('journal_unavailable'); + } + if (exists === false) return Object.freeze({ exists: false, value: null }); + if (exists !== true) reject('journal_unavailable'); + + let value; + try { + value = snapshot.data(); + } catch { + reject('journal_unavailable'); + } + return Object.freeze({ exists: true, value }); +} + +const LEASE_BUSY = Object.freeze({ + journalSchemaVersion, + lifecycleSchemaVersion, + outcome: 'lease_busy', + state: 'leased', +}); +const TERMINAL_SUCCEEDED = Object.freeze({ + journalSchemaVersion, + lifecycleSchemaVersion, + outcome: 'terminal_succeeded', + state: 'succeeded', +}); +const TERMINAL_FAILED_FINAL = Object.freeze({ + journalSchemaVersion, + lifecycleSchemaVersion, + outcome: 'terminal_failed_final', + state: 'failed_final', +}); + +function createLeaseResult(fenceEpoch, leaseExpiresAt) { + const expiresAt = readTimestamp(leaseExpiresAt); + if (!validLifecycleNumber(fenceEpoch) || expiresAt === null) { + reject('journal_unavailable'); + } + return Object.freeze({ + journalSchemaVersion, + lifecycleSchemaVersion, + outcome: 'lease_acquired', + state: 'leased', + fenceEpoch, + leaseExpiresAt: expiresAt, + }); +} + +function validateLifecycleResult(result) { + if (result === LEASE_BUSY + || result === TERMINAL_SUCCEEDED + || result === TERMINAL_FAILED_FINAL) { + return result; + } + + const value = readExactOwnDataObject(result, [ + 'journalSchemaVersion', + 'lifecycleSchemaVersion', + 'outcome', + 'state', + 'fenceEpoch', + 'leaseExpiresAt', + ], 'journal_unavailable'); + const expiry = readExactOwnDataObject( + value.leaseExpiresAt, + ['seconds', 'nanoseconds'], + 'journal_unavailable', + ); + if (value.journalSchemaVersion !== journalSchemaVersion + || value.lifecycleSchemaVersion !== lifecycleSchemaVersion + || value.outcome !== 'lease_acquired' + || value.state !== 'leased' + || !validLifecycleNumber(value.fenceEpoch) + || !Number.isSafeInteger(expiry.seconds) + || expiry.seconds < -62135596800 + || expiry.seconds > 253402300799 + || !Number.isSafeInteger(expiry.nanoseconds) + || expiry.nanoseconds < 0 + || expiry.nanoseconds > 999999999 + || !Object.isFrozen(result) + || !Object.isFrozen(value.leaseExpiresAt)) { + reject('journal_unavailable'); + } + return result; +} + +function buildLeasedLifecycle(prepared, previous = null) { + const fenceEpoch = previous === null ? 1 : previous.record.fenceEpoch + 1; + const commandRevision = previous === null ? 2 : previous.record.commandRevision + 1; + if (!validLifecycleNumber(fenceEpoch) || !validLifecycleNumber(commandRevision)) { + reject('journal_record_invalid'); + } + + return Object.freeze({ + lifecycleSchemaVersion, + commandKeyHash: prepared.commandKeyHash, + state: 'leased', + commandRevision, + fenceEpoch, + leaseOwnerFingerprint: prepared.leaseOwnerFingerprint, + leaseAcquiredAt: prepared.occurredAt, + leaseExpiresAt: prepared.proposedLeaseExpiresAt, + createdAt: previous === null + ? prepared.occurredAt + : copyTimestamp(previous.createdAt), + updatedAt: prepared.occurredAt, + terminalCommitmentKind: null, + terminalCommitmentHash: null, + }); +} + +function buildTerminalLifecycle(prepared, lifecycle, state) { + const commandRevision = lifecycle.record.commandRevision + 1; + if (!validLifecycleNumber(commandRevision)) reject('journal_record_invalid'); + + return Object.freeze({ + lifecycleSchemaVersion, + commandKeyHash: prepared.commandKeyHash, + state, + commandRevision, + fenceEpoch: lifecycle.record.fenceEpoch, + leaseOwnerFingerprint: lifecycle.record.leaseOwnerFingerprint, + leaseAcquiredAt: copyTimestamp(lifecycle.leaseAcquiredAt), + leaseExpiresAt: copyTimestamp(lifecycle.leaseExpiresAt), + createdAt: copyTimestamp(lifecycle.createdAt), + updatedAt: prepared.occurredAt, + terminalCommitmentKind: state === 'succeeded' ? 'business_record_digest' : null, + terminalCommitmentHash: state === 'succeeded' + ? prepared.terminalCommitmentHash + : null, + }); +} + +function buildLifecycleAudit(prepared, lifecycleRecord, eventType, fromState) { + return Object.freeze({ + auditSchemaVersion: lifecycleAuditSchemaVersion, + aggregateType: 'commerce_command', + commandKeyHash: prepared.commandKeyHash, + commandRevision: lifecycleRecord.commandRevision, + eventType, + fromState, + toState: lifecycleRecord.state, + environment: prepared.expected.environment, + callerScopeKind: prepared.callerScopeKind, + commandType: prepared.expected.commandType, + fenceEpoch: lifecycleRecord.fenceEpoch, + leaseExpiresAt: lifecycleRecord.leaseExpiresAt, + occurredAt: lifecycleRecord.updatedAt, + }); +} + +async function readLifecycleContext(transaction, prepared) { + const commandSnapshot = await transaction.get(prepared.commandRef); + const registrationAuditSnapshot = await transaction.get(prepared.registrationAuditRef); + const lifecycleSnapshot = await transaction.get(prepared.lifecycleRef); + const command = readSnapshot(commandSnapshot); + const registrationAudit = readSnapshot(registrationAuditSnapshot); + const lifecycleDocument = readSnapshot(lifecycleSnapshot); + + if (command.exists !== registrationAudit.exists) reject('journal_record_invalid'); + if (!command.exists) { + if (lifecycleDocument.exists) reject('journal_record_invalid'); + reject('command_not_registered'); + } + const registration = validateExistingPair( + command.value, + registrationAudit.value, + prepared.expected, + ); + + if (!lifecycleDocument.exists) { + if (compareTimestamps(prepared.occurredAtParts, registration.createdAt) < 0) { + reject('journal_record_invalid'); + } + const nextAuditRef = prepared.values.db.collection(AUDIT_COLLECTION).doc( + auditDocumentId(prepared.commandKeyHash, 2), + ); + const nextAudit = readSnapshot(await transaction.get(nextAuditRef)); + if (nextAudit.exists) reject('journal_record_invalid'); + return Object.freeze({ + lifecycle: null, + nextAuditRef, + }); + } + + const lifecycle = parseLifecycle(lifecycleDocument.value, prepared.expected); + if (compareTimestamps(lifecycle.createdAt, registration.createdAt) < 0) { + reject('journal_record_invalid'); + } + const currentAuditRef = prepared.values.db.collection(AUDIT_COLLECTION).doc( + auditDocumentId(prepared.commandKeyHash, lifecycle.record.commandRevision), + ); + const currentAudit = readSnapshot(await transaction.get(currentAuditRef)); + if (!currentAudit.exists) reject('journal_record_invalid'); + validateLifecycleAudit(currentAudit.value, lifecycle, prepared.expected); + + let nextAuditRef = null; + if (lifecycle.record.commandRevision < MAXIMUM_LIFECYCLE_NUMBER) { + nextAuditRef = prepared.values.db.collection(AUDIT_COLLECTION).doc( + auditDocumentId(prepared.commandKeyHash, lifecycle.record.commandRevision + 1), + ); + const nextAudit = readSnapshot(await transaction.get(nextAuditRef)); + if (nextAudit.exists) reject('journal_record_invalid'); + } + + return Object.freeze({ + lifecycle, + nextAuditRef, + }); +} + +function writeLifecycleTransition(transaction, prepared, context, lifecycleRecord, auditRecord) { + if (context.nextAuditRef === null) reject('journal_record_invalid'); + if (context.lifecycle === null) { + transaction.create(prepared.lifecycleRef, lifecycleRecord); + } else { + transaction.set(prepared.lifecycleRef, lifecycleRecord); + } + transaction.create(context.nextAuditRef, auditRecord); +} + +const REGISTERED_NEW = Object.freeze({ + journalSchemaVersion, + outcome: 'registered_new', + state: COMMAND_STATE, +}); +const REGISTERED_EXISTING = Object.freeze({ + journalSchemaVersion, + outcome: 'registered_existing', + state: COMMAND_STATE, +}); + +async function registerCommerceCommand(input) { + const prepared = prepareIdentity(input, INPUT_FIELDS); + const { values, callerScopeKind } = prepared; + let occurredAt; let commandRef; let auditRef; try { - occurredAt = Timestamp.now(); - if (readTimestamp(occurredAt) === null) reject('journal_unavailable'); - Object.freeze(occurredAt); - commandRef = values.db.collection(COMMAND_COLLECTION).doc(commandKey.commandKeyHash); + occurredAt = captureTrustedTimestamp(); + commandRef = values.db.collection(COMMAND_COLLECTION).doc(prepared.commandKeyHash); auditRef = values.db.collection(AUDIT_COLLECTION).doc( - `commerce_command_${commandKey.commandKeyHash}_0000000001`, + auditDocumentId(prepared.commandKeyHash, COMMAND_REVISION), ); } catch (error) { if (error instanceof CommerceCommandJournalError) throw error; @@ -347,7 +914,7 @@ async function registerCommerceCommand(input) { environment: values.environment, callerScopeKind, commandType: values.commandType, - payloadFingerprint: fingerprint.payloadFingerprint, + payloadFingerprint: prepared.expected.payloadFingerprint, state: COMMAND_STATE, revision: COMMAND_REVISION, createdAt: occurredAt, @@ -356,7 +923,7 @@ async function registerCommerceCommand(input) { const auditRecord = Object.freeze({ auditSchemaVersion, aggregateType: 'commerce_command', - commandKeyHash: commandKey.commandKeyHash, + commandKeyHash: prepared.commandKeyHash, commandRevision: COMMAND_REVISION, eventType: 'command_registered', fromState: null, @@ -366,14 +933,6 @@ async function registerCommerceCommand(input) { commandType: values.commandType, occurredAt, }); - const expected = Object.freeze({ - commandKeyHash: commandKey.commandKeyHash, - endpointSchemaVersion: values.endpointSchemaVersion, - environment: values.environment, - callerScopeKind, - commandType: values.commandType, - payloadFingerprint: fingerprint.payloadFingerprint, - }); try { const result = await values.db.runTransaction(async (transaction) => { @@ -384,14 +943,14 @@ async function registerCommerceCommand(input) { if (command.exists !== audit.exists) reject('journal_record_invalid'); if (command.exists) { - validateExistingPair(command.value, audit.value, expected); + validateExistingPair(command.value, audit.value, prepared.expected); return REGISTERED_EXISTING; } transaction.create(commandRef, commandRecord); transaction.create(auditRef, auditRecord); return REGISTERED_NEW; - }); + }, TRANSACTION_OPTIONS); if (result !== REGISTERED_NEW && result !== REGISTERED_EXISTING) { reject('journal_unavailable'); @@ -403,11 +962,147 @@ async function registerCommerceCommand(input) { } } +async function runLifecycleTransaction(prepared, callback) { + try { + const result = await prepared.values.db.runTransaction(callback, TRANSACTION_OPTIONS); + return validateLifecycleResult(result); + } catch (error) { + if (error instanceof CommerceCommandJournalError) throw error; + reject('journal_unavailable'); + } +} + +async function acquireCommerceCommandLease(input) { + const prepared = prepareLifecycleOperation(input, ACQUIRE_INPUT_FIELDS, 'acquire'); + + return runLifecycleTransaction(prepared, async (transaction) => { + const context = await readLifecycleContext(transaction, prepared); + if (context.lifecycle === null) { + const lifecycleRecord = buildLeasedLifecycle(prepared); + const auditRecord = buildLifecycleAudit( + prepared, + lifecycleRecord, + 'command_lease_acquired', + 'registered', + ); + writeLifecycleTransition( + transaction, + prepared, + context, + lifecycleRecord, + auditRecord, + ); + return createLeaseResult( + lifecycleRecord.fenceEpoch, + lifecycleRecord.leaseExpiresAt, + ); + } + + const { lifecycle } = context; + if (lifecycle.record.state === 'succeeded') return TERMINAL_SUCCEEDED; + if (lifecycle.record.state === 'failed_final') return TERMINAL_FAILED_FINAL; + if (compareTimestamps(prepared.occurredAtParts, lifecycle.leaseExpiresAt) < 0) { + if (lifecycle.record.leaseOwnerFingerprint !== prepared.leaseOwnerFingerprint) { + return LEASE_BUSY; + } + return createLeaseResult( + lifecycle.record.fenceEpoch, + copyTimestamp(lifecycle.leaseExpiresAt), + ); + } + + const lifecycleRecord = buildLeasedLifecycle(prepared, lifecycle); + const auditRecord = buildLifecycleAudit( + prepared, + lifecycleRecord, + 'command_lease_taken_over', + 'leased', + ); + writeLifecycleTransition( + transaction, + prepared, + context, + lifecycleRecord, + auditRecord, + ); + return createLeaseResult( + lifecycleRecord.fenceEpoch, + lifecycleRecord.leaseExpiresAt, + ); + }); +} + +function exactTerminalRetry(prepared, lifecycle, state) { + if (lifecycle.record.state !== state + || lifecycle.record.leaseOwnerFingerprint !== prepared.leaseOwnerFingerprint + || lifecycle.record.fenceEpoch !== prepared.values.expectedFenceEpoch) { + return false; + } + if (state === 'succeeded') { + return lifecycle.record.terminalCommitmentHash === prepared.terminalCommitmentHash; + } + return true; +} + +async function finalizeCommerceCommand(input, fields, state) { + const operation = state === 'succeeded' ? 'complete' : 'fail'; + const prepared = prepareLifecycleOperation(input, fields, operation); + + return runLifecycleTransaction(prepared, async (transaction) => { + const context = await readLifecycleContext(transaction, prepared); + const { lifecycle } = context; + if (lifecycle === null) reject('lease_stale'); + + if (lifecycle.record.state !== 'leased') { + if (!exactTerminalRetry(prepared, lifecycle, state)) reject('terminal_conflict'); + return state === 'succeeded' ? TERMINAL_SUCCEEDED : TERMINAL_FAILED_FINAL; + } + + if (compareTimestamps(prepared.occurredAtParts, lifecycle.leaseAcquiredAt) < 0 + || compareTimestamps(prepared.occurredAtParts, lifecycle.leaseExpiresAt) >= 0 + || lifecycle.record.leaseOwnerFingerprint !== prepared.leaseOwnerFingerprint + || lifecycle.record.fenceEpoch !== prepared.values.expectedFenceEpoch) { + reject('lease_stale'); + } + + const lifecycleRecord = buildTerminalLifecycle(prepared, lifecycle, state); + const eventType = state === 'succeeded' + ? 'command_succeeded' + : 'command_failed_final'; + const auditRecord = buildLifecycleAudit( + prepared, + lifecycleRecord, + eventType, + 'leased', + ); + writeLifecycleTransition( + transaction, + prepared, + context, + lifecycleRecord, + auditRecord, + ); + return state === 'succeeded' ? TERMINAL_SUCCEEDED : TERMINAL_FAILED_FINAL; + }); +} + +async function completeCommerceCommand(input) { + return finalizeCommerceCommand(input, COMPLETE_INPUT_FIELDS, 'succeeded'); +} + +async function failCommerceCommand(input) { + return finalizeCommerceCommand(input, FAIL_INPUT_FIELDS, 'failed_final'); +} + Object.freeze(CommerceCommandJournalError.prototype); Object.freeze(CommerceCommandJournalError); module.exports = Object.freeze({ journalSchemaVersion, + lifecycleSchemaVersion, CommerceCommandJournalError, + acquireCommerceCommandLease, + completeCommerceCommand, + failCommerceCommand, registerCommerceCommand, }); diff --git a/functions/commerceCommandJournal.test.js b/functions/commerceCommandJournal.test.js index 0382fa0b..24ab1df9 100644 --- a/functions/commerceCommandJournal.test.js +++ b/functions/commerceCommandJournal.test.js @@ -10,12 +10,22 @@ const { } = require('./commerceCommandIdentity'); const { journalSchemaVersion, + lifecycleSchemaVersion, CommerceCommandJournalError, + acquireCommerceCommandLease, + completeCommerceCommand, + failCommerceCommand, registerCommerceCommand, } = require('./commerceCommandJournal'); const COMMAND_ID = '018f1f6a-9d2b-4c3d-8e5f-0123456789ab'; const OTHER_COMMAND_ID = '123e4567-e89b-42d3-a456-426614174000'; +const LEASE_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; +const OTHER_LEASE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; +const TERMINAL_REFERENCE_FINGERPRINT = 'd'.repeat(64); +const OTHER_TERMINAL_REFERENCE_FINGERPRINT = 'e'.repeat(64); +const GOLDEN_LEASE_OWNER_FINGERPRINT = '5d99ad039ee873195d1a765728e5946e87a959bf6765ccc9ea775541b10bccf1'; +const GOLDEN_TERMINAL_COMMITMENT = 'abf0fc9f071bb52ec4a0f5500e61f71b14066bf12bf27befca69ef90f7d118ab'; const RAW_CALLER = 'private-runner@example.test'; const HOSTILE_CANARY = 'private-runner@example.test/token?secret=do-not-copy'; const NOW_MILLIS = 1800000000123; @@ -51,13 +61,21 @@ function documentPath(collectionName, documentId) { return `${collectionName}/${documentId}`; } -function reference(collectionName, documentId) { +function referenceFromPath(path_) { + const parts = path_.split('/'); return Object.freeze({ - id: documentId, - path: documentPath(collectionName, documentId), + id: parts[parts.length - 1], + path: path_, + collection: jest.fn((collectionName) => Object.freeze({ + doc: jest.fn((documentId) => referenceFromPath(`${path_}/${collectionName}/${documentId}`)), + })), }); } +function reference(collectionName, documentId) { + return referenceFromPath(documentPath(collectionName, documentId)); +} + function snapshot(value) { return Object.freeze({ exists: value !== undefined, @@ -92,7 +110,10 @@ function createMockDb(seed = [], options = {}) { const transaction = Object.freeze({ get: jest.fn(async (ref) => snapshot(readView.get(ref.path))), create: jest.fn((ref, value) => { - writes.push(Object.freeze({ ref, value })); + writes.push(Object.freeze({ operation: 'create', ref, value })); + }), + set: jest.fn((ref, value) => { + writes.push(Object.freeze({ operation: 'set', ref, value })); }), }); callbackRuns.push({ transaction, writes }); @@ -102,7 +123,9 @@ function createMockDb(seed = [], options = {}) { if (options.rejectCommit) throw options.rejectCommit; for (const write of finalWrites) { - if (store.has(write.ref.path)) throw new Error('mock create precondition failed'); + if (write.operation === 'create' && store.has(write.ref.path)) { + throw new Error('mock create precondition failed'); + } } for (const write of finalWrites) store.set(write.ref.path, write.value); if (Object.prototype.hasOwnProperty.call(options, 'returnedValue')) { @@ -172,6 +195,43 @@ function exactPair(input, timestamp = Timestamp.fromMillis(NOW_MILLIS)) { }); } +function leaseInput(db, overrides = {}) { + return { + ...validInput(db), + leaseId: LEASE_ID, + ...overrides, + }; +} + +function completionInput(db, overrides = {}) { + return { + ...leaseInput(db), + expectedFenceEpoch: 1, + terminalReferenceFingerprint: TERMINAL_REFERENCE_FINGERPRINT, + ...overrides, + }; +} + +function failureInput(db, overrides = {}) { + return { + ...leaseInput(db), + expectedFenceEpoch: 1, + ...overrides, + }; +} + +function lifecyclePaths(input, revision = 2) { + const identity = expectedIdentity(input); + return Object.freeze({ + ...identity, + lifecyclePath: `${identity.commandPath}/lifecycle/current`, + lifecycleAuditPath: documentPath( + 'auditEvents', + `commerce_command_${identity.commandKeyHash}_${String(revision).padStart(10, '0')}`, + ), + }); +} + function captureError(callback) { return Promise.resolve() .then(callback) @@ -354,6 +414,754 @@ describe('atomic commerce command registration', () => { }); }); +describe('commerce command lease, fence, and terminal lifecycle', () => { + let nowMillis; + let timestampNow; + + beforeEach(() => { + nowMillis = NOW_MILLIS; + timestampNow = jest.spyOn(Timestamp, 'now') + .mockImplementation(() => Timestamp.fromMillis(nowMillis)); + }); + + afterEach(() => jest.restoreAllMocks()); + + async function registerAtBaseAndAcquire(mock, overrides = {}) { + await registerCommerceCommand(validInput(mock.db)); + nowMillis += 1000; + return acquireCommerceCommandLease(leaseInput(mock.db, overrides)); + } + + test('creates an exact separate lifecycle and revision-2 audit without mutating B2A', async () => { + const mock = createMockDb(); + const args = leaseInput(mock.db); + const paths = lifecyclePaths(args); + await registerCommerceCommand(validInput(mock.db)); + const commandBefore = mock.store.get(paths.commandPath); + const auditBefore = mock.store.get(paths.auditPath); + nowMillis += 1000; + + const result = await acquireCommerceCommandLease(args); + + expect(result).toEqual({ + journalSchemaVersion: 1, + lifecycleSchemaVersion: 1, + outcome: 'lease_acquired', + state: 'leased', + fenceEpoch: 1, + leaseExpiresAt: { + seconds: Math.floor((NOW_MILLIS + 61000) / 1000), + nanoseconds: ((NOW_MILLIS + 61000) % 1000) * 1000000, + }, + }); + expect(Object.isFrozen(result)).toBe(true); + expect(Object.isFrozen(result.leaseExpiresAt)).toBe(true); + expect(mock.store.get(paths.commandPath)).toBe(commandBefore); + expect(mock.store.get(paths.auditPath)).toBe(auditBefore); + expect(mock.store.get(paths.lifecyclePath)).toEqual({ + lifecycleSchemaVersion: 1, + commandKeyHash: paths.commandKeyHash, + state: 'leased', + commandRevision: 2, + fenceEpoch: 1, + leaseOwnerFingerprint: GOLDEN_LEASE_OWNER_FINGERPRINT, + leaseAcquiredAt: expect.any(Timestamp), + leaseExpiresAt: expect.any(Timestamp), + createdAt: expect.any(Timestamp), + updatedAt: expect.any(Timestamp), + terminalCommitmentKind: null, + terminalCommitmentHash: null, + }); + expect(mock.store.get(paths.lifecycleAuditPath)).toEqual({ + auditSchemaVersion: 2, + aggregateType: 'commerce_command', + commandKeyHash: paths.commandKeyHash, + commandRevision: 2, + eventType: 'command_lease_acquired', + fromState: 'registered', + toState: 'leased', + environment: 'test', + callerScopeKind: 'firebase_uid', + commandType: 'merch.checkout.create', + fenceEpoch: 1, + leaseExpiresAt: expect.any(Timestamp), + occurredAt: expect.any(Timestamp), + }); + const lifecycle = mock.store.get(paths.lifecyclePath); + const audit = mock.store.get(paths.lifecycleAuditPath); + expect(lifecycle.createdAt).toBe(lifecycle.leaseAcquiredAt); + expect(lifecycle.updatedAt).toBe(lifecycle.leaseAcquiredAt); + expect(lifecycle.leaseExpiresAt).toBe(audit.leaseExpiresAt); + expect(lifecycle.updatedAt).toBe(audit.occurredAt); + expect(JSON.stringify({ result, lifecycle, audit })).not.toContain(LEASE_ID); + expect(JSON.stringify({ result, lifecycle, audit })).not.toContain(RAW_CALLER); + expect(JSON.stringify({ result, lifecycle, audit })).not.toContain(COMMAND_ID); + }); + + test('same-holder recovery is byte-preserving while another active holder is busy', async () => { + const mock = createMockDb(); + const first = await registerAtBaseAndAcquire(mock); + const paths = lifecyclePaths(leaseInput(mock.db)); + const lifecycleBefore = mock.store.get(paths.lifecyclePath); + const auditBefore = mock.store.get(paths.lifecycleAuditPath); + const sizeBefore = mock.store.size; + nowMillis += 5000; + + const recovered = await acquireCommerceCommandLease(leaseInput(mock.db)); + const busy = await acquireCommerceCommandLease(leaseInput(mock.db, { + leaseId: OTHER_LEASE_ID, + })); + + expect(recovered).toEqual(first); + expect(busy).toEqual({ + journalSchemaVersion: 1, + lifecycleSchemaVersion: 1, + outcome: 'lease_busy', + state: 'leased', + }); + expect(Object.isFrozen(busy)).toBe(true); + expect(busy).not.toHaveProperty('fenceEpoch'); + expect(busy).not.toHaveProperty('leaseExpiresAt'); + expect(mock.store.size).toBe(sizeBefore); + expect(mock.store.get(paths.lifecyclePath)).toBe(lifecycleBefore); + expect(mock.store.get(paths.lifecycleAuditPath)).toBe(auditBefore); + const lastTwoRuns = mock.callbackRuns.slice(-2); + for (const { transaction, writes } of lastTwoRuns) { + expect(transaction.create).not.toHaveBeenCalled(); + expect(transaction.set).not.toHaveBeenCalled(); + expect(writes).toEqual([]); + } + }); + + test('expiry equality permits one higher-fence takeover and rejects the old worker', async () => { + const mock = createMockDb(); + await registerAtBaseAndAcquire(mock); + const args = leaseInput(mock.db); + const paths = lifecyclePaths(args); + const rootBefore = mock.store.get(paths.commandPath); + const firstAuditBefore = mock.store.get(paths.auditPath); + nowMillis = NOW_MILLIS + 61000; + + const takeover = await acquireCommerceCommandLease(leaseInput(mock.db, { + leaseId: OTHER_LEASE_ID, + })); + + expect(takeover).toMatchObject({ + outcome: 'lease_acquired', + state: 'leased', + fenceEpoch: 2, + }); + const lifecycle = mock.store.get(paths.lifecyclePath); + expect(lifecycle).toMatchObject({ + commandRevision: 3, + fenceEpoch: 2, + state: 'leased', + }); + expect(lifecycle.createdAt.toMillis()).toBe(NOW_MILLIS + 1000); + expect(lifecycle.leaseAcquiredAt.toMillis()).toBe(NOW_MILLIS + 61000); + expect(mock.store.get(lifecyclePaths(args, 3).lifecycleAuditPath)).toMatchObject({ + auditSchemaVersion: 2, + commandRevision: 3, + eventType: 'command_lease_taken_over', + fromState: 'leased', + toState: 'leased', + fenceEpoch: 2, + }); + expect(mock.store.get(paths.commandPath)).toBe(rootBefore); + expect(mock.store.get(paths.auditPath)).toBe(firstAuditBefore); + + nowMillis += 1; + await expectSafeError( + () => completeCommerceCommand(completionInput(mock.db)), + 'lease_stale', + ); + await expectSafeError( + () => failCommerceCommand(failureInput(mock.db)), + 'lease_stale', + ); + }); + + test('the same holder after expiry receives a new fence and the old fence stays stale', async () => { + const mock = createMockDb(); + await registerAtBaseAndAcquire(mock); + const args = leaseInput(mock.db); + const paths = lifecyclePaths(args); + nowMillis = NOW_MILLIS + 61000; + + const takeover = await acquireCommerceCommandLease(args); + + expect(takeover).toMatchObject({ + outcome: 'lease_acquired', + state: 'leased', + fenceEpoch: 2, + }); + expect(mock.store.get(paths.lifecyclePath)).toMatchObject({ + commandRevision: 3, + fenceEpoch: 2, + leaseOwnerFingerprint: GOLDEN_LEASE_OWNER_FINGERPRINT, + }); + nowMillis += 1; + await expectSafeError( + () => completeCommerceCommand(completionInput(mock.db)), + 'lease_stale', + ); + await expect(completeCommerceCommand(completionInput(mock.db, { + expectedFenceEpoch: 2, + }))).resolves.toMatchObject({ + outcome: 'terminal_succeeded', + state: 'succeeded', + }); + }); + + test('holder and fence must each match independently before finalization', async () => { + const mock = createMockDb(); + const attempts = [ + () => completeCommerceCommand(completionInput(mock.db, { + expectedFenceEpoch: 2, + })), + () => failCommerceCommand(failureInput(mock.db, { + expectedFenceEpoch: 2, + })), + () => completeCommerceCommand(completionInput(mock.db, { + leaseId: OTHER_LEASE_ID, + })), + () => failCommerceCommand(failureInput(mock.db, { + leaseId: OTHER_LEASE_ID, + })), + ]; + await registerAtBaseAndAcquire(mock); + const paths = lifecyclePaths(leaseInput(mock.db)); + const lifecycleBefore = mock.store.get(paths.lifecyclePath); + const sizeBefore = mock.store.size; + nowMillis += 1; + + for (const attempt of attempts) { + await expectSafeError(attempt, 'lease_stale'); + } + expect(mock.store.size).toBe(sizeBefore); + expect(mock.store.get(paths.lifecyclePath)).toBe(lifecycleBefore); + }); + + test('success is terminal, commitment-bound, and exact-retry safe after expiry', async () => { + const mock = createMockDb(); + await registerAtBaseAndAcquire(mock); + const args = leaseInput(mock.db); + const paths = lifecyclePaths(args); + nowMillis += 5000; + + const completed = await completeCommerceCommand(completionInput(mock.db)); + const lifecycleAfter = mock.store.get(paths.lifecyclePath); + const auditAfter = mock.store.get(lifecyclePaths(args, 3).lifecycleAuditPath); + const sizeAfter = mock.store.size; + + expect(completed).toEqual({ + journalSchemaVersion: 1, + lifecycleSchemaVersion: 1, + outcome: 'terminal_succeeded', + state: 'succeeded', + }); + expect(lifecycleAfter).toMatchObject({ + state: 'succeeded', + commandRevision: 3, + fenceEpoch: 1, + leaseOwnerFingerprint: GOLDEN_LEASE_OWNER_FINGERPRINT, + terminalCommitmentKind: 'business_record_digest', + terminalCommitmentHash: GOLDEN_TERMINAL_COMMITMENT, + }); + expect(auditAfter).toMatchObject({ + auditSchemaVersion: 2, + commandRevision: 3, + eventType: 'command_succeeded', + fromState: 'leased', + toState: 'succeeded', + fenceEpoch: 1, + }); + expect(JSON.stringify(auditAfter)).not.toContain(GOLDEN_TERMINAL_COMMITMENT); + expect(JSON.stringify(completed)).not.toContain(GOLDEN_TERMINAL_COMMITMENT); + expect(JSON.stringify({ lifecycleAfter, auditAfter, completed })) + .not.toContain(TERMINAL_REFERENCE_FINGERPRINT); + + nowMillis += 120000; + await expect(completeCommerceCommand(completionInput(mock.db))).resolves.toBe(completed); + await expect(acquireCommerceCommandLease(leaseInput(mock.db, { + leaseId: OTHER_LEASE_ID, + }))).resolves.toBe(completed); + expect(mock.store.size).toBe(sizeAfter); + expect(mock.store.get(paths.lifecyclePath)).toBe(lifecycleAfter); + expect(mock.store.get(lifecyclePaths(args, 3).lifecycleAuditPath)).toBe(auditAfter); + + await expectSafeError( + () => completeCommerceCommand(completionInput(mock.db, { + terminalReferenceFingerprint: OTHER_TERMINAL_REFERENCE_FINGERPRINT, + })), + 'terminal_conflict', + ); + await expectSafeError( + () => failCommerceCommand(failureInput(mock.db)), + 'terminal_conflict', + ); + await expectSafeError( + () => completeCommerceCommand(completionInput(mock.db, { + leaseId: OTHER_LEASE_ID, + })), + 'terminal_conflict', + ); + }); + + test('fixed final failure is terminal and has no reason or commitment', async () => { + const mock = createMockDb(); + await registerAtBaseAndAcquire(mock); + const args = leaseInput(mock.db); + const paths = lifecyclePaths(args); + nowMillis += 5000; + + const failed = await failCommerceCommand(failureInput(mock.db)); + const lifecycleAfter = mock.store.get(paths.lifecyclePath); + const auditAfter = mock.store.get(lifecyclePaths(args, 3).lifecycleAuditPath); + + expect(failed).toEqual({ + journalSchemaVersion: 1, + lifecycleSchemaVersion: 1, + outcome: 'terminal_failed_final', + state: 'failed_final', + }); + expect(lifecycleAfter).toMatchObject({ + state: 'failed_final', + commandRevision: 3, + fenceEpoch: 1, + terminalCommitmentKind: null, + terminalCommitmentHash: null, + }); + expect(auditAfter).toMatchObject({ + eventType: 'command_failed_final', + toState: 'failed_final', + }); + expect(JSON.stringify({ lifecycleAfter, auditAfter })).not.toMatch(/reason|error|provider/i); + + nowMillis += 120000; + await expect(failCommerceCommand(failureInput(mock.db))).resolves.toBe(failed); + await expect(acquireCommerceCommandLease(leaseInput(mock.db, { + leaseId: OTHER_LEASE_ID, + }))).resolves.toBe(failed); + await expectSafeError( + () => completeCommerceCommand(completionInput(mock.db)), + 'terminal_conflict', + ); + }); + + test('register remains exact and read-only with active and terminal lifecycle state', async () => { + for (const terminal of [null, 'succeeded', 'failed_final']) { + const mock = createMockDb(); + const args = leaseInput(mock.db, { + commandId: terminal === null + ? COMMAND_ID + : terminal === 'succeeded' + ? OTHER_COMMAND_ID + : 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', + }); + await registerCommerceCommand(validInput(mock.db, { commandId: args.commandId })); + const paths = lifecyclePaths(args); + const rootBefore = mock.store.get(paths.commandPath); + const auditBefore = mock.store.get(paths.auditPath); + nowMillis += 1000; + await acquireCommerceCommandLease(args); + if (terminal === 'succeeded') { + nowMillis += 1; + await completeCommerceCommand({ + ...args, + expectedFenceEpoch: 1, + terminalReferenceFingerprint: TERMINAL_REFERENCE_FINGERPRINT, + }); + } else if (terminal === 'failed_final') { + nowMillis += 1; + await failCommerceCommand({ ...args, expectedFenceEpoch: 1 }); + } + + const result = await registerCommerceCommand(validInput(mock.db, { + commandId: args.commandId, + })); + expect(result).toEqual({ + journalSchemaVersion: 1, + outcome: 'registered_existing', + state: 'registered', + }); + expect(mock.store.get(paths.commandPath)).toBe(rootBefore); + expect(mock.store.get(paths.auditPath)).toBe(auditBefore); + } + }); + + test('transaction callback retries reuse one clock and deterministic transition records', async () => { + const placeholder = createMockDb(); + const baseInput = validInput(placeholder.db); + const pair = exactPair(baseInput); + const mock = createMockDb(pair.seed, { callbackRuns: 4 }); + timestampNow.mockClear(); + + const result = await acquireCommerceCommandLease(leaseInput(mock.db)); + + expect(result).toMatchObject({ outcome: 'lease_acquired', fenceEpoch: 1 }); + expect(timestampNow).toHaveBeenCalledTimes(1); + expect(mock.db.runTransaction).toHaveBeenCalledWith( + expect.any(Function), + { maxAttempts: 10 }, + ); + expect(mock.callbackRuns).toHaveLength(4); + const lifecycleWrites = mock.callbackRuns.map(({ writes }) => writes[0].value); + expect(new Set(lifecycleWrites.map((value) => value.leaseAcquiredAt)).size).toBe(1); + expect(new Set(lifecycleWrites.map((value) => value.leaseExpiresAt)).size).toBe(1); + expect(new Set(lifecycleWrites.map((value) => value.leaseOwnerFingerprint))).toEqual( + new Set([GOLDEN_LEASE_OWNER_FINGERPRINT]), + ); + }); +}); + +describe('lifecycle fail-closed record and input boundary', () => { + let nowMillis; + + beforeEach(() => { + nowMillis = NOW_MILLIS; + jest.spyOn(Timestamp, 'now') + .mockImplementation(() => Timestamp.fromMillis(nowMillis)); + }); + + afterEach(() => jest.restoreAllMocks()); + + async function createActiveLease() { + const mock = createMockDb(); + await registerCommerceCommand(validInput(mock.db)); + nowMillis += 1000; + await acquireCommerceCommandLease(leaseInput(mock.db)); + return mock; + } + + test('requires the exact registration pair and does not repair an orphan lifecycle', async () => { + let mock = createMockDb(); + await expectSafeError( + () => acquireCommerceCommandLease(leaseInput(mock.db)), + 'command_not_registered', + ); + expect(mock.store.size).toBe(0); + + const placeholder = createMockDb(); + const paths = lifecyclePaths(leaseInput(placeholder.db)); + const orphan = Object.freeze({ hostile: HOSTILE_CANARY }); + mock = createMockDb([[paths.lifecyclePath, orphan]]); + await expectSafeError( + () => acquireCommerceCommandLease(leaseInput(mock.db)), + 'journal_record_invalid', + [paths.commandKeyHash], + ); + expect(mock.store.get(paths.lifecyclePath)).toBe(orphan); + expect(mock.store.size).toBe(1); + }); + + test.each([ + ['future schema', (record) => ({ ...record, lifecycleSchemaVersion: 2 })], + ['unknown state', (record) => ({ ...record, state: 'provider_sending' })], + ['extra field', (record) => ({ ...record, unexpected: HOSTILE_CANARY })], + ['uppercase owner fingerprint', (record) => ({ + ...record, + leaseOwnerFingerprint: record.leaseOwnerFingerprint.toUpperCase(), + })], + ['bad expiry', (record) => ({ + ...record, + leaseExpiresAt: Timestamp.fromMillis(record.leaseExpiresAt.toMillis() + 1), + })], + ['revision/fence mismatch', (record) => ({ ...record, commandRevision: 4 })], + ])('rejects malformed lifecycle without repair: %s', async (_label, mutate) => { + const mock = await createActiveLease(); + const paths = lifecyclePaths(leaseInput(mock.db)); + const malformed = mutate(mock.store.get(paths.lifecyclePath)); + mock.store.set(paths.lifecyclePath, malformed); + const auditBefore = mock.store.get(paths.lifecycleAuditPath); + + await expectSafeError( + () => acquireCommerceCommandLease(leaseInput(mock.db)), + 'journal_record_invalid', + [paths.commandKeyHash], + ); + + expect(mock.store.get(paths.lifecyclePath)).toBe(malformed); + expect(mock.store.get(paths.lifecycleAuditPath)).toBe(auditBefore); + }); + + test('rejects a missing/malformed current audit or preseeded next audit without mutation', async () => { + for (const corruption of ['missing-current', 'malformed-current', 'preseed-next']) { + const mock = await createActiveLease(); + const args = leaseInput(mock.db); + const paths = lifecyclePaths(args); + const lifecycleBefore = mock.store.get(paths.lifecyclePath); + if (corruption === 'missing-current') { + mock.store.delete(paths.lifecycleAuditPath); + } else if (corruption === 'malformed-current') { + mock.store.set(paths.lifecycleAuditPath, Object.freeze({ hostile: HOSTILE_CANARY })); + } else { + mock.store.set( + lifecyclePaths(args, 3).lifecycleAuditPath, + Object.freeze({ hostile: HOSTILE_CANARY }), + ); + } + const before = cloneStore(mock.store); + + await expectSafeError( + () => acquireCommerceCommandLease(leaseInput(mock.db)), + 'journal_record_invalid', + [paths.commandKeyHash], + ); + + expect(mock.store).toEqual(before); + expect(mock.store.get(paths.lifecyclePath)).toBe(lifecycleBefore); + } + }); + + test('an earlier-captured retry observes active state but cannot finalize it', async () => { + const mock = await createActiveLease(); + const args = leaseInput(mock.db); + const paths = lifecyclePaths(args); + const lifecycleBefore = mock.store.get(paths.lifecyclePath); + const sizeBefore = mock.store.size; + + nowMillis = NOW_MILLIS; + await expect(acquireCommerceCommandLease(args)).resolves.toMatchObject({ + outcome: 'lease_acquired', + state: 'leased', + fenceEpoch: 1, + }); + await expect(acquireCommerceCommandLease(leaseInput(mock.db, { + leaseId: OTHER_LEASE_ID, + }))).resolves.toEqual({ + journalSchemaVersion: 1, + lifecycleSchemaVersion: 1, + outcome: 'lease_busy', + state: 'leased', + }); + await expectSafeError( + () => completeCommerceCommand(completionInput(mock.db)), + 'lease_stale', + ); + nowMillis = NOW_MILLIS + 61000; + await expectSafeError( + () => completeCommerceCommand(completionInput(mock.db)), + 'lease_stale', + ); + + expect(mock.store.size).toBe(sizeBefore); + expect(mock.store.get(paths.lifecyclePath)).toBe(lifecycleBefore); + }); + + test('registration time orders the first lease and every stored lifecycle', async () => { + const futureRegistrationMock = createMockDb(); + nowMillis = NOW_MILLIS + 10000; + await registerCommerceCommand(validInput(futureRegistrationMock.db)); + const futurePaths = lifecyclePaths(leaseInput(futureRegistrationMock.db)); + const rootBefore = futureRegistrationMock.store.get(futurePaths.commandPath); + const auditBefore = futureRegistrationMock.store.get(futurePaths.auditPath); + nowMillis = NOW_MILLIS; + + await expectSafeError( + () => acquireCommerceCommandLease(leaseInput(futureRegistrationMock.db)), + 'journal_record_invalid', + ); + expect(futureRegistrationMock.store.get(futurePaths.commandPath)).toBe(rootBefore); + expect(futureRegistrationMock.store.get(futurePaths.auditPath)).toBe(auditBefore); + expect(futureRegistrationMock.store.has(futurePaths.lifecyclePath)).toBe(false); + expect(futureRegistrationMock.store.has(futurePaths.lifecycleAuditPath)).toBe(false); + + const earlierMock = createMockDb(); + nowMillis = NOW_MILLIS; + await registerCommerceCommand(validInput(earlierMock.db)); + nowMillis = NOW_MILLIS + 1000; + await acquireCommerceCommandLease(leaseInput(earlierMock.db)); + const earlierPaths = lifecyclePaths(leaseInput(earlierMock.db)); + const earlierLifecycle = earlierMock.store.get(earlierPaths.lifecyclePath); + const earlierLifecycleAudit = earlierMock.store.get(earlierPaths.lifecycleAuditPath); + const laterPair = exactPair( + validInput(createMockDb().db), + Timestamp.fromMillis(NOW_MILLIS + 10000), + ); + const corruptMock = createMockDb([ + ...laterPair.seed, + [earlierPaths.lifecyclePath, earlierLifecycle], + [earlierPaths.lifecycleAuditPath, earlierLifecycleAudit], + ]); + const before = cloneStore(corruptMock.store); + nowMillis = NOW_MILLIS + 20000; + + await expectSafeError( + () => acquireCommerceCommandLease(leaseInput(corruptMock.db)), + 'journal_record_invalid', + ); + expect(corruptMock.store).toEqual(before); + }); + + test.each([ + ['succeeded', completeCommerceCommand, completionInput], + ['failed_final', failCommerceCommand, failureInput], + ])('terminal %s remains a read-only observation for an earlier-captured retry', async ( + state, + finalize, + buildFinalizeInput, + ) => { + const mock = await createActiveLease(); + const paths = lifecyclePaths(leaseInput(mock.db)); + nowMillis += 5000; + const terminalInput = buildFinalizeInput(mock.db); + const terminalResult = await finalize(terminalInput); + const terminalBefore = mock.store.get(paths.lifecyclePath); + const sizeBefore = mock.store.size; + nowMillis = NOW_MILLIS; + + await expect(acquireCommerceCommandLease(leaseInput(mock.db, { + leaseId: OTHER_LEASE_ID, + }))).resolves.toEqual({ + journalSchemaVersion: 1, + lifecycleSchemaVersion: 1, + outcome: state === 'succeeded' ? 'terminal_succeeded' : 'terminal_failed_final', + state, + }); + await expect(finalize(terminalInput)).resolves.toBe(terminalResult); + expect(mock.store.size).toBe(sizeBefore); + expect(mock.store.get(paths.lifecyclePath)).toBe(terminalBefore); + }); + + test('maximum revision/fence state never wraps on takeover or terminal transition', async () => { + const placeholder = createMockDb(); + const base = validInput(placeholder.db); + const pair = exactPair(base); + const args = leaseInput(placeholder.db); + const paths = lifecyclePaths(args, 9999999999); + const createdAt = Timestamp.fromMillis(NOW_MILLIS); + const acquiredAt = Timestamp.fromMillis(NOW_MILLIS + 100000); + const expiresAt = Timestamp.fromMillis(NOW_MILLIS + 160000); + const lifecycle = Object.freeze({ + lifecycleSchemaVersion: 1, + commandKeyHash: paths.commandKeyHash, + state: 'leased', + commandRevision: 9999999999, + fenceEpoch: 9999999998, + leaseOwnerFingerprint: GOLDEN_LEASE_OWNER_FINGERPRINT, + leaseAcquiredAt: acquiredAt, + leaseExpiresAt: expiresAt, + createdAt, + updatedAt: acquiredAt, + terminalCommitmentKind: null, + terminalCommitmentHash: null, + }); + const audit = Object.freeze({ + auditSchemaVersion: 2, + aggregateType: 'commerce_command', + commandKeyHash: paths.commandKeyHash, + commandRevision: 9999999999, + eventType: 'command_lease_taken_over', + fromState: 'leased', + toState: 'leased', + environment: 'test', + callerScopeKind: 'firebase_uid', + commandType: 'merch.checkout.create', + fenceEpoch: 9999999998, + leaseExpiresAt: expiresAt, + occurredAt: acquiredAt, + }); + const mock = createMockDb([ + ...pair.seed, + [paths.lifecyclePath, lifecycle], + [paths.lifecycleAuditPath, audit], + ]); + const before = cloneStore(mock.store); + + nowMillis = NOW_MILLIS + 100001; + await expectSafeError( + () => completeCommerceCommand(completionInput(mock.db, { + expectedFenceEpoch: 9999999998, + })), + 'journal_record_invalid', + ); + nowMillis = NOW_MILLIS + 160000; + await expectSafeError( + () => acquireCommerceCommandLease(leaseInput(mock.db, { + leaseId: OTHER_LEASE_ID, + })), + 'journal_record_invalid', + ); + expect(mock.store).toEqual(before); + }); + + test.each([ + ['non-UUID lease', () => leaseInput(createMockDb().db, { leaseId: HOSTILE_CANARY })], + ['command ID reused as lease', () => leaseInput(createMockDb().db, { leaseId: COMMAND_ID })], + ['zero fence', () => completionInput(createMockDb().db, { expectedFenceEpoch: 0 })], + ['huge fence', () => completionInput(createMockDb().db, { + expectedFenceEpoch: 10000000000, + })], + ['bad terminal fingerprint', () => completionInput(createMockDb().db, { + terminalReferenceFingerprint: HOSTILE_CANARY, + })], + ])('rejects lifecycle input before Firestore: %s', async (_label, buildInput) => { + const input = buildInput(); + await expectSafeError( + () => (Object.prototype.hasOwnProperty.call(input, 'terminalReferenceFingerprint') + ? completeCommerceCommand(input) + : acquireCommerceCommandLease(input)), + 'invalid_command_input', + ); + expect(input.db.runTransaction).not.toHaveBeenCalled(); + }); + + test('rejects extra, missing, accessor, custom-prototype, and proxy lifecycle inputs', async () => { + const mock = createMockDb(); + const extra = { ...leaseInput(mock.db), unexpected: HOSTILE_CANARY }; + const missing = leaseInput(mock.db); + delete missing.leaseId; + const inherited = Object.assign( + Object.create({ inherited: HOSTILE_CANARY }), + leaseInput(mock.db), + ); + let getterCalls = 0; + const accessor = leaseInput(mock.db); + Object.defineProperty(accessor, 'leaseId', { + enumerable: true, + get() { + getterCalls += 1; + return HOSTILE_CANARY; + }, + }); + let trapCalls = 0; + const proxy = new Proxy(leaseInput(mock.db), { + ownKeys() { + trapCalls += 1; + return []; + }, + }); + + for (const value of [extra, missing, inherited, accessor, proxy]) { + await expectSafeError( + () => acquireCommerceCommandLease(value), + 'invalid_command_input', + ); + } + expect(getterCalls).toBe(0); + expect(trapCalls).toBe(0); + expect(mock.db.runTransaction).not.toHaveBeenCalled(); + }); + + test('redacts Firestore failures and rejects a forged transaction result', async () => { + for (const options of [ + { rejectBeforeCallback: new Error(`${HOSTILE_CANARY}/private-path`) }, + { rejectCommit: new Error(`${HOSTILE_CANARY}/private-path`) }, + { returnedValue: { outcome: HOSTILE_CANARY } }, + ]) { + const placeholder = createMockDb(); + const pair = exactPair(validInput(placeholder.db)); + const mock = createMockDb(pair.seed, options); + await expectSafeError( + () => acquireCommerceCommandLease(leaseInput(mock.db)), + 'journal_unavailable', + ['private-path'], + ); + } + }); +}); + describe('exact existing identity and partner validation', () => { beforeEach(() => { jest.spyOn(Timestamp, 'now').mockImplementation(() => Timestamp.fromMillis(NOW_MILLIS + 5000)); @@ -605,18 +1413,25 @@ describe('fixed input and infrastructure failure boundary', () => { }); describe('closed source and export boundary', () => { - test('exports only registration primitives and grants no execute/send/lease/result permission', () => { + test('exports only the unused registration/lifecycle API and no provider-send permission', () => { const api = require('./commerceCommandJournal'); expect(journalSchemaVersion).toBe(1); + expect(lifecycleSchemaVersion).toBe(1); expect(Object.keys(api).sort()).toEqual([ 'CommerceCommandJournalError', + 'acquireCommerceCommandLease', + 'completeCommerceCommand', + 'failCommerceCommand', 'journalSchemaVersion', + 'lifecycleSchemaVersion', 'registerCommerceCommand', ]); expect(Object.isFrozen(api)).toBe(true); expect(Object.isFrozen(CommerceCommandJournalError)).toBe(true); expect(Object.isFrozen(CommerceCommandJournalError.prototype)).toBe(true); - expect(Object.keys(api).join(' ')).not.toMatch(/execute|send|lease|fence|result|replay|provider/i); + expect(Object.keys(api).join(' ')).not.toMatch( + /execute|send|renew|release|reconcile|replay|provider/i, + ); }); test('has only the approved local, Firestore, and Node utility dependencies', () => { @@ -625,11 +1440,13 @@ describe('closed source and export boundary', () => { expect(imports.sort()).toEqual([ './commerceCommandIdentity', 'firebase-admin/firestore', + 'node:crypto', 'node:util', ]); expect(source).not.toMatch(/require\(['"]\.\/index|createCheckout|stripeWebhook/); expect(source).not.toMatch(/\b(?:fetch|XMLHttpRequest|console|logger)\s*[.(]/); expect(source).not.toMatch(/Math\.random|randomUUID|process\.env|node:fs|node:http|node:https|child_process/); + expect(source).toContain('maxAttempts: 10'); expect((source.match(/Timestamp\.now\(\)/g) || [])).toHaveLength(1); expect(source.indexOf('Timestamp.now()')).toBeLessThan(source.indexOf('.runTransaction(')); }); diff --git a/tests/firestore-rules/shop-and-locked.test.js b/tests/firestore-rules/shop-and-locked.test.js index df3616bb..2d0afa70 100644 --- a/tests/firestore-rules/shop-and-locked.test.js +++ b/tests/firestore-rules/shop-and-locked.test.js @@ -351,6 +351,31 @@ describe('server-only operational collections', () => { }); }); + describe.each([ + ['anonymous', undefined], + ['member', { uid: 'member-1', role: 'member' }], + ['browser admin', { uid: 'admin-1', role: 'admin' }], + ])('checkoutRequests lifecycle — %s', (_label, auth) => { + const existingPath = 'checkoutRequests/synthetic-existing/lifecycle/current'; + const newPath = 'checkoutRequests/synthetic-new/lifecycle/current'; + const lifecycle = { + lifecycleSchemaVersion: 1, + state: 'leased', + commandRevision: 2, + fenceEpoch: 1, + }; + + test('CANNOT read, create, update, or delete lifecycle/current', async () => { + await seed(existingPath, lifecycle); + const client = await db(auth); + + await assertFails(client.doc(existingPath).get()); + await assertFails(client.doc(newPath).set(lifecycle)); + await assertFails(client.doc(existingPath).update({ state: 'succeeded' })); + await assertFails(client.doc(existingPath).delete()); + }); + }); + describe.each([ ['promoCodes/PROMO1', 'promoCodes/PROMO2', { discountPercent: 10 }], ['ratelimits/checkout_ip__1.2.3.4', 'ratelimits/checkout_ip__5.6.7.8', { count: 5 }],