Change evidence report

Paid-group refund handoff enforcement: reviewer evidence pack

PR 1914 completes issue #1337 after the stage-1 marker writer fully replaced old API replicas. The shared paid-group transaction now refuses insertion when the locked payment carries bookingRefundStartedAt. Migration 0193 adds a group-scoped database guard covering active INSERT and inactive-to-active or booking-group UPDATE paths, including writers outside the shared helper. Seated-group replay remains authoritative before refusal, and the trigger excludes solo payment rows.

Range
ae298657f030882ed47a5ac1aaed8200dd2270bc…e3f31065618d17639b29f1d35063a045057f87e1
Source state
fix/1337-group-close-guard-enable at e3f310656, pushed as draft PR 1914 before evidence source was added
Generated

Reviewer brief

Outcome and scope

Refund-first and seat-first orderings now converge without losing money or seats

The application probes durable group rows under payment-row then slot locks before honoring the marker. A seated group replays successfully and cannot be refunded. An unseated marked payment is refused before any seat write. The database trigger enforces the same boundary for direct INSERT and inactive-to-active or group-id UPDATE paths. Twelve PostgreSQL regressions, API typecheck, migration-policy tests, fresh schema convergence, and two independent final reviews passed.

The rolling-deploy prerequisite is satisfied

Stage 1 merged in PR 1913 and production run 32834391227 successfully applied migration 0192 and completed Deploy production applications before this branch was published. That Fly deployment step replaced and drained the old API replicas. The run later failed while staging the PR evidence frontend because Vercel returned a protected-deployment 302; the base release run 32826688178 had already failed at the same unrelated step. The completed API deployment is not rolled back by that later frontend failure.

Claim map

Evidence coverage

Each material changed behavior or state maps to direct evidence or an explicit gap.

Behavior or claimEvidenceStatus
A group cannot gain an active seat after its payment starts compensationApplication marker check, database trigger, direct INSERT regression, inactive-to-active UPDATE regression, and refund-first lock-wait regressionCovered
An already seated group is never refunded because a marker existsApplication ordering keeps the in-lock seated-row replay ahead of marker refusal, with existing seat-first concurrency coverageCovered
Unexpected writers cannot bypass the application helper0193 BEFORE INSERT OR UPDATE OF status, booking_group_id trigger and direct SQL-path regressionsCovered
The database guard does not overblock solo payments or inactive group historyFriend-customer payment scoping and a PostgreSQL solo-row regression; cancelled rows insert before an attempted guarded activationCovered
The complete production release, including evidence and CRM/widget frontend promotion, is greenGap outside this PR's API behavior. The API deployment succeeded, but a pre-existing PR evidence frontend protection failure stopped later frontend stages.Gap

Observed change

Review evidence

Evidence is grouped by the reviewer question each item addresses.

Concurrency contract

Table

Both transaction orders preserve the authoritative outcome

Payment-row then slot-lock order serializes the decision. The seated-row probe runs before marker refusal, so whichever state commits first determines the safe result.

Winning transactionWaiting transaction observesResult
Seat write commits firstCommitted active rows under both locksReplay success, no refund
Compensation marker commits firstNo active rows and non-null bookingRefundStartedAtInsertion refused, compensation remains authoritative
Transaction commit is ambiguousAuthoritative replay under both locksCommitted seats replay; only a proven unseated state can remain refundable
Direct or future writer bypasses helperDatabase trigger joins booking group to marked group paymentActive INSERT or activation UPDATE fails with check_violation

Application guard

Code

Seated replay precedes marker refusal under the same lock order

The transaction already locked the captured payment row and then the slot. It first replays a committed group. Only an unseated payment reaches marker refusal.

Authoritative decision at code head e3f310656.After
packages/api/src/routes/widget-book-checkout.ts:5185-5197
const seatedRows = await readGroupRows(tx, bookingGroupId);
if (groupStillHoldsASeat(seatedRows)) {
  return { kind: "already_seated", rows: seatedRows };
}
if (
  lockedPayment?.bookingRefundStartedAt ||
  lockedPayment?.status === "refunded" ||
  lockedPayment?.status === "failed"
) {
  return { kind: "compensation_started" };
}

Database guard

Code

0193 guards active INSERT and activation or group-id UPDATE

Only reservations tied to a payment with non-empty friend customer ids and a committed refund marker are rejected. Cancelled and no-show history remains writable, while any transition into a seat-holding status is checked.

Complete enforcement shape in migration 0193.After
packages/api/drizzle/0193_booking_refund_guard.sql:7-26
IF NEW.booking_group_id IS NOT NULL
  AND NEW.status IN ('confirmed', 'waitlisted', 'pending_approval', 'completed')
  AND EXISTS (
    SELECT 1
    FROM payments
    WHERE booking_group_id = NEW.booking_group_id
      AND COALESCE(friend_customer_ids_csv, '') <> ''
      AND booking_refund_started_at IS NOT NULL
  )
THEN
  RAISE EXCEPTION 'group booking compensation has already started'
    USING ERRCODE = 'check_violation';
END IF;

CREATE TRIGGER reservations_booking_refund_guard
BEFORE INSERT OR UPDATE OF status, booking_group_id ON reservations
FOR EACH ROW
EXECUTE FUNCTION reject_group_booking_after_refund_started();

Behavioral regression matrix

Table

PostgreSQL coverage exercises the enforcement boundaries

The same real-database suite contains the adjacent seat-write invariants, so the new guard is checked alongside existing ban, replay, capacity, and ordinary insertion behavior.

ScenarioExpected resultObserved
Marked group, direct active INSERTcheck_violation, no seatPass
Marked group, cancelled row activatedcheck_violation, row remains inactivePass
Writer waits behind refund decisionApplication refusal after lock releasePass
Retry after marker with earlier cutoff timeRefusal despite otherwise bookable cutoffPass
Marked solo paymentActive INSERT remains outside group triggerPass
Unmarked ordinary groupSeats insert normallyPass

Confidence

Validation

Pass

API typecheck

bun run typecheck in packages/api exited 0 after the final guard and tests.

Pass

PostgreSQL enforcement suite

12 tests passed with 0 failures and 37 assertions after recreating the local database and migrating through 0193.

Pass

Fresh migration chain and schema convergence

One empty database migrated through 0193 and a separate empty database received drizzle-kit push. assert-migration-chain reported 193 migrations applied and matching schemas, including the raw trigger/function allowlist.

Pass

Migration-path policy

scripts/ci-migration-paths.test.ts passed 2 tests with 10 assertions.

Pass

Independent final correctness and security reviews

Both reviewers found no remaining group financial-integrity, tenant/group crossover, replay, error-path, lock-order, trigger-scope, migration, or rolling-compatibility gap after stage-1 deployment.

Warn

Exact-head CI

Pending on draft PR 1914. Local focused proof passed before this evidence source was authored.

Limits

Known issues, risks, and gaps

The group invariant is closed; remaining limits are explicit scope and unrelated release infrastructure

Solo bookings remain outside the group handoff The application marker decision is entered only for paid groups, and the trigger requires a payment with non-empty friend customer ids. A PostgreSQL regression proves a marked solo payment is not blocked. This PR does not claim to change solo compensation semantics.

The database guard depends on payment group identity The trigger joins reservations.booking_group_id to payments.booking_group_id and then verifies a non-empty friend customer list. Modern paid-group checkout always persists both. Legacy or tampered rows without group identity are already rejected by the surrounding booking flow rather than guessed.

Production frontend staging currently fails after API deployment Run 32834391227 deployed the API successfully, satisfying the replica-drain gate, then failed because the PR evidence Vercel deployment returned HTTP 302 even with the trusted OIDC header. Base run 32826688178 failed identically. This does not roll back the API, but the overall production workflow remains red until that separate protection configuration is corrected.