Skip to content

PAY-002B2B — Add command leases, fencing, and terminal commitments #169

Closed

Description

@daliu

Officer impact: None. This is unused server-only safety infrastructure. It does not add checkout, refund, admin, or officer actions.

Officer documentation: None — no officer-visible behavior, procedure, permission, provider setting, collected data, website surface, or deployed service changes. Commerce remains NOT AVAILABLE YET.

Deployment evidence: Closure may prove source, synthetic local tests, independent review, merge, and exact-commit hosted CI only. It must separately state website publication, runmprc.com, Firebase deployment, Stripe/provider configuration, and live behavior. This issue must not claim any of those external outcomes.

Parent and dependencies

Atomic PAY-002B2B child of the catalogued PAY-002 command-safety design.

At creation, exact remote main is e1f887203e4ba75d22908f3ef51259a339aaaa08; there is no open pull request, duplicate PAY-002B2B issue, or active overlapping claim.

Problem

B2A can register one command, but registration is intentionally not permission to act. Two trusted workers can still observe the same registration and both begin work. A worker whose lease expired can also finish after a newer worker took over unless every terminal journal transition checks a monotonic fence.

This slice adds a transactional lease/fence and terminal commitment. It deliberately does not make a Stripe POST safe and never returns an execute/send decision.

Design decision: preserve the B2A root

Keep these two B2A records byte-compatible and immutable for their lifetime:

  • checkoutRequests/{commandKeyHash}
  • auditEvents/commerce_command_{commandKeyHash}_0000000001

Store mutable lifecycle state separately at:

checkoutRequests/{commandKeyHash}/lifecycle/current

Why: B2A's exact parser accepts only the version-1, revision-1 registered root. Mutating or adding fields to that root would break safe registration retries and silently widen #165's proven contract. A missing or malformed root/revision-1 pair, an orphan lifecycle, or a lifecycle without its exact current audit partner fails closed without repair.

Deleting a parent Firestore document does not delete its subcollections. No record in this design may be deleted until #110 defines a durable duplicate barrier and approved retention behavior.

Exact APIs

Unused server-only exports from functions/commerceCommandJournal.js:

acquireCommerceCommandLease({
  db,
  environment,
  callerScope,
  commandId,
  commandType,
  endpointSchemaVersion,
  payload,
  leaseId,
})

completeCommerceCommand({
  db,
  environment,
  callerScope,
  commandId,
  commandType,
  endpointSchemaVersion,
  payload,
  leaseId,
  expectedFenceEpoch,
  terminalReferenceFingerprint,
})

failCommerceCommand({
  db,
  environment,
  callerScope,
  commandId,
  commandType,
  endpointSchemaVersion,
  payload,
  leaseId,
  expectedFenceEpoch,
})

All three recompute the B1 command identity and validate the exact B2A root/revision-1 audit. They never accept a caller-supplied command hash.

leaseId is one canonical lowercase UUID v4 generated by trusted server code once per logical worker attempt, before calling this module. It must differ from commandId. It is never accepted directly from a browser, stored raw, logged, returned, or copied into an audit event. The module stores only a versioned, length-framed, domain-separated SHA-256 fingerprint bound to the command hash. The UUID is concurrency identity, not authorization or a bearer capability.

There is no lease renewal in this slice. A later real provider saga may add bounded renewal only if PAY-002B2C proves the need and retry boundary.

Fixed outputs

Every output and nested value is frozen and fixed-shape.

Lease acquired, expired lease taken over, or exact same-holder lost-response retry:

{
  journalSchemaVersion: 1,
  lifecycleSchemaVersion: 1,
  outcome: lease_acquired,
  state: leased,
  fenceEpoch: positive bounded integer,
  leaseExpiresAt: { seconds, nanoseconds }
}

Different holder while the lease is active:

{
  journalSchemaVersion: 1,
  lifecycleSchemaVersion: 1,
  outcome: lease_busy,
  state: leased
}

Terminal success/failure, including an exact terminal retry or an acquire after terminal state:

{ journalSchemaVersion: 1, lifecycleSchemaVersion: 1,
  outcome: terminal_succeeded, state: succeeded }

{ journalSchemaVersion: 1, lifecycleSchemaVersion: 1,
  outcome: terminal_failed_final, state: failed_final }

No output contains a raw lease ID/fingerprint, command hash/path, caller, UUID, payload/fingerprint, terminal commitment, business/provider ID, Checkout URL, provider key, authorization decision, or shouldExecute/shouldSend flag. A busy result exposes no fence or expiry.

Trusted clock and lease rules

  • Fixed lease duration: 60 seconds, owned by source code. Callers cannot set it.
  • Capture exactly one trusted Firestore Timestamp.now() before each transaction callback can run.
  • The callback may rerun and must reuse that same timestamp and derived expiry.
  • Firestore receives the fixed code-owned maximum of 10 transaction attempts. An exhausted provider retry maps to the fixed unavailable error; callers may retry the whole operation with the same command and lease IDs.
  • A first lease mutation requires captured time at or after the immutable registration time. For an existing lease, an earlier-captured Firestore retry is observation-only: the same holder recovers, another holder is busy, and terminal state is observed; it never takes over or finalizes.
  • now < leaseExpiresAt is active.
  • Equality with expiry is expired.
  • First acquisition uses fence 1 and command revision 2.
  • An expired takeover, including reuse of the same leaseId, increments both fence and revision exactly once.
  • Same-holder retry before expiry returns the original fence/expiry and performs no write.
  • A different active holder gets lease_busy and performs no write.
  • Fence and revision are bounded positive safe integers and never wrap.

A lease/fence is concurrency evidence only. It does not authorize the caller, reserve inventory/capacity, validate a price/refund, or permit a provider request.

Lifecycle document

Exact fields at checkoutRequests/{commandKeyHash}/lifecycle/current:

lifecycleSchemaVersion: 1
commandKeyHash: lowercase SHA-256
state: leased | succeeded | failed_final
commandRevision: 2..9999999999
fenceEpoch: 1..9999999999
leaseOwnerFingerprint: lowercase SHA-256
leaseAcquiredAt: Firestore Timestamp
leaseExpiresAt: Firestore Timestamp
createdAt: Firestore Timestamp
updatedAt: Firestore Timestamp
terminalCommitmentKind: null | business_record_digest
terminalCommitmentHash: null | lowercase SHA-256

State-specific invariants:

  • leased: terminal fields are both null; expiry is strictly after acquisition; updatedAt equals leaseAcquiredAt; first lease also has createdAt equal to that timestamp.
  • takeover: preserves createdAt, writes a new acquisition/expiry/update time, increments revision/fence, and leaves terminal fields null.
  • succeeded: preserves owner, fence, acquisition, expiry, and creation fields; increments revision; updatedAt is the terminal transition time; both terminal commitment fields are populated.
  • failed_final: preserves owner, fence, acquisition, expiry, and creation fields; increments revision; updatedAt is the terminal transition time; both terminal commitment fields are null.

Unknown fields, prototypes, accessors, proxies, symbols, versions, states, timestamp relationships, hashes, revisions, or fences fail closed without repair.

Terminal commitment boundary

terminalReferenceFingerprint is an already-redacted lowercase SHA-256 supplied by later trusted domain code. B2B derives and stores only:

SHA-256(length-framed domain, version, commandKeyHash,
        kind=business_record_digest, terminalReferenceFingerprint)

This is a write-once, command-bound commitment, not a dereferenceable result, authorization fact, or proof that a Firestore/Stripe action happened. It is not returned and is not copied to audit events. PAY-002B2C and PAY-002C/D must atomically create or verify a domain-specific server-only result envelope before invoking terminal success, then own actual response replay. This generic journal must not invent or validate race/shop/provider schema.

Terminal rules:

  • completeCommerceCommand and failCommerceCommand require the exact stored holder fingerprint and expectedFenceEpoch while now < leaseExpiresAt.
  • Wrong holder, stale fence, or an expired nonterminal lease rejects without a write.
  • A successful terminal transition stores only the derived commitment. No raw path, provider/business ID, URL, payload, PII, or response is stored.
  • Final failure stores no reason, provider error, response body, or retry text.
  • Exact holder/fence/outcome/commitment retry returns the same fixed terminal result without a write, even after the old lease expired.
  • A different success commitment, success-versus-failure conflict, or mismatched terminal holder/fence rejects.
  • Terminal state never returns to leased and cannot be overwritten.

Transition audit

Every real lifecycle mutation atomically creates one deterministic event:

auditEvents/commerce_command_{commandKeyHash}_{commandRevision padded to 10 digits}

Exact audit version-2 fields:

auditSchemaVersion: 2
aggregateType: commerce_command
commandKeyHash: lowercase SHA-256
commandRevision: 2..9999999999
eventType: command_lease_acquired | command_lease_taken_over |
           command_succeeded | command_failed_final
fromState: registered | leased
toState: leased | succeeded | failed_final
environment: closed enum
callerScopeKind: closed enum
commandType: trusted bounded slug
fenceEpoch: positive bounded integer
leaseExpiresAt: Firestore Timestamp
occurredAt: Firestore Timestamp

The audit deliberately excludes the owner fingerprint and terminal commitment. Same-holder recovery, busy observation, terminal replay, and every rejection append no event.

Before any write, the transaction reads and validates the immutable root, revision-1 audit, lifecycle (if present), exact current-revision audit partner (if lifecycle exists), and absence of the next deterministic audit ID. A preseeded next audit, missing current partner, malformed/future/orphan record, overflow, or unexpected state fails without repair. The callback contains only Firestore reads/writes and pure comparison of values prepared before entry: no clock, random generator, logger, network, Stripe, filesystem, environment lookup, or mutable application-state side effect.

State machine

registered + acquire -> leased, fence 1, revision 2
leased + same active holder -> leased (read-only recovery)
leased + another active holder -> busy (read-only)
leased + expired holder -> leased, fence + 1, revision + 1
leased + exact active holder/fence + commitment -> succeeded, revision + 1
leased + exact active holder/fence + final failure -> failed_final, revision + 1
succeeded + exact terminal retry -> succeeded (read-only)
failed_final + exact terminal retry -> failed_final (read-only)
terminal + acquire -> fixed terminal observation (read-only)
stale/conflicting/malformed/overflow -> reject (read-only)

Concurrent takeover and terminal completion are safe because both read the lifecycle document. Firestore forces the losing transaction to rerun against the committed state.

Exact source boundary

Code and tests:

  • functions/commerceCommandJournal.js
  • functions/commerceCommandJournal.test.js
  • functions/commerceCommandJournal.emulator.test.js
  • firestore.rules
  • tests/firestore-rules/shop-and-locked.test.js

Minimum exact design/status hunks only:

  • STRIPE_COMMERCE_DESIGN.md
  • SYSTEM_DESIGN.md
  • SECURITY.md
  • IMPLEMENTATION_PLAN.md
  • GITHUB_ISSUES.md
  • GITHUB_ISSUE_SLICES.md

Post a scope amendment before editing an endpoint, functions/index.js, provider adapter, workflow, package/lockfile, index, environment file, UI, officer guide, or production/provider setting/data.

Firestore Rules boundary

Add an explicit recursive deny for nested checkoutRequests documents and prove anonymous, signed-in/member, and browser-admin clients cannot read, create, update, or delete lifecycle/current. Admin SDK access remains trusted server code/IAM, not browser-admin authorization. Existing root and auditEvents denial must remain.

Required tests

Mock/unit tests:

  • Exact input/output/lifecycle/audit schemas, frozen nested output, one trusted timestamp, and no callback-local side effect.
  • Canonical UUID v4 plus command-ID reuse rejection; prototype/accessor/proxy/symbol/extra-key rejection.
  • Golden command-bound lease fingerprint and terminal commitment; raw values/canaries absent from storage, output, errors, audits, and source-visible logging.
  • First acquisition, same-holder lost-response recovery, another-holder busy, same/different-holder expiry takeover, and exact expiry equality.
  • Holder/fence/expired mismatch matrix.
  • Exact success/failure terminal retries; different commitment and success/failure conflicts.
  • Missing/malformed/future/orphan root/lifecycle/current-audit, preseeded next audit, registration/lifecycle timestamp regression, earlier-captured observation versus mutation, and revision/fence maximum.
  • Repeated transaction callback simulation proves one timestamp and prepared immutable values.
  • registerCommerceCommand remains byte-preserving and returns registered_existing with active and terminal lifecycle documents present.
  • No endpoint/index import; no execute/send/provider-plan/reconciliation/result-replay API; no Stripe/logger/network/random dependency.

Existing hosted emulator file functions/commerceCommandJournal.emulator.test.js (the exact file gated by #167):

  • 24 distinct-holder first-acquire race: one fence-1 lease and one revision-2 audit; all others busy.
  • 24 same-holder lost-response race: one mutation, identical fence/expiry results, no extra audit.
  • Expired multi-holder takeover: one new fence/revision; all others busy/recover; old holder/fence cannot finish.
  • Concurrent complete-versus-fail and distinct-commitment races: exactly one terminal mutation/event; terminal state never changes.
  • Exact terminal retry and later acquire are read-only fixed terminal observations.
  • Preseed next audit/commit failure leaves lifecycle and audit atomic.
  • Malformed/future/orphan lifecycle/current audit remains untouched.
  • Root registration and revision-1 audit stay byte/update-time equivalent, and registration still works after leased/succeeded/failed state.

Rules:

  • Explicit nested lifecycle read/create/update/delete denial for anonymous, ordinary member, and browser-admin contexts.
  • Existing protected collection and collection-group tests remain green.

Run Node 20 focused unit/emulator tests, Functions lint/full tests, frontend Jest, SPA, Rules, diagnostic build, workflow tests, diff/sensitive scan, and at least two independent exact-snapshot reviews.

Acceptance criteria

  • B2A root and revision-1 audit remain exact and immutable through every lifecycle state.
  • One active holder/fence exists; exact recovery is read-only; another active holder is busy without leaked lease details.
  • Expiry/takeover increments the monotonic fence once; stale/expired workers cannot finalize.
  • Success/final-failure are terminal, exact-retry safe, conflict safe, and append one deterministic event only for a real mutation.
  • Terminal commitment is command-bound, stored only in lifecycle, and explicitly not claimed as provider/business proof or replay.
  • Every malformed/orphan/future/preseeded/overflow state fails closed without repair.
  • Browser access to the lifecycle subcollection is explicitly denied and tested.
  • The existing CI-001B3 — Gate the commerce command journal on the Firestore emulator #167 hosted emulator job runs the expanded focused suite and passes on PR and merge commits.
  • Full relevant local/hosted checks and independent reviews pass.
  • Root docs distinguish registration, lease/fence, terminal commitment, provider-plan/send safety, deployment, and live behavior.
  • No runtime adoption, endpoint/index export, Firebase deployment, Stripe/provider action, production read/write, TTL, or officer behavior is changed or claimed.

Migration and rollback

Additive target schema only. No backfill, production read, provider inventory, deployment, or existing business-record mutation. Before adoption/deployment, use #113-approved private inventory boundaries for any existing collection collision; unknown records fail closed. No index is required.

Rollback is the reviewed unused source/schema/docs commit only. Never delete synthetic or future journal records as a casual rollback; deletion can remove duplicate evidence and remains blocked on #110.

Required follow-on

PAY-002B2C must bind the immutable Stripe account/sandbox/API version/endpoint/operation/mode/parameters plan, provider attempt/key, and pre-POST evidence; stop automatic POSTs after a conservative stored cutoff or unknown send time; preserve ambiguous outcomes; reconcile verified provider/business evidence; and allow only proven-safe attempt advancement. PAY-002C/D then own domain result envelopes and actual server-side response replay.

PAY-002C/D, PAY-003B, PAY-004, and PAY-005 must depend on B2C, not treat this B2B lease as provider-send permission.

Excluded

  • Endpoint/index export or runtime adoption.
  • Stripe/provider call, key, object, response, API plan, send permit, timeout retry, reconciliation, or attempt advancement.
  • Domain business-record/result-envelope creation or dereferenceable result replay.
  • Authorization, price, discount, tax, capacity, inventory, refund, webhook, mail, rate-limit, admin, or confirmation-capability behavior.
  • Renewal, release-to-registered, TTL, deletion, backfill, migration, package/lockfile/workflow/index/environment/UI/officer/provider changes.
  • Firebase deployment, production read/write, live checkout, or live testing.

Claim protocol

Creation is not a claim. Before editing: confirm exact current origin/main, assign the issue, post CLAIMED by /root at <UTC> with branch codex/issue-<number>-command-leases, apply status:in-progress, and use an isolated worktree.

Activity

  1. added
    priority:P0Launch blocker or urgent security risk
    type:securitySecurity or privacy boundary
    area:firebaseFirebase services and data
    size:MMedium multi-file issue
    status:readyDependencies complete and ready to claim
    area:stripeStripe payments and financial lifecycle
    status:in-progressClaimed and actively being implemented
    and removed
    status:readyDependencies complete and ready to claim
    on Jul 13, 2026
  2. self-assigned this
    on Jul 13, 2026
  3. daliu commented on Jul 13, 2026

    @daliu
    ContributorAuthor

    CLAIMED by /root at 2026-07-13T17:30:52Z on branch codex/issue-169-command-leases, exact base e1f887203e4ba75d22908f3ef51259a339aaaa08. Ownership is limited to the 11 paths in the issue. No endpoint, index export, workflow, package/lockfile, provider, UI, officer, environment, or production change is claimed.

  4. daliu commented on Jul 13, 2026

    @daliu
    ContributorAuthor

    CONTRACT AMENDMENT RECORDED — 2026-07-13T17:57:57Z

    Firestore can retry a transaction callback after another worker commits. Therefore, one trusted time is captured before each journal call and reused across callback retries, but an earlier captured time may observe a state that was committed later by a competing call. The durable contract is:

    • first lifecycle mutation requires captured time at or after registration creation;
    • every stored lifecycle creation time is at or after registration creation;
    • an earlier-time observation of an already active lease is read-only: the same holder recovers it and another holder receives busy;
    • an earlier-time observation of an already terminal state is read-only and returns only the fixed terminal outcome;
    • a nonterminal finalization observed before the stored acquisition time fails closed as lease_stale;
    • no earlier-time observation can take over, finalize, repair, or otherwise mutate stored state.

    The lifecycle transactions use the code-owned fixed Firestore retry limit of 10. This reduces emulator lock-timeout noise without allowing callers to select retries or weakening conflict checks. Test cleanup pre-tracks deterministic audit revisions so a failed assertion cannot leak state into a later run.

    Current pre-PR local evidence: unit 72/72; exact opt-in Firestore emulator 15/15 on three consecutive independent runs after the contention hardening; full Functions 730 passed with 16 intentionally skipped; Firestore Rules 308/308; frontend 132/132; SPA 10/10; selected ESLint, diff check, and diagnostic production build passed. This is synthetic source/test evidence only. It does not prove merge, Firebase deployment, Stripe configuration, a website publication, or live behavior.

  5. daliu commented on Jul 13, 2026

    @daliu
    ContributorAuthor

    EXTRACTION FINISHED — 2026-07-13T18:01:22Z

    Reviewable PR: #172
    Exact head: 2a60a36
    Exact base: 5732e5a

    The diff is exactly the 11 claimed paths. Two independent exact-head reviews approve it. The claim remains active through hosted checks, merge, and post-merge evidence. No Firebase, Stripe/provider, website, or production change has occurred at this stage.

  6. daliu commented on Jul 13, 2026

    @daliu
    ContributorAuthor

    COMPLETED AND CLAIM RELEASED — 2026-07-13T18:07:46Z

    PR #172 merged to main as 2eff107 from exact reviewed head 2a60a36.

    Evidence:

    • two independent exact-head reviews approved the 11-path diff;
    • pull-request CI 29272779994 passed Frontend, Functions, Firestore Rules, and the dedicated Commerce command journal emulator job;
    • post-merge CI 29272942326 passed the same four jobs at the merge SHA;
    • local/review evidence included unit 72/72, repeated emulator 15/15, full Functions 730 passed plus 16 opt-in skips, Rules 308/308, frontend 134/134, SPA 10/10, lint, diff check, and production build.

    Deployment truth:

    • protected production release 29273070135 passed exact-main and exact-CI preflight;
    • it then failed closed before checkout/build because the required public App Check site-key variable was absent;
    • Firebase and GitHub Pages jobs were skipped;
    • no Netlify production deploy was recorded, and runmprc.com still served older main.58ede287.js and main.cdd8bda4.css assets during the dated check;
    • no Stripe/provider configuration, production data, or live command behavior changed. The new journal API is intentionally unused and not exported by any endpoint.

    The #169 claim and all 11 file paths are released after a controlled refresh to main 2eff107 or later. PAY-002B2C still owns provider-plan binding, ambiguous outcomes, reconciliation, and safe provider-attempt advancement.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:firebaseFirebase services and dataarea:stripeStripe payments and financial lifecyclepriority:P0Launch blocker or urgent security risksize:MMedium multi-file issuetype:architectureArchitecture and contract designtype:reliabilityReliability and recoverytype:securitySecurity or privacy boundary

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions