This page is the machine-readable markdown of the SoundCheck round-2 review, for feeding to Claude Code or any AI agent ("read https://elmspark.com/soundcheck-review-r2-md-x7k4/"). To keep a file instead: save this page as soundcheck-review-r2.md (everything below this line is the markdown, verbatim). Human-friendly version: the interactive review.
# SoundCheck: Critical Review, Round 2 (the code audit)
**ElmsPark Consultants · 15 July 2026 · reviews `donjjjordan/soundcheck` at commit `c0f48b1` · follows round 1 (the brief review) and Don's response document.**
Canonical interactive version: https://elmspark.com/soundcheck-review-r2-x7k4/
Private review for Don and Kenn. Not for publication.
> **Note for the AI agent reading this (Claude Code or similar).** This file is the machine-readable port of the round-2 review. Finding IDs F1 to F14 are stable references; every citation is `path:line` at commit `c0f48b1` (line numbers may drift on later commits, the file names will not). If you are working in the soundcheck repo, a suggested order of work is at the end of this document. Keep `DECISIONS.md` updated as you act on any of this; that log is the best thing about the repo.
---
## The verdict
**This is a real, honest, demo-grade prototype whose hardest problems are not in the code. The code needs a day of fixes. The venture needs one number: will venues reply?**
Round 1 reviewed the brief and Don's team took it seriously: every fix the response document claims was pushed to the repo is genuinely there, mostly done well. This round is the first look at the code itself. It holds up better than most one-day builds have any right to, and the DECISIONS.md discipline (28 logged assumptions, each with reasoning) is rarer than the code. But the audit also found five material defects the response could not have known about, and the flagship claim, "the 10 gig-killers are caught automatically", is today about half true on the live data path.
| Stat | Value |
|---|---|
| Unit tests | 33/33 pass, typecheck clean (executed for this audit) |
| Response-doc claims traced to code | 12: 8 verified, 3 partial, 1 not held |
| Material defects found | 5 (none fatal, all cheap to fix) |
| Gig-killers solidly automated end to end today | 4 of 10 |
| Total build time | ~9 hours (6 commits, one day). The code is not the moat. |
### Scorecard
| Dimension | Score |
|---|---|
| Honesty of claims | 8/10 |
| Data model quality | 9/10 |
| Code correctness | 5/10 |
| Security posture | 4/10 |
| Differentiator coverage today | 5/10 |
| Strategy (after round 1) | 8/10 |
Security and correctness scores are normal for a one-day prototype and are listed, not hidden. They matter only because the demo is about to be shown around and the wedge is about to touch real venues.
### What is genuinely good
- **The venue-intake data model is the best artefact in the repo.** The 11-section, 77-field schema with versioned setups and the sound limiter as a first-class object (`prisma/schema.prisma`) is exactly the right core, and it was built first, per the brief's own guardrail.
- **Round-1 fixes were real, same-day, and verifiable.** The Congestion Charge correction became a review-dated config with a staleness test that fails the suite after 13 months (`src/config/uk-rates.ts`). That is the right pattern, generalised.
- **Money handling is careful.** Integer pence everywhere, the all-in price fully decomposed and stored at booking so it cannot grow later, and the server recomputes the quote rather than trusting the browser.
- **The mock boundaries are honest.** No Stripe key means an explicitly mock provider; a live key is refused at runtime until a deliberate flag is set after solicitor sign-off (`src/server/payments/index.ts:14`).
- **The contract reads like the brief promised.** No non-circumvention, no hidden-contact clause, cooling-off correctly framed as goodwill (CCR 2013 exemption named), "payment protection" language throughout.
- **Three of the brief's acceptance criteria already pass:** the 85 dB venue produces a "no live drums" verdict shown to both sides; a London venue flags ULEZ £12.50 and the £18 Congestion Charge to the act; and there is no "get a quote" wall anywhere in the client path.
---
## The response document, claim by claim
Round 1's fixes were promised in writing ("what you clone is what this document claims"). Each checkable claim traced to the code:
| Claim | Where checked | Verdict |
|---|---|---|
| Congestion Charge £18, rates centralised, 13-month staleness tripwire | `src/config/uk-rates.ts`, `uk-rates.test.ts` (3 tests) | **VERIFIED** |
| "Escrow" gone from every user-facing surface | UI says "payment protected" / "Deposits protected"; contract says "payment protection". Only internal enum names remain, as declared. | **VERIFIED** |
| Runtime guard refuses live Stripe keys until legal sign-off | `src/server/payments/index.ts:14-18` throws on `sk_live` | **VERIFIED** |
| Cooling-off reframed: CCR 2013 exemption + 60-day guard | `src/domain/contract.ts:47`, clause 6 | **VERIFIED** |
| Limiter norms restated as our policy, not a market statistic | `src/domain/limiter.ts:5-11` | **VERIFIED** |
| Stripe card-hold limits verified, warning at top of the payments code | `src/server/payments/stripe.ts:6-13` | **VERIFIED** |
| Venue form asks the core sixteen fields, including the two outdoor ones | `src/app/venue/[token]/page.tsx`: exactly 16 inputs, mobile-first, no login | **VERIFIED** |
| Readiness "never fakes a green" (unknowns go amber) | `src/domain/readiness.ts` + tests | **VERIFIED** |
| "The live demo already exists", 3-minute quickstart | All the described paths exist and the unit suite passes. The full app was not executed in this audit (no Postgres on the review machine), so the end-to-end demo claim is credible but unprobed. | **PARTLY CHECKED** |
| "The slot state machine already holds dates during checkout" | HELD status exists and is used, but the availability check races under concurrency (F1) and abandoned checkouts leak HELD dates forever (F7) | **PARTLY TRUE** |
| Reviews are booking-linked only | True structurally (1:1 with a booking in the schema). No live review-collection flow exists yet; today's reviews are seed data. | **PARTLY TRUE** |
| Bookings move through an enforced state machine | The machine exists and is tested, but nothing calls its guard: `assertTransition` has zero production callers, and the one real transition in the app is illegal by the machine's own rules (F2) | **NOT HELD** |
### The brief's own Definition of Done, scored today
| Acceptance criterion (from Don's brief) | Today |
|---|---|
| Client books unattended: search → all-in price → available act → pay → contract → venue goes green | **PARTIAL.** Works with mock money; the venue link is never actually sent (email is mocked), so "green" needs a human to pass the link on |
| Each of the 10 gig-killers detected and surfaced, a test per item | **PARTIAL.** 4 solid, 3 partial, 3 weak; killers 6 and 7 have no tests |
| Act sees budget and venue up front, never pays per lead, is paid fast | **PARTIAL.** Display is real; payout, settlement and refund flows do not exist yet (Phase 3, as planned) |
| 85 dB venue auto-produces "no live drums, electronic-kit line-ups" shown to both sides | **PASS** |
| London ULEZ venue auto-flags the charges to the act | **PASS** (correct 2026 rates, act-facing note, heuristic zones documented as demo-only) |
| No "get a quote" wall anywhere in the standard client path | **PASS** |
| Compliance badges reflect real, verified, in-date PLI/PAT/ID | **FAIL.** Badges render seed data; there is no verification pipeline, expiry is never date-checked, and lapsed compliance does not block booking (F4) |
---
## New findings from the code audit
Things round 1 could not see because it only had the brief. Five material, nine hardening.
### Material (fix before anyone outside the family sees the demo)
**F1 · HIGH · Two clients can book the same act on the same date.**
The availability check runs *before* the database transaction, and inside the transaction the slot is updated unconditionally (`src/server/booking-actions.ts:54-59` and `:177-183`). Two overlapping checkouts for the same act and date both pass the check and both become CONFIRMED bookings; the second silently overwrites the slot. That is precisely the "double-booked wedding" the plan treats as a guarantee event. The sequential case ("that date was just taken") is handled, so the gap is only visible under real concurrency.
*Fix: make the slot update conditional inside the transaction (updateMany where status is AVAILABLE, throw if nothing updated). Roughly ten lines.*
**F2 · HIGH · The booking state machine is decorative: its guard is never called, and the first real transition is illegal.**
The machine's own header says "the API layer must call `assertTransition` before persisting any status change" (`src/domain/booking-states.ts:5`). Nothing does: outside its tests it has zero callers. Worse, the one real transition in the app jumps CONFIRMED straight to CONTRACT_SIGNED (`src/server/booking-actions.ts:249-254`), which the machine forbids (it requires CONTRACT_SENT in between), so wiring the guard up as written would break the flow. Ten of the sixteen states (VENUE_OUTREACH, VENUE_READY, PRE_EVENT, EVENT_DAY, PERFORMED, SETTLED and the exception states) are unreachable by any code path today. Not a crash; it matters because the whole automation story ("a booking moves itself forward") hangs off this machine, and Phase 3 builds on top of it.
*Fix: one helper (load booking, assert transition, write status) used by every status write, plus either add the CONTRACT_SENT step or amend the machine. Half a day.*
**F3 · HIGH · The admin console is the fall-through: any account that is not a matched client or musician gets it.**
The dashboard branches "if client... if musician... otherwise render admin" (`src/app/dashboard/page.tsx:145` onwards). There is no `role === "ADMIN"` check. A user with the VENUE role (the enum exists) or any future role lands on the admin view: booking totals, protected-deposit sums, recent bookings with client names, and live venue outreach links.
*Fix: one explicit role guard. Two lines.*
**F4 · MEDIUM · Vetting and fee floors are displayed, not enforced.**
The booking action fetches the act's compliance records and then never reads them (`src/server/booking-actions.ts:45-48`): lapsed or missing PLI/PAT does not block a booking, though the brief and the acceptance criteria say it must. The badge component never date-checks expiry, so an expired-but-once-verified record still renders as a green badge (`src/components/Badges.tsx`). `meetsMinimumFee` is written and unit-tested but never called, and `serviceRadiusMiles` is never read anywhere, so an act can be booked outside its radius at a mispriced travel fee (see F12).
*Fix: three guards in the booking action plus a date check in the badge. An hour or two.*
**F5 · MEDIUM · The PLI half of gig-killer 9 is missing, quietly.**
The readiness engine's input declares `actPliVerified` and the mapper computes it (`src/lib/readiness-input.ts:62`), but the engine never references it (`src/domain/readiness.ts`): only PAT is checked. The venue-side field for required PLI cover (`requiredPliCoverPence`) exists in the schema and is read by nothing. The dead input suggests the rule was intended and lost.
*Fix: one rule in the engine plus one field mapped through. An hour, including the test.*
### Hardening (fine for a laptop demo, required before the wedge touches real people)
**F6 · Sessions are signed with a hardcoded fallback secret.**
If `SESSION_SECRET` is not set, cookies are signed with the literal string "dev-only-secret" (`src/lib/session.ts:13`), and `SESSION_SECRET` is not even listed in `.env.example`, so every install runs on the public constant. The cookie also lacks the `secure` flag, and the signed value has no expiry or revocation (the 30-day limit is browser-side only). The crypto that exists is done properly (HMAC with timing-safe comparison; scrypt for passwords). Fix: require the secret at boot, add `secure`, embed an expiry.
**F7 · An abandoned Stripe checkout blocks the date forever.**
On the real-Stripe path the slot is set to HELD before redirecting to Checkout, but nothing handles `checkout.session.expired` and there is no reaper job, so a client who closes the tab leaves that act's date HELD indefinitely (`src/server/booking-actions.ts:180`, `src/app/api/stripe/webhook/route.ts`). The mock path is unaffected.
**F8 · Booking references are generated by counting rows.**
`count() + 1` (`src/server/booking-actions.ts:21-25`) means two concurrent bookings can mint the same SC-2026-NNNN and the second dies on the unique constraint with a 500. Use a sequence or retry loop.
**F9 · A venue is identified by postcode alone, so different venues can merge and inherit each other's "confirmed" setup.**
Booking reuses any venue with the same postcode (`src/server/booking-actions.ts:89-100`) and applies its latest CONFIRMED setup instantly (the caching moat, D20). Rural and shared postcodes cover multiple properties, and the client's typed venue name is ignored on reuse: the hotel next door's limiter reading can be served as "confirmed" for a different building. Match on postcode plus normalised name, or ask the client to confirm the matched venue.
**F10 · The product copy claims outreach that never happens.**
The booking page tells the client "We have asked [venue] to confirm their setup" (`src/app/bookings/[ref]/page.tsx:138-143`). Nothing is ever sent: email is mocked (honestly disclosed in the README and D19, but the UI does not carry the caveat). The repo's own rule is "don't fake automation with hidden manual steps". Reword until Resend lands ("Here is the venue's two-minute link, share it with your contact").
**F11 · The signed contract is re-rendered live, never frozen.**
Contract text is regenerated from current booking data on every page view (`src/app/bookings/[ref]/page.tsx:70-81`); no copy or hash is stored at signature time (`Contract.documentUrl` is always null). If booking data changes after signing, the "signed" document silently changes with it. Persist the text (or a hash) when the first party signs.
**F12 · Unknown postcodes silently geocode to central London, locking in a wrong price.**
The demo geocoder falls back to a central-London centroid for any postcode not in its small table (`src/lib/geo.ts:34,47`). A Newcastle act quoted for a Cornwall wedding shows zero travel miles, and the all-in guarantee then locks the wrong figure in. The stub is documented, but the failure mode is silent mispricing; refusing to quote would honour the pricing promise better until the real lookup lands.
**F13 · Venue tokens never expire and stay live after confirmation.**
The tokenised form is the right pattern (crypto-random, no login), but a leaked link can rewrite a venue's "confirmed" setup at any time, months later, and flip bookings' readiness (`src/server/venue-actions.ts`). Add expiry and lock-after-confirm with an explicit re-open.
**F14 · Smaller loose ends.**
The guest-count search input is collected and ignored; `zod` is a dependency that is never imported; the VenuePack, Thread and Message tables are never written (the pack is a live page, messaging has no UI); the admin queue's "escalate if within 21 days" is a label with no date logic; no rate limiting on login; no CI workflow on the repo, so "tests pass" is only ever a local claim. Add a ten-line GitHub Action so the suite gates every push.
---
## The 10 gig-killers, honestly scored
Three columns: does the rule exist in the engine, can real data actually reach it in the live product, and is it tested.
| # | Gig-killer | Rule in engine | Live data path | Test |
|---|---|---|---|---|
| 1 | Unannounced sound limiter | RED | On form | Yes |
| 2 | Limiter set too low / C-weighted / mic too close | PARTIAL: threshold yes; C-weighting only warns in the readout and never affects the RAG level | PARTIAL: limiter type and mic proximity are in the schema but not on the form | Partial |
| 3 | Performance space too small | RED | On form | Yes |
| 4 | No power near stage / shared kitchen circuit | PARTIAL: socket count yes; the shared-circuit field (`dedicatedCircuit`) is read by nothing | Partial | Yes |
| 5 | Curfew vs booked finish-time mismatch | RED | **UNREACHABLE**: no live surface collects a finish time (not the venue form, not the booking form), and neither seeded venue sets one, so this rule can only fire in unit tests | Yes |
| 6 | Guests already in the room at soundcheck | WEAK: ambers only if a window is missing entirely; never compares the actual times | Not collected | No |
| 7 | Brutal load-in (stairs, tiny doors, gravel) | WEAK: "stairs" ambers; door sizes, distance and surface are schema-only | Partial | No |
| 8 | No parking / unbudgeted ULEZ + Congestion Charge | AMBER | Yes: correct 2026 rates; zone lookup is a documented demo heuristic, barred from production by D6 | Yes |
| 9 | Missing PAT / PLI paperwork on arrival | HALF: PAT reds correctly; PLI never checked (F5) | Half | PAT only |
| 10 | Outdoor / marquee: no rain cover, unlevel ground | RED / AMBER | On form | Yes |
**Net: 4 solid (1, 3, 8, 10), 3 partial (2, 4, 9), 3 weak or unreachable (5, 6, 7).** None of this is hard to finish, and the core insight survives contact with the code: the limiter flow, the one that matters most, works end to end. But "the 10 gig-killers are caught automatically (DONE)" should read "5 or 6 of 10 today". The fastest wins are wiring finish-time collection (killer 5 is fully built and fed by nothing) and the one-line PLI rule.
---
## The business, round 2
Round 1's five structural criticisms were accepted and the plan was reordered around the wedge (a free venue-check for musicians' existing gigs) with written kill/go gates. That was the right response, and the gates are the right instrument. These five points are what round 2 adds; none reverses the wedge decision, all of them shape it.
1. **The VAT trap inside the headline commission.** The plan recommends publishing 15% against "Encore's 20%". But Encore's 20% is VAT-inclusive (roughly 16.7% before VAT), and if SoundCheck's 15% commission bears VAT, as it normally will once the platform is VAT-registered, the act's effective cost is 18%. The proud headline gap of five points is really about one, and for some acts it could invert. D5 (platform VAT unmodelled) and D25 (the 15% recommendation) each hold half of this problem. **Get the accountant's answer before any comparison copy is written, and make every published comparison VAT-explicit.**
2. **The venue-data publication plan needs a consent line, now.** The demand hypothesis of programmatic venue pages ("can we have a live band at [venue]?") is genuinely clever, and it is also a purpose-limitation problem: a venue manager who spends two minutes helping one couple's booking has not agreed to a public profile of their limiter, curfew and quirks. Publishing it anyway is the fastest way to burn the exact goodwill the wedge depends on. **The fix costs one field: add "may we show a public band-ready profile of your venue?" to the intake form before the first real outreach goes out.**
3. **Clawback is the unpriced corner of the guarantee.** The refund ladder is now properly capped (good), but the money flow pays the act's deposit promptly and then, if the act cancels, refunds the client in full (plus £100) and chases the act for recovery. The 2% reserve only balances if recovery mostly succeeds and cancellations arrive at the average rate, and they will not: they cluster on peak Saturdays, in the exact weeks a replacement is hardest. The response already names the mitigation as a question for the solicitor (hold the payout inside the Stripe balance until nearer the event where the 90-day window allows). **Make that the default position, not an option.**
4. **The wedge has unlisted prerequisites.** Before the first real venue is emailed: ICO registration and a privacy notice; a warmed sending domain; a recorded letter-of-authority pattern so contacting venues "in the musician's name" is both lawful and deliverable (From: the platform, Reply-To: the act, consent stored); and the named human's actual name and hours. The G1 gate also needs denominator discipline: 50 outreaches across genuinely distinct venues, time-boxed, not 50 sends into one friendly chain. And instrument who completes each form (venue staff vs the act on their behalf), or the venue-response number the whole venture pivots on will be quietly polluted.
5. **Still unanswered anywhere, and it is the ops question.** Who answers the phone at 21:40 on a June Saturday when a limiter trips mid-first-dance? Peak support hours for this business are precisely the hours a solo founder with a life is off. Related: what does "vetted" actually mean at launch (who watches the videos, who checks references), and who is the named human the response promises for venue liaison "from day one"? These are headcount and rota questions, not code questions, and the £38 per-booking ops line in the unit economics is a guess until someone owns them.
---
## The call sheet
Seven decisions, in the order they unblock each other. The recommendation is in each one.
1. **Wedge: go.** The response's own plan (free venue-check for existing gigs, instrumented from the first send, G1/G2/G3 gates in writing) is the right next move, and this audit confirms the organs it reuses (tokenised intake, readiness engine, venue pack) genuinely exist. Add the prerequisites above to Sprint 0. Agree who is the named human.
2. **Fix-four before anyone outside the family sees the demo.** F1 (double-booking race), F2 (wire the state machine), F3 (admin fall-through), F6 (session secret), plus the F10 copy fix. Roughly a day of work, listed file by file above.
3. **Commission: park the public number until the accountant answers the VAT question.** Keep 15% as the working assumption; write every comparison VAT-explicit.
4. **Add the venue-consent field to the intake form now,** before the first outreach.
5. **Approve the solicitor question pack and a budget for it.** The pack in the response doc is good. Add clawback/payout timing (hold payout toward T-14 inside Stripe's window) as a default position to test.
6. **Book the ten conversations this week.** The three venue coordinators matter most. Nothing else in Phase 3 or 4 starts until G1 and G2 have data. The 3-to-5-day wedge build estimate is plausible given what already exists, provided the fix-four lands first.
7. **Name: decide nothing today.** Brand tokens are genuinely swappable in the code (checked), so naming is not load-bearing. GigSure is the strongest candidate on the shortlist; check domains and trademarks when the wedge shows signal.
**If only one thing gets agreed:** the wedge goes ahead, and its success gate is the venue response rate, measured exactly as written in the response document's section 5. Everything else in this review is fixable with ordinary engineering. That number is the venture.
---
## Method and evidence
- **What was reviewed:** the full repository `donjjjordan/soundcheck` (private; reviewed via the ElmsPark fork) at commit `c0f48b1`, "Response doc for Kenn's review + critic-pass fixes", 14 July 2026. Every source file was read (66 files: schema, migrations, all domain modules, server actions, payments, every page and component, seed, config). Six commits, all 14 July 2026, 13:43 to 22:50.
- **What was executed:** `npm ci` (clean), the full unit suite (`vitest run`: 4 files, 33 tests, 33 passed) and the TypeScript check (`tsc --noEmit`: no errors), on 15 July 2026.
- **What was not executed:** the running application (the audit machine has no Postgres; the README quickstart needs one) and the real-Stripe path (no keys, matching the repo's own declared status). Findings about runtime behaviour (F1, F7) are from code analysis, not live reproduction.
- **Taken on trust:** the market research in Don's brief (competitor commissions, Encore/Poptop positioning, limiter prevalence). The response document says the TfL, Stripe, legislation and FCA claims were re-verified against primary sources; the code matches those claims everywhere it encodes them, and the load-bearing ones are consistent with our own knowledge, but this audit did not re-run that research.
- **Prior rounds:** round 1 (14 July) reviewed the build brief only, before repo access. The response document and DECISIONS.md D22 to D28 record what changed as a result. This document is the first review of the code itself.
---
## Suggested order of work (for the agent acting on this review)
1. **Fix-four plus the copy nit, before the demo is shown further:** F1 (conditional slot update inside the transaction), F2 (a `transitionBooking` helper calling `assertTransition` on every status write, plus the missing CONTRACT_SENT step or an amended machine), F3 (explicit ADMIN role guard on the dashboard), F6 (require `SESSION_SECRET` at boot, add the `secure` cookie flag, add it to `.env.example`), F10 (honest outreach copy). Run `npm run test` and `npm run typecheck` after each.
2. **Close the cheap differentiator gaps:** the PLI rule (F5); collect a finish time somewhere real so killer 5 can actually fire; compare the actual times for killer 6; let C-weighting and close-mic affect the RAG level, and add limiter type + mic proximity to the venue form (killer 2); date-check badge expiry and enforce compliance, minimum fee and service radius at booking (F4).
3. **Add CI:** a ten-line GitHub Action running `vitest` and `tsc --noEmit` on every push, so "tests pass" stops being a local-only claim.
4. **Then the wedge build** per the response document's Sprint 1, with the prerequisites from "The business, round 2" above (consent field, ICO registration, letter-of-authority sending pattern, instrumentation of who completes each form).
5. **Log what you change in `DECISIONS.md`** with the finding IDs, matching the discipline already in the repo.
---
*SoundCheck critical review, round 2 · ElmsPark Consultants · 15 July 2026 · private, not for publication · repo state reviewed: `c0f48b1` · canonical interactive version: https://elmspark.com/soundcheck-review-r2-x7k4/*