> ## Documentation Index
> Fetch the complete documentation index at: https://docs.soundchecklive.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Recurring Pitfalls

> Cross-component traps that keep re-biting Soundcheck engineers, with the rule that avoids each one

The traps below have each caused a real regression more than once. Every root-agent `AGENTS.md` links here. Format per entry: **Symptom → Root cause → Rule**, with repo file references in backticks.

***

## 1. E2E endpoint-coverage CI gate

* **Symptom:** `api-events-deploy` CI fails with "endpoint not covered", even though unit tests pass locally.
* **Root cause:** `.github/workflows/api-events-deploy.yml` runs e2e with `E2E_ENFORCE_COVERAGE=1`, which asserts every route in the OpenAPI snapshot (`docs/openapi/soundcheck.json`) has at least one e2e hit.
* **Rule:** Ship a matching e2e spec in `e2e/` for new/changed routes, or add a justified waiver in `e2e/src/coverage/waivers.ts`. The legacy `coverageExclusions.ts` list is retired.

## 2. Civil days format in UTC; Instants format in the viewer timezone

* **Symptom:** An event dated "Jul 3" renders as "Jul 2" for some users; or an audit/setlist clock shows UTC hours ("2:15 PM" for a 9:15 AM CT action).
* **Root cause:** Civil days (`event_date`, `LedgerItem.date`, offer/day keys, Studio noon-Z / Flex encodings) were formatted in the viewer TZ, or Instant fields (`created_at`, `updatedAt`, audit `createdAt`) were forced through the civil UTC helper.
* **Rule:** Route at the call site — do not guess Instant vs civil from midnight. Civil days use `formatShortDate` / `formatShortDisplayDate` (any trailing `Z` → `timeZone: 'UTC'`; bare `YYYY-MM-DD` via `parseDateKeyLocal`). Instants use `formatInstantDate` / `formatInstantDateTime` (viewer TZ, including Today/Yesterday grouping). Past-ness uses the gig IANA zone (`isPastEventDate(value, now, zone)`). Never hand-roll `format(date, 'MMM d')`.
* **Enforced by types:** zone-dependent helpers take an `EventZone`, a branded string whose only constructor is `resolveEventZone(eventZone, orgZone)` in each package's `temporalDate.ts` (event zone, then org zone, then the America/Chicago default). There is no zoneless "today": use `todayIn(zone)`. A raw `string` zone does not compile, and `check:exported-inline-types` rejects `as EventZone` and `x is EventZone` outside `temporalDate.ts`.

## 3. Bare `Date` is banned — civil days are `PlainDate` (ENG-582)

* **Symptom:** A bare `YYYY-MM-DD` parses as UTC midnight through `new Date('2026-02-30')`-style calls; the day silently shifts in negative-UTC-offset zones, and civil days get mixed with instants in one type.
* **Root cause:** `Date` is an instant wearing a local-zone costume. Civil days (`LedgerItem.date`, API `YYYY-MM-DD`), instants (`created_at`), and zoned wall clocks all collapse into it.
* **Rule:** Civil days are `Temporal.PlainDate`, instants are `Temporal.Instant`, zoned wall clocks are `Temporal.ZonedDateTime`. Parse `YYYY-MM-DD` with `parseCivilDay` (never `new Date('YYYY-MM-DD')`); read the clock with `nowEpochMs`/`nowInstant`. The `check:bare-date` gate (in `check-all` / `checks` in all three packages) bans `new Date(`, `Date` statics, `instanceof Date`, and `Date` type references outside an allowlist. The only remaining `Date` values sit behind `bare-date:interop` markers (native pickers, Prisma rows, third-party SDK contracts) and file-wide `bare-date:interop:file` markers.

## 4. PATCH undefined-vs-omitted semantics

* **Symptom:** A PATCH that omits a field wipes it, or sending `""` fails to clear it.
* **Root cause:** Update operations treat **`undefined` = "field not provided, leave unchanged"** and **present values (incl. empty string/array) = "set to this value"**. Assigning through without an `!== undefined` guard collapses the distinction. See `api-events/src/systems/venues/updateVenueOperation.ts`.
* **Rule:** In update paths, branch on `!== undefined` before writing a column; only touch the field when the caller actually sent it.

## 5. Prisma JSON column double-encoding

* **Symptom:** A JSON/array column round-trips as a quoted string or nested JSON string.
* **Root cause:** Prisma `Json` fields expect a plain JS value. Passing an already-`JSON.stringify`'d string (or stringifying twice on read/write) double-encodes the payload.
* **Rule:** Write plain objects/arrays to `Json` columns; parse once on read if the wire shape is ambiguous.

## 6. Hand-synced key lists drift

* **Symptom:** A field exists in the API and DB but is missing from a client, an MCP tool, or a type.
* **Root cause:** Several lists are maintained by hand and have no compiler link to their source of truth: `mcp-ts/src/tools/catalog.ts` (plus `catalog-gap.ts`), the Zod schemas in `web/src/lib/schemas/api.ts`, and hand-written FE type mirrors.
* **Rule:** When you add/rename an API field or MCP tool, update every mirror in the same PR. (See #9 for the Zod-specific failure mode.)

## 7. Always-on SWR keys fetch behind flags

* **Symptom:** A flagged-off feature still hits the API (network tab shows requests for a hidden surface).
* **Root cause:** `useSWR(key, fetcher)` fetches whenever `key` is truthy, independent of whether the component renders.
* **Rule:** Gate the **key**, not just the render: `useSWR(enabled ? key : null, fetcher)`. Passing `null` disables the fetch.

## 8. N+1 queries in list operations

* **Symptom:** A list endpoint gets slow as data grows; DB CPU climbs on a hot path.
* **Root cause:** Operations that fetch related rows in a loop (or use per-row subqueries) execute one query per parent row.
* **Rule:** Prefer `include`/`select` with Prisma, a single JOIN query, or batch fetches; verify the query plan for hot list endpoints.

## 9. Fresh worktrees lack generated artifacts

* **Symptom:** A brand-new checkout or `git worktree` fails type-check/build with missing modules or undefined symbols.
* **Root cause:** Generated files are gitignored: Prisma client output, `node_modules`, OpenAPI snapshots, and the generated API client.
* **Rule:** Regenerate or install before building — `npm install` in each package root, `cd api-events && npm run prisma:generate`, and `npm run generate:api-client` (mobile/web client). In a worktree, run `npm run worktree:setup` instead. Never symlink `node_modules` or generated dirs from the main checkout: pre-commit `npm ci`, `postinstall`, and `pretypecheck` write through the links into the main checkout.

## 10. Web Zod schemas strip undeclared fields

* **Symptom:** An API field is present in the JSON and the TS type but reads as `undefined` in the component.
* **Root cause:** `web/src/lib/schemas/api.ts` parses responses through `z.object(...)`, which **drops any key not declared on the object** — no error, just silently gone. (The `position_name` field once fell back to "Unknown Position" for exactly this reason; see the inline note in that file.)
* **Rule:** Declaring the field on the matching Zod schema is a required part of adding an API response field on the web side.

## 11. User-scoped vs org-scoped events hooks

* **Symptom:** An admin/org-wide view shows only the signed-in user's gigs.
* **Root cause:** `web/src/lib/hooks/events/useEventsSWR.ts` and `GigsContext` are **user-scoped** by design.
* **Rule:** Org-wide views must use the org-scoped hook `web/src/lib/hooks/org/useOrgEventsSWR.ts` (org-scoped `getEvents(orgId)` service call), not the user-scoped context.

## 12. Clerk local development

* **Symptom:** Locally, sign-in works but the app can't find the user/org, or api-events rejects the JWT.
* **Root cause:** Real Clerk can't reach `localhost` webhooks; the emulator must receive webhooks on `:4000` and JWT verification must target `:4711`.
* **Rule:** Use `npm run dev:infra`, seed with `cd api-events && npm run db:seed:demo-full`, restart `clerk-emulator`, then `cd api-events && npm run dev:emulator` and `cd web && npm run dev:emulator`. See `.agents/skills/local-clerk-emulator/SKILL.md`. Don't set `ENABLE_BASIC_AUTH` — it has been removed and fails closed.

## 13. Client-side re-capture of API outcomes double-counts analytics

* **Symptom:** A PostHog metric (acceptances, gigs created, payouts) counts roughly 2× reality, or two event names track the same action with diverging counts.
* **Root cause:** A client captured its own PostHog event after calling an API endpoint whose service already emits the canonical backend event on the same transition (e.g. mobile `invite_accepted` vs backend `invitation_accepted`, web `payout_sent` vs backend `payment_settled`). ENG-530 removed five such duplicates.
* **Rule:** Server-backed state transitions are tracked by the **backend only** (`api-events/src/observability/analytics.ts`). Clients emit events only for interactions with no server transition. Need client context on a backend event? Add a request field + property (see `creation_source` on `event_created`), not a client event. Full grammar: `docs/engineering/reference/analytics-taxonomy.mdx`.

## 14. Fresh Rust crates execute at compile time

* **Symptom:** An agent (or human) runs `cargo update` / `cargo add`, then `cargo test`/`build`, and a freshly published crates.io version runs `build.rs` / proc-macro code on the laptop *before* CI ever sees the lockfile.
* **Root cause:** Cargo has no stable install-time cooling window (RFC 3923 `min-publish-age` is nightly-only). Lockfile rewrites are safe (index metadata only); the compromise fires on the first compile. A Cursor-only shell hook does not cover Claude Code or a raw terminal.
* **Rule:** For `infrastructure-services/gcp-platform-signals/`, keep the four-layer policy in `AGENTS.md`: (1) `build.rustc-wrapper` → `.cargo/rustc-guard.mjs` (editor-agnostic), (2) the Cursor `cargo-release-age` rule in `scripts/agent-hooks/cursor-rules.mjs`, (3) CI `scripts/check-cargo-release-age.mjs`, (4) Renovate `minimumReleaseAge: "2 days"`. Pin offenders with `cargo update -p <crate> --precise <older>` — never set `CARGO_MIN_RELEASE_AGE_DAYS=0` without an explicit user grant.

## 15. Money columns must stay `Decimal`, not `Float`

* **Symptom:** A new price/fee/amount column lands as Prisma `Float` / PostgreSQL `DOUBLE PRECISION`, or an agent "follows" outdated guidance and reintroduces float money storage.
* **Root cause:** Older docs/skills said "Prisma `Float` + migration SQL `DECIMAL` override." The schemas now declare `Decimal @db.Decimal(12,2)` (percents use Decimal with their own scale). JSON wire fields may still be `number`/`float64` — that is a separate, pre-existing HTTP contract.
* **Rule:** New money DB fields are `Decimal @db.Decimal(12,2)` in `api-events/src/db/schema.prisma`. Only geo (`latitude`/`longitude`) and ML `confidence` may use `Float`. Follow `.agents/skills/monetary-handling/SKILL.md`; the guard is `api-events/scripts/schema/moneySchema.test.ts`.

## 16. OpenAPI path templates are client param names

* **Symptom:** After `docs-ci` syncs `docs/openapi/soundcheck.json` from TypeScript emit, web/mobile call sites fail typecheck because `{ id }` no longer matches `{ userId }` / `{ organizationId }` / `{ eventId }`.
* **Root cause:** `swagger-typescript-api` derives path-param objects from OpenAPI `{param}` names. Method names such as `v1UsersDetail` come from each operation's `operationId`, which the emitter derives from the path (`api-events/scripts/openapi/operationIds.ts`) and `npm run check:operation-ids` compares with both generated clients, so they stay stable; the destructured param does not.
* **Rule:** Emit uses Encore templates (`api-events/scripts/openapi/fieldShapeToOpenApi.ts`). PRs assert emit matches Encore extract in `api-events-deploy.yml` `openapi-parity`. Push `docs-ci` `sync-openapi-spec` still emits and asserts before auto-committing the spec. Do not rewrite `{userId}` back to `{id}` to preserve old client param objects — update call sites instead. Query/body/response shapes belong in `openapiRoute()` annotations (`src/systems/shared/openapiRoute.ts`); named `$ref` schemas belong in sibling `openapiSchema()` calls. Wrap-style `openapiRoute(api.raw(...), meta)` hides the endpoint from Encore's compiler, so call it after the `export const name = api.raw(...)` assignment. Coverage compares emit to `scripts/openapi/testdata/openapi.baseline-ops.json` (frozen pre-overlay checklist), not the spec this branch writes. Compare `openapiSchema()` catalog keys to that same emit — dest sync only copies `$ref`'d schemas, so a leftover committed key can hide an unused registration until the next PR. When a PR edits `docs/openapi/soundcheck.json`, Docs CI also compares that snapshot's catalog to emit so a docs-only unused schema cannot remain as source of truth.

## 17. Do not edit applied Prisma migration SQL

* **Symptom:** A “fixed” backfill in `api-events/src/db/migrations/` looks correct in git, but qa/prod leftovers never move.
* **Root cause:** `prisma migrate deploy` records the migration name (and checksum) in `_prisma_migrations` and does not re-run an already-applied file. A later edit of `20260307140000_destination_charge_payouts` (day-equality wrap on invitation PAYABLE `date`) never executed; `20260316000000_fix_ledger_tz_backfill` is the applied day-equality rewrite.
* **Rule:** Never edit applied migration SQL to change data. Add a new migration. If the repo file drifted from what qa/prod ran, restore the applied predicate so fresh empty Postgres matches history. New migrations must also be replay-safe after apply (`migrationIdempotence.policy.json`). Ledger date semantics and leftover inventory: `docs/engineering/backend/ledger-payable-dates.mdx`.

## 18. Ngrok agent authtoken is not the API key

* **Symptom:** `ngrok http` fails with `ERR_NGROK_107` ("The authtoken you specified is properly formed, but it is invalid") even though a leftover token file exists, or a tunnel that was up dies after you reload a different file.
* **Root cause:** ngrok issues two look-alike secrets. The **agent authtoken** (`NGROK_AUTHTOKEN`, dashboard Authtokens) starts tunnels. The **API key** (`NGROK_API_KEY`, dashboard API keys) only talks to `api.ngrok.com`. Scratch files named `ngrok.authtoken` are often the API key; the working tunnel token is often `ngrok.agent.authtoken`. Feeding the API key to `ngrok config add-authtoken` produces 107 — the same code as a revoked agent token.
* **Rule:** Start tunnels only with the agent authtoken. Name leftovers `ngrok.agent.authtoken` and `ngrok.api.key` — never store an API key as `ngrok.authtoken`. If 107 fires, try the file that previously logged `started tunnel` before asking for a new token. Do not print either secret. Skill: `.agents/skills/ngrok-tunnels/SKILL.md`.

## 19. `npx shadcn add` from a third-party registry rewrites design tokens

* **Symptom:** After adding one component from a non-shadcn registry, `web/src/app/globals.css` has different `--success` / `--warning` / `--info` values, unrelated overlays lose focus inside a Sheet, and the copied source is Base UI rather than Radix.
* **Root cause:** Two independent registry behaviors. (1) A registry URL is a template — `https://reui.io/r/{style}/{name}.json` — and the CLI substitutes the `style` from `components.json`. Ours is `new-york`, which reui.io answers with a **307 redirect to `base-nova`**, the Base UI port of the same component. (2) A registry item and its transitive `registryDependencies` may each carry a `cssVars` block, which the CLI merges into the project stylesheet, and `dependencies` that pull umbrella packages (`radix-ui`) — a second copy of `@radix-ui/react-focus-scope` gives each overlay its own stack, the exact duplicate `npm run check:radix-dedupe` exists to catch.
* **Rule:** Before adding from an unfamiliar registry, fetch the item JSON by hand and read `files[].target`, `cssVars`, `dependencies`, and `registryDependencies`. Pin the style segment literally rather than letting `{style}` resolve. For anything larger than a single file, vendor with a re-runnable script instead of the CLI — see `web/scripts/vendor-reui.ts` and `web/src/components/reui/README.md`, which write only inside one directory, never install, record upstream hashes, and keep local edits in a `PATCHES` list that throws when upstream drifts. `git diff src/app/globals.css` must be empty after any registry add.

## 20. Leftover db twins and swallowed identity-link writes

* **Symptom:** Accept/decline-with-signup returns HTTP 200, but the invitation never appears in the recipient mailbox and EventMember / availability side effects no-op.
* **Root cause:** The Encore port kept `*Crud.ts` (HTTP entity repositories) next to narrower `foo.ts` helpers. A leftover second SQL body (`invitation.ts::updateInvitationUserId`) or a `try { await updateInvitationUserId } catch { capture*; continue }` in `invitationSignupOperations.ts` can leave `Invitation.userId` null after a successful-looking request. Clerk webhooks do not stamp that column.
* **Rule:** Fail closed on `Invitation.userId` and ledger counterparty writes (`signupDatabaseError()`). Treat Clerk “already a member” as membership success so a retry can still stamp those writes. After accept/decline phase 2 commits, a refetch miss is not a write — capture and return a linked fallback row. A PENDING accept/decline signup after the event date may continue only when a `clerk_pending_invitation_status_updates` row already matches that request's target status, event organization, and resolved user — a leftover decline marker or a linked `Invitation.userId` from `pending-with-signup` is not enough. Do not add a new repair table. Already-member Clerk answers keep that row until accept/decline phase 2 commits. First-time signup also keeps the row through identity and counterparty writes and deletes it only after phase 2 returns 200, so a failed transition can still retry after the event date. Backup-promote notify claims `processedEvent` before send, passes `throwOnFailure` so a swallowed topic publish cannot seal the claim, releases on publish throw, and walks up to three event ids (`base`, `failed-retry`, `failed-retry-2`) when any delivery is `failed`. Mixed `sent` and `failed` rows are different channels (`NotificationDelivery` is unique on eventId+channel), so the next attempt id retries only the failed channels. A reclaimed lease still publishes unless every stored delivery is already `sent` and none `failed`. Unlinked EMAIL backups publish to `InvitationTopic` instead of awaiting SendGrid on the decline request. Further provider failures are terminal for that promote. Bulk EventMember sync stays capture-and-continue; Clerk webhooks can repair EventMember but do not stamp `Invitation.userId`. Do not add a second `export function` of the same name under `api-events/src/db/` unless it is a true independent helper on `DUPLICATE_DB_EXPORT_ALLOWLIST` with the exact colliding file set (`npm run check:duplicate-db-exports`). Do not flatten the `*Crud` / `foo.ts` split.

## 21. HITL option `style` is not the answer's direction

* **Symptom:** The user clicks **Cancel** on a CheckAI Confirm Card, the transcript says "Rejected", and the gated write still lands (PRO-339 web mystery-shop: `archive_event` ran after Cancel).
* **Root cause:** The agent writes the `ask_question` options itself, and it marks the destructive **approve** option `style: "danger"` (`{ id: "confirm", label: "Yes, archive it", style: "danger" }`). The web picker read `danger` as "decline" and sent `optionId: "confirm"`; the button text came from a separate label lookup, so the UI and the wire disagreed.
* **Rule:** Resolve Confirm Card answers by meaning only — `pickConfirmOption` in `web/src/lib/checkai/confirm.ts`: the first approve/decline word in the label decides (id only when the label has none), `style` is ignored, and nothing is chosen by position; with no clear match the client answers in words (`yes` / `no`). Button labels must come from the same picker so the button shown is the option sent. The eval driver in `checkai/evals/confirm-gate.eval.ts` mirrors the rule.

## 22. `vi.clearAllMocks()` leaves queued `mockResolvedValueOnce` values behind

* **Symptom:** A test passes alone and fails in the full file, or starts failing after an unrelated test above it changes; the mock returns a value that test never set.
* **Root cause:** `clearAllMocks` (and `clearMocks: true`) only wipes call history. A `mockResolvedValueOnce` / `mockRejectedValueOnce` / `mockReturnValueOnce` that an earlier test queued but never consumed survives into the next test and is returned first.
* **Rule:** Reset shared mocks in `beforeEach` with `vi.resetAllMocks()`. On Vitest 3+ (every package here is on 5) that also restores the implementation passed to `vi.fn(impl)` in a `vi.mock` factory, so factories keep working. Set per-test return values inside the test, not in a shared `beforeEach` that some tests never consume.

## 23. Node 20 shells break tests and type-checks (`Promise.withResolvers is not a function`)

* **Symptom:** Web or api-events Vitest fails with `Promise.withResolvers is not a function`, or `tsc` crashes, only on one machine or agent VM.
* **Root cause:** The repo targets Node 22 (`.nvmrc` is `22.23.1`, EAS and CI pin it), but only the repo root has an `.nvmrc`, and many shells and agent sandboxes default to Node 20. Nothing fails fast on the wrong major.
* **Rule:** Run `node -v` before debugging a test or type-check failure that looks impossible. Use Node 22 (`nvm use` at the repo root, or put a Node 22 `bin` first on `PATH`) for every package, including `web/` and `api-events/`.

## 24. Tests that read the local clock or time zone pass in one zone only

* **Symptom:** A test passes in CI and fails on a laptop (or the reverse), or starts failing on a particular day; typical offenders assert a formatted time such as `"23:00"` or "has this day passed".
* **Root cause:** Only `api-events/vitest.integration.config.ts` pins `TZ`. Root, `web/`, and api-events unit configs run in the machine's zone with the real clock, so wall-clock assertions depend on where and when they run.
* **Rule:** A test that formats or compares wall-clock time pins both: `vi.setSystemTime(...)` for "now", and an explicit IANA zone passed to the code under test (or `process.env.TZ` set before the module loads). Never hard-code a future date for a "not yet past" assertion.

## 25. Bare `npx tsc` runs the wrong compiler

* **Symptom:** `npx tsc --noEmit` in the repo root or `api-events/` runs out of memory, or reports different errors from the pre-commit hook and CI.
* **Root cause:** Root and `api-events/` keep classic TypeScript 6 as `typescript` and install TypeScript 7 under the `typescript7` alias. `npx tsc` resolves the classic compiler, which needs a much larger heap on this codebase and is not the gate. `web/` installs TypeScript 7 as `typescript`.
* **Rule:** Type-check through the package script (`npm run type-check` at the root, `npm run typecheck` in `api-events/` and `web/`). If you must call the binary, use `node ./node_modules/typescript7/bin/tsc` in root and `api-events/` with the same `-p` / `--project` argument as the script.

## 26. Mobile generated-client calls never throw

* **Symptom:** A mobile save shows "saved successfully", or an optimistic toggle stays flipped, while the server rejected the request. A `try/catch` or a React Query `onError` around the call never runs.
* **Root cause:** `generatedApiCore.execute` (`lib/api/generatedApiCore.ts`) catches every transport and HTTP error and resolves `{ success: false, message, status }`. Services built on it (most of `lib/api/`) inherit that, and some reduce the result further to `boolean` or `null`.
* **Rule:** Check `response.success` (or the service's `null` / `false`) at every call site. In a `useMutation` / `useQuery` `queryFn`, throw when `!response.success` so React Query sees the failure. Detect specific HTTP errors with `response.status`, never by searching `message` for `'404'`.

## 27. A sync that restates an old CRM stage demotes a live gig

* **Symptom:** A gig someone activated goes back to DRAFT the next morning, or a settled job reopens, with no person having touched it.
* **Root cause:** The HubSpot backfill re-posts every deal still in Quote Requested daily, and Flex keeps sending Tentative while a quote sits there. Each restates where its own record stands, which derives DRAFT.
* **Rule:** Status writes from automated syncs (Flex, the HubSpot deal backfill) go through `setEventProjectStatus` (`api-events/src/db/importedEventLifecycle.ts`), which only moves a gig forward along DRAFT → ACTIVE/UNARCHIVED → COMPLETED → SETTLED. Any status may cancel. Un-cancelling resumes at ACTIVE, never DRAFT. ARCHIVED and TEMPLATE gigs are never touched, and neither are gigs scheduled for deletion. Restating the current status changes nothing, not even `updatedAt`, so callers can hang one-time work (closing out a cancelled gig's crew) off `changed`. A refused move still returns the row with its real status; only a missing gig returns `found: false`, which is the caller's cue to tombstone the quote link. People can still make any of these moves by hand, and a spreadsheet import that a person reviewed and confirmed still writes the status it names.

## 28. Flex creation defaults are destructive when used as corrections

* **Symptom:** A quote link-back replaces the real title or address with a placeholder, or moves a multi-day gig from its load-in day onto its show or load-out day.
* **Rule:** `planFlexQuoteImport` separates the creation header from owner-only corrections. Creation can default title, location, timezone, and an unstated date. Corrections carry only stated values and only reach DRAFT gigs through `correctImportedEventTx`; schedule fields retain the `schedule_set_by_user_at` guard. A load-out alone never changes the opening date. A show start can change it only when the merged quote has no known load-in. Quote fields remain merge-only: omitting or emptying a load-in does not clear an earlier one. Explicit clearing requires a separate contract change.
* **Links and deletion:** Keep quote association, additive customer links, blank-only venue links, and event creation in the same transaction. Customers have no association provenance, so changing the quote's customer cannot safely remove earlier clients. Read links from the merged quote, scope both sides to the organization, and keep hidden gigs hidden. A tombstone blocks replacement creation until an explicit relink or an observed change from a known deal stage clears it; an unknown previous stage is not a transition.
* **Execution boundaries:** Take the run-of-show advisory lock before locking an event row. Lifecycle goes through `commitImportedEventLifecycle`; cancellation recovery runs after commit whenever the resulting state is CANCELLED, including retries with no new transition. External identities remain independent best-effort writes after commit: catching an identity SQL failure inside the header transaction would still abort that transaction. Staffing carries a deferred source plan so its parsing and shared shift writer remain inside the existing post-commit failure boundary. Parse the fallback clock before claiming; claim the quote before decoding labor or writing shifts. This intentionally preserves at-most-once staffing, including the existing crash-after-claim loss window.

## 29. Lock-taking DDL in a migration needs an explicit `BEGIN`

* **Symptom:** A boot-time migration hangs the deploy on a lock, or fails and every later deploy dies with `P3009`.
* **Root cause:** `prisma migrate deploy` does not run a file in one transaction unless the file has `BEGIN` / `COMMIT`, so a bare `SET LOCAL lock_timeout` is a no-op and a `DROP TABLE` of a table with a foreign key to a live table waits indefinitely on the referenced table. A rolled-back script still leaves a failed `_prisma_migrations` row.
* **Rule:** Wrap lock-taking DDL in `BEGIN; SET LOCAL lock_timeout = '2s'; ... COMMIT;`, with each risky statement in a bounded-retry `DO` block that ends in `RAISE NOTICE` for optional cleanup or `RAISE EXCEPTION` for a required change. `npm run check:migration-lock-safety` rejects new migrations that break this. Template and the P3009 recovery runbook: `docs/engineering/backend/migration-lock-safety.mdx`.

## 30. `import * as` of a CommonJS module that sets its own `default` crashes on mobile

* **Symptom:** A module fails to load with `Cannot assign to property 'default' which has only a getter`. Gig chat showed "This chat is unavailable right now" because `@twilio/conversations` (`dist/logger.js`, `import * as loglevelLog from 'loglevel'`) never loaded.
* **Root cause:** `@expo/metro-config` 57.x builds the namespace for a CommonJS module in `namespaceWrapHelper` (`build/transform-plugins/helpers.js`). It copies every own key as a getter-only property, `default` included, then assigns `n.default = e`, which throws in strict mode. `loglevel` sets `defaultLogger['default']`. Babel's interop and 58.x skip `default`; no 57.x release does.
* **Rule:** Keep the package's `react-native` entry and shim only that import: `metro.config.js` `namespaceImportShims` sends `loglevel`, when imported from `@twilio/conversations/dist/`, to the ESM wrapper `lib/chat/loglevelNamespace.ts`, which the helper returns as is. Do not redirect the package to its `browser` or `main` build: those pull in core-js, whose Promise polyfill recurses on Hermes (`Maximum call stack size exceeded`), and `main` also needs Node-only modules. Do not use a `patches/` fix either, because `@expo/fingerprint` hashes that folder and the change would hold back OTA updates. `lib/chat/__tests__/twilioNamespaceImports.test.ts` fails when a Twilio dist adds another such import, and it tells you to drop the shim once the app is on SDK 58.

## 31. An npm script change makes the shared Revyl build stale

* **Symptom:** Revyl devices show an older dev than what was merged, and the `revyl-dev.yml` run summary's `Seed build needed` row says `true` ("no compatible revyl build found").
* **Root cause:** Expo's runtime fingerprint hashes the `package.json` scripts (`packageJson:scripts`), not only native code. By default only `android`/`ios` scripts without `run` are skipped. PR 3267 added scripts and moved dev's iOS fingerprint away from the seed build's, so `revyl` channel updates stopped reaching it.
* **Rule:** Before a Revyl pass, read the latest `revyl-dev.yml` run. A stale build is a blocker for Chris, never an agent EAS build. `fingerprint.config.js` now sets `SourceSkips.PackageJsonScriptsAll` (ENG-1955), so builds made after it ignore script edits; builds made before it still go stale on one. Details: `revyl-flows/README.md`, "When the shared build goes stale".


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.