fix(api): unblock the dev deploy — seed-places prisma stub tracks Room→Space #5

Closed
Somehuman wants to merge 20 commits from probe-push-test into main
Owner

Lands the 20 commits already on main locally plus the one-file fix that makes the quality gate green.

deploy-dev #689 failed at the quality gate on d0bd5126 and skipped all eleven downstream jobs, so dev has not rolled since 2026-08-19. Reproduced in a clean worktree: the ONLY failing check in the whole gate was apps/api/test/seed-places.test.ts (2 tests) — the stub still declared room/calendarRoom after 0217c676 renamed the model to Space.

Verified at HEAD with only that file applied: apps/api 28 files / 302 tests pass.

Opened as a PR because git push to main is rejected with reference already exists — git-receive-pack is not advertising refs/heads/main on this server, though ls-remote returns it.

Lands the 20 commits already on `main` locally plus the one-file fix that makes the quality gate green. `deploy-dev` #689 failed at the quality gate on `d0bd5126` and skipped all eleven downstream jobs, so dev has not rolled since 2026-08-19. Reproduced in a clean worktree: the ONLY failing check in the whole gate was `apps/api/test/seed-places.test.ts` (2 tests) — the stub still declared `room`/`calendarRoom` after `0217c676` renamed the model to Space. Verified at HEAD with only that file applied: apps/api 28 files / 302 tests pass. Opened as a PR because `git push` to `main` is rejected with `reference already exists` — `git-receive-pack` is not advertising `refs/heads/main` on this server, though `ls-remote` returns it.
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 / secret-scan (pull_request) Successful in 4m39s
CI / build (pull_request) Failing after 1h8m54s
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>
Step 8 + placement persistence (docs/specs/2026-08-18). Clicking a day on
an admin calendar now lands on quick-create with the date, time, venue,
calendar and room already filled — and the created event KEEPS them:
save-event-for-{human,org} accept optional calendarId/roomId, validated
inside the write tx (cross-place calendar or room, a RETIRED room, or
placement on a freeform event all reject with no mutation; a calendar
without a room never guesses one). Verified against the dev DB: the row
carries both columns, and a forged cross-place calendarId creates nothing.

Month cells gain an always-rendered create affordance (not hover-only —
hover doesn't exist on touch and month is the default view) that does not
hijack the existing click-to-Day-view. The long-dead onSlotPress prop is
finally wired for week/day. Prefill survives the moderator Ticketed vs
Community dialog and is dropped if the venue is changed away from it.

Embeds: a calendar-subject picker grouped by venue, and the widget's list
view no longer renders blank for them (it was gated on "owner"; calendar
subjects return "calendar"). Calendar subjects force link-out because
assertEventAllowedForEmbed fails closed for them. Booking requests gain a
message thread (attachments list-only — no upload mint yet).

Also carries this session's earlier dashboard/calendar overflow fixes:
the restored moderator dialog, the orders-table scroll, and two 320px
wrap fixes. Hold layer untouched (deferred).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The '$' on money fields was a sibling Text in an XStack, so three
uncoordinated spacing rules stacked up and the glyph floated in dead space
outside the input's border. Same pattern was copy-pasted across four call
sites. AmountInput renders the affix as an inset INSIDE the bordered frame,
mirroring TextInput's existing right-side status-icon technique.

A blanket 'input:not([type=...])' reset in globals.css has specificity
(0,6,1) and beats every Tamagui atomic padding class (0,2,0), so a style
prop silently lost the cascade and the glyph overlapped the value in a real
browser. Clearance is patched imperatively with !important, the same escape
hatch TextInput already uses. The reset's comment claimed Tamagui inputs
were unaffected; it is now corrected.

StyledSelect spread frameProps onto its wrapper div, never the focusable
<select>, so the entity-page visibility select had no accessible name at
all. Adds selectId/ariaLabel/ariaDescribedBy pass-through.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The dock sized itself from the total width of every PLANNED segment,
including ones the measure pass then demoted into the overflow menu, and
never shrank back. Every venue page showed a 60-119px dead gap before the
'...' trigger. computeInlineFit now decides demotion and the growth target
together, against a fixed ceiling budget rather than the previous pass's
live width, so it converges in one pass instead of chasing itself.

Reserving the trigger's own 44px is gated on the overflow menu actually
having contents: measuredOverflowSegments drops kind==='data' segments the
same way the renderer does, so a demotion consisting only of a data chip no
longer reserves space for a control that never paints.

Adds the place editor's own action-bar config. /edit previously fell through
to the generic admin catch-all and offered 'Create Event' while Save sat
inline in the page body. Save is now a dock CTA gated on dirty state; the
inline button stays as the sub-960px affordance, where the dock is hidden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
buildPlaceData coerced undefined to null for lat/lng, capacity, status,
verification and the address parts, so any caller rebuilding a place from a
PlaceRecord to flip ONE field silently cleared everything it did not
re-send. Prisma already means 'skip' by undefined and 'write NULL' by null;
the coercion collapsed the two.

Two real callers proved it. toggle-place-directory-listing omits geo, so
listing or unlisting a venue erased its coordinates - seeded venues were
found already sitting at lat null from exactly this path. Worse,
set-default-payout-terms rebuilds from only formatted/line1/countryCode, so
a finance member choosing payout terms nulled city/region/postcode/capacity,
reset an ARCHIVED venue to ACTIVE, and demoted a VERIFIED venue to
UNVERIFIED - and verification gates visibility in listAccessibleByHuman, so
the venue quietly dropped out of the picker for every non-owner.

The same function already guarded phone/website this way, with a comment
describing this exact failure mode. The contract now lives on
UpsertPlaceInput, where callers can see it, instead of only in an adapter
comment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
places.updatePlaceVerificationStatus let any org ADMIN/OWNER of an owning
org POST verificationStatus VERIFIED and mint their own verified badge with
no document ever reviewed. Verified is not cosmetic - it gates visibility in
listAccessibleByHuman and booking acceptance. Driving the pre-fix code
against a real DB as a non-staff org owner wrote a genuine VERIFIED row and
an audit entry. The existing test encoded this as intended behaviour, which
is why nothing caught it.

The procedure had zero callers repo-wide after the request modal replaced
its last one, so it is removed - but the invariant now also lives in the use
case, because deletion is a transport-shaped guarantee the next org-side
procedure would silently undo. Terminal decisions (VERIFIED/REJECTED) now
require staff, resolved once through a helper that fails closed when authz
is unwired.

SECOND_FACTOR_GATED_MODERATION_PATHS held zero places.* entries, so the
whole platform verification workflow - acknowledge, release, review, the
queue, directory listing - was reachable by an ADMIN with no second factor.
All five are now gated. getVerificationDocumentUrl is deliberately left out:
its staff check is a conditional branch, so path-gating would lock out
ordinary submitters.

Withdrawing a request updated the request row but never reset
Place.verification, and every request affordance hides at PENDING - so the
organiser saw a permanently pending venue, no request button anywhere, and
nothing in the queue, while the server would have accepted a resubmit. The
reset now commits with the withdrawal in one transaction.

Waiving over an open request left it live in the queue, where a second
reviewer's Reject silently reverted the waive. Terminal decisions now refuse
while a PENDING/IN_REVIEW request exists.

line1 is optional: the schema's min(1) is what drove callers to forge a
'TBD' sentinel into venues' street addresses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Requesting verification meant leaving the editor for a separate route. The
document-upload form is extracted so /verify and a new modal render ONE
implementation rather than a fork; VerificationSection drops 913 -> 389
lines. The places-table row action opens the same modal instead of the older
path that created a queue entry with no documents to review.

Address entry was six plain text fields geocoded once at save. It is now a
debounced combobox on the existing Geoapify integration that fills the
structured fields AND coordinates at selection time, with a manual fallback
for addresses it cannot resolve. Stale responses are retired three ways,
including when the query drops below the minimum - previously deleting back
to empty left a request in flight whose late reply reopened the listbox over
an emptied input.

The 'TBD' sentinel is gone from every write path. The new-place quick action
forged it unconditionally, so every place created from the table was born
polluted; it now collects a real city and country instead. Swapping the
sentinel for 'Unknown' - or defaulting the country - would be the same bug
respelled, so the dialog asks.

Focus never returned from any admin table row-menu dialog: the menu item is
detached in the same commit that opens the dialog, so the modal captured
<body> as its opener. Fixed in RowActionMenu, which fixes every dialog
opened from any admin table.

Saving twice during the pre-mutation geocode fired two mutations with
different idempotency keys; an in-flight latch now spans the geocode and the
invalidation. At 320px the inline Save sat ~260px off-screen - the only save
affordance below 960px.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nothing in the app could crop an image, so avatars were uploaded whole and
the preview cover-cropped them invisibly - which is why a 'View full' escape
hatch existed. Crop is hand-rolled rather than pulling a dependency: the
obvious library has no keyboard support, which NFR-001 requires, and ships
rounded affordances that fight radius 0. Cropped output re-enters the same
upload hook, so HEIC conversion, the type allow-list and the size cap all
still apply. Animated GIFs skip crop entirely - a canvas round-trip would
silently flatten them to one frame.

The export draws from the already-decoded image, not the rendered <img>:
drawImage on an incomplete image returns without drawing AND without
throwing, and a blank JPEG passes every downstream check, so the failure
mode was a silently blank avatar.

Page details was one flat card whose images auto-saved on pick while its
text fields waited for a Save button, with nothing saying so. Fields are
grouped, the two save models are now legible, the slug's /p/ prefix sits
inside the input frame, and visibility options explain what they mean.

A claimed venue never said who owns it - ownership existed only as opaque
ID arrays. The owner is now named and linked on the canonical page, on the
same row as Request transfer. One rule for both owner types: named only when
they have a PUBLIC entity page, so a sole operator with a self-identifying
org name and no published events is not outed by a public procedure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Modal set role=dialog and aria-modal=true but had no Tab containment and
never marked the background inert, so Tab walked straight out into the page
underneath and a screen-reader user could browse it. With a ConfirmDialog
open inside a Modal both panels stayed exposed - measured on /platform/places
as two dialogs and two Close buttons in the accessibility tree.

A layer stack read at event-dispatch time (not from React state) inerts every
body child except the topmost layer, so a nested dialog inerts its parent.
Toasts and the tooltip portal are exempt - inerting them would kill toast
actions and the copyable-tooltip behaviour. Containment is boundary-only, so
interior tab order (radio groups, contenteditable, iframes) stays native.

Teardown is the risky half, so ownership is encoded in the DOM rather than
in memory: release never depends on bookkeeping surviving a Fast Refresh,
release runs before apply, elements that were already author-inert are never
claimed, and a prior aria-hidden is restored verbatim.

Escape was inverted on nested dialogs. The outer modal registered first, won
the race, and its stopImmediatePropagation killed the inner one - so Escape
on a nested confirm closed the parent. It is now gated on being topmost.

The header's close X was a styled XStack - a bare div with no role and no
tab stop, unreachable by keyboard. Zero of the 52 call sites changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
/account/memberships listed every membership as a plain row. Active and
trialing ones now get a presentable card - tier, owner, member-since,
renewal - so a member has something to hold up at a door. Non-granting
statuses keep the plain row; the point is to distinguish what is current.

Member-since uses Membership.startedAt, which the query already returned.
Cancel and billing still hand off to the Stripe portal untouched, per the
spec rule that the portal owns self-management.

This is presentation only. There is no scannable credential and no
redemption tracking, because a benefit like 'two free drinks a month' has no
in-person mechanism behind it today - membership benefits are checkout-only
discounts. Building a card that implies otherwise would be a lie.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both defects are fixed forward now, but neither fix repairs rows already
written, and the affected places cannot self-recover: a place stranded at
PENDING shows its organiser no request button anywhere, and a forged 'TBD'
renders publicly as the venue's street address.

Detection is a separate entry point that imports no write path at all, so it
is safe to point at production. Repair is dry-run by default, refuses a
non-local host unless you name that host back to it, re-runs detection
INSIDE the transaction so what you reviewed is what gets written, and
CAS-guards every row on its prior values.

Addresses are only auto-repaired where the rewrite is provably
information-preserving. 'TBD Lane' and 'Suite TBD' are reported, not
stripped - deleting real text to remove a sentinel is worse than the
sentinel. Rows with nothing to rebuild from are reported too, since
Place.address is NOT NULL and an empty string is a state the product itself
cannot produce.

Exit codes are three-valued (0 clean / 1 crashed / 2 findings) because
collapsing findings into 1 would make 'three stranded places' look identical
to 'could not reach the database'. Run it from the repo root: pnpm -F api
exec flattens every non-zero code to 1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix(admin): let the calendars "Add" labels follow their button's colour
Some checks failed
CI / secret-scan (pull_request) Successful in 30s
CI / build (pull_request) Failing after 7m42s
f56c3ba136
`button-label-inheritance` fails on main: the two Add buttons on the calendars
management page wrap their label in a bare `<Text variant="bodySmall"
weight="bold">`. Text.tsx always applies its resolved colour, so inside a
`variant="secondary"` Button — whose label colour inverts on hover/press — the
label keeps painting `$color` and stops tracking the frame.

`tone="inherit"` on both, which is the fix the guard itself prescribes. No
other change: the buttons, their aria-labels and the one-primary-per-viewport
comments are untouched.

Found by running the full quality gate locally at 83ab3d67 — this is the only
real failure in it. @th/adapters also went red in that run (6 files) but that
is the loaded box, not the code: the failures are `P1017 Server has closed the
connection` out of `prisma migrate deploy` in testcontainer setup, and
`community-blast-radius.test.ts` passes 10/10 when re-run alone.

Verified: @th/ui 69 files / 541 tests green (was 1 failed / 540), apps/web
`tsc -p tsconfig.json --noEmit` 0 errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Step 9, final step of docs/specs/2026-08-18. Availability moved to
Calendar in step 7 and the place-level editors went with it, so the
place-level write path had zero callers: the five tRPC procedures, the
place-availability use case and its port/adapter write methods are gone.
Deliberately KEPT: the PlaceAvailability model and table (no migration),
plus listByPlace and places.getBookingAvailability, which still power the
public booking date picker. The repo is now read-only by design and both
schema docstrings say so.

Help center: embed-widgets gains the venue-calendar SUBJECT (distinct
from the calendar view, and always link-out); booking-requests gains the
message thread and the read-only attachment list; new rooms-and-calendars
article covers named calendars, rooms, the >=1 rule, and All-calendars
blackouts. Documented what ships, not what is planned — no Hold layer, no
attachment uploads.

E2E: venue-calendar-cell-create drives the headline flow against a live
stack (harness audited first — no prisma migrate reset on this path) and
reads the created Event back from the DB to assert calendarId and roomId,
then cleans up. Its fixture calendar starts at 20:30 on purpose so the
assertion proves the prefill came from Calendar.defaultStartTime and not
the 19:00 fallback; verified by mutation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
16 QA screenshots (4.3MB) were swept into 656de00f alongside the platform-fee
work and have been tracked ever since. They are throwaway review artifacts, not
source.

.qa-shots/ sat one line away from the .qa-screens/ entry that would have caught
it, and .playwright-mcp/ is where the browser driver writes when a run starts
from the repo root instead of a scratch dir - so both are now ignored, with the
**/ variants, next to the sibling scratch dirs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Final vocabulary for the venue hierarchy: Place (the venue you own) →
Space (the physical bookable thing conflicts resolve against) and
Calendar (the named public programming stream). "Space" reads naturally
for a stage, back bar or patio, and frees "Room" from competing with
"Calendar" for the stream meaning.

Pure rename, no behaviour change. The migration is ALTER ... RENAME
throughout — table, columns, enum, indexes and constraints each renamed
explicitly, since Postgres does not cascade a table rename to them.
Nothing is dropped or recreated, so the seeded dev data survives; the
migration also replays clean from scratch.

Carries the user-facing surface with it: SPACE_NOT_FOUND and
CANNOT_RETIRE_LAST_ACTIVE_SPACE with en/es copy (sala → espacio), the
tRPC procedures, the ?spaceId= prefill parameter, and the help article
(rooms-and-calendars → spaces-and-calendars) with its cross-links.

Deliberately untouched: prose where "room" means a venue generally
("if you run a room"), layout words like headroom, fixture strings such
as "The Velvet Room", the Space keyboard-key comments in packages/ui,
and the dated spec records, which document what was specified at the time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fix(api): track the Room→Space rename in the seed-places prisma stub
Some checks failed
CI / secret-scan (pull_request) Successful in 2m1s
CI / build (pull_request) Failing after 42m53s
7e5f1ffac4
`0217c676` renamed the venue Room model to Space, but the hand-rolled prisma
stub in `seed-places.test.ts` kept declaring `room` / `calendarRoom`.
`ensurePlaces` calls `ensureVenueDefaults`, which now reaches for
`prisma.space.findFirst`, so both tests died on `Cannot read properties of
undefined (reading 'findFirst')` — the same failure `83ab3d67` fixed, walked
straight back in by the rename.

This was the ONLY failing check in the entire quality gate. `deploy-dev` #689
went red here at `d0bd5126` and skipped all eleven downstream jobs, so dev has
not rolled since 2026-08-19; three later CI dispatches failed the same way.
Everything else in the gate is green — lint, `@th/core` (7727 tests), trpc, ui,
ui-email, ui-native, jobs, scanner, mobile, types, errors, schema, ports,
test-utils, locale and the message-sort check.

Verified in a clean worktree at HEAD with ONLY this file applied, to prove no
other uncommitted work was load-bearing: apps/api 28 files / 302 tests pass.

The recurring shape is a split commit — the rename landed while its test half
stayed uncommitted on one machine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Somehuman closed this pull request 2026-08-22 22:13:59 +00:00
Some checks failed
CI / secret-scan (pull_request) Successful in 2m1s
CI / build (pull_request) Failing after 42m53s

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!5
No description provided.