Skip to content

Engineering Soundness Masterfile

Status: Active Last reviewed: 2026-07-19 (Wave-5 UX residuals + 0.7.0 build finished) Companion artifact: the Engineering Soundness dashboard (published separately; same content, visual) Sibling deliverable: club-surfaces-unification.md is the surface-system masterfile; this is its engineering counterpart. Live tracker: the project-wide status + roadmap lives in the Project Cockpit (this work is the R3-6 slice). The "Where we are & where we're headed" section below is this masterfile's own current-state view; §0–§4 are the deep reference behind it. Owner queue: ../audits/ux-residual-queue-2026-07-19.md.

This file is to engineering what the surface-system doc is to UI: the single place that ties every audit → its confirmed findings → the durable invariant it produced → the mechanical gate that now enforces it. It is written so an agent picking up cold can see what was wrong, why the fix is foundational (not a spot-patch), and what still needs a human decision. Every claim carries a file:line or migration number — no abstract principles, per the multi-modal-research mandate.


Where we are & where we're headed

Start here for status. The Project Cockpit is the single cross-project tracker (this is its R3-6 slice); the reference sections §0–§4 below hold the evidence.

Current state (2026-07-19)✅ shipped · 🔷 gated on an install/device · ⛔ blocked externally · 🟡 owner decision.

TrackStateDetail
Engineering campaign (§0–§3)✅ completeAll 5 audits' findings on prod (migrations 00434–00439, edge fn, OTA ae318003) + the 10 invariants (§3) each gate-enforced. Outcome re-audit found & fixed 7 defects in the fixes; no further recursion warranted (gate-verified).
0.7.0 OTA-safe build🔷 built, install pendingEAS build e56d3b16 (v0.7.0) is FINISHED — the distinct-runtime binary for expo-image (INV 3.7). Everything shipped since 03cc65e7 is pushed but on no device yet.
UX audit (111 findings)✅ Waves 1–5 shippedRe-verified all 111 vs live code → 50 already fixed + ~50 canonical shipped (3962e8d7) + dues-overpay copy (00439); 10 in the owner queue. Full gate green.
On-device QA owed🔷 needs a device (rides 0.7.0)offline RSVP failure toast (not phantom confetti); forced-reauth identity wipe; + the ~50 Wave-5 UX changes.

The one gate in front of everything: install the 0.7.0 build + smoke-prove it, then the OTA baseline advances (runtimeVersion 0.6.3→0.7.0) and every commit since 03cc65e7 reaches devices. guard-ota-release.mjs refuses an expo-image OTA at 0.6.3 until then.

Where we're headed — ordered by what unblocks the most:

  1. Install 0.7.0 → advance baseline → resume OTA — the single delivery gate. (owner install + smoke)
  2. 5 wireframe-first UX features (queue §A): dues-amount editing (#6), overdue-dues view (#7), inter-club challenge propose/decline (#8), poll create+vote (#49), format-scoped leaderboard (#99). Mockup-first before code. (owner design)
  3. 2 external unblocks (queue §B): Kakao/Google/Apple OAuth (EXT-11), CPO contact data. (owner / external)
  4. 2 scoped data-layer tasks (queue §C): venues→court*venues find-or-create RPC (#59), policy_versions JOIN (#79). *(agent-doable)_
  5. Surface-system buildclub-surfaces-unification.md, the next R3 design-led track (spec-approved, not started).

Provenance

The campaign ran as five audits, one campaign: a chain of adversarial audits, each verifying the last. Findings are "confirmed" only after an independent verifier reproduced them.

#AuditScopeConfirmedDisposition
1UX audit (2026-07)all 69 screens111Waves 1–5 shipped (~100 of 111); 10 in the owner queue (see the top status section)
2Duplicate / model-drift (2026-07-18)components + pagespart of 38shipped in 5 waves (commits 144421b6 + extraction)
3Canonical-bypass (2026-07-18)primitives + custom deviationspart of 38shipped same waves
4Engineering soundness (2026-07-19)whole codebase12shipped (commit 505e6674, migrations 00434–00436, edge fn, OTA ae318003)
5Outcome re-audit (2026-07-19)the fixes from #4 themselves7shipped (commits 96ee42fd + 9d26e7b8, migrations 00437–00438)

Audit #5 is the load-bearing one: it turned the adversarial lens on the remediation itself and found 7 real defects in the fixes — 4 HIGH. That is the campaign's central lesson: a fix is a change, and a change deserves the same adversarial scrutiny as the original code.


1. The enforcement stack (what mechanically blocks a regression today)

The invariants in §3 are only real to the degree a gate enforces them. Current gates:

  1. TypeScript strict — all workspaces. Blocks type violations.
  2. ArchUnitTSpackages/app/src/config/__tests__/architecture-archunit.test.ts, 16 fitness rules (hex direction, @twomore/ui purity, cycles, feature-package deps).
  3. DB constraints + pgTAP — 30 files / 296 assertions (yarn db:test), a blocking CI job. RLS/CHECK/NOT-NULL fail queries on wrong data; pgTAP asserts RLS/RPC/trigger behavior.
  4. ESLint baseline + 52 @twomore ruleseslint.config.mjs; ratchet baseline (eslint.ratchet.config.mjs); new violations always fail.
  5. Pre-commit (husky + lint-staged) — Prettier on staged files.
  6. Pre-pushdocs:check + typecheck + test + lint:strict (max-warnings 0) + lint:agents-md + verify:no-stale-claims.
  7. CI — mirrors pre-push + drift analyzer (non-blocking) + Supabase type-drift gate + the blocking pgtap job.
  8. yarn metrics — architecture-health JSON, uploaded as a 90-day CI artifact.
  9. Stop hook — blocks session end without yarn check passing.
  10. guard-ota-release.mjs — refuses an OTA from a dirty/unsynced tree, when native-sensitive files changed since the baseline, or (new, §3.7) when a native dep manifest changed without an app-version bump.

The gap this campaign closed: gates 1–9 catch shape violations. The engineering audit found the damage lives in semantics that typecheck fine — a queue that drops a live mutation, a migration that double-counts an honest counter, an OTA that crashes old binaries. Those need either a targeted test (added per finding) or a new mechanical guard (§3.7). Each §3 invariant names its enforcing gate.


2. Audit findings — evidence ledger

2.1 Engineering-soundness audit (#4) — 12 confirmed, all shipped (commit 505e6674)

Security (prod migrations + edge fn):

  • 00434 — profiles rating-v2 self-forgery. profiles_update RLS is a blanket self-row grant (auth.uid() = id, no column scope); the only backstop on computed columns is the BEFORE-UPDATE protect_profiles_computed allowlist — which missed 7 columns (ELO / rating_state / rating_source family). A self-row PATCH could forge a permanent top-tier ELO surviving recompute. 00434 extends the allowlist + remediates drifted rows from elo_history ground truth; pgTAP 8/8.
  • 00435 — dues paid-amount lost-update race. sync_dues_paid_amount read-then-wrote the rollup with no lock; two concurrent confirmations lost one. Fix: FOR UPDATE on the dues row.
  • 00436 — waitlist promotion aborts the hold-expiry cron. autolock_session_on_capacity called promote_next_waitlisted unguarded; a tier-ineligible candidate raised and aborted the entire */5min expire_unpaid_session_holds sweep platform-wide. Fix: BEGIN…EXCEPTION WHEN OTHERS so a bad candidate is skipped, not fatal.

Client (the OTA bundle):

  • Offline-queue error classification: a permanent business rejection now dequeues + surfaces to the user (was blind-retry-then-silent-drop); isAlreadyAcceptedError recognizes the real ConflictError; the network banner no longer false-success-toasts abandoned entries.
  • 13 queue executors gained the postReplay cache-reconciliation hook.
  • SessionProvider forced-signout path wipes cache / MMKV / Sentry (identity leak into the next account on the device).
  • useUpdateSession board-post gained an idempotency key + error toast.
  • 3 adapters (dm / safety / saved-session) moved off supabase as any onto the typed client.
  • One canonical PII-key list + a drift test.
  • Dead waitlist rules deleted.

3 bugs surfaced mid-fix (the audit-while-fixing loop working): the 00436 waitlist guard itself, the SessionProvider effect-deps for the identity wipe, and a send-push claim-before-send ordering that an agent's shared-tree git reset had wiped.

2.2 Outcome re-audit (#5) — 7 confirmed in the fixes, all shipped

#SevWhereDefectFix (commit)
1HIGHreplay-mutation-queue.tsisPermanentRejection treated every DomainError as permanent; but base.adapter.handleError wraps every unmatched Postgrest error (incl. timeouts, code='') into a generic PG_<code> DomainError → a transient/timeout queued mutation was dropped + reported failed on attempt 1default transient; permanent only on SQLSTATE class 22/23/42 or a known hint; empty/08/25/40/53/57 retry (96ee42fd)
2HIGHidempotency.tsisAlreadyAcceptedError matched the constraint-index name, but handleError builds ConflictError from details || message and Postgres details carries the column name, not the index → idempotent replays never recognizedmatch the stable 'idempotency_key' marker present in both forms (96ee42fd)
3HIGH00434singles_matches_played resync used COUNT(*) over elo_history, but 00130 writes a second row per match (confidence_bonus) → honest counter doubled → premature rating_state/rating_source advanceCOUNT(DISTINCT match_id) WHERE reason-bearing match rows (00437, prod)
4LOW00414confirm_dues_payment overpay ceiling is a read-then-write with no lock → two managers confirming distinct receipts on one dues row both pass the guardFOR UPDATE on the dues row (00438, prod)
5LOWsession-provider.test.tsxthe identity-leak wipe under review had zero assertions covering itassert clear + clearUserScopedMmkv + setSentryUser(null) fire on the forced-signout path (96ee42fd)
6HIGHweb buildexpo-image / expo-image-manipulator (native, added in 03cc65e7) broke the Next.js production build — avatar-bubble is an app-wide @twomore/ui primitive used on web tooreact-native-web expo-image shim + self-contained image-resize.web.ts + next.config alias (96ee42fd)
7MEDimage-resize.tsresizeImageForUpload force-resized to maxDimension when source dims were unknown → upsized, contradicting its "never upsizes" doconly downsize when known to exceed the cap; unknown dims re-compress without resize (96ee42fd)

+1 latent hazard the re-audit's scope didn't name, found while shipping its fixes (§3.7): the expo-image native modules were added at version 0.6.3 with no bump, defeating the appVersion runtimeVersion OTA gate. Fixed: bump to 0.7.0 + a new guard (9d26e7b8).


3. Derived engineering invariants (the foundational rules)

Each is a durable rule extracted from a finding, stated so it generalizes past the one bug, with its evidence and its enforcing gate. These are the deliverable — the fixes are just their first application.

3.1 Offline-queue rejection defaults to TRANSIENT

A queued mutation is dropped as permanent only on a recognized permanent signal — Postgres SQLSTATE class 22 (data), 23 (integrity), 42 (access/syntax, incl. 42501 RLS), or a known business hint. Never via "it is a DomainError": base.adapter.handleError (base.adapter.ts:62) wraps every unmatched Postgrest error — including a 20s-AbortController timeout (code='') — into a generic PG_<code> DomainError. Classifying those as permanent silently discards live mutations. Evidence: replay-mutation-queue.ts isPermanentRejection / isTransientSqlState. Gate: mutation-queue.test.ts regression block (timeout-shaped + transient SQLSTATEs → retry).

3.2 Idempotent-replay recognition matches the error shape handleError actually produces

handleError builds ConflictError from details || message (base.adapter.ts:38), and Postgres details on a unique violation reads Key (idempotency_key)=(…) already exists. — the column name, not the constraint-index name. Match on the stable 'idempotency_key' substring, which both the message and the details contain. Evidence: idempotency.ts isAlreadyAcceptedError. Gate: mutation-queue.test.ts isAlreadyAcceptedError case built from the real ConflictError.

3.3 A resync/backfill counts by the invariant it claims — never COUNT(*)

A counter maintained "+1 per match row" cannot be resynced with COUNT(*) over a table that holds more than one row per match. elo_history has a second row per match (confidence_bonus, 00130) and seed rows with NULL match_id. Resync with COUNT(DISTINCT match_id) filtered to the reason that actually drives the counter. Evidence: 00434 (the bug) → 00437 (the fix); 00130. Gate: the migration is idempotent + touches only drifted rows; ground-truth recompute is the check.

3.4 A read-then-write ceiling needs a row lock

Any "sum the siblings, then decide whether this one fits" check is a TOCTOU unless it serializes on a shared row. Both dues paths take FOR UPDATE on the parent dues row so concurrent confirmations serialize and the second re-reads the first's committed row. Evidence: 00435 (sync_dues_paid_amount), 00438 (confirm_dues_payment). Gate: the lock is structural; the pattern is the canon.

3.5 A forced sign-out scrubs the prior identity on EVERY path

queryClient.clear() + clearUserScopedMmkv() + setSentryUser(null) must fire on every sign-out, including the interval-driven MAX_REFRESH_FAILURES path — otherwise the next account on the device inherits the previous user's cached data, MMKV partition, and Sentry identity. Evidence: session-provider.tsx:340. Gate: session-provider.test.tsx spies on all three wipe calls on the forced-signout path.

3.6 A blanket self-row RLS UPDATE needs a column-allowlist trigger — kept exhaustive

profiles_update grants the whole self row; computed columns (ELO, ratingstate, rating_source) are protected only by the BEFORE-UPDATE protect_profiles_computed allowlist. Every new computed column must be added to that allowlist in the same migration, or a self-PATCH can forge it. This is the general rule for any table with a blanket self-row write policy. _Evidence: 00434 (7 columns the 00388 allowlist missed). Gate: pgTAP self-forgery test; the entity→migration checklist.

3.7 A native-module addition requires an appVersion bump

Under runtimeVersion: { policy: "appVersion" }, the runtime equals the app version. Adding a native module (expo-image, expo-image-manipulator, MMKV, …) without bumping the version makes the new build OTA-indistinguishable from the pre-change binary at the same version — a JS OTA that references the new native module is delivered to, and crashes, old installs (top-level requireNativeModule throws before userland try/catch). Bump the version so the native build gets a fresh runtime that gates old binaries out (the MMKV 0.1.0→0.2.0 precedent; here 0.6.3→0.7.0). Evidence: 03cc65e7 added expo-image at 0.6.3; fixed in 9d26e7b8. Gate (new): guard-ota-release.mjs assertNativeDepChangeBumpedRuntime — refuses a preview OTA when a dependency manifest changed since the baseline without a version bump.

3.8 A trigger's best-effort side work must not abort the transaction

When a trigger calls into optional downstream work (waitlist promotion, notification enqueue), wrap it so a failure of the side work cannot roll back the primary write — especially when the trigger runs inside a batch cron, where one bad row otherwise aborts the whole sweep. Evidence: 00436 (autolock_session_on_capacitypromote_next_waitlisted, guarded). Gate: pgTAP expire_unpaid_holds_promotion asserts the healed (skip-not-abort) behavior.

3.9 A native module used by a @twomore/ui primitive needs a web shim

@twomore/ui primitives (avatar-bubble) render on both mobile and web. A native import there breaks the Next.js build unless it has a react-native-web shim (via a next.config alias) and, for helper modules, a self-contained .web.ts platform variant that imports nothing native. Evidence: apps/web/lib/shims/expo-image.tsx, packages/app/src/lib/image-resize.web.ts. Gate: yarn workspace @twomore/web build (CI); the web build is the check.

3.10 Standing canon reinforced this campaign

  • Every SECURITY DEFINER fn: search_path = '' + REVOKE EXECUTE FROM PUBLIC + explicit GRANT (the 00425 sweep revoked 51 private fns; ALTER DEFAULT PRIVILEGES REVOKE does not suppress the hardwired PUBLIC-EXECUTE default — use inline per-fn REVOKE).
  • Never write PII into JSONB (signals.payload, client_errors.context); write userId, resolve names at read time.
  • Presentation reaches Supabase only via @/registry; adapters never as any the client (3 fixed this campaign).

4. How to use this file

  • Before writing a fix, check §3 for the invariant your change touches — the foundational rule is already stated; apply it, don't re-derive it.
  • Before an OTA, guard-ota-release.mjs runs the §3.7 + native-sensitive checks for you.
  • When a new class of defect is confirmed, add its invariant to §3 with evidence + a gate, and wire the gate into §1. An invariant with no gate is a comment, not an enforcement.
  • This file supersedes ad-hoc recollection of "why we did X"; the migration numbers and file:line anchors are the durable record.

Markdown remains the source of truth. Run yarn docs:check before handoff.