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-deployCI fails with “endpoint not covered”, even though unit tests pass locally. - Root cause:
.github/workflows/api-events-deploy.ymlruns e2e withE2E_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 ine2e/src/coverage/waivers.ts. The legacycoverageExclusions.tslist 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, auditcreatedAt) 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 trailingZ→timeZone: 'UTC'; bareYYYY-MM-DDviaparseDateKeyLocal). Instants useformatInstantDate/formatInstantDateTime(viewer TZ, including Today/Yesterday grouping). Past-ness uses the gig IANA zone (isPastEventDate(value, now, zone)). Never hand-rollformat(date, 'MMM d'). - Enforced by types: zone-dependent helpers take an
EventZone, a branded string whose only constructor isresolveEventZone(eventZone, orgZone)in each package’stemporalDate.ts(event zone, then org zone, then the America/Chicago default). There is no zoneless “today”: usetodayIn(zone). A rawstringzone does not compile, andcheck:exported-inline-typesrejectsas EventZoneandx is EventZoneoutsidetemporalDate.ts.
3. Bare Date is banned — civil days are PlainDate (ENG-582)
- Symptom: A bare
YYYY-MM-DDparses as UTC midnight throughnew 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:
Dateis an instant wearing a local-zone costume. Civil days (LedgerItem.date, APIYYYY-MM-DD), instants (created_at), and zoned wall clocks all collapse into it. - Rule: Civil days are
Temporal.PlainDate, instants areTemporal.Instant, zoned wall clocks areTemporal.ZonedDateTime. ParseYYYY-MM-DDwithparseCivilDay(nevernew Date('YYYY-MM-DD')); read the clock withnowEpochMs/nowInstant. Thecheck:bare-dategate (incheck-all/checksin all three packages) bansnew Date(,Datestatics,instanceof Date, andDatetype references outside an allowlist. The only remainingDatevalues sit behindbare-date:interopmarkers (native pickers, Prisma rows, third-party SDK contracts) and file-widebare-date:interop:filemarkers.
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!== undefinedguard collapses the distinction. Seeapi-events/src/systems/venues/updateVenueOperation.ts. - Rule: In update paths, branch on
!== undefinedbefore 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
Jsonfields 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
Jsoncolumns; 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(pluscatalog-gap.ts), the Zod schemas inweb/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 wheneverkeyis truthy, independent of whether the component renders. - Rule: Gate the key, not just the render:
useSWR(enabled ? key : null, fetcher). Passingnulldisables 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/selectwith 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 worktreefails 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 installin each package root,cd api-events && npm run prisma:generate, andnpm run generate:api-client(mobile/web client). In a worktree, runnpm run worktree:setupinstead. Never symlinknode_modulesor generated dirs from the main checkout: pre-commitnpm ci,postinstall, andpretypecheckwrite 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
undefinedin the component. - Root cause:
web/src/lib/schemas/api.tsparses responses throughz.object(...), which drops any key not declared on the object — no error, just silently gone. (Theposition_namefield 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.tsandGigsContextare user-scoped by design. - Rule: Org-wide views must use the org-scoped hook
web/src/lib/hooks/org/useOrgEventsSWR.ts(org-scopedgetEvents(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
localhostwebhooks; the emulator must receive webhooks on:4000and JWT verification must target:4711. - Rule: Use
npm run dev:infra, seed withcd api-events && npm run db:seed:demo-full, restartclerk-emulator, thencd api-events && npm run dev:emulatorandcd web && npm run dev:emulator. See.agents/skills/local-clerk-emulator/SKILL.md. Don’t setENABLE_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_acceptedvs backendinvitation_accepted, webpayout_sentvs backendpayment_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 (seecreation_sourceonevent_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, thencargo test/build, and a freshly published crates.io version runsbuild.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-ageis 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 inAGENTS.md: (1)build.rustc-wrapper→.cargo/rustc-guard.mjs(editor-agnostic), (2) the Cursorcargo-release-agerule inscripts/agent-hooks/cursor-rules.mjs, (3) CIscripts/check-cargo-release-age.mjs, (4) RenovateminimumReleaseAge: "2 days". Pin offenders withcargo update -p <crate> --precise <older>— never setCARGO_MIN_RELEASE_AGE_DAYS=0without an explicit user grant.
15. Money columns must stay Decimal, not Float
- Symptom: A new price/fee/amount column lands as Prisma
Float/ PostgreSQLDOUBLE PRECISION, or an agent “follows” outdated guidance and reintroduces float money storage. - Root cause: Older docs/skills said “Prisma
Float+ migration SQLDECIMALoverride.” The schemas now declareDecimal @db.Decimal(12,2)(percents use Decimal with their own scale). JSON wire fields may still benumber/float64— that is a separate, pre-existing HTTP contract. - Rule: New money DB fields are
Decimal @db.Decimal(12,2)inapi-events/src/db/schema.prisma. Only geo (latitude/longitude) and MLconfidencemay useFloat. Follow.agents/skills/monetary-handling/SKILL.md; the guard isapi-events/scripts/schema/moneySchema.test.ts.
16. OpenAPI path templates are client param names
- Symptom: After
docs-cisyncsdocs/openapi/soundcheck.jsonfrom TypeScript emit, web/mobile call sites fail typecheck because{ id }no longer matches{ userId }/{ organizationId }/{ eventId }. - Root cause:
swagger-typescript-apiderives path-param objects from OpenAPI{param}names. Method names such asv1UsersDetailcome from each operation’soperationId, which the emitter derives from the path (api-events/scripts/openapi/operationIds.ts) andnpm run check:operation-idscompares 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 inapi-events-deploy.ymlopenapi-parity. Pushdocs-cisync-openapi-specstill 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 inopenapiRoute()annotations (src/systems/shared/openapiRoute.ts); named$refschemas belong in siblingopenapiSchema()calls. Wrap-styleopenapiRoute(api.raw(...), meta)hides the endpoint from Encore’s compiler, so call it after theexport const name = api.raw(...)assignment. Coverage compares emit toscripts/openapi/testdata/openapi.baseline-ops.json(frozen pre-overlay checklist), not the spec this branch writes. CompareopenapiSchema()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 editsdocs/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 deployrecords the migration name (and checksum) in_prisma_migrationsand does not re-run an already-applied file. A later edit of20260307140000_destination_charge_payouts(day-equality wrap on invitation PAYABLEdate) never executed;20260316000000_fix_ledger_tz_backfillis 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 httpfails withERR_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 toapi.ngrok.com. Scratch files namedngrok.authtokenare often the API key; the working tunnel token is oftenngrok.agent.authtoken. Feeding the API key tongrok config add-authtokenproduces 107 — the same code as a revoked agent token. - Rule: Start tunnels only with the agent authtoken. Name leftovers
ngrok.agent.authtokenandngrok.api.key— never store an API key asngrok.authtoken. If 107 fires, try the file that previously loggedstarted tunnelbefore 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.csshas different--success/--warning/--infovalues, 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 thestylefromcomponents.json. Ours isnew-york, which reui.io answers with a 307 redirect tobase-nova, the Base UI port of the same component. (2) A registry item and its transitiveregistryDependenciesmay each carry acssVarsblock, which the CLI merges into the project stylesheet, anddependenciesthat pull umbrella packages (radix-ui) — a second copy of@radix-ui/react-focus-scopegives each overlay its own stack, the exact duplicatenpm run check:radix-dedupeexists to catch. - Rule: Before adding from an unfamiliar registry, fetch the item JSON by hand and read
files[].target,cssVars,dependencies, andregistryDependencies. 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 — seeweb/scripts/vendor-reui.tsandweb/src/components/reui/README.md, which write only inside one directory, never install, record upstream hashes, and keep local edits in aPATCHESlist that throws when upstream drifts.git diff src/app/globals.cssmust 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 narrowerfoo.tshelpers. A leftover second SQL body (invitation.ts::updateInvitationUserId) or atry { await updateInvitationUserId } catch { capture*; continue }ininvitationSignupOperations.tscan leaveInvitation.userIdnull after a successful-looking request. Clerk webhooks do not stamp that column. - Rule: Fail closed on
Invitation.userIdand 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 aclerk_pending_invitation_status_updatesrow already matches that request’s target status, event organization, and resolved user — a leftover decline marker or a linkedInvitation.userIdfrompending-with-signupis 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 claimsprocessedEventbefore send, passesthrowOnFailureso 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 isfailed. Mixedsentandfailedrows are different channels (NotificationDeliveryis unique on eventId+channel), so the next attempt id retries only the failed channels. A reclaimed lease still publishes unless every stored delivery is alreadysentand nonefailed. Unlinked EMAIL backups publish toInvitationTopicinstead 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 stampInvitation.userId. Do not add a secondexport functionof the same name underapi-events/src/db/unless it is a true independent helper onDUPLICATE_DB_EXPORT_ALLOWLISTwith the exact colliding file set (npm run check:duplicate-db-exports). Do not flatten the*Crud/foo.tssplit.
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_eventran after Cancel). - Root cause: The agent writes the
ask_questionoptions itself, and it marks the destructive approve optionstyle: "danger"({ id: "confirm", label: "Yes, archive it", style: "danger" }). The web picker readdangeras “decline” and sentoptionId: "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 —
pickConfirmOptioninweb/src/lib/checkai/confirm.ts: the first approve/decline word in the label decides (id only when the label has none),styleis 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 incheckai/evals/confirm-gate.eval.tsmirrors 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(andclearMocks: true) only wipes call history. AmockResolvedValueOnce/mockRejectedValueOnce/mockReturnValueOncethat an earlier test queued but never consumed survives into the next test and is returned first. - Rule: Reset shared mocks in
beforeEachwithvi.resetAllMocks(). On Vitest 3+ (every package here is on 5) that also restores the implementation passed tovi.fn(impl)in avi.mockfactory, so factories keep working. Set per-test return values inside the test, not in a sharedbeforeEachthat 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, ortsccrashes, only on one machine or agent VM. - Root cause: The repo targets Node 22 (
.nvmrcis22.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 -vbefore debugging a test or type-check failure that looks impossible. Use Node 22 (nvm useat the repo root, or put a Node 22binfirst onPATH) for every package, includingweb/andapi-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.tspinsTZ. 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 (orprocess.env.TZset 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 --noEmitin the repo root orapi-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 astypescriptand install TypeScript 7 under thetypescript7alias.npx tscresolves the classic compiler, which needs a much larger heap on this codebase and is not the gate.web/installs TypeScript 7 astypescript. - Rule: Type-check through the package script (
npm run type-checkat the root,npm run typecheckinapi-events/andweb/). If you must call the binary, usenode ./node_modules/typescript7/bin/tscin root andapi-events/with the same-p/--projectargument 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/catchor a React QueryonErroraround 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 oflib/api/) inherit that, and some reduce the result further tobooleanornull. - Rule: Check
response.success(or the service’snull/false) at every call site. In auseMutation/useQueryqueryFn, throw when!response.successso React Query sees the failure. Detect specific HTTP errors withresponse.status, never by searchingmessagefor'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 evenupdatedAt, so callers can hang one-time work (closing out a cancelled gig’s crew) offchanged. A refused move still returns the row with its real status; only a missing gig returnsfound: 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:
planFlexQuoteImportseparates 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 throughcorrectImportedEventTx; schedule fields retain theschedule_set_by_user_atguard. 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 deploydoes not run a file in one transaction unless the file hasBEGIN/COMMIT, so a bareSET LOCAL lock_timeoutis a no-op and aDROP TABLEof 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_migrationsrow. - Rule: Wrap lock-taking DDL in
BEGIN; SET LOCAL lock_timeout = '2s'; ... COMMIT;, with each risky statement in a bounded-retryDOblock that ends inRAISE NOTICEfor optional cleanup orRAISE EXCEPTIONfor a required change.npm run check:migration-lock-safetyrejects 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-config57.x builds the namespace for a CommonJS module innamespaceWrapHelper(build/transform-plugins/helpers.js). It copies every own key as a getter-only property,defaultincluded, then assignsn.default = e, which throws in strict mode.loglevelsetsdefaultLogger['default']. Babel’s interop and 58.x skipdefault; no 57.x release does. - Rule: Keep the package’s
react-nativeentry and shim only that import:metro.config.jsnamespaceImportShimssendsloglevel, when imported from@twilio/conversations/dist/, to the ESM wrapperlib/chat/loglevelNamespace.ts, which the helper returns as is. Do not redirect the package to itsbrowserormainbuild: those pull in core-js, whose Promise polyfill recurses on Hermes (Maximum call stack size exceeded), andmainalso needs Node-only modules. Do not use apatches/fix either, because@expo/fingerprinthashes that folder and the change would hold back OTA updates.lib/chat/__tests__/twilioNamespaceImports.test.tsfails 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.ymlrun summary’sSeed build neededrow saystrue(“no compatible revyl build found”). - Root cause: Expo’s runtime fingerprint hashes the
package.jsonscripts (packageJson:scripts), not only native code. By default onlyandroid/iosscripts withoutrunare skipped. PR 3267 added scripts and moved dev’s iOS fingerprint away from the seed build’s, sorevylchannel updates stopped reaching it. - Rule: Before a Revyl pass, read the latest
revyl-dev.ymlrun. A stale build is a blocker for Chris, never an agent EAS build.fingerprint.config.jsnow setsSourceSkips.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”.