Land main: settlement blocker sweep + venue hierarchy + seed-places fix #3

Closed
Somehuman wants to merge 5 commits from tmp-push-probe into main
Owner

Transport-only PR: direct pushes to refs/heads/main are being rejected with "reference already exists" (Forgejo branch-table desync). Fast-forward-only merge, no merge commit.

Transport-only PR: direct pushes to refs/heads/main are being rejected with "reference already exists" (Forgejo branch-table desync). Fast-forward-only merge, no merge commit.
Step 6 (docs/specs/2026-08-18): places.{rooms,calendars,availability}
procedures with output schemas + strict inputs (availability is full-PUT
— isOpen required); getCalendarEvents split admin/public with the mode
server-pinned; bookingRequests.messages.{post,list,markRead} (post
rate-limited 30/h per actor, proven to write nothing on exhaustion) +
attachments.{add,list,delete} with storageKey claim-once (interim guard
until the Step-8 upload mint); notify composed at the router layer
mirroring the received-notification exactly. Embed subjectType
"calendar": authz via the calendar's place across upsert/list/revoke,
widget payloads structurally leak-free (serialized-payload pins),
checkoutEligible false fail-closed. Public read collapses place-gate
codes to CALENDAR_NOT_FOUND (no existence oracle). ROOM_NOT_FOUND /
CALENDAR_NOT_FOUND / CANNOT_RETIRE_LAST_ACTIVE_ROOM /
CALENDAR_SLUG_TAKEN promoted to USER_FACING with en+es copy (parity
gates green). Hold layer untouched (deferred).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
b7c80321 landed the five ledger-cutover blockers, but the file set was grown by
typechecking a clean tree until it closed — which is a compiler-shaped boundary,
not a behaviour-shaped one. These eight files are what that boundary left behind:
each one is load-bearing at runtime and invisible to `tsc`.

1. **The resale claim refund was outside the refund mutex.** b7c80321 wrapped
   refund-full-order, refund-order-items, cancel-event's per-order loop and both
   reconcile arms, so `refund-op:{orderId}` looked complete — but
   `confirmApprovedClaimRefund` refunds a RESALE order and acquired nothing. A
   buyer refund and an approved-claim refund on the same order could therefore
   both pass their pre-checks, both call Stripe, and both commit: the exact
   double-refund the mutex exists to prevent, through the one door left open.
   The mutex now spans the Stripe refund AND the mark-refunded tx; a conflict
   rides the existing reviewApprovedClaim catch, so the claim's own idempotency
   record is failed and an admin retries via allowApprovedResume.

2. **`markItemsPaidOut` restamped history on every heal replay.** The payout
   batch's ledger-heal sweep legitimately replays the stamp for an item whose
   other ledger effects were lost to a crash; without a `paidOutAt: null` guard
   the replay rewrote the record of when money actually moved to tonight. Now
   write-once, with the port doc stating the semantics and why they matter (the
   stamp gates whether a later refund attempts a Stripe transfer reversal — a
   lost stamp silently skips the clawback).

3. **A regression pin for the composition blocker.** The starvation fix itself
   (headroom-paired reversal credits) is already in b7c80321; what was missing is
   the test that holds it there. cancel-event releases its mutex before its batch
   ledger tx, so the `charge.refunded` webhook its own refund fires can commit
   debits in that gap — after which cancel's tx found nothing left to debit and
   never wrote the payee's TRANSFER_REVERSAL_CREDIT, billing them for money they
   had already returned. The new test drives the real reconcile use case and the
   real writers through that exact interleaving and asserts the credit survives.

4/5. `NoopIdempotency.release` in the transcoder container (fourth implementor of
   the owner-checked release primitive), and a PROCESS SCOPE note on the e2e
   run-batch route: its claims live under the `api` idempotency namespace and are
   invisible to the jobs-process cron, so an item it abandons mid-flight is not
   recoverable by the nightly sweep. Money is safe either way (Stripe keys and
   PAYOUT-entry uniqueness are namespace-blind); the note exists so the pattern
   is not copied into a prod path.

Verified in a clean worktree at cfd93666 with ONLY these eight files applied, so
none of it is propped up by the ~150 other uncommitted files in this checkout:
`pnpm typecheck` plus apps/{api,jobs} `tsc -p tsconfig.json --noEmit` all clean;
@th/core 610 files / 7677 tests, the adapters money+infra suites 12 files / 133
tests, and the two new specs green.

Deliberately excluded: the memberships/promos, entity-reports, venue-rooms and
CAB workstreams are also uncommitted here. Notably packages/errors/src/codes.ts
and transport/trpc/src/utils.ts are dirty with THEIR hunks, not mine — my error
code shipped in b7c80321, so staging those files would have swept their work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Step 7 (docs/specs/2026-08-18): /admin/[slug]/places/[placeId]/calendars —
calendar list (default badge, read-only slug, room chips, no-active-rooms
warning), create/edit with room assignment, and a rooms section where
retire is disabled-with-reason on the last ACTIVE room (server error still
surfaces if raced). The place-level hours/blackout editors are ABSORBED and
re-targeted to Calendar per FR-007: full-PUT (isOpen always sent), dayOfWeek
XOR date, scope immutable, and calendarId null = a single place-wide row
presented as "All calendars" (chip-marked when viewed inside a calendar).
Old editors deleted and unwired from the place profile page. Driven at 1440
and 320: zero console errors, scrollWidth == clientWidth. Two 320px overflow
defects found and fixed during the drive — the hours column's 0%-basis flex,
and a note field whose containerWidth prop belongs to AmountInput and was
silently ignored by LabeledTextInput (dead prop removed at all 5 sites).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`PlatformSettingsRepo.getAll()` is a `findMany` with no `where`, and it sits on
the hottest path in the API: `getFeatureGates` calls it, and that use case runs
from `features.*`, `events.*` and `ticket-types.*` — so one batched tRPC request
commonly triggered it more than once.

Measured in production over 7d (2026-08-19, `docs/ops/resource-burn.md`):
**37,700 full-table reads of PlatformSetting against 18,950 tRPC requests** —
~2 per request — for a table that changes on the order of days. That was ~2 of
the ~17.8 queries/request behind Neon's +31% growth since 2026-07-30.

Adds `withCachedPlatformSettings`, a read-through cache applied at the
composition root beside `withGuardedRepos` rather than inside
`createPrismaRepos`. A repo that silently caches is a nasty surprise when you go
hunting for stale reads, so the caching is a visible wiring decision.

Also collapses a cold-cache stampede: concurrent callers share one in-flight
load. Without that, a batched request on a cold cache lets every caller miss and
issue its own full-table read — the exact pileup being removed.

TTL is 60s, not the 10 minutes `createFooterDefaultsCache` uses, because these
rows drive FEATURE GATES. A toggle that takes ten minutes to appear reads as a
broken toggle; a minute reads as a save.

Invalidation, which is subtler than it looks:

  - The five writers that call `repos.platformSettings.upsert(...)` directly
    (theme presets, POS terminal location) go through the cached instance and
    invalidate themselves.
  - `updatePlatformSetting` does NOT. It writes inside `repos.tx(...)`, and
    transaction-scoped repos are built fresh from the tx client, so the cached
    wrapper never sees that write. Left alone, the main settings-editing path
    would have kept serving pre-write values for the full TTL. The platform
    router now invalidates explicitly for it — unconditionally, since this
    cache holds the whole table so any key makes the snapshot stale.
  - The TTL remains the backstop for OTHER instances, which get no
    invalidation signal.

Any future write path that goes through a transaction needs the same explicit
invalidation; noted in the decorator's docs.

Verified: build 8/8 · @th/adapters 1,292 (incl. 8 new) · @th/trpc 627 ·
@th/core 7,907. `apps/api` has 2 failures in test/seed-places.test.ts that
predate this change (its prisma stub lacks `room`, which `places.ts` began
requiring in f42f16b4) — fixed separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
test(api): stub the venue models seed-places started needing
Some checks failed
CI / build (pull_request) Failing after 1h8m54s
CI / secret-scan (pull_request) Successful in 4m39s
83ab3d6708
Both `ensurePlaces` tests have been failing on `main` with
`TypeError: Cannot read properties of undefined (reading 'findFirst')`.

`ensurePlaces` calls `ensureVenueDefaults`, which touches `room`, `calendar`
and `calendarRoom`. Those arrived with the venue hierarchy (f42f16b4); the
test's hand-rolled prisma stub was last touched in 2f363704 and only ever
exposed `place` and `placeOwnership`. So the seed reached
`prisma.room.findFirst` on an object with no `room` and threw before the
assertions ran.

Replaces the two inline stub literals with one `makePrismaStub` helper covering
every model `places.ts` touches, so the next model added to the seed path is a
single edit rather than a hunt through each case. `room.findUnique` /
`findFirst` return null so the seed takes its create branch — the path a fresh
database actually follows.

Pre-existing failure, unrelated to aabb3f9a; split out so the perf change and
this stay separately revertable.

Verified: apps/api 29 files / 335 tests, all passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Somehuman closed this pull request 2026-08-19 20:28:28 +00:00
Some checks failed
CI / build (pull_request) Failing after 1h8m54s
CI / secret-scan (pull_request) Successful in 4m39s

Pull request closed

Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Somehuman/hearthfire!3
No description provided.