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:lineor 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.
| Track | State | Detail |
|---|---|---|
| Engineering campaign (§0–§3) | ✅ complete | All 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 pending | EAS 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 shipped | Re-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:
- Install 0.7.0 → advance baseline → resume OTA — the single delivery gate. (owner install + smoke)
- 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)
- 2 external unblocks (queue §B): Kakao/Google/Apple OAuth (EXT-11), CPO contact data. (owner / external)
- 2 scoped data-layer tasks (queue §C): venues→court*venues find-or-create RPC (#59),
policy_versionsJOIN (#79). *(agent-doable)_ - Surface-system build —
club-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.
| # | Audit | Scope | Confirmed | Disposition |
|---|---|---|---|---|
| 1 | UX audit (2026-07) | all 69 screens | 111 | Waves 1–5 shipped (~100 of 111); 10 in the owner queue (see the top status section) |
| 2 | Duplicate / model-drift (2026-07-18) | components + pages | part of 38 | shipped in 5 waves (commits 144421b6 + extraction) |
| 3 | Canonical-bypass (2026-07-18) | primitives + custom deviations | part of 38 | shipped same waves |
| 4 | Engineering soundness (2026-07-19) | whole codebase | 12 | shipped (commit 505e6674, migrations 00434–00436, edge fn, OTA ae318003) |
| 5 | Outcome re-audit (2026-07-19) | the fixes from #4 themselves | 7 | shipped (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:
- TypeScript strict — all workspaces. Blocks type violations.
- ArchUnitTS —
packages/app/src/config/__tests__/architecture-archunit.test.ts, 16 fitness rules (hex direction,@twomore/uipurity, cycles, feature-package deps). - 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. - ESLint baseline + 52
@twomorerules —eslint.config.mjs; ratchet baseline (eslint.ratchet.config.mjs); new violations always fail. - Pre-commit (husky + lint-staged) — Prettier on staged files.
- Pre-push —
docs:check+typecheck+test+lint:strict(max-warnings 0) +lint:agents-md+verify:no-stale-claims. - CI — mirrors pre-push + drift analyzer (non-blocking) + Supabase type-drift gate + the blocking
pgtapjob. yarn metrics— architecture-health JSON, uploaded as a 90-day CI artifact.- Stop hook — blocks session end without
yarn checkpassing. 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_updateRLS is a blanket self-row grant (auth.uid() = id, no column scope); the only backstop on computed columns is the BEFORE-UPDATEprotect_profiles_computedallowlist — 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 fromelo_historyground truth; pgTAP 8/8. - 00435 — dues paid-amount lost-update race.
sync_dues_paid_amountread-then-wrote the rollup with no lock; two concurrent confirmations lost one. Fix:FOR UPDATEon the dues row. - 00436 — waitlist promotion aborts the hold-expiry cron.
autolock_session_on_capacitycalledpromote_next_waitlistedunguarded; a tier-ineligible candidate raised and aborted the entire*/5minexpire_unpaid_session_holdssweep platform-wide. Fix:BEGIN…EXCEPTION WHEN OTHERSso 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);
isAlreadyAcceptedErrorrecognizes the real ConflictError; the network banner no longer false-success-toasts abandoned entries. - 13 queue executors gained the
postReplaycache-reconciliation hook. SessionProviderforced-signout path wipes cache / MMKV / Sentry (identity leak into the next account on the device).useUpdateSessionboard-post gained an idempotency key + error toast.- 3 adapters (dm / safety / saved-session) moved off
supabase as anyonto 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
| # | Sev | Where | Defect | Fix (commit) |
|---|---|---|---|---|
| 1 | HIGH | replay-mutation-queue.ts | isPermanentRejection 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 1 | default transient; permanent only on SQLSTATE class 22/23/42 or a known hint; empty/08/25/40/53/57 retry (96ee42fd) |
| 2 | HIGH | idempotency.ts | isAlreadyAcceptedError 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 recognized | match the stable 'idempotency_key' marker present in both forms (96ee42fd) |
| 3 | HIGH | 00434 | singles_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 advance | COUNT(DISTINCT match_id) WHERE reason-bearing match rows (00437, prod) |
| 4 | LOW | 00414 | confirm_dues_payment overpay ceiling is a read-then-write with no lock → two managers confirming distinct receipts on one dues row both pass the guard | FOR UPDATE on the dues row (00438, prod) |
| 5 | LOW | session-provider.test.tsx | the identity-leak wipe under review had zero assertions covering it | assert clear + clearUserScopedMmkv + setSentryUser(null) fire on the forced-signout path (96ee42fd) |
| 6 | HIGH | web build | expo-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 too | react-native-web expo-image shim + self-contained image-resize.web.ts + next.config alias (96ee42fd) |
| 7 | MED | image-resize.ts | resizeImageForUpload force-resized to maxDimension when source dims were unknown → upsized, contradicting its "never upsizes" doc | only 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_capacity → promote_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 REVOKEdoes not suppress the hardwired PUBLIC-EXECUTE default — use inline per-fn REVOKE). - Never write PII into JSONB (
signals.payload,client_errors.context); writeuserId, resolve names at read time. - Presentation reaches Supabase only via
@/registry; adapters neveras anythe 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.mjsruns 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:lineanchors are the durable record.