Wedding planning · build status

VELA

A Hungarian-first wedding planner on top of a supplier marketplace. The ledger below is the authority; this page is generated from it.

generated 2026-09-27 17:27 UTC tree bd7ca6f 122 slices from docs/BUILD-LEDGER.md

Overall

95%complete
115 complete6 in progress1 nothing to build 122 total

Phases

Cross-product couplings and the site

V1 – V121 · 122 slices

115 complete · 6 in progress · 1 nothing to build

Every module

SliceNameStatusThe ledger’s note
Cross-product couplings and the site
V1FoundationcompleteCI HAS NOT RUN SINCE 2026-08-22 13:13 — GitHub Actions refuses to start the job: "recent account payments have failed or your spending limit needs to be increased". It is billing, not the tree. Every commit after a5d8422 has been verified by hand instead. This sentence is in a Notes cell rather than in prose above the table because only the table reaches the status page — the blocker was recorded in a blockquote first and was therefore invisible on the board for a full day, which is the 2026-08-13 rule recurring on the largest blocker of the day. Verified by grepping the deployed page: its three matches for "billing" were all the billing schema. Next 15 App Router on Cloudflare Workers via OpenNext, Drizzle, Neon Postgres, Wrangler deploy. Five custom domains on one Worker
V2Identity and sessionscompleteidentity.user, identity.session, hashed session tokens, actorForWedding resolving owner or collaborator
V3Authorisationcomplete@vela/auth can() over typed resources. Every server action re-checks — a page's check is not inherited
V4Copy cataloguecompleteThe review screen now says who wrote the drafts, 2026-08-22. It read "Machine draft", which means machine *translation*; nothing in this catalogue came from a translation engine. Every draft in every locale was composed by someone who does not speak the language, from the English. That changes the reviewer's job — correcting machine output means hunting grammatical slips, when the real risk is a sentence that says the wrong thing confidently. The status now reads "Draft — written by someone who does not speak this language", the filter says "a draft no speaker has seen", and a callout says to treat a filled box as a starting point to argue with rather than a translation to correct. Put on the status, not the key's context, because context lives on message_key and is shared across all 17 locales. content.message is the render-time source of truth, not the seed files. ICU MessageFormat, market-scoped keys, machine status renders. The seed inserts and never renames, so editing a string in en.ts is a default for a database that has never seen the key, not a way to change what a live product says — and a stale packages/i18n/dist is a second way an edit silently fails to land, so build before seeding. Two tools close the gap: copy-values[:prod] prints what a database actually holds per locale and names a missing key rather than counting it, because the seed's own "newly inserted" counter disagreed with reality on production on 22 Aug; copy-set[:prod] changes one live string from outside the app, writing the message, a message_revision keeping the previous value and an attributed audit_log row — the same three writes the CMS makes — and refusing unless --expect matches. Closed 2026-08-22: the "Book a DJ" rename is on production. copy-set:prod refused it — --expect "Book music" did not match — which is how the item turned out to be already done rather than outstanding; a blind UPDATE would have been a silent no-op
V5Hungarian couple lenspartial"Every non-operator surface localised" was not true of the one a couple works in. On 2026-08-22 the plan page rendered 43 of its 44 task titles in English — task.* had no Hungarian at all beyond one string written by hand through the CMS — and verify:couple-lens swept that exact page in Hungarian and passed, because it hunts professional vocabulary rather than untranslated English. All 47 keys are written and seeded at machine; the vocabulary-leak test then caught two of them using Forgatókönyv, the professional term for run_of_show, and both were rewritten to the couple term. **dependency.* followed, 40 more. Those are the "why is this waiting" line under a blocked task, and 15 of 41 were measured rendering in English on her page — a first probe of three sentences said zero, because three probes is a guess and the whole set is a measurement. 15 became 0. The owner reviewed 48 on 22 Aug and rewrote 32 of them — 16 approved as they stood — and all 32 are now held verbatim in docs/COPY-REVIEW-HU-2026-08-22.md**, generated from copy-drift:prod hu rather than retyped, because until then the review had exactly one copy. sync.audit_log attributes every one of the 61 message.update rows to a single actor, the owner, between 09:54Z and 18:45Z. which copy-progress:prod measures and copy-drift:prod itemises — the seed reports what it *offered*, never what a speaker has read, and the two databases disagreed by 48 for two hours with nothing saying so. Eight of those rewrites said something the English did not, and the English was corrected to follow — and one conflicted with the V27 DJ/band split outright — resolved the same day, the split holds, and the Hungarian is back to *Foglaljatok DJ-t*: docs/COPY-SOURCE-DRIFT-2026-08-22.md. book_photographer is the sharpest — the Hungarian tells a couple to book a videographer too, which is a different supplier and a different budget line, so the two products currently plan different weddings. A Hungarian couple now meets no English at all — overview, plan, help, day, legal and guests measured against every English-only string in the catalogue, 0 on all six. pressure.* (27) turned out to be couple-facing after being written off as operator surface: it renders on /help and is conditional, so measuring one page understates it. Plus workflow.* (9) and template.* (2). **The only English left is service.product.* (24), excluded on purpose — that is the corpus four human writers per language compose at /write/<token>, so KNOWN_UNTRANSLATED is now a boundary between two owners rather than a backlog. The 48 reviewed strings existed on production and nowhere else, and now do not — copy-progress read 635 machine / 48 machine_reviewed there against 683 machine / 1 human on local, so local held none of the review rather than a stale copy of it, and every local harness swept draft text while the live product served approved text. Two things close it: all 32 rewrites are held verbatim in docs/COPY-REVIEW-HU-2026-08-22.md, and copy-pull carries the review back into a local database — 48 pulled, re-running is a no-op, local now reads 635 / 48 exactly as production does. It writes the same three rows the CMS writes and the target must be local, with no flag to change it: run backwards it would replace a reviewer's sentences with a laptop's drafts, 48 at a time, which is the one act copy-set demands --expect before doing once — proven to refuse a remote target. It will not create a key, because a key missing locally is an unrun seed and not a translation to invent. The couple lens then swept the reviewed text for the first time, green, and the Hungarian sweep grew 26 814 → 27 177 characters, which is the pull landing rather than a claim that it did. The 1 374/1 372 message gap is not production being behind: local holds the pre-split ui.welcome.callout in both locales and production holds only the rendered .lead/.body pair, which is the whole difference — found by diffing the key sets, because a count says two databases differ and never which row nor in whose favour. Still partial:** 635 of 683 Hungarian strings are unreviewed and mine. hungarian-coverage.test.ts lists them and fails on anything new, a tripwire rather than an audit that would be red the day it was written
V6Couple overview and plancompleteCountdown, health signals with a severity reduce, task timeline
V7Guests and householdscompleteHouseholds that split, plus-ones, child/infant/adult, side, dietary
V8RSVPcompleteTokenised per-household links, no login, Hungarian-first
V9InvitationscompleteCompose, send, track. Real mail through the Worker
V10SeatingcompleteTables, drag-and-drop assignment, per-guest seating so a household can split, printing, capacity fit at 90% = tight
V11AccommodationcompleteRoom blocks held not booked; release date is the loudest thing on the page. StayProvider seam for RateHawk / Booking.com
V12Marketplace (in-wedding)completeVenues and suppliers by region, photos, demo labelling on the row. The no public surface caveat is closed by V21: /directory is the no-login surface, and this stays the session-gated one a couple works inside
V13event.clinic directory clientcomplete@vela/directory, three-state result (ok with items / ok empty / unavailable). Shape-checked, not cast. Now also carries seatsAtLeast(), three-state for the same reason — R11a makes capacity_max_pax a derived cross-layout maximum, which can rule a venue out and never in. See ACCEPTED-sibling-rulings.md
V14Enquiry flowcompleteCouple → supplier enquiry. Demo listings refuse at the write, not only in the UI
V15Supplier replycompleteTokenised link matching event.clinic's method: written reply, yes/no on the date, price range, visit date, attachments. The unreachable-state blocker is gone. V24 made seen reachable on open, and the whole lifecycle was driven end to end on 13 Aug: sent → seen on open → replied on submit, with a 800 000–950 000 Ft quote landing on the enquiry and the reply not charging a second lead credit. It was never an inbox that was missing
V16Enquiry emailcompleteBrevo-backed send so the reply link actually arrives
V17BudgetcompleteFour kinds kept apart, spend rows always add, per-category rollup, uncosted categories named. Verified at 4 690 000 Ft against the database
V18Astro site typecheckcomplete@astrojs/check had never once run — neither it nor typescript was installed. 7 errors found and fixed; astro check now gates verify
V19Couple-lens leak harnesscompleteDrives the real server, signs in, sweeps rendered HTML for professional vocabulary. Coverage guard fails by name if a route is unswept. Added to CI on 2026-08-22 — and CI stopped running that same afternoon on a billing failure, so it is still hand-run only. Run by hand at 2b1fb45 on 2026-08-22 because CI cannot run: both sweeps green, 28 228 and 26 814 characters, no professional term reached either lens — it starts the real server after the build that already produced .next, probing /sign-in rather than /directory so a sibling's live API cannot decide whether the job passes. Two defects surfaced getting it there. The RSVP block is a loop over minted invitations, so with no households it swept zero invitation pages and still printed "No professional term reached the couple lens" — the page its own comment calls the one where jargon costs an actual reply; it now refuses and names seed:guests. And seed:guests itself took limit(1), leaving the second couple with no guest list at all. Both locales are swept now: the seed hardcoded primaryLocale: 'en' for both weddings, so a freshly seeded database could only ever sweep the English lens on a Hungarian-first product — Eszter and Dani (languages: ['hu'], zero international guests) now render hu. 46 488 characters across the two sweeps
V20Revenue modelcompletebilling: vendor accounts, plans, subscriptions, agreements, placements, credit ledger, one append-only revenue_event. Written, read, and now visible. V23 reads agreement/vendor_account and writes revenue_event; V24 writes credit_ledger; /admin/revenue runs the P&L the schema was shaped for — one query grouped by kind and month, plus lead credits summed from the ledger rather than read off a balance that could drift. Verified on local against the database: 120 000 Ft commission_accrued, 1 of 1 vendor accounts with an agreement in effect, one lead credit consumed. Read-only by design: revenue_event is append-only and its rows come from things that happen, not from an operator typing a number
V21Public marketplacecomplete (Hungary)/directory + 40 city pages + 1 228 venue pages, its own segment — app/(marketing)/ untouched, still a README, still another batch's. Indexed; the only public VELA surface that is. R1 shipped 13 Aug, so venue pages read per-field provenance/confidence and show a photo, type and description with how far each can be trusted — but decide on none of it. Still prints no capacity: capacity_max_pax is null everywhere and R11a makes it a derived cross-layout maximum. /directory/sitemap.xml carries 1 269 URLs hourly; robots.txt now disallows the tokenised surfaces at source. Hungary only — other markets are a country parameter away but unbuilt
V22Taxonomy expansioncomplete14 → 21, not ~28. The seven the owner actually named — szertartásvezető, vőfély, anyakönyvi hivatal, meghívó, nászút, ruha, táncoktatás — plus three new groups (ceremony, paper, after). No categories were invented to reach a round number nobody had verified. Market-scoped by an optional markets on the need, absent meaning everywhere, so the fourteen universal ones do not carry a list that goes wrong by omission the day a market opens. supplyForMarket() filters in one place and drops groups that empty out. R6 holds: this is VELA's own vocabulary, no upstream id is a key. The existing no-duplicate-match test caught vofely stealing mc's Wedding MC
V23Accept a quotecompletemarketplace.quote_acceptance + enquiry_status.accepted in 0021 (on production since 2026-08-13), probed 6 of 6 guards refusing by name with a paired control. A quote is a *range*, so the accepted amount is its own fact. acceptQuote() writes the spend as committed with supplier_id set — the first thing ever to write that column — derives the category from the enquiry rather than trusting a caller, and accrues commission only where a signed agreement is in effect. Driven end to end in the browser: a 5 000 000 Ft amount outside a 1.2–1.8M quote refused server-side with the max attribute removed, then 1 500 000 Ft accepted → enquiry accepted, spend committed under photography, revenue_event 120 000 Ft at 800 bps, and the budget page showing it. All four axes now wired. task_id was null on 13 Aug because workflow.task carries no service category. It carries template_task_code, and seven correspond to a need — written as an explicit map, not book_${category}, which works for four and silently misses flowers→book_florist, photography→book_photographer and band→book_music. Only an open task closes: an already-ticked or not_applicable item is a couple's decision an acceptance has no business overruling. Verified on the florist case end to end
V24Lead metering on opencompleteScoped down from "supplier inbox", deliberately. The reply page already says VELA is not building a supplier portal — event.clinic is one. What was actually missing was the two writes that fire when a supplier opens their link: seen becomes reachable (only from sent, so a re-open cannot drag replied backwards), and credit_ledger meters the lead. Charged once however many times the link is opened, via on conflict do nothing against the partial unique index rather than a read-then-write two opens can interleave through. An expired link bills nothing. Verified in the browser: first open → seen + 1 credit, second open → still 1, expired link → still sent, still 1
V25Public directory harnesscompleteIt said 40 cities and swept one. The summary read "40 cities, 415 venues on budapest, all swept clean" after visiting one city page and three venue pages — true about the counting, false about the sweeping, and this repository's own rule broken by its own harness: *three probes is a guess, the whole set is a measurement*. All 40 of 40 city pages are swept now — status, raw copy keys and the lang attribute — and one venue is collected from each city while that page is being fetched anyway, so 42 venue pages across 40 cities are checked instead of 3 from one, exercising forty shapes of upstream row rather than a single city's data. Deduplicated, because a venue listed under two cities proves nothing twice. The summary now states what was swept rather than what was counted. The weekly production watch is written, merged — and has never fired on its schedule. It is a scheduled Actions workflow and GitHub Actions is blocked on account billing, so the board must not be read as saying production is being watched. The one clean sweep it has to its name was a manual dispatch. Run by hand at 2b1fb45 on 2026-08-22 because CI cannot run: swept production clean on exit 0, 40 cities, 415 venues on Budapest, 1 269 sitemap URLs (1 290 since V40), 12 sampled and reachable. .github/workflows/directory-watch.yml sweeps velawel.com on a Monday cron and on demand, treating exit 2 as a notice rather than a failure. It was dormant until m0-foundations merged, because GitHub runs scheduled workflows only from the default branch; dispatched from main straight after the merge and it swept production clean — 40 cities, Budapest 415, 1 269 sitemap URLs (1 290 since V40) — so this is verified by a real run rather than by the file existing. Three-state since 2026-08-22. It used to report a sibling's outage as a VELA defect: when event.clinic does not answer, /directory lists no cities and the run failed with "listed no city pages at all" on exit 1. That is the exact collapse packages/directory exists to prevent — "could not ask" and "found a defect" are different sentences. It now exits 2 for could-not-run, matching verify:money, so a caller can tell them apart without parsing prose, and it matters most for the intended use: pointed at production on a schedule, a check that goes red on a transient upstream outage gets muted. Proven both ways against stand-in pages — exit 2 when the unavailable sentence is present, exit 1 for a directory that is genuinely empty — and production still sweeps clean on exit 0. It sweeps the live pages: no raw copy keys, no city page for a junk value, unknown slugs 404 rather than rendering an indexable shell, sampled sitemap URLs resolve, robots.txt still disallows /reply/. The capacity assertion is vacuous today and deliberately kept — capacity_max_pax is null everywhere, and the day it lands is the day somebody renders it. Self-tests every detector first, and was pointed at a base URL with no directory to confirm it exits non-zero rather than sweeping nothing clean. Needs a running server, so it is not in pnpm verify
V26Money-rule probescomplete28 rules as of 2026-08-22. CI is configured to run them and CI has not run since 13:13 — GitHub Actions is refusing to start on a billing failure, so "in CI" describes the workflow file, not anything that executed. Run by hand at 2b1fb45 on 2026-08-22 because CI cannot run: 28 of 28 rules held, each refused by name, fixtures rolled back. A check that is only in a workflow nobody can start is a check that is not running — it never did. They run against the fresh service-container database, before the seed, which is the honest place to ask because it is the shape production was built from. That closes the loop on V28: placement_no_overlap is an EXCLUDE constraint drizzle cannot express, so nothing in the schema file recreates it and a regeneration would ship without it silently — the schema comment says these probes are what defends it, and nothing was running them. Proven by dropping the constraint on a rehearsal database built the way CI builds one: 2 of 28 named rules fail and the step exits 1. The fixtures are now its own. It used to borrow a real supplier for the enquiry every acceptance and credit probe hangs off — a subselect guarded by a WHERE EXISTS — so on a database with no suppliers the enquiry was silently not created and two probes failed against a foreign key instead of the rule they name: 2 of 28 red on a schema that was entirely correct, which is the direction that costs most, because the fix that suggests itself is deleting the probe. It now builds region, location and venue itself, every slug probe-prefixed, and passes 28 of 28 on a database with zero seed data. The rollback proof counted probe organisations only while the fixtures write named rows into seven tables; it now counts all seven, proven able to fail by planting a probe-prefixed region. Originally 19 database-level rules, each probed by trying to break it and each asserting the specific constraint that refused it, because exception when others passes for a typo in the probe. Own savepoint per probe: on the first hand-run the first refusal aborted the transaction and four later probes printed "transaction is aborted", which reads like a refusal and had never run. Paired controls throughout — a credit *purchase* against an already-charged enquiry must still be legal. Extended for V27's subscriptions, including the case I nearly got wrong in the portal: the index predicate covers trialling as well as active, so a trialling row collides with an active one. Proven able to fail twice over, by dropping quote_acceptance_amount_positive and then subscription_one_active_idx (2 failures each, green again on restore, and both restored definitions checked against the migration). Refuses any non-localhost database, rolls back, then asserts the rollback took
V27Vendor portalpartialThe missing part is still real pricing. Asked on 2026-08-22: the provisional figures are not the right numbers and real ones will follow, but the owner chose to seed them to production meanwhile — so all three plans are on production now, 3 of 3 carrying provisional: true, which is what makes /vendor print the warning rather than a settled price. That flag is counted by the seed rather than assumed. /vendor is still inert there because production has 0 vendor accounts, which is also why V28 has nobody to sell a placement to. It is then one edit to packages/db/src/seed/plans.ts dropping provisional: true, then seed:plans against production — prices are data, not a migration. Every plan price is provisional and unagreed, so nothing can be billed from it. /vendor, self-serve, chosen over operator-run after the auth gap was flagged. No new schema: identity.user → organisation_membership → organisation ← vendor_account, so a vendor signs in with the same magic link as everyone else. The standing 'no supplier portal' decision holds for the enquiry inbox and billing is the exception, because a vendor cannot pick a VELA plan inside event.clinic. Three HU plans seeded and every price is provisional — marked in the seed, in entitlements, and above the fold on the page. Nothing charges anybody: choosing a plan writes a subscription, and after two changes there was still exactly one revenue_event, the one V23 wrote. A change cancels and inserts rather than updating plan_id, so charged_minor keeps the price agreed at the time — verified with hu_standard cancelled at 9 900 Ft and hu_premium active at 24 900. The action resolves the vendor from the session rather than taking an id, because it is a callable endpoint. noindex and disallowed in robots.txt
V28PlacementspartialDeployed 2026-08-17, migration 0022 on Neon. The sentence that stood here named a Worker version by hand and said this was *still inert on production* — both were false by 2026-08-28 and neither could have been true for long: a hand-written version is stale the next time anybody deploys, which is the rule this repository already wrote down and then broke in its own ledger. Ask wrangler deployments list. Inert is false too: production carries an active vendor account and three plans, confirmed by deploy-check-rows:prod. What remains is not a build part — it is that nothing has been sold, because every plan price is provisional and unagreed, and that is V27's blocker and the owner's number to give. Kept 🟡 rather than promoted to ✅ deliberately: the machinery is built and deployed, and it has never once been exercised commercially, so calling it complete would be claiming evidence that does not exist. The last unused piece of V20, and the defect it closes is that hu_premium promised placements: 1 while billing.placement was empty with nothing rendering a placement anywhere. Three parts. Sell: /admin/placements, operator-run rather than self-serve, because rank 1 on a city page exists once and a queue for a scarce good is not worth building before anything is sold. The venue is given as its public slug and resolved server-side, so the id is never typed, a city-page slot lands on that venue's own city page, and a venue failing the Igen guard cannot be sold a city page at all. Guarded by a new placement resource requiring internal_support — admin_area admits any content role, which is right for copy and wrong for a screen that sells things; a translator gets a sentence and no form, checked in the browser. Render: /directory and the 40 city pages hoist paid positions to the top in rank order, disclosed on the row and in a sentence that only appears when there is something to disclose. Hoisted before the 24-row slice, which is asserted rather than commented — hoisting the slice would drop a paid venue sitting at position 25. Guard: placement_no_overlap, a btree_gist exclusion in 0022, replacing a unique index that had two holes and coalescing the nullable scope columns so the national slot is a scope rather than a gap. Drizzle cannot express an exclusion constraint, so nothing in the schema file recreates it and verify:money probing it by name is what defends it — proven by dropping it and watching 2 of 28 fail, then restoring. verify:money is now 28 rules; the placement probes sell in market PB on probe-city, because the first version sold rank 1 on eger in HU and was refused by a slot an operator had genuinely sold. V29 built the create path, 2026-08-24 — so what remains is one command against production, not a slice. Nothing could create a vendor account, which is what V28 was actually waiting on. The docs called it a business step; it is a build one. The only insert into billing.vendor_account in the repository is verify-money.ts's fixture, which rolls back — there is no screen, script or seed that creates the organisation → organisation_membership → vendor_account chain vendorAccountFor() reads, and the local row is the hand-written fixture id 33333333-…. So V27 and V28 are not waiting on sales, they are waiting on an unbuilt slice. docs/OWNER-ACTIONS-2026-08-22.md sets it out. Missing, as it stood on 22 Aug — two of the three are closed now. *A slot whose venue leaves the page is not reported* → V30 reports it, and V39 taught it about position too. *Nothing renders listing_related* → V39 renders it and it is sellable. *Nothing renders couple_recommendation* → still true, and refused at the write, because that one is a ruling for the owner rather than a missing page. *Production has nobody to sell to* → no longer true. Production carries one active, non-demo vendor account (VELA test vendor — not a customer, vendor-check@velawel.com) with a member who can sign in, so V27 and V28 are live rather than inert: /vendor renders a real account and /admin/placements has somebody to sell to. 3 plans, 0 subscriptions, 0 placements, 0 revenue events — nothing has been charged. Nothing charges anybody — no revenue_event is written, for the reason /admin/revenue states on its own face
V29Vendor account create pathcompleteDeployed 2026-08-24, Worker 5a0d9e1b, no migration — org_status already carried what this needed. The slice that un-blocks V27 and V28, and the first thing it corrected was the reason they were blocked: the ledger said "0 vendor accounts" and called it a business step, but nothing in the product could create one — the only insert into billing.vendor_account anywhere was a verify-money fixture that rolls itself back, and the local row was a hand-written 33333333-…. Selling harder would never have produced a row. Both paths, on the owner's instruction (D-29a): /admin/vendors creates an account for somebody else; /vendor lets a business create its own, replacing a dead end that read "claim your listing in event.clinic first" — true about where a business lives and useless as an instruction, because following it exactly returned you to the same sentence. Both write the same three rows in one transaction — organisation, membership, account — because a half-created vendor is invisible to every screen that joins the whole chain and still holds the slug. They differ in one field, who vouched: an operator typing it is active, a stranger typing it is pending_verification. The distinction is enforced, not described — /vendor promises a self-registered business that a paid position cannot be arranged until somebody confirms them, so vendorOptions() excludes pending and sellSlot refuses it at the write; filtering the dropdown alone would have made the promise a decoration, because a vendor id arrives in a request and a form rendered a minute ago still holds one. sellableRefusal() is a pure function for that reason: inline it was provable only by driving a browser, which is how a rule ends up asserted in a comment and enforced nowhere — proven able to fail by disabling the pending clause, 1 of 12 red, green on restore. Slug folding is tested against Hungarian, because a naive version turns Vőfély Kft. into v-f-ly-kft. Both paths were driven in a browser against a real database: A Helyszín Kft. → a-helyszin, active, a user row created for an address that had never signed in; Bőrönd Virág Bt. → borond-virag-bt, pending, ec_company_id null because self-registration never asserts which event.clinic company it is. Closed 2026-08-28. The one named missing part was the production vendor account, and it is there: deploy-check-rows:prod reports billing.vendor_account vela-test-vendor active, 2026-08-23. Somebody ran seed-vendor:prod after this row was written and the row was never updated, so the ledger contradicted itself for five days — **this row said V27 and V28 were *still inert on production* while the V28 row beside it said *no longer true* — and the status page is generated from both. That is the failure mode the ledger's own header warns about: if a row here is wrong, the page is wrong. Checked against production rather than against the other row, because two documents agreeing is not evidence and two disagreeing does not say which one is right. is_demo is the wrong flag for it and that is worth knowing:** the owner asked for a *demo* vendor so the screens would stop being inert, but is_demo is precisely what keeps an account off /admin/placements and refuses it in sellSlot, so a demo row would have left both screens exactly as empty. The seeded account is real and labels itself in its name and in a vendor-check@velawel.com billing address, which deploy-check-rows now reports and deliberately never deletes — two screens go inert without it, so removing it is its own decision. Nothing charges anybody, no revenue_event is written, and every plan price is still provisional
V30Placement delivery watchcompleteDeployed 2026-08-24, Worker d759da0c, no migration. Closes V28's own recorded gap — "a slot whose venue leaves the page is not reported, so a vendor could pay for silence". Nothing could surface it: the operator screen lists what was sold, and a sold slot looks identical whether or not it renders, while the public page cannot say either because the failure mode is the absence of output — no row, no badge, no disclosure sentence. hoistPlaced already knew, holding both what was sold and what is on the page while every caller downstream sees only the survivors; it now returns the slots it could not place. The distinction that makes the report readable is asserted: one venue sold two ranks appears once and is not undelivered, or the check would fire every time a vendor bought positions 1 and 2. verify:placements compares every live slot against a live fetch of the page it sits on, through the same hoistPlaced the page uses — re-implementing the match would let the harness pass while the page dropped a slot for a reason it had not copied. Three exit codes, copied from verify:directory: a page rendering no venues exits 2, because a sibling's outage must not name every paying vendor as unserved. The directory row now carries data-venue-id — the page rendered slugs and a slot holds an event.clinic id, so there was nothing to match on. Proven both ways against the real Eger placement: green at 1 sold / 1 rendering, then re-pointed at a venue not on that page and it exits 1 naming the vendor, the price and the end date; restored and green again, with the restore value captured before the sabotage. V28's other note was mis-stated and is corrected here. listing_related and couple_recommendation were recorded as "nothing renders them"; nothing sells them either, and there is no page for them — no related-venues section on a venue page, no promoted slot in the couple's marketplace. They are unbuilt product and the enum was the only thing suggesting otherwise, so sellSlot now refuses both: it is a callable endpoint and a surface arrives in its argument. couple_recommendation is ruled out permanently (D-40a, 2026-08-24) — the couple's planning tool stays free of paid placement. Its refusal now reads as a closed door rather than a queue, and a test asserts the difference, because *not yet* and *never* are the same length and that difference is the whole decision. The enum value stays: removing it is a migration that buys nothing and loses the record that somebody considered this and said no
V31Deploy and delivery checkscompleteverify:deployed answers the one question a git tree cannot hold: is production running this build? Built because the ledger named ec6cd82c as live on 2026-08-22 while Cloudflare had superseded it twice that day — a commit saying "Deploy X" is somebody's memory of a deploy, right when written and stale an hour later. The dangerous direction is the other one: the tree moves on, everyone assumes a deploy happened, and production quietly serves last week's code — which, with CI blocked on billing, nothing else would notice. It compares bytes, not status codes, because a 307 does not prove a route is deployed and a missing asset returns 200 carrying index.html. That is not theoretical: pointed at a host holding none of these files, all 12 chunks came back 200 and were caught only by the HTML check. Proven on all three branches — green byte-for-byte against production; 12 missing against the wrong host; and DIFFERENT local 4064, served 4061 after appending three bytes to one local chunk, green again on restore. Deterministic by size and name rather than sampled, so a red run reproduces instead of being dismissed as flakiness. It is now step 6 of DEPLOY.md, before every other check, since all of those can pass against last week's code. verify:placements is documented there too and deliberately not automated: it needs DATABASE_URL on production, and directory-watch.yml is deliberately "no secrets, no database, no deploy" — putting a database credential in a scheduled workflow is a decision for the owner, and saying it is hand-run beats a workflow step that silently skips when a secret is absent
V32A vendor's own billing factscompleteDeployed 2026-08-24, Worker 12330b33, no migration — and the deploy was proven by V31's verify:deployed rather than by a status code. The owner asked for a vendor's own screens, which touches the standing decision that VELA does not build a supplier portal because event.clinic is one. This stays inside the exception V27 already named — billing — rather than quietly widening it: no message body, no reply, no supplier workflow, none of what V24 scoped out. The ledger, not the balance. /vendor printed a number, which is exactly what a supplier cannot argue with: it says how much is left and nothing about what was taken or why. credit_ledger has carried enquiry_id since V20 for the stated reason that *a disputed charge can be traced to its lead*, and nothing rendered that trace to the one person who would ever dispute it. Invoice details are now self-service — billing email and adószám — and three things deliberately are not: the trading name, because it is the slug and the public label and a silent rename would move a URL an operator may have sold a placement against; ec_company_id, which stays operator-only because a vendor asserting which event.clinic company they are is an unchecked claim about a record VELA does not own; and the organisation status, because correcting an invoice address is not a confirmation and a self-registered business must not be able to promote itself with a form. The action takes no account id — a server action is a callable endpoint — and updateBillingDetails re-checks ownership against the same session, two locks on one door because the helper is reachable from anywhere. Driven in a browser against a real database: the ledger renders the 13 Aug lead_opened charge against its enquiry, and saving wrote both the row and an attributed audit_log entry. One heading was renamed after reading the rendered page — a stat tile and a section were both called "Lead credits"
V33The document languagecompleteDeployed 2026-08-24, Worker 71a7fea7, no migration. app/layout.tsx hardcoded lang="en" for the whole application, so every Hungarian surface in VELA declared itself English. Measured on production: /directory and the 40 city pages serve *Esküvői helyszínek Magyarországon* inside <html lang="en">. Two harms, and the quieter one is worse — a search engine mis-attributes the language of 1 269 indexed pages (as it was then; 1 290 now) on a Hungarian-first product, and a screen reader pronounces Hungarian with English phonemes, which is unintelligible and inaudible to everyone not using one, which is why it survived a full day of work on exactly this subject. The irony is worth keeping: the Astro site fails its build if a single Hungarian *word* loses its lang="hu", while the app served whole Hungarian *pages* declared as English. <html> exists only in the root layout, which renders before any page has asked the database anything, so middleware forwards the path and the layout maps it. Only route-determined locales are answered: /directory qualifies because the page hardcodes loadCatalogue('hu','HU') — VELA is open in Hungary and nowhere else — while the couple lens, the RSVP card and the supplier reply are deliberately left alone and a test asserts it, because each is in the language of a *row* and guessing hu would be right most of the time and wrong for the first English-speaking couple, mislabelling a page in the other direction for a reader who cannot tell us. It changed how /directory is cached, and that is a trade-off for the owner to settle. Reading headers() in the root layout forces dynamic rendering for every route: /directory went from ○ … 15m to ƒ, measured by building it both ways. The upstream data is unaffected — directory-client.ts fetches with its own next: { revalidate: 900 } — so this is render cost, not seven API calls a visit; live TTFB is ~1.7s. And the previous state was not obviously better: open-next.config.ts configures no incremental cache, on the stated grounds that *every page in VELA is force-dynamic* — which stopped being true when V21 added /directory, the one public identical-for-everyone surface, exactly the case that comment says caching is for. With no cache to revalidate into, a prerendered /directory is fixed at build time, and this product deploys about weekly. So the change traded *possibly a week stale and fast* for *fresh and slower*, which is a judgement about the product rather than a defect. The fix that gets both is multiple root layouts via route groups — each group renders its own <html>, so the directory can be statically hu with no headers() anywhere — and it means moving every top-level route into a group, which is not a thing to do unsupervised at 04:00. verify:directory checks the language, and the proof is that it passed against the fixed local build and failed against production, naming /directory and /directory/city/budapest — a failure invisible on the page, because it looks perfect to any human reader who is not listening to it
V34Two root layoutscompleteDeployed 2026-08-24, Worker 6fe724b8, no migration. Closes the trade-off V33 introduced. Reading headers() in a root layout makes every route in the application dynamic, and /directory fell from a 15-minute ISR page to per-request rendering — measured by building both ways, not reasoned about. Route groups give each surface its own root layout, so nothing is read at request time: /directory is ○ … 15m again and still serves lang="hu", while the rest of the app stays en. The grouping is the decision now, written where it cannot drift from what ships, and document-lang.ts plus the middleware header it needed are deleted rather than left as dead code. The group is (hu) and not (public) on purpose — when the directory serves a second market one constant stops being true and this becomes a decision again; naming it after the language makes that impossible to miss. Two things moved: rsvp.css was imported by the reply page and the directory as ../rsvp/rsvp.css, accurate about its history and misleading about its job, so it is public-surfaces.css at the app root — those two are in different groups now and that path would have reached across them; and five segment layouts sit a level deeper, so their globals.css import does too. The couple lens caught the move, which is the guard working. Its coverage check reads app/wedding/[id] from disk to catch a hand-maintained route list going stale — and a hand-written path to that list is the same failure one level up, so it threw ENOENT. It finds the directory wherever the groups put it now, and refuses on zero or more than one: zero means the guard silently checks nothing, two means a hand-picked answer is a coin toss. Proven by moving the folder away and watching it refuse by name. What is still not cached, and why that is a decision rather than a defect: /directory now sends the right headers — s-maxage=900, stale-while-revalidate instead of private, no-cache, no-store — but x-nextjs-cache reads MISS on every request and TTFB is unchanged at ~1.7s, because open-next.config.ts configures no incremental cache and a Worker response does not touch the edge cache on its own (no cf-cache-status at all). The three options are kvIncrementalCache (a KV namespace, real 15-minute ISR), r2IncrementalCache, or staticAssetsIncrementalCache (no new resources, but frozen at build time, and this product deploys about weekly). That file's own comment calls adding one *"a later decision"* and says it belongs with the public surfaces — which /directory now correctly declares itself to be. Owner's call
V35The incremental cachecompleteDeployed 2026-08-24, Worker eca00a17, KV namespace f8cbaddad6… bound as NEXT_INC_CACHE_KV. /directory sent s-maxage=900, stale-while-revalidate and answered x-nextjs-cache: MISS on every request, re-rendering 1 228 venues' worth of data each time — headers are not a cache, and there was nowhere to put a rendered page. open-next.config.ts said so deliberately, on the grounds that *every page in VELA is force-dynamic* — true when written, untrue from the day V21 opened the directory — and the same comment said caching *"belongs with the marketing and marketplace surfaces… where caching actually pays"*. KV rather than R2: small HTML and JSON blobs, read on nearly every request, written rarely, and KV reads are edge-local. The binding name is fixed by OpenNext — renaming NEXT_INC_CACHE_KV to something tidier disables caching silently rather than failing. Measured on production: the index went from ~1.7–2.3s and MISS every time to 0.13s and HIT every time. The parameterised routes needed a second thing. revalidate = 900 on city/[city] and venue/[slug] was another promise nothing kept — a dynamic-parameter route renders per request unless Next is told the parameters can be cached, so those pages carried no cache header at all. generateStaticParams returning an empty list gives on-demand ISR: nothing is built ahead of time, so no build is coupled to event.clinic answering — a build running while the sibling is down would otherwise ship a directory with no pages instead of failing loudly — and the first reader pays the render while everyone after them gets KV. City pages read MISS → HIT → STALE-while-revalidating at 0.16s, and a venue page was re-measured afterwards on a real slug — 1552-boutique-hotel, STALE then HIT, so revalidation genuinely completes rather than the entry freezing. The first venue measurement used demo-balatoni-pinceszet, which is a local fixture and 404s on production: it proved only that Next caches a 404, which it does, for the same fifteen minutes. A cache measurement taken against a page that does not exist is the cheapest way to believe a cache works. What must not be cached was checked, not assumed: a shared cache in front of a signed-in product is how one person gets another person's wedding. /vendor, /sign-in and /welcome all still answer private, no-cache, no-store with no cache header; every route outside the directory is force-dynamic, so no page's HTML is cacheable at all — and if one ever drops it, the cache will not ask. An unknown city slug is still a 404 rather than an indexable empty shell. Two things the cache broke that nothing would have reported. A KV-cached page can outlive the build's chunk hashes, so after a deploy it references filenames Cloudflare no longer serves — harmless only because nothing in that segment needs the JavaScript, and the reasoning is pinned in a test rather than a comment: the day a filter or a lightbox lands there, those fifteen minutes become a window where a public page is visibly broken and verify:deployed would not notice, because it compares the *new* build. And verify:placements compares a live database against a page that may be fifteen minutes old, so an operator selling a slot and running the check would have been told their brand-new placement was delivering nothing — the same false accusation verify:directory was corrected for, pointed at a paying customer. Slots younger than the cache window are now named and excluded from the verdict rather than silently dropped, because "everything is fine" and "one slot is too new to check" are different sentences
V36The couple's document languagecompleteDeployed 2026-08-24, Worker 88798554, no migration. Closes the remainder V33 named and V34 made affordable. A couple's lens and a supplier reply are each in the language of a row, and until now the document wrapping them said en. This entry was first written claiming more than that and is corrected here: every couple page already sets lang={wedding.locale} on its shell, and the reply and RSVP pages do the same, so the words a screen reader speaks were already marked correctly. What was actually wrong is narrower — the *document* declared en while being Hungarian, which governs the title announcement and is the claim the page makes about itself. A smaller fix than the sentence it replaced, and worth saying so: overstating a fix is the same failure as understating a defect. The split is what made it free: every route under (en) is already force-dynamic — checked against the build output, not assumed — so one indexed read on the paths that need it costs nothing on the rest, and /directory kept its static root and its cache, verified in the same build and live afterwards (x-nextjs-cache: HIT). Single-column reads rather than the existing loaders, because loadWedding and loadReply each fetch a whole view that the page is about to fetch again and neither is wrapped in React cache() — reusing them would double a real query to set one attribute. The reply lookup matches on the token hash, exactly as loadReply does; a lookup on the raw value would be a second, weaker way in. It never throws — a layout that can fail takes the route down, and the worst outcome here is a mispronounced heading, so a bad id, a database blip or a malformed locale all fall back to en. Guessing hu would have been a new defect rather than the old one reversed: the product is Hungarian-*first*, not Hungarian-only, and an English-speaking couple whose plan claims Hungarian hears it in Hungarian phonemes and — unlike the reader of a public page — cannot tell us. /rsvp/[token] is still en on purpose, with a test asserting it — though not for the reason first given: that page *does* resolve a catalogue and already carries lang on its shell. The real reason is that its content is therefore already marked, and buying the document-level declaration costs a second token lookup in a layout on a page strangers reach by link. verify:couple-lens asserts the declared language per wedding rather than against a constant — the sweep runs for a Hungarian couple and an English one, and a check expecting hu would pass for the wrong reason on one of them. Proven by pinning the layout to the default and watching it name the route, the declared language and the wedding's
V37Suspending a vendorcompleteDeployed 2026-08-24, Worker 12a79bd6, no migration — org_status already had suspended. Closes a gap sellableRefusal had named in a comment: suspended and archived passed through, because nothing in the product could reach them. True when written; the moment /admin/vendors grew a suspend button it stopped being true, and an unexercisable branch became one an operator triggers in a click. The two refusals say different things on purpose — an unconfirmed vendor is waiting for somebody to look, a suspended one is a decision somebody already made, and "confirm them first" would send an operator to undo it. Reinstating never returns a vendor to active: org_status holds one value, so there is no record of what they were before, and promoting them back would make suspend-then-reinstate a way to launder an unchecked business into a sellable one with nobody looking. They return to pending_verification and are confirmed again; the audit row says so in words. It takes nothing away — no rows deleted, no membership revoked, the vendor can still sign in — and they are told on their own page rather than discovering it by asking why a placement was never arranged. The list and the rule had already drifted, and testing caught it: V29 taught vendorOptions about pending_verification in SQL, V37 taught sellableRefusal about suspended, and the SQL knew nothing — so a suspended vendor was still offered in the sell dropdown a second after being suspended. The write refused, which is the half that matters, but a list offering what the form will reject tells an operator to try something impossible. vendorOptions now filters through sellableRefusal itself: one rule, read twice, cannot disagree with itself. Four copies of that rule existed. The vendors screen counted "sellable" with one, under a comment admitting it mirrored sellableRefusal — and a comment acknowledging a duplicate is not a defence against it. The vendor seed had a fourth in a package that cannot import the rule at all, which would have counted a suspended vendor as sellable; rather than sync a copy nothing can check, it now reports the states — facts that query owns — and names where the answer lives. The laundering guard is a tested rule, not a ternary: suspensionMove asserts that reinstating never lands on active, plus the no-op cases so a double click cannot write a spurious audit row. Deployed with the de-duplication as d47884b4. Driven in a browser end to end — suspend, disappearance from the sell form, the vendor's own notice, reinstate to pending, confirm back — each step leaving an attributed audit row
V38Correcting a vendorcompleteDeployed 2026-08-24, Worker a435575a, no migration. Three fields were set once at creation and permanent — legal name, trading name, and the event.clinic company id — so a typo in an invoice name could never be fixed and a vendor whose company was identified *after* signing up could never be linked to it, which is the one connection between a VELA vendor and the sibling product. Writing it disproved V32's stated reason. That slice kept the name out of the vendor's own hands because the trading name "is the slug", so a rename would move a URL an operator had sold a placement against. It is not: freeSlug runs once at creation, the value is stored, nothing regenerates it, and identity.organisation.slug appears in no URL at all — the only thing that reads it is the operator's own list, and the directory routes by an event.clinic *venue* slug, a different column on a different record. Verified by renaming and watching a-helyszin stay a-helyszin. The true reason is duller and holds: the name is what an operator agreed with and what goes on an invoice, so operators change it and vendors ask — and the sentence the vendor reads was corrected too, since it had been repeating the same wrong claim back to them. The company id is shape-checked rather than trusted, because a typo links a vendor to nothing while looking linked on the screen; proven in the browser, where a malformed value is refused by name and the previous value survives. The edit sits behind a disclosure whose summary carries whether the account is linked, so a closed row still answers the question the column it replaced used to
V39Related venues, and a real listing_relatedcompleteDeployed 2026-08-24, Worker c6230cc3, no migration; catalogue seeded to production first, because the new code reads two new keys and a missing key renders as its own name. 1 228 venue pages where every page is a dead end is a directory a reader leaves after one venue and a crawler finds as 1 228 orphans; each page now lists the other venues in its town. By town and nothing else, and the copy says so — capacity_max_pax is null on every row, there is no style, no price and no wedding-suitability signal, so "similar venues" would be a claim the data cannot support, which is the kind of small lie that makes a reader distrust the true parts of a page. That makes listing_related a real surface: V28 recorded it and couple_recommendation as rendering nowhere, V30 found nothing sold them either and refused both, and this builds the page for one — so it is typed, offered in the sell form and allowed. couple_recommendation stays refused, because that one is a ruling rather than a missing page. Town-scoped exactly like the city page and for the same reason: the town is resolved from the venue rather than typed, so a Budapest venue cannot be sold a position in Eger, and the Igen guard covers it. Hoisted before the slice through the same hoistPlaced, and the venue is removed from its own list first, which makes paying to be listed as related to yourself impossible rather than unlikely. Two follow-ons a vendor would have found first. /vendor described a slot from its rank and town alone — unambiguous while category_city was the only town-scoped surface, and identical for both once this one existed, so a vendor holding one of each saw the same line twice for two separate purchases. slotDescription says *where* a slot appears rather than naming the surface, because listing_related is a database word and nobody should learn the schema to read their own account. And suspension now says what it does not do: it stops new sales and does not cancel a placement already paid for and running, with the live count shown before an operator confirms — whether it *should* cancel is a refund question, and a refund question is not decided by a button label. Deployed as 9c5596a8. The neighbours rotate, deployed as b27b7e20: taking the first six of the town list meant all 415 Budapest venue pages linked to the *same* six, leaving the other 409 reachable from the city page alone and showing a reader who clicked through two venues the same list twice — the internal linking this section exists to create mostly would not have happened. Rotating by the page's own position gives every venue a different set and makes the 415 pages collectively link to all 415. Deterministic rather than shuffled, because these pages are cached in KV and a list that changed per render would put the cached copy and the next render at odds for no reason a reader could understand. And they had no way back up. Every one of those 1 228 pages was a terminal node — sideways links to six neighbours, nothing to the town it is in or to the directory it belongs to — so a reader arriving from a search hit a dead end and a crawler found the bulk of the site pointing only at more of the bulk, with city pages reachable from /directory alone. A breadcrumb fixes it with no new copy: directory.back already existed for the city page's own link up, and a town is a proper noun. Deployed separately as 34211d79, and verify:directory asserts it — passing locally and failing against production before the deploy, naming the venues, because the failure is invisible otherwise: the page looks complete either way. verify:placements needed two corrections. It mapped a surface to a page and knew nothing about this one, so it would have checked /directory and called the slot undelivered; there is no single page for a listing_related slot, so it asks the town's city page for a neighbour and pairs each row's venue id with its link — picking a slug blind could land on the promoted venue's own page, whose related list excludes it by design, a guaranteed false failure. And its closing sentence claimed *"rendering where it was sold"* while only checking presence, so a slot bought at rank 1 and rendering sixth passed; it checks the paid block now. That immediately accused a slot sold four minutes earlier on a page rendered before the sale, so the cache window applies here as it does to an undelivered slot — a check that cries wolf on its first real run is one nobody trusts on its second
V40Every venue reachablecompleteDeployed 2026-08-24, Worker e6c6c43e, no migration; catalogue seeded to production first. Measured from the public pages alone: 506 of 1 228 venue pages — 41% of the only indexed surface VELA has — were reachable from no listing page at all. They were in the sitemap and linked from nowhere, because citiesOf gives a town a page only at four venues or more and /directory shows 24, so everything in a small town fell through. A crawler treats a page nothing links to as low priority; a reader cannot find it. Pages of sixty, alphabetical, rather than dropping the four-venue threshold — dropping it was the smaller change and the worse one, turning ~250 towns with one or two venues into ~250 thin pages, which is the shape search engines treat as doorway pages; 21 solid pages linking to every venue exactly once is the same coverage without that risk. Sorted with the Hungarian collator, because a byte comparison files Ötkert after Zsolnay and that looks broken to the only people this is for. The page number is parsed strictly, so /directory/all/007 404s rather than serving page 7 at a second URL — duplicate content on a surface whose entire purpose is being indexed. The A–Z pages are in the sitemap and linked from the index, or they would be orphans themselves and the problem would have moved up a level. verify:directory asserts reachability and reproduced the 506 independently against production before the deploy; after it, 0 orphaned and the sitemap is 1 290 URLs. A sitemap is not a link — that is why this is asserted separately from the sitemap check, which only proves the URLs resolve. And the listing was a chain before it was a tree: prev and next alone put page 15 fourteen clicks from page 1, so the far end of the alphabet would have inherited exactly the neglect this listing was built to end — a reader gives up long before fourteen, and a crawler reads depth as unimportance. Twenty-one numbers print in full, so every page is one click from every other; deployed as a65d7c57
V41The venue type, in Hungarian, and a way incompleteDeployed 2026-08-28, Worker 6e20c914, no migration — nothing persists venue_type and R6 says nothing may. Forty city pages printed castle_manor and hotel_resort at Hungarian readers while the venue page two clicks away rendered *Kastély vagy kúria* from the same field, because the rule lived inside a component instead of in a function both could call — so the venue page's own comment, *never the raw key, that is the failure this codebase has already shipped once*, was true and had shipped again next door. Measured on production across all 1 228 Hungarian venues before the fix, and it is one missing function with four shapes rather than four defects: the venue page was correct for 18 codes, rendered English for 22 (Amusement park, Stadium, Art gallery, Historic Building), rendered nothing for the 7 that are absent from event.clinic's own vocabulary as well, and every city page rendered the raw key for all of them. /venue-types?locale=hu returns byte-identical English to ?locale=en — the same accepted-and-silently-ignored shape the city page already documents for ?city= — so there is no upstream Hungarian to wait for and the fallback can never resolve to any. All 47 in-use codes are seeded where 18 were; the 29 new ones land at machine for a speaker to clear at /admin/copy/hu, which is 29 more unreviewed strings and still strictly better than English on a Hungarian-first page. The earlier note said translating the tail meant *inventing Hungarian for zoo_aquarium*; measured, that is Állatkert vagy akvárium, a dictionary entry. The second way in: /directory/type/<slug>, 26 pages, sitemap 1 290 → 1 316. ACCEPTED-sibling-rulings.md §5 permits venue_type as *a display label and as a filter that narrows a list without excluding anything from it*, and V40's A–Z is what makes the second half true — a couple who never opens a type page loses no venue, including the 32 that carry no type at all. Nothing is sold here: no hoistPlaced, no liveSlots, and a check that fails if paid-placement wording ever appears, because selling position inside a bucket an AI assigned is the half of the rule that is not about labels. Types under four venues render but carry noindex, and the sitemap reads the same constant so it cannot advertise a page it then tells a crawler to discard. verify:directory could never have caught the original leak. Its rawKeys detector hunts *dotted* keys and the defect emitted a bare enum token with no dot in it, so the harness one directory over reported forty leaking pages clean for weeks — the V5 lesson exactly. rawTypes is a second detector with its own self-test, matching multi-word codes only, since hotel and museum occur in real venue names and a check that cries wolf gets deleted. Proven able to fail: dropping hu('directory.venue_type.stadium') turned exactly one test red naming *stadium (11 venues)*, green on restore. And the first deploy shipped a raw key anyway, which is the finding worth keeping. aa3c8a51 served directory.types_title as its own name on the public index. Seeding copy to production before deploying code that reads it is the documented rule, it was followed, and it is not sufficient: next build prerenders every route with no dynamic parameters against .env.local, so the statically generated HTML that ships carries the *build machine's* catalogue. The dynamic routes beside it were correct from the first byte — generateStaticParams returning [] renders per request against production — which is the tell. Local seeded, rebuilt, redeployed as 6e20c914, artifact grepped rather than assumed. DEPLOY.md step 5 now says so
V42The couple lens runs in HungariancompleteNo deploy and no migration — this is a harness slice, and the product it checks did not change. verify:couple-lens signed in as couple@vela.example, walked that couple's eleven pages and printed *No professional term reached the couple lens.* That couple's wedding is the English one. The seeds also create calm.couple@vela.example, who owns the Hungarian wedding, and nothing reached it — so the command DEPLOY.md prints, on a Hungarian-first product, swept eleven English pages and reported green. The Hungarian sweep was real and it was opt-in, through VELA_COUPLE_EMAIL, an environment variable named in no document. That is how V5's 27 177-character Hungarian measurement was taken, and it is why running the documented command could not reproduce the figure. Nothing was wrong with V5's number; what was missing was any way to get it again without knowing the trick. The file already carried the argument against itself. The lang assertion says it is asserted per wedding rather than against a constant *because the sweep runs for a Hungarian couple and an English one* — describing behaviour the data did not produce, so that branch had exactly one value to compare and could not have caught a Hungarian page claiming lang="en". A comment admitting a rule is not a defence against it, which is the third time this repository has written that sentence. Now: every account owning a wedding is signed in, queried rather than listed, because a hand-maintained list of couples rots the way the hand-maintained list of routes beside it rots and that one already needed its own guard. A seed adding a third market is swept the day it lands. The default run went 28 218 → 53 854 characters, en and hu, and 22 couple pages instead of 11. The guard is the point, not the extra pages. The run refuses to report clean if any locale holding a wedding went unswept, asked of the database rather than compared to a literal ['hu', 'en'] that somebody would have to remember to edit. VELA_COUPLE_EMAIL still narrows deliberately and relaxes the guard to what was asked for; a run with no variable set gets no such licence. Proven able to fail: restoring the original defect — signing in as couple@vela.example alone — exits 1 with *Swept en but the database also holds weddings in hu*, and green on restore. What it did not find: no professional term reaches either locale, and no page mismatches its own lang. The Hungarian pages were already clean — the defect was never that they were dirty, it was that nothing had looked
V44One command runs every harnesscompleteNo deploy. The consequence of the GitHub Actions blocker, addressed rather than waited on. Actions has refused to start since 22 August over billing, and OWNER-ACTIONS-2026-08-22.md §1 says to verify by hand until it is fixed — which means six commands, different environment variables each, two needing a production build served locally, in an order that matters because verify:deployed must run first (every other check can pass against last week's code). Nobody runs six commands in the right order every time; they run two and infer the rest. pnpm verify:all runs all six. It exits 1 on failure, 2 if anything was skipped, and 0 only when every check actually ran and passed — because *a check that did not run is not a check that passed*, which is the defect found in four separate harnesses this week. Exit 2 is the convention verify:directory already used for could-not-run, reused rather than invented. --allow-skips is a flag you pass, never a default you inherit. Its first run found two things, and the second is the one worth keeping. verify:placements was pointed at @vela/db and lives in @vela/app, so it had been unrunnable — and once fixed it printed *Aranymetszés Fotó — bought rank 1, showing at 4*, which reads as a live commercial defect and is the most alarming sentence this repository can produce. It was false. The runner had handed it the local database and the production URL, the two defaults sitting next to each other; the local seed holds three placements and production holds none, so the harness faithfully compared a fixture on one machine to a page served from another. Pinned to a matched pair now, with the episode written into the code beside it: aiming a suite's data at one environment and its assertions at another is how a green run lies and how a red one cries wolf about money
V46The 404, measured properly and half-fixedpartialThe overnight note called this a Cloudflare defect. It is not, and the correction is the finding. It reproduces identically under a plain next start, so OpenNext was never involved. Measured four ways at runtime, which is what the earlier session should have done: a not-found.tsx at app/, at app/(hu)/, at app/(hu)/directory/, and with the page forced dynamic — all four serve <html id="__next_error__"> with an empty body for a notFound() call. The earlier judgement was made by grepping the built _not-found.html, the wrong artifact: a segment not-found never appears there, it is rendered per request, and only a running server can answer the question. The cause is what V34 costs. Two root layouts and no app/layout.tsx leave Next no layout to compose a notFound() boundary into, so it falls to the bare shell wherever the file sits. What did get fixed: a URL matching *no route at all* now renders the real Hungarian page instead of Next's English *404: This page could not be found.*, and the robots tag is noindex, nofollow alone where production served noindex and index, follow together. Not worked around with a soft 404. Rendering at HTTP 200 would give the human a good page and hand a crawler up to 1 228 junk URLs; on a 1 316-page indexed directory that is the worse trade by a distance. The status line is what a stale sitemap URL turns on, and it is correct. The dead (hu) file was deleted rather than left as decoration, and no assertion was left for the body — it would be red the day it was written. The architecture question is answered, 2026-08-29: keep the split. V46 left *is V34 still worth its price* open, so it was measured rather than argued. (en)/layout.tsx reads headers() to pick a lang, which costs nothing there because every (en) route is already force-dynamic; (hu)/layout.tsx is a constant, and that is precisely what keeps the 1 269 indexed pages statically renderable and behind the V35 KV cache. Merging to one root layout has two outcomes and both are worse than a blank 404 body: read headers() and every route in the application turns dynamic — /directory measured falling from ○ … 15m to ƒ, which is 41 pages each paging the whole country, ~533 upstream requests a window — or hardcode one lang and reinstate the V33 defect where the entire public surface served Hungarian inside lang="en". So the blank body is the honest price of a good trade, not an outstanding bug, and this row stays 🟡 as a record of a cost rather than a queue item
V47One couple cannot read another's weddingcompleteThe most consequential invariant in the product, and nothing tested it. The couple lens proves no jargon and no English reach a couple; verify:money proves the billing constraints refuse what they should; verify:directory proves the public pages render. None of them asked whether the person reading a wedding is entitled to it. verify-directory.ts gets closest and stops short — it checks cache headers on /vendor, /sign-in and /welcome under a comment calling that *the check with the worst failure mode on this list, one person handed another person's wedding*. Those three routes carry nobody's wedding. The eleven that do were never asked. verify:isolation mints a session for every wedding-owning account and requests every other couple's eleven routes with it: 22 cross-couple requests, all refused, plus an unauthenticated request per wedding, all 307. The positive control is load-bearing — each owner also reads their own plan page, because if session minting or the base URL broke, every cross-request would 404 and the run would report perfect isolation while testing nothing. Proven able to fail: removing the line that skips the owner's own wedding turns all 22 red, naming the couple, the route and the first eighty characters of what leaked. Found nothing. Authorisation is sound, and that is the point of writing it down
V48Three harnesses stop overstating themselvescompleteNo deploy. Every one of these was a check whose output claimed more than its input. visibleText threw away every attribute a reader meets — alt, title, placeholder, aria-label went out with the tag that carried them, and both the couple lens and the directory sweep build every assertion on that function. So a placeholder="Run of show" was invisible to the harness whose own self-test uses that exact phrase to prove it can see, and an English alt on a venue photograph was invisible to the sweep that exists to find English on Hungarian pages. The first fix substituted the value in place and read empty every time, because the words were still between the angle brackets when the next line stripped them — the test caught that, which is the only reason it is a comment and not a second silent hole. Measured after: the couple lens went 53 854 → 54 497 characters, which is the attribute text arriving rather than a claim that it did. **verify:deployed compared the twelve *largest* of sixty-nine client chunks and said "byte for byte".** The largest are React, the polyfills, webpack and main-* — the bundles whose hashes do not move when application code changes — while the per-route chunks sat at the tail, unsampled. It now takes an even stride through the sorted paths, still deterministic, and says what it actually proved: *12 of 69 client chunks match production byte for byte. The Worker bundle is not compared.* On a product whose (en) routes are all force-dynamic, that Worker bundle is where the logic lives, and naming the gap is worth more than a sentence that covers it. The couple lens swept three invitations, chosen by uuid. Three arbitrary rows is the *three probes is a guess* shape, inside the file that wrote that sentence down — and worse, invitations never touched localesSwept, so the V42 guard that refuses to report clean on an unswept locale was structurally unable to see them. All ten are swept now, 54 497 → 58 521 characters, they count toward the guard, and the card's lang is asserted for the first time — it was declared only on the shell and nothing had ever checked it
V49A private page can never become cacheablecompleteNo deploy. The property with the worst failure mode in the product, asserted where it can be. verify-directory.ts checks cache-control on /vendor, /sign-in and /welcome under a comment calling that *the check with the worst failure mode on this list* and describing the failure as *one person is handed another person's wedding*. None of those three routes carries anybody's wedding. The eleven under /wedding/[id], plus /rsvp/[token], /reply/[token], /write/[token], /pro/[id] and every /admin screen, were never checked — and that harness *cannot* check them: it holds no session and no token. So it is asserted in the source instead, over every route at once. The failure it guards is a one-line change that reads as a performance tweak: export const revalidate = 60 on wedding/[id]/guests/page.tsx turns the V35 KV cache into a shared cache of a private page, and the next couple to ask for their guest list gets somebody else's for up to a minute — while every automated check in the repository stays green, including V47's isolation harness, because its own requests are correctly authorised. It is the cache in front that hands the page on, and no request-level check can see that. (hu)/directory is exempt and named as such, because it is the one surface deliberately cacheable; directory-is-static.test.ts beside it guards the other half of that bargain. Proven able to fail: swapping force-dynamic for revalidate = 60 on the guests page turns it red naming the file, green on restore. Each it() carries its own non-empty guard rather than relying on a sibling, because vitest runs them independently. Also closed: robots.txt was checked for three of its nine Disallow rules — the six skipped included /pro/, /write/ and /admin/ — and the check now reads the served file rather than a second hand-typed list, since a hand-typed list is exactly what this repository keeps finding rotted. And a city page linking to no venue used to contribute nothing to the sweep and say nothing about it
V50Five money constraints nobody testedcomplete**No deploy. verify:money said *28 money rules hold* while five constraints on billing had no probe naming them at all — found by asking pg_constraint what exists and diffing it against what the file names. The five: agreement_commission_sane, credit_ledger_delta_nonzero, plan_price_not_negative, revenue_event_signs_agree, revenue_event_vat_rate_sane. A migration weakening any one would have shipped green — a commission above 100%, a negative plan price, or VAT charged in the opposite direction to the net, all accepted, all reported as rules holding. Now 34 rules. Writing the probes proved the harness's own design three times over.** The first attempt was refused by a *type* error, the second by a not-null column, the third by revenue_event_amounts_agree firing before revenue_event_signs_agree — and each time the run reported refused by the WRONG rule, naming what actually refused it. A harness that accepted *any* refusal would have marked all three green and added coverage that tested nothing. That is the whole argument for asserting the constraint by name, demonstrated by accident. The signs probe now uses −1000 + 270 = −730, so the amounts still add and only the sign rule can refuse it. And the list is now checked against the database rather than maintained by hand, which is the same lesson RAW_TYPE_PATTERN cost: a run fails if any constraint in billing or marketplace.quote_acceptance is unnamed by a probe. Scoped there deliberately — media_asset_credit_required_for_licensed and venue_capacity_range_is_sane are real constraints and are content rules, and pulling them in would make the assertion red on arrival, which is how a check gets muted and then deleted. Proven able to fail: deleting one probe reports *1 money constraint exists with no probe naming it: plan_price_not_negative*
V51The copy gate could not see a localecomplete**No deploy. copy-values exists to answer *is the value there* before a page renders it** — its docstring cites the V19 incident where a crawler indexed a raw key, and its exit code is documented as something that *can gate a deploy rather than merely inform one*. It built its found-set from the key alone and discarded the locale. So a key present in English and absent in Hungarian counted as present and the command exited 0 — on a Hungarian-first product whose only indexed surface is Hungarian, the exact failure it was written to gate, passing the gate. A missing row is quieter than a raw key and just as wrong: the fallback chain serves English. It now reports NO HU separately from MISSING and exits non-zero on either, defaulting to hu because that is the market VELA is open in, with --locale= for anything else. Proven on a real row: service.product.consult_budget_check.description exists in English, has no Hungarian, and now exits 1 saying so where it exited 0 before
V52Four echoes, and a check I wrote that could not failcompleteNo deploy. The finding worth keeping is the last one, and it is mine. robots.txt advertises two sitemaps and this harness fetched one. The unchecked one is the Astro marketing site's — and **this file's own docstring names *a 500 on /robots.txt and /sitemap.xml* as one of the two defects that motivated writing it**, then fetches /robots.txt and /directory/sitemap.xml and never the file in its own sentence. It is also where the collision would return, since static assets in public/ are served ahead of the Worker. Now fetched, asserted to be a sitemap, asserted not to be the *directory's* sitemap, and all three URLs resolved rather than sampled — a sitemap advertising a 404 spends somebody's crawl budget on nothing. 40 of 40 city pages swept clean was an echo. Both numbers came from the same parse of /directory, so the line said *everything I found, I checked* — equally true of a run that found four. The right-hand number now comes from the sitemap, which is built from the same citiesOf call and fetched in the same run, so disagreement is a finding. And the check I wrote for that could not fail. It sat below the if (failures.length > 0) gate, next to the summary line it fed, so check() pushed onto an array that had already been read: the finding was collected, never reported, and the run exited 0 regardless. Caught by sabotaging the sweep to visit three cities and watching it stay green — a check that cannot fail, written during a review of checks that cannot fail, by the person doing the reviewing. Moved above the gate, re-sabotaged, and it now reports *36 city page(s) are in the sitemap and not linked from /directory* and names them. Every other harness was then swept for the same shape: zero push() calls after a gate in any of them. Two smaller ones. migrate.ts printed a table count and gated only the schema count, so ten schemas could all exist with a migration's worth of tables missing inside them — precisely the database reconcile.ts can produce, since it writes migration history on the operator's word without checking the schema. Floored at 70 against a measured 76, a floor rather than an exact number because a check that must be edited on every migration gets edited without being read. And public-markets.test.ts compared publicMarkets() to publicMarkets() — an argument-free deterministic function against itself, proving determinism that was never in doubt and passing whether the answer was right, wrong or empty, while the Astro sitemap is built from that function. It now names the answer: ['hungary']
V53A–Z pagination, counted rather than de-duplicatedcompleteNo deploy. Measured first: 1 228 venues across 21 pages, each appearing exactly once, matching the sitemap exactly — no defect, and nothing guarding it. The existing reachability check collects slugs into a Set, so a venue listed on two pages and a venue listed on none are indistinguishable to it; it can only ever see the second. Pagination is a slice over a sorted list and 21 pages means 20 boundaries, each able to repeat a row or drop one. Both halves are now asserted. The first run of the new check reported five venues the sitemap had forgotten — and all five were fiction. bilanx-re, vacduk, horvath-lovastanya-rendezvenykozpont-es: every one 404s, because they are fragments of longer slugs. The RSC flight payload repeats each href as data and splits long strings across chunk boundaries, so matching the raw body harvests truncations. The Set version absorbed that harmlessly — it only ever asked whether a *real* slug was present — and counting asks the opposite question, which turns the same junk into findings. Routed through hrefs(), which strips <script> first. This is the defect that helper's own docstring records — 422 venues counted on a Budapest page listing 415 — arriving a second time in a second place
V54A reviewer could not be made without making an admincompleteFound by answering a question rather than by auditing. D-10 says a translator reaches /admin/copy *without being handed the keys*, and the policy implements exactly that — adminArea() admits internal_support or any content grant. Nothing could create a content grant. grant-role writes identity.user_role and accepts only internal_admin and internal_support; invite-copy writes content.copy_assignment, the tokenised /write/<token> composing flow, which is a different thing. So the only routes to a Hungarian reviewer were making them internal_support — every admin screen, including the one that sells things — or hand-writing SQL against identity.content_grant while somebody waited. **A decision recorded in the policy and unreachable from outside becomes *just make them an admin*.** grant-content is the missing half: --email --role content_reviewer --locales hu, refusing to create the account for the reason grant-role refuses (a typo would grant review rights over a language to an address nobody controls, and email is citext, so that row would capture the real person's first sign-in). Updates rather than duplicates a live grant, since two rows for one role make *which locales does this person cover* ambiguous and the policy reads every row. A unit test pins the role list against ContentRole, because @vela/db must not import the policy engine and a role the policy does not recognise produces a grant that admits nobody, silently. Verified in a browser, and it corrected the script's own first claim. A content_reviewer reaching /admin/placements and /admin/vendors gets 200 with zero forms and a sentence saying the sign-in does not carry that — D-10 working as written. But the nav renders, so *copy and nothing else* was wrong, and /admin/revenue renders read-only for any content grant. Harmless today because nothing has ever been charged; the moment prices land, a translator hired for one language can read the revenue. Filed as OWNER-ACTIONS §7 with two honest options, to be settled before the prices, because afterwards the question is retroactive
V55A translator could read the revenuecompleteDecided and applied rather than left as a question. V54 filed this as OWNER-ACTIONS §7 with two options — leave it, or guard it like /admin/placements. Guarded, because the precedent is unambiguous and the direction is the safe one. /admin/revenue gated on admin_area alone, which admits any content grant — that is D-10 and it is right for /admin/copy. Measured in a browser 2026-08-29: a content_reviewer hired to approve one language opened /admin/revenue at HTTP 200 and read billing.revenue_event across all four streams. It was harmless only because nothing has ever been charged, and the plan prices are the next thing to land — so it was settled before the prices rather than retroactively, which is the whole reason not to wait for an answer. The argument is V28's, one screen along. placementSale() exists because admin_area admits a translator and selling rank 1 on the Budapest page is not translator work; knowing what the business earns is not a smaller thing than knowing what it sells, and *read-only* is not the distinction that matters. New revenue_figures resource requiring internal_support, mirroring placement exactly. Refused as a sentence, not a redirect, matching the placements page: somebody legitimately holding a content role who clicks Revenue in the nav is told what happened rather than bounced to a sign-in form that cannot help them. Verified end to end with a positive control: content_reviewer → /admin/copy 200 (D-10 intact), /admin/revenue 200 with the refusal and no figures; internal_admin → figures visible. Without that second half the guard could have been breaking the page for everyone and looked identical. Three policy tests, including one for a signed-out caller
V56The lead-metering harness, and why the route was never testablecompleteWritten, wired, and honestly reporting could-not-run. /reply/[token] was the highest-consequence uncovered page in the repository, and the standing explanation was that opening one charges a supplier a lead credit so a harness would spend money. True, and not the blocker. Measured 2026-08-29: nothing in packages/db/src/seed/ creates an enquiry at all. The only enquiry rows in a developer database are hand-inserted leftovers from an earlier session, and all four point at a wedding_id that no longer exists — so loadReply's inner join to wedding.wedding_project finds nothing and the route returns 404 for every one. The flow has never rendered locally. That, not the billing, is why nobody tested it, and it took writing the harness to find out. verify:lead-metering opens the link three times and asserts the supplier is charged exactly once, to the right account — the rule recordSupplierOpened enforces with on conflict do nothing, worth real money in both directions: twice bills a supplier for a lead they received once, zero gives the product away. verify:money already probes the partial unique index, and a constraint is not a behaviour — that probe says nothing about whether the page opens or whether it charges the right vendor. Local-only with the same refusal as its siblings, and it deletes the ledger row it creates so it is repeatable. **It exits 2 and verify:all prints *7 of 8 checks ran and passed, 1 did not run* — which is the runner earning its design on a real case rather than a hypothetical. It goes green the day an enquiry seed exists. Schema gap found on the way: marketplace.enquiry.wedding_id has no foreign key, where venue_id and supplier_id both have one with ON DELETE CASCADE. That is why four orphaned rows could exist. In production a hard-deleted wedding would leave its enquiries pointing at nothing and a supplier's reply link 404ing silently, possibly after the lead was charged. Not applied tonight: it is a production schema change on the money path, it fails while violating rows exist, and the honest sequence is measure, clean, then constrain. OWNER-ACTIONS §8. CLOSED 2026-08-29 by V57's seed: the harness is green and repeatable.**
V57The enquiry seed, and the prices are agreedcompleteseed:enquiry makes one enquiry a supplier can actually open, and verify:lead-metering went from could-not-run to green: *opened the reply link 3 times; charged exactly once, to the right vendor.* pnpm verify:all now reports 8 of 8 ran and passed, nothing skipped — the first time the money path has ever been exercised outside production. One enquiry, sent, from a real wedding to a supplier that holds a vendor account: without the wedding the page 404s, without sent no reply form is offered, and without the vendor account recordSupplierOpened charges nothing and exits cleanly, which is correct for an unclaimed listing and would make a green run mean nothing. Local only, with no :prod script, because a fabricated enquiry on production is a lead a supplier can be billed for and a couple never sent. No reply token is seeded — only a hash is stored, and a seed holding a usable link is a seed that leaks one. The sabotage found something better than expected. Removing on conflict do nothing from recordSupplierOpened did not double-charge; it returned HTTP 500 on the second and third open. So the partial unique index is the real guard against billing twice, and the clause is what makes a supplier's second visit succeed rather than error — a distinction no amount of reading would have produced. And the harness was not repeatable at first. recordSupplierOpened sets status = 'seen', so selecting on sent ran green once and reported could-not-run forever after — caught because the sabotage exited 2 where 1 was expected, and *the sabotage proved nothing because the harness never reached it*. It resets the status now; safe against enquiry_wedding_supplier_uq, which covers sent | seen | replied, so moving between two of them keeps the same pair in the same index. The plan prices are agreed (owner, 2026-08-29): free, 9 900 Ft, 24 900 Ft, unchanged figures, now without provisional: true. Seeded to production — 0 of 3 rows carry provisional, so /vendor prints prices instead of a warning. The seed's own closing line said *Every price is PROVISIONAL. Nobody has agreed these* unconditionally, and printed it directly beneath its own 0 of 3 measurement; it reads the count now
V58The document event.clinic can act oncompletedocs/REQUEST-TO-EVENTCLINIC-2026-08-29.md, re-measured the same day across all 1 228 published Hungarian venues rather than cited from the ledger. Three requests, ordered by how much of the work is already done on their side. city is null on 181 and the town is not missing — it is in the wrong field. 40 of 40 sampled null-city venues carry the town as the first component of address_line: "Budapest, Kálvária tér 1, 1089 Hungary", "Tét, 9100 Hungary". Four of the six worked examples are in Budapest, the largest town in the set. We do not parse it and say why: R6 forbids treating an upstream label as a key, and deriving city ourselves would mean silently disagreeing with them later about where a venue is. seated_max_pax is on the list endpoint only, unclassified by either map, and its 15 values are per-room rather than per-venue — Müpa Budapest reads 150 against a hall seating about 1 700. capacity_max_pax is 0 of 1 228 and the ask is only whether it is coming, since *silence* is the one answer that cannot be worked with. Both curl commands in the document were run before sending and reproduce their claims. It also states plainly what VELA is not asking for — no proposal-queue slot for venue_type, no holding back the AI descriptions, no credential and no private endpoint — because being exact about that is what makes the three requests credible
V59The foreign key enquiry.wedding_id never hadcompleteApplied to production 2026-08-29, migration 0023. venue_id and supplier_id have had one since 0000, both on delete cascade; wedding_id had none, so an enquiry could outlive its wedding — and loadReply inner-joins wedding_project, so an orphan makes /reply/[token] 404 for a supplier whose lead may already have been charged, on the very page the debit happens. Measure, clean, constrain: enquiry-integrity:prod reported production carrying 0 enquiry rows, so a validated constraint applies with nothing to scan and no lock worth avoiding. Writing the script corrected its own first claim. It said soft-deleting orphans was enough because the constraint would be deleted_at-blind — exactly backwards, since a foreign key checks every row in the table. Proven by attempting the constraint after soft-deleting four rows and watching it refuse, naming the same missing wedding. --fix keeps the record for real data; --purge is the one that unblocks the migration; the difference is the point rather than a footnote. It also reports whether the constraint exists, because a migration record says a file ran and only pg_constraint says the rule does. The constraint caught something on its first run. verify:money's fixture inserted an enquiry with wedding_id = gen_random_uuid() — a wedding that could not exist. Harmless for as long as nothing forbade it; the new key refused it immediately. The suite that exists to prove the money rules hold had been manufacturing the exact orphan they now forbid. Fixture given a real wedding inside the same rolled-back transaction, and the rollback proof widened to count it
V60The market a visitor's country puts them incompleteCloudflare already sends CF-IPCountry on every request at no cost — no lookup, no dependency, no third party — and /api/where-am-i was already reading it. Middleware now forwards it once as x-vela-country, so exactly one place knows the header's name. The safety property is that opening a market stays a review, not a geo-lookup. marketForCountry answers from publicMarkets(), which returns only markets whose tradition module a native speaker has reviewed — today Hungary alone. A Polish visitor gets null until that review happens, and when the Polish module is signed off, Polish visitors route there with no code change here. A test pins that: it asserts the open set is exactly ['HU'] and that PL and DE return nothing, so the day it changes, it changes deliberately. Geography sets a default and never a restriction. Nothing blocks, redirects or hides on it, and the fallback for a visitor we cannot place is Hungarian rather than English — VELA is open in Hungary and nowhere else, so English would be a habit rather than a decision. Accept-Language is honoured only when it names a language an open market speaks. A Hungarian couple planning from London is in London by IP and Hungarian by intent
V61The front door is the marketplacecompleteDeployed 2026-08-29 as Worker 3195c123; velawel.com now serves 1 228 real venues instead of a scroll animation. The owner's judgement was that the animation did not carry its weight and the product's own directory is the better introduction — a couple arriving at a wedding portal is looking for venues. The page renders 24 towns with counts, 12 venue types, 12 venues with photographs, all from event.clinic, in Hungarian, verified on production. Dynamic, and it is the only page here that is. It reads the visitor's country, so it cannot be one cached page — a page that varies per reader and is stored once serves the wrong reader. The directory's 1 269 statically rendered pages are untouched: the page is dynamic, the data is not, since allVenues is cached for fifteen minutes either way, so a render is a few cache reads and no upstream request. The Astro home is unstaged, not deleted. /plan and /weddings-in/hungary still come from it and restoring the old front door is one deleted line — but a static asset in public/ is served ahead of the Worker, so leaving index.html there would have silently won and the Next page would never have rendered. Two mistakes worth keeping. {count, number} threw *Argument "count" has a type but no branches* on the first render: this project's formatter does plurals and nothing else, so grouping moved to the call site. And the comment explaining that claimed Hungarian writes *1 228* — it does not. CLDR sets minimumGroupingDigits: 2 for hu, so four digits are written 1228 and grouping begins at five, 12 280. The code was right and the comment was wrong; Intl settled it
V62The suppliers, and what the data lets them bepartialMeasured before anything was built. /companies?country=HU returns 1 738 companies, and across all of them: legal_name 100%, website 72.6%, trading_name 0%, sectors 0%. /companies/detail returns the same six fields and there is no /sectors endpoint to join against. So a supplier here is a name and usually a website — no category, no town, no description, no photograph. **A couple cannot ask this for *photographer in Eger*, so the page does not pretend they can.** /directory/suppliers/<n> is an alphabetical index linking out to each business's own site, and its own lede says categories do not exist yet. Useful for *who exists*, useless for *who does what*, and saying so is the difference between a thin page and a dishonest one. noindex: 18 pages of names with no description is what a search engine calls thin content, and the businesses' own sites are the ones that deserve to rank. Deriving a category from the name is refused. Roughly a third of these legal names contain a trade word — Fotó, Virág, Zenekar — and classifying on that is what R6 forbids: a coincidence of language treated as a category, confidently wrong in a way no reader could correct. Filed as Request 4 with event.clinic instead, with the three asks ordered by preference. Hungarian collation throughout, and initials folded so Árnyék files under A and Őszi under O — otherwise a reader looking under A finds it empty. Non-letters file under #, which is where +52 Event & Gastro Hall belongs. Partial until sectors arrives: the surface is as good as the data allows and the data does not allow much
V63The front door, designed rather than assembledcompleteDeployed 2026-08-29 as Worker 910784e5. V61 made / the marketplace and it looked like an RSVP invitation with a list stapled to it — because it reused public-surfaces.css, whose .rsvp__shell is a 560px single column built for a guest on a phone who will visit once. The messaging came back. V61 had dropped the Astro site's positioning entirely; the hero is *Esküvőtervező, amely tudja, hol vagytok* — the Hungarian of *a wedding plan that knows where it is* — with the original argument beneath it, and the two claims that justify it: only the civil ceremony marries you, and every claim is read by someone who grew up with it. Inventory first, argument second, because a reader who came for venues should get venues. Built on @vela/ui/tokens.css, not a new palette. Warm paper #fffdfa, warm inks, the existing terracotta — the identity was already chosen and this adopts it. New home.css for what the RSVP sheet cannot do: a 1080px shell, a photo grid at 4/3 with loading="lazy", browse chips carrying counts because *Budapest 415* tells a reader something *Budapest* does not. Two things caught by opening a browser rather than trusting a green build. The stylesheet used var(--v-*) and never imported the tokens — computed styles came back Times at 16px with black borders, and a typecheck would never have said so. And .home__label exists because the browse headings first reused the claim style, setting *Település szerint* at the same weight as the page's argument: a filing label competing with a position. Single-theme, matching the rest of the product, which defines no dark palette anywhere
V64Four owner decisions, and the review list they requirecompleteTaken 2026-08-29, in answer to four questions where guessing would have been worse than asking. D-64a — self-serve couple signup is next, and it is the largest slice left: a couple cannot create their own wedding today, and /welcome telling them somebody must link their account is true and useless as an instruction. It is the single biggest gap between VELA and Zola or The Knot. D-64b — fold the Astro site into the app. / is already Next; /plan and /weddings-in/hungary follow, in Hungarian. Carry check-html.mjs and check-font-coverage.mjs across first — both have caught real defects and folding without them loses guards nothing else provides. D-64c — keep writing Hungarian at machine, on the condition the public half is reviewable first. The owner chose shipping over holding surfaces back, so copy-priority makes the condition runnable: 103 of 632 unreviewed strings render on a public, indexed page — 70 directory.*, 17 home.*, 16 legal.* — and the other 529 sit behind a session. The count everyone quoted was 632, which is not actionable; 103 is a morning. Judged by namespace rather than guessed, because a wrong guess sends a reviewer to the wrong strings. D-64d — VELA does not build its own supplier categories. Stay a pure consumer of event.clinic; a second source of truth is the thing not to build. Asked instead in docs/MESSAGE-TO-EVENTCLINIC-supplier-categories.md, which states plainly that we refused to derive categories from company names — a third of them contain a trade word and R6 forbids exactly that
V65Self-serve couple signupcompleteDeployed 2026-08-30 as Worker d861d5e6, no migration; the production catalogue was seeded first because the new page reads nineteen keys that did not exist there. Order confirmed rather than assumed: copy-values:prod --key ui.start.title answered MISSING before the seed and the real sentence after it, in both locales. D-64a, and the largest slice that was left. /welcome told every new account that *"VELA doesn't yet let you create a wedding yourself. Someone needs to link your account to one"* — true, useless as an instruction, and the end of the road for anybody who arrived unaided; it is why a stranger could not become a user. It is now a three-field form, and the plan the engine computes is the plan they land on. createWedding in @vela/db is the one path that makes a wedding and the seed calls it too, so the path exercised on every laptop is the path production runs — the alternative was two implementations drifting apart the day the schema gained a required child row, which is D-64d's second-source-of-truth objection arriving through a different door. /welcome/start is a route handler rather than a server action, so a plain <form method="post"> reaches it: no client JavaScript on a stranger's first page, and — the reason that mattered — a harness can drive the route a person uses instead of inserting the state the journey was meant to earn. verify:signup walks the whole thing over HTTP: magic link, session, form, plan, and it asserts the number of task titles rendered on /plan equals the couple-visible rows in the database, which a 200 cannot. Five refusals — a past date, 31 February, an empty name, 9 999 guests, no session at all — must each leave zero weddings behind, because a validation message with a row behind it is not a refusal. Proved it can fail four separate ways: dropping the past-date guard (10 failures, the cascade naming *created a wedding anyway*), breaking the form action (1), truncating the plan to five tasks (1, and it surfaced an FK violation rather than the count mismatch — honest, and a different bug than the one aimed at), and dropping one task from each rendered phase (*renders 33 tasks; the database holds 43*). Two defects were found by opening a browser and by nothing else. /welcome had no stylesheet at all — no layout, no import — and had been rendering Times at 16px for as long as it existed, on the surface that decides whether VELA looks serious; it typechecked, built and returned 200 throughout. And request.url is not the address a request was made to: under next start it reports localhost whatever Host arrived, so an absolute redirect built from it hands a newcomer a session and then sends them to a different origin — a different cookie jar — and they arrive at their own new wedding signed out. Both signup redirects are relative now (RFC 7231 §7.1.2, checked rather than assumed), which is right on all five hostnames without knowing any of them, and verify:signup asserts it. The harness had been green through that defect precisely because it reads the path out of a Location and re-fetches against its own base. **ui.signin.lede said *"VELA internal tooling. Access is granted per person."* — the single sentence that turned a stranger away at the door — moved by copy-set compare-and-set, because the seed inserts and never renames. The Hungarian rows for the same two keys did not exist, so the seed inserted the new text there directly: one key, one commit, two locales, two different mechanisms. No migration — the 13th slice needing none. / gained the way in it never had: home.plan_cta points at /sign-in, because /welcome refuses anybody without a session. Known and deliberate:** a wedding's date is set once and nothing rebuilds a plan when it moves, which self-serve makes visible for the first time (until now only the seed set dates); the duplicate guard is check-then-insert rather than a unique index, so two simultaneous posts could still make two weddings — a unique index would forbid a second marriage permanently, and a visible duplicate row is the smaller problem; and <html lang> on /welcome is en while the body is in the reader's language on its own subtree, the same trade V36 took deliberately for /rsvp/[token]. A couple starting twelve months out lands on *"Néhány dolog csúszásban van"* — that is the engine being right, not a defect. Re-running seed:wedding cascades: it took the guest households and the enquiry with it and turned verify:couple-lens and verify:lead-metering into honest could-not-runs. Re-run seed:guests and seed:enquiry after it. The deploy was proved and then un-proved, and the second part is the finding. verify:deployed matched all twelve sampled chunks byte for byte straight after wrangler deploy — including app/(en)/welcome/page-…, which had been MISSING an hour earlier and is the clearest possible evidence the slice shipped. A later run, after an unrelated rebuild, went red on one chunk and printed *Production is not running this build* — the most alarming sentence this harness can produce, and false. Measured rather than argued: the deployed and local files are the same 4 020 bytes with identical logic, differing only in webpack module ids and in the hashed ids inside createServerReference(...), which Next mints per build. Three builds of one unmodified tree produced a304ec61a20a4e87, a5b012c1299d8ad8 and da186f60135517dd for that file. next build is not byte-reproducible for any chunk holding a server action, the content hash is in the filename, so a rebuilt chunk is looked for at an address production never had and 404s. verify:deployed now counts those separately and says so — it still fails, because a genuinely absent chunk looks identical from there and excusing a class silently would be the opposite mistake, but it names the likelier cause and says to settle it with wrangler deployments list. Settled: d861d5e6 at 09:07:03Z, 100% of traffic, superseding 910784e5. The lasting rule is that a byte comparison must be made against the artefact that was deployed; a fresh build cannot reconstruct it. The two English sign-in strings needed copy-set, the Hungarian did not — the seed inserts and never renames, and the Hungarian rows for those keys did not exist, so one key edited in one commit landed by two different mechanisms per locale. ui.signin.lede [en] read *"VELA internal tooling. Access is granted per person."* until today. Verified live: / carries the way in (Kezdjétek el a saját terveteket → /sign-in, no raw keys), /sign-in reads the new title and lede, /welcome 307s a stranger to sign-in, and a POST /welcome/start with no session answers 303 to a relative /sign-in and creates nothing — the harness's fifth negative control, run against production.
V45The 404, and the country code nobody readcompleteDeployed 2026-08-28. Found by a subagent audit, verified against production before anything was changed. Three defects on the public directory, all of the shape this repository keeps finding: a machine value rendered because nothing looked at it. HU was printed to Hungarian readers on every venue page. [venue.city, venue.country] put a raw ISO code in ~1 228 indexed <title> tags — 1552 Boutique Hotel — Eger, HU — and on the index the location line joined city · region · country. Measured across all 1 228 venues: region is null on every one and country is never null, so that line could only ever print the town and HU, or — for the 181 venues with no city — the bare string HU and nothing else. country always being present also meant the where_unknown fallback beside it could never fire: the one case it was written for was exactly the case that rendered HU. Both dropped; the fallback now works and reads *Nincs megadva helyszín*. A country line earns its place the day a second market exists. Every 404 served a blank page. No not-found.tsx existed anywhere in the app, so notFound() fell through to Next's default: <html id="__next_error__"> with no lang, an empty body (the 404 sentence lives only in the RSC payload, so a crawler and a JS-blocked reader get nothing), and two contradictory robots tags — noindex from the error boundary and index, follow inherited from the directory layout. Every unknown slug hit it, and so does every sitemap URL that goes stale when event.clinic unpublishes a venue. The harness asserted status === 404 under a comment reading *must 404, not render an empty page that gets indexed* — the requirement written in prose beside a check that measured only the status line. The first fix read its two sentences from the catalogue and the body came back empty anyway — metadata applied, so the file was wired, but the component threw and Next fell back to the same blank shell. That is an argument against the design: a 404 has to render when the database does not, since a stale link and an outage are the two moments a reader most often meets it. The words are hardcoded now, the one deliberate exception to copy living in the catalogue, and /admin/copy cannot reach them. Smaller cost than a blank page. Three harness gaps closed with it: rawTypes ran on the city loop and the type loop and skipped /directory in between, which renders labelFor(code) ?? code and is a raw-token surface exactly like them; the ~21 A–Z pages were fetched only to harvest links, so the six keys that render nowhere else were never checked on any page; and the 404 now has assertions for visible text, lang and robots rather than a status code and a hopeful comment
V43The couple lens sees English, not just jargoncompleteNo deploy and no migration — a harness slice, like V42. V5's defect was that the plan page rendered 43 of its 44 task titles in English while this harness passed, because it hunts professional vocabulary rather than untranslated English: two wrongnesses, one detector. The fix then was a one-off measurement plus a seed-level coverage test, and neither of those reads a page. V41 then shipped the same class from a different source — event.clinic's labels rendering *Amusement park* on a Hungarian page — so the gap is real and it recurs from places a seed test cannot see. The detector is built from the catalogue rather than from a list of English words. A function-word list would catch both and would also fire on Art Quarter Budapest and Science Hotel, which are venue names and correct; a check that cries wolf gets muted and then deleted, which this file already says about its own route list. Built this way it only ever reports a string VELA itself knows is English, so a false positive is a catalogue bug rather than a judgement call. The first version cried wolf anyway, and what it caught is worth keeping. It flagged a key whose Hungarian equalled its English, on the reasoning that identical means untranslated, and fired immediately on category.hu_menyecske_ruha.name and custom.hu_hajnali_tyukleves.name — *Menyecske ruha* and *Hajnali tyúkleves*, the bride's second dress and the soup served at dawn. Both are Hungarian, both are correct, and both are identical in the English row because English has no word for them. In a Hungarian-first product built around tradition modules that is not an edge case, it is the normal shape of a cultural term. The rule is now *missing*, not *identical* — which is exactly the shape that produced V5, where task.* had no Hungarian row at all and the fallback chain served English. Stated blind spots, rather than discovered ones: anything carrying an ICU placeholder, because You have {count} guests never appears literally and matching it would mean reimplementing the formatter; anything under 12 characters or of one word, because Email and OK sit inside Hungarian sentences; and service.product.*, which is the corpus four human writers per language own at /write/<token>. That is the price of never crying wolf. It covers the invitation too, which it should have done first. The detector went onto the couple's eleven pages and not onto the three a guest opens — the same shape of hole V42 had just closed, in a file whose own comment calls the invitation the one page where a stray word costs an actual reply. Proven able to fail twice. A fixture in selfTest proves it sees a key with no Hungarian and stays silent on the *Menyecske ruha* case, on a translated key, and on service.product.*. End to end, deleting the hu row for ui.rsvp.note.plus_one exits 1 naming *If you are bringing someone, tell us their name in the message below.* on the invitation page; restored by re-running the seed and verified byte-identical to what was deleted, because an untested restore restores nothing. Found nothing on the product. Both locales are clean. The success line now reads *no professional term and no untranslated English*, because it said only the first while checking both, and a success message narrower than the run understates it while a wider one is how a harness lies
V66The structural check the marketing pages never hadcompleteThe first half of D-64b: check-html.mjs carried out of apps/site and into this application as verify:public-html, before anything is deleted. The rules are unchanged — one <h1>, no skipped heading level, a canonical without a trailing slash, an absolute og:image, a lang on <html>, every Hungarian term inside lang="hu", and no animated figure left at its start value. What changed is the source: Astro built static files so the check read dist/, and this application renders per request, so it drives a running server and reads the bytes a reader is served. Measured on 2026-08-30, and it is the argument for the whole fold: /plan and /weddings-in/hungary return 404 on a local server and 200 in production. They are static files in public/, and Cloudflare's asset server resolves /plan to plan.html where next start does not — so the two pages this check most wants to read have never been reachable by any local harness, and every check they ever passed ran against dist/ rather than against what a reader gets. It went red on its first run, on seven real defects, which is the best evidence it was worth carrying. Not one page under the (hu) root emitted an og:image, so every link to the directory shared anywhere rendered as a bare grey rectangle; and /directory — the page the whole surface links back to — was the only indexed route with no canonical at all. Fixed with metadataBase and a tracked opengraph-image.png beside the root layout rather than an asset under public/, which is build output and gitignored and would have taken the card with it the day apps/site goes; file-based metadata is inherited, so one file covers the front door and all 1 320 directory pages, and the canonicals became absolute in the same edit. Two rules moved rather than being copied. The Hungarian-term rule now fires only where the document language is *not* Hungarian — marking a Hungarian word as Hungarian inside a Hungarian document is noise, and these pages are in Hungarian now; and a canonical and a share card are obligations of an indexed page, so /directory/suppliers/*, which is noindex, follow on purpose, is exempted from those two and held to every structural rule, with a control asserting the exemption widens no further. One rule did not come across, and that is recorded rather than silent: the inline classList.add('js') marker gated opacity: 0 rules a GSAP timeline animated away, and the Next pages do not rebuild that machinery — nothing here is hidden pending a script, so a check for the marker could never fail, and a check that cannot fail is decoration this repository has removed once before for the same reason. The comment says to restore it with the first reveal animation. Proven able to fail, 13 ways. One clean fixture that must report nothing, then thirteen single mutations of it — a second <h1>, no <h1>, a skipped level, a missing canonical, a trailing slash, a missing og:image, a relative one, no lang, the wrong lang, an unmarked kikérő, a figure left at 0, a figure with no target, a figure with a second source of truth — each asserting which rule fired rather than that the run went red, because when others passes for any refusal. The count in the summary is cases.length rather than a literal, so it cannot overstate the run. Sweeps 7 pages and asserts every one of the 5 directory shapes in the sitemap is represented, discovered from the sitemap rather than listed, so a new shape joins the sweep the day it appears. Pinned to the local server in verify:all: aimed at production it would describe the page already live, which is a report and not a gate. And it says what it did not sweep. Those two 404 locally and answer in production, so a silent omission would make a local run read as a sweep of the whole public surface when it is a sweep of the part that exists locally — a summary wider than the run, which is the defect this repository has now found in five separate harnesses. The line removes itself when they become Next routes. Run against production it already names its own next slice: /plan and /weddings-in/hungary fail with *<html lang="en"> but this surface is served as hu*, which is D-64b's remaining half stated by a harness rather than by a document. Tenth harness; verify:all now runs 10. Built on 01c131f.
V67The marketing pages, in Hungarian, and apps/site retiredcompleteD-64b, finished. /plan and /weddings-in/<market> are Next routes under app/(hu), written in Hungarian, and the Astro project is gone — 60 files, 78 tests, one build step. Deployed 2026-08-30 as Worker aac711bb. It had to be one deploy, and that is the trap worth recording. A static asset in public/ is served *ahead of* the Worker, so public/plan.html — staged there by apps/site — would have silently won against a new /plan route: the page would build, deploy, pass a 200 check and never render. Adding the routes and retiring the stage step are the same change. The reviewed content was already Hungarian and none of it was rewritten. Measured before writing anything: all 26 domain keys the pages render — the professions, the customs, the registrar's five documents, the booking windows — already resolved to Hungarian, as did day.timing.* and day.offset.*. Only the pages' own voice was new: 51 keys, plan.*, guide.* and marketing.*, seeded to production *before* the deploy because the new code reads them and marketingContent throws rather than rendering a key. offsetMessage finally has its caller. It was added beside describeOffset months ago with a comment saying *"when that page moves onto the catalogue too, this becomes the only one"* — describeOffset built "about 4 hours before the reception" by concatenation down to hour${hours === 1 ? '' : 's'}, which cannot be translated. This is that move; describeOffset has no callers left. The fold bought D-10's other half. Both Astro content modules apologised in their own comments for reading EN_DEFAULTS: *"a static site cannot honour the database is the editable source, because what is built is what ships until the next build"*. These read the live catalogue at revalidate = 900, so a reviewer's correction at /admin/copy/hu reaches the public page in fifteen minutes with no deploy. That is also why verify:font-coverage matters more here than it did there, and it is the eleventh harness: the shipped subset can now fall behind the shipped copy without anybody touching the repository, and it is the only thing that would notice. Proven able to fail by deleting ő and ű from the manifest — red naming both, green on restore — and its remedy came with it, because a check whose fix path was deleted is a check that gets muted: subset-fonts, font-tools and setup:fonts are carried, reading their glyphs from a running server rather than from dist/. Re-cutting produced byte-identical fonts and a manifest differing by one codepoint (\n), so the cut is reproducible. Three things were carried as properties rather than as files. plan.test.ts and hungary.test.ts asserted that professions and customs render as prose rather than as ^[a-z_]+(\.[a-z0-9_]+)+$; they exercised a module that read repository constants, so the same assertion is now a rule in verify:public-html, checked against served bytes — and its clean fixture immediately caught the first pattern matching plan.md in a sentence, which is what a negative control is for. gate.test.ts's review-gate assertions were already duplicated in packages/market-view/src/public-markets.test.ts, so nothing was lost. plan-dates.ts came across with 17 tests, minus countdownPhrase, which picked its own English plural. Two false statements were not carried across. Both pages still said *"VELA is not open yet. Leave an address and we will tell you when it is"* and converted to a waitlist — untrue since V65 shipped self-serve signup that morning, on a public indexed page. The conversion is now /sign-in and /directory, the two things that exist. The *"one country of sixteen"* framing went with it, and with it a hardcoded MARKET_TOTAL = 16 that nothing could check. A bug that only a browser could find. The <h1> rendered *Házasságkötés Magyarország* — grammatical nonsense — because {countryIn} was wired to the nominative. Hungarian case endings are not composable (*Magyarország* → *Magyarországon*, but *Ausztria* → *Ausztriában*), so market.region_in.* is a separate key per market. It typechecked, it built, and all eleven harnesses passed. V49's guard fired, correctly, and was tightened rather than widened. The new pages declare revalidate = 900, which private-routes-are-dynamic.test.ts reads as a private page becoming cacheable. They belong on its allowlist — but an allowlist is how that guard erodes, so a fourth assertion now proves nothing on the list reads a session (@vela/auth or cookies()); adding (en)/wedding to it turns 12 routes red. /sitemap.xml is a route now, generated from the same publicMarkets() gate generateStaticParams reads, so the set of pages and the set of URLs cannot come apart — the Astro version had hungary.astro hardcoded beside a generated sitemap. [market] rather than a file called hungary, for the same reason. The static assets have a tracked home. apps/app/public/ was gitignored wholesale as stage output; the fonts, favicon, robots.txt and manifest would have vanished with apps/site. og:image is app/(hu)/opengraph-image.png instead — file-based metadata, inherited by every route under that root. And one tool went stale in the same commit that made it wrong: copy-priority still filed plan.* under *behind a session or a token*, so it would have sent a reviewer past the two newest public pages while printing a smaller, confident number. Re-measured after fixing it: 702 unreviewed Hungarian strings, 153 of them on a public indexed page — 70 directory.*, 30 plan.*, 18 home.*, 16 guide.*, 16 legal.*, 3 marketing.*. That is up from 651 and 104, and it is the D-64c bargain working as agreed rather than a regression: ship, then review the public half first.
V68CI had been failing for three weeks and said so in a way nobody could readcompleteEvery CI run on every branch had failed since V61, and the reason was a stale assertion rather than a defect. Found by checking gh run list after V67 rather than by anybody reporting it: twelve consecutive failures, including both of V65's. The handout said *GitHub Actions runs again* and *nothing else is blocked*, both true about the billing block that stopped it in August and both misleading — it ran, and it failed every time. The step was The Worker actually carries the site, and it demanded index.html. V61 made / a Next route and stage.sh stopped shipping that file, with a comment saying so; the CI assertion beside it was never updated. So from V61 the job died on a line whose error read *the Worker bundle is missing the site* — alarming, false, and permanent. A check that is always red is a check nobody reads, and it hid whatever else might have broken underneath it for three weeks. V67 took it from one missing file to five. The assertion is inverted, which is the half worth keeping. /, /plan, /weddings-in/<market> and /sitemap.xml are Worker routes now, so the old list asserted that files must be *present* which must now be *absent*: a static asset in public/ is served ahead of the Worker, and a stray plan.html would shadow the route silently — the page builds, deploys, returns 200 and never renders. That is not hypothetical; it is why V67 had to be a single deploy. What must be present is what is genuinely an asset: robots.txt, site.webmanifest, favicon.svg, two woff2 files and the coverage manifest. And the gap V67 opened is closed. verify:public-html and verify:font-coverage needed a dist/ directory when Astro built one and need a running server now — and CI already starts one for the couple lens, so they cost one command each. The directory is skipped there, explicitly and loudly. Its sitemap is generated from event.clinic's live API, and this workflow states as a principle that a sibling's availability must not decide whether the job passes — the readiness probe is /sign-in for that reason. So VELA_SKIP_DIRECTORY=1 is a flag rather than a fallback: a fallback would shrink the sweep whenever the upstream hiccuped and print the same green either way, which is the collect-and-excuse shape this repository has been bitten by. The flag makes the run print NOT SWEPT: every /directory/* shape above the clean line, every time. pnpm verify:all still sweeps them in full before every deploy, where an outage is a person's problem to read rather than a red build. Also re-measured rather than assumed: V46's finding that notFound() from a page reaches no not-found.tsx still holds. A probe file at app/(hu)/not-found.tsx was built and asked for /weddings-in/poland; the text appeared only inside the RSC flight payload and the body was empty, exactly as the four placements recorded in V45 predicted. Two root layouts and no app/layout.tsx leave Next nothing to compose the boundary into. That is now the most visible 🟡 left: /weddings-in/<unreviewed> is a guessable public URL and it returns a blank white page at HTTP 404.
V69The 404 has a page now, and the ones that still do not are namedcompleteDeployed 2026-08-30 as Worker fdf7e1ef, no migration and no catalogue seed. Verified against production with a cache-buster rather than against the deploy's exit code: /weddings-in/poland and /kjhsdfkjh both return 404 carrying one <html lang="hu">, the rendered Hungarian sentence, the .rsvp container and a stylesheet, while /, /directory, /plan, /weddings-in/hungary and /sign-in all still answer 200 — and /directory/city/<unknown> still serves the error shell, which is the open half being visible rather than a surprise. The push needed the owner's hands: git push is refused by the auto-mode classifier and an agent cannot lift it, so the work sat committed-and-unpushed until they ran it. Deploying first was refused deliberately — production would have run a commit nothing could reconstruct, and CI would never have seen it. Three sessions moved not-found.tsx between four locations and the file was never the variable. V45, V46 and V68 each measured an empty body and each concluded the placement was wrong; the conclusion drawn was too general, and re-deriving it cost most of this session. Measured in next/dist/server/app-render/app-render.js rather than by moving files again: without a global not-found, createNotFoundLoaderTree passes an empty component set — literally no layout — and it is reached only from the server-action branch anyway, so an ordinary GET calling notFound() ends at the error path near line 1388 and serves the bare shell. A fifth placement was built and measured to confirm it: (hu)/not-found.tsx with no <html> of its own, which is the one variant nobody had tried, and it fails identically. So does the same file with the root one deleted. Two mechanisms fix different halves. experimental.globalNotFound plus app/global-not-found.tsx, which Next renders as the *page* rather than as a boundary, so it supplies its own document legitimately and needs no root layout — that fixes every URL matching no route. And dynamicParams = false on /weddings-in/[market], which turns an unreviewed market from a page that renders and then refuses into a URL that matches nothing, answered by the above at a real 404: /weddings-in/poland was the guessable public URL V68 ranked as the most visible partial and it now serves a real page. It costs nothing there and could not be done on the directory. The market list is publicMarkets(), compiled in and changed only by a deploy, so closing it adds no coupling the sitemap did not already have; the five directory routes return an empty parameter list precisely so no build depends on event.clinic answering, and closing theirs would 404 the whole directory the first time the sibling was down during a build. A defect nobody had recorded, and it is why this looked fixed for four months. V45's file rendered its own <html> because nothing wraps it — but Next emitted its error shell anyway, so the served bytes carried a second <html> and <body> nested inside the first. Browsers unnest that silently. The outer element had no lang, and two robots tags shipped where V45 is recorded as having reduced them to one. There is one document now. And the 404 was unstyled. Its classes were written against public-surfaces.css and no file on that route imported it — (hu)/layout.tsx does not, and a root not-found.tsx inherits nobody's imports — so every 404 this product has ever served rendered as Times on white. Found by asking the served bytes for a stylesheet link, which is the only way it shows: the markup was right and the class names were spelled right. Eight new rules, 22 planted defects, and two of them caught false greens in this session's own work. The rule written to catch the exact defect the product shipped swept the whole document and passed on an empty body, because <title>Nincs ilyen oldal carries the same sentence and survives having its tags stripped. Planting it found that; reading the code would not have. It reads the <body> and uses visibleText, which strips script bodies, so the flight payload cannot satisfy it. Proved against the product as well as against fixtures: aimed at /directory/city/<unknown>, all four applicable rules fire. The second false green was found by opening a browser, and it is the more useful one. The stylesheet rule went green on a page that was *less readable than the unstyled one it replaced* — dark grey serif on the browser's own dark ground. Nearly every rule in public-surfaces.css hangs off .rsvp: the background, the ink colour, the font, and the html:has(.rsvp) selector that reaches the document. The 404 carried only .rsvp__shell, so the file loaded and almost none of it matched. The three surfaces that share that stylesheet all wrap their children in a .rsvp div from a layout, and (en)/reply/layout.tsx had documented the same trap in its own comment. This page has no layout, so it wraps itself. Asking for a <link rel="stylesheet"> is not asking whether the rules applied, so the harness now asks for the container too — a weak proxy, said to be one in the comment, and the half an HTTP check can reach. The still-blank half is printed on every run as a STILL BLANK line beside the clean one, rather than left to a document — same discipline as V68's NOT SWEPT. A stale claim was deleted rather than carried: verify:directory said the deployed Worker served neither the lang nor the sentence and that OpenNext ignored the built artifact, across three build-and-deploy cycles. Re-measured against production on 2026-08-30 and it is false — velawel.com/kjhsdfkjh returns the real document, byte-identical to a local next start. Cloudflare was never the variable. The trade V46 settled is untouched: /directory still builds as ○ … 15m and /weddings-in/hungary still revalidates at 900s, so V34's static surface and D-10's no-deploy correction path both survive. globalNotFound is experimental and that is a real cost, carried because the alternative is a merged root layout; the harness asserts the rendered body, the single <html lang="hu"> and the absence of the error shell, so the day the flag stops working it says so. pnpm verify:all was 10 of 11 before the deploy, with verify:deployed red for the honest reason that the tree was ahead of production. One claim in this session's own comments was wrong and is corrected: they said the fix left one robots tag. Production serves two — Next's own NonIndex beside this app's — and the improvement is that both now read noindex, where V45's defect was noindex and index, follow contradicting each other. Measured on production rather than assumed from the diff.
V70The 404s that asked to be indexed, and the review queue that hid a third of its public halfcompleteDeployed 2026-08-30 as Worker 2895f8a1, no migration — 24 on disk, 24 on Neon — with the production catalogue seeded first, because the new front-door section reads two keys that did not exist there. The first finding was not looked for: the baseline verify:all was red before anything was changed. verify:directory said *the 404 page carries an indexable robots tag*, and the 2026-08-30 handout had recorded the 404 work as mostly closed. It was not a stale assertion. /directory/city/nincs-ilyen-varos-xyz returned HTTP 404 carrying <meta name="robots" content="index, follow">, under the directory index's own title, on production. Five routes did it, and the fifth is what named the mechanism. city, type and venue on an unknown slug return a bare { title: 'Velawel' } from generateMetadata and could be explained by ordinary inheritance. /directory/suppliers/99999 served index, follow as well — while suppliers/[page] declares robots: { index: false, follow: true } unconditionally, on every render. Its own tag was nowhere in the bytes. So it is not inheritance: a notFound() raised inside a page makes Next discard that page's generateMetadata result entirely and compose the error render from the layout's metadata alone, plus the shell's noindex. The served head carried the layout's title, description, og, twitter and robots on a route whose page had asked for the opposite. That is why it outlived V45's not-found.tsx and V69's rewrite of it — three slices of 404 work moved files around the page layer while the wrong claim was arriving from above it, and why fixing it on any page could not have worked. The fix is one line removed. robots came off app/(hu)/directory/layout.tsx and onto the pages, which are absent from an error render by construction. Probed on production against a control: all five now serve noindex alone, while /directory and the city pages still say index, follow, /directory/suppliers/1 still says noindex, follow, and /directory/all/1 still carries no tag — indexable by absence, deliberately, because stating it there would put the claim back on that route's own out-of-range 404. verify:public-html had named these five in a prose string and swept none of them, on the reasoning that a page with no body cannot pass the body rules. True, and it gave up every rule that needs no body — so four of the five were unmeasured on either environment for as long as the line existed. They are swept for status and robots now, through a notFoundHeadOnly that filters inspect404 rather than restating it, so the two cannot drift. Two more planted defects, 24 in all, and the one that earns its place is negative: a bodiless fixture with every body rule broken at once, which the head-only inspector must report nothing about, since a filter that dropped every problem would pass both positive cases by never firing. copy-priority was wrong rather than merely stale, and that distinction is the slice. V67's own ledger row records fixing it once, for plan.*. The same commit made /weddings-in/<market> public, whose <h1> is built from market.region_in.* — Magyarországon, machine-written, never read by a speaker, rendering at 44px on an indexed page while the tool filed all 92 market.* strings as *behind a session*. Adding market to the namespace list would have been the same harm reversed: 37 of those 92 are market.enquiry.*, the in-app enquiry flow. A namespace with a private half cannot be classified, so the unit is now the dotted prefix, matched on segment boundaries, and the list moved to @vela/i18n/public-reach.ts where the review workbench reads the same one. verify:copy-reach is the twelfth harness and it exists because none of that could fail. It fetches every public surface — from verify:public-html's own discovery, so there is one definition of public rather than two — and looks for the values of keys the list calls private. It found three more wrong on its first run: custom, category and day, ten keys, the Hungarian descriptions of *kikérő*, *vőfély*, *menyasszonytánc*, *kontyoló*, the midnight cake and the *hajnali tyúkleves*, on both marketing pages. Production went from 153 public strings to 186 of 704 — 31 a stranger reads that the review tool was hiding, plus the two this slice wrote. It states what it cannot do. It catches under-declaration only, because a sweep sees the states it visits; and it declines to attribute 236 values under 20 characters and 6 that more than one key shares, and prints both counts rather than reporting clean over them. Its first report was itself narrower than its run — it listed the three market.* prefixes under *nothing of theirs was found* when they had never been eligible, being twelve and fourteen and six characters long. Now separated: *not confirmed* means looked for, *not looked for* means below the floor. The positive control is load-bearing: every assertion is negative, so at least one string the list does call public must be found where it belongs, or a visibleText that returned empty text would report nine clean pages forever. Proven able to fail three ways: dropping custom from the list names its six keys and the two pages; making the reader return empty text fires the positive control by name; and both go green on restore. The reviewer can now reach that half. The workbench had no filter for it at all, so copy-priority printed a number in a terminal and /admin/copy/hu opened on all 704 with an area dropdown mixing public and private — picking six areas one at a time from a printed list is not a queue. ?reach=public filters it, copy-priority links straight there, and the SQL uses starts_with rather than LIKE because market.region_in contains an underscore and an underscore is a single-character wildcard. Verified in a browser signed in as reviewer.hu@vela.example, the content_reviewer persona rather than an admin: 154 rows locally, every one in a declared-public namespace, no ui, no help, no dependency. The link half-works from cold and the tool says so — the host redirect keeps the query string, the admin layout's redirect to /sign-in drops it deliberately, so a cold click lands on all 704 and reads as the tool having lied. copy-priority had also been silently dropping 88 strings past the eighth private area, so its own listed private counts summed to 461 against a stated 549. The two marketing pages are linked from the front door, which is the second item this session was handed and the older half of the same disease. They were in /sitemap.xml and reachable from no page in the product. A sitemap is not a link — this repository learned that with 506 orphaned venue pages, wrote the sentence into verify:directory, and guarded /directory/venue/* alone, so the identical defect recurred on the next pages the product added and was invisible to a harness with the rule written in it. The marketing sitemap is asserted against the front door's own hrefs now: red on production before the deploy naming both pages, 3/3 after. The link text is each page's own title, plan.title and guide.title, so a reviewer correcting an <h1> corrects the link pointing at it and the two cannot come apart — which is also why the section needed two new strings rather than four. The market it links to is publicMarkets(), not marketForCountry: the second list is what /weddings-in/[market] closes its parameters over, and today both are Hungary, which is exactly when to write it correctly. Only a browser found the last defect. Two adjacent .home__cta links rendered with a measured gap of zero pixels — *Az esküvőtök, visszafelé számolvaHázasságkötés Magyarországon*, one unreadable run of Hungarian, because .home__cta is inline-block with no horizontal margin and JSX emits no whitespace between two elements on separate lines. It typechecked, it built and every harness passed. No byte-level rule was added for it: a nav bar and a chip list legitimately emit adjacent anchors, and a guard that cries wolf gets muted. CI went red once, on this slice's own check, and the reason is recorded rather than patched away. The new 404 sweep asserted a 404 on five routes that deliberately serve 200 when event.clinic cannot be reached — *an empty fetch is not an empty city*, and 404ing would drop a real page from the index over a third party's outage. CI never has event.clinic, so the check made a sibling's availability decide the build, three hundred lines below the docstring saying a flag exists to prevent exactly that. They are behind VELA_SKIP_DIRECTORY now and named in the same NOT SWEPT line. And one test was deleted for being unable to fail. publicPrefixFor sorted its matches longest-first; planting the naive version showed the test passed with the sort removed, because segment matching already guarantees a single match. The sort is gone and the invariant that makes that true is asserted instead — adding market beside market.region turns five assertions red
V71The directory had a route in and no route outcompleteDeployed 2026-08-30 as Worker 6ecf7c2b, no migration — 24 on disk, 24 on Neon — with the production catalogue seeded first for one new key. V70 fixed the front door and left the assertion guarding the front door. That is the smaller half of the problem, and the check inherited the blind spot the venue orphan check has carried since V40: it proved the rule about the pages it had in mind, and the rule is about a property. Measured against production on 2026-08-30 with the front-door link already live — /directory, a city page, an A–Z page, a type page, a venue page and the supplier index all linked to neither guide. The reachability was fixed and the reading was not. 1 228 venue pages, 44 city pages, 26 type pages and 21 A–Z pages are the whole of VELA's indexed surface, and a reader who arrives on a castle in Eger from a search — the journey this surface exists to serve — had no route to *what do we actually have to book, and by when* except back out through the front door. The obvious edit was the layout, and it was wrong. app/(hu)/directory/layout.tsx wraps all six routes at once, and has no catalogue — so it would call loadCatalogue, which reads every row of content.message with no request-level dedup, on pages that have already done exactly that. apps/app/lib/wedding.ts is being rewritten on two unmerged branches (claude/frosty-blackwell-82a8a8, claude/wonderful-volhard-331ec4) for precisely that reason, so editing it here would have collided with work that already exists and that this session does not own. Checked before writing rather than after. So the strings arrive from each page's own translator. DirectoryGuides takes Pick<Translate, 't' | 'f'>: no second catalogue read, nothing another session owns touched, one line per call site and six call sites. Five of the six pages needed f as well as t, which the typechecker named rather than the run. The separator is the type page's existing idiom, and that is V70's lesson applied rather than restated. V70 put two .home__cta links side by side and shipped a measured gap of zero pixels — one unreadable run of Hungarian — because JSX emits no whitespace between elements on separate lines. {' · '} cannot fail that way and needs no new CSS. Verified in a browser at a real viewport, which took two attempts. The first measurement returned shellWidth: 48, footWidth: 0 and a ten-line footnote, because the pane was hidden and the viewport had collapsed — geometry read from a hidden pane is not geometry. At 1280×900: 12px between the links, the second wrapping at its word boundary with both words intact, above the event.clinic attribution line. verify:directory now names which pages must carry the link rather than checking the one it had in mind. Seven shapes — the door, the index, and one of each thing under it — each failing by name. A check that accepted *some directory page links to it* would pass while 1 227 of 1 228 venue pages did not, which is the exact shape of the 506-orphan measurement. Proven both ways: 1 of 7 against production before the deploy, naming all six missing shapes, and 7 of 7 against a local build. Each page also carries its own non-empty guard, because an empty href set is a page that served nothing rather than a page that is missing a link. One new key, directory.guides_lead. It went into the reviewer's public queue by itself, because directory is a declared prefix in public-reach.ts — the loop V70 built, closing without anybody remembering to file it
V72A couple can keep a venue without spending a supplier's moneycompleteDeployed 2026-08-30 as Worker 66fcf53e, with migration 0024_shortlist_entry applied to production first and the catalogue seeded before that. The gap was commercial before it was a feature. Until this slice the only thing a couple could do with a listing was enquire, and an enquiry is metered: sendEnquiry mints a reply token, emails the business, and billing.credit_ledger debits that supplier's lead credit the moment they open the link (V24). So a couple choosing between three venues out of 1 228 had two options and both were bad — commit to a metered ask, or lose the venue when the tab closed. The shape of the second is a couple sending eight enquiries to decide between three, spending five businesses' money on a question that was never about them. marketplace.shortlist_entry is the cheap half of the funnel that was missing, and it exists to protect the expensive half: nothing in it writes to billing, sends anything, or is visible to a supplier. The shape is enquiry's throughout, deliberately — one-of-two-targets as a check constraint, partial unique indexes, and wedding_id as a bare uuid because marketplace does not import from wedding. Inventing a third arrangement for the fourth table that points at a wedding would have been the inconsistency. The foreign key is there from the first migration, which is the whole of what V59 cost. drizzle-kit cannot emit a cross-schema one — the column carries no .references() because the target is in another schema — so it is appended by hand. That is the exact omission that left enquiry.wedding_id with no foreign key for twenty-two migrations, until four orphaned rows turned up on a developer database and closing it became its own slice. The indexes are partial on deleted_at, and that is load-bearing rather than a convention. Un-saving is a soft delete: with a plain unique index the removed row would refuse the same venue forever, and the failure would read as a bug in the button rather than in the schema. Six probes in verify:money, 34 rules to 40, and its constraint audit widened to the new table so they cannot quietly stop existing. Proven by dropping the one-target check: one probe reports *was ACCEPTED*, the other reports *refused by the WRONG rule — got shortlist_entry_wedding_venue_uq* — the harness naming the rule rather than taking any refusal as proof, which is the property a negative assertion needs. Restored and re-read with pg_get_constraintdef, because a constraint with the right name is not a constraint with the right definition. verify:shortlist is the thirteenth harness and guards one property no type can express. A couple who keeps a venue, removes it and keeps it again presents a row the partial index cannot see — so a plain insert succeeds and leaves two rows for one venue, one dead and one live, invisible until something counts them and permanent thereafter. setShortlisted revives instead. Proven by replacing the revive with a plain insert: red naming exactly that assertion, green on restore, and the restore verified byte-identical. It declares needs: 'local database' beside verify:money rather than joining pnpm verify, because that suite deliberately runs without one. It also needed --conditions=react-server, since server-only throws in plain Node, and an explicit process.exit(0): the first run hung for five minutes with every assertion passed, because lib/shortlist.ts imports the application's own connection pool and nothing closes it. The screen is /wedding/[id]/shortlist, and the enquiry form is on it. A shortlist a couple cannot act from is a dead end that sends them back to the marketplace to find the row again, so the ask belongs where the comparison happens. The nav gains an entry straight after the marketplace; both route guards picked the page up on their own, because verify:couple-lens reads the directory rather than a hand-maintained list and private-routes-are-dynamic sweeps every page. One defect only a running page could find, and it is the finding worth keeping. Both copy builders started life exported from the 'use client' components they serve. That typechecks and builds, and then 500s on every render: *Attempted to call enquiryCopyFrom() from the server but enquiryCopyFrom is on the client.* A client module exports a reference to the server, not a function — React can render it or pass it as a prop, never call it — and the type system has nothing to say, because EnquiryCopy is the same plain object on both sides of the boundary. They live in market/copy.ts now, importing the types from the components, since a type import is erased. Verified end to end in a browser rather than by inference: twenty keep buttons on the marketplace, one click, one live row in Postgres for *Romhányi Kastély*, and the shortlist screen rendering it with its city, its sample-data tag, *1 kept*, and an enquiry form correctly refusing to write to a sample listing Inert on production, and deliberately so rather than untested. Production holds 0 weddings, so no couple route can be exercised there — true of all twelve, not of this one. What was verified on production is what can be: migration 0024 applied and its constraints read back with pg_get_constraintdef rather than trusted from an exit code, the eleven new keys seeded before the deploy, and Worker 66fcf53e live at 100% asked of Cloudflare. The behaviour was proven against a local production build with a real click and a real row. A control was needed to see that. The first production probe requested /wedding/<local-id>/shortlist and got 404 — identical to a route that does not exist, because loadWedding runs before the authorisation check and a wedding id from the laptop is not one on Neon. Two 404s for two different reasons is not evidence, which is what asking the control found CORRECTION, same day — this duplicated a design the data model already had. packages/db/src/schema/wedding.ts defines wedding.shortlist and wedding.saved_item, both empty and read by nothing, and the older pair is richer: named lists, a note and a rank, an external_url so a couple can save a venue VELA does not list, and share_token_hash under a comment saying why it exists — *shortlists are shareable, a couple sending one to a parent is a free acquisition loop.* That last one is the commercial point and V72 does not have it. I did grep for an existing shortlist before building, and the command ended | head -5 — the schema file was not in the first five hits, so my own truncation hid it. Second time in one session: a production constraint check read with tail -12 had made a present check constraint look missing an hour earlier. Truncating a search you are using to decide whether a thing exists turns *I did not find it* into *it is not there*. Nothing is broken and nothing was reverted at speed — choosing between two data models is structural, so it is D-75 in docs/OWNER-DECISIONS-2026-08-30.md with a recommendation to migrate onto the older pair and drop this table, which holds 0 production rows
V73CI had no marketplace, so a quarter of the harnesses never ran therecompleteNo deploy and no migration — a CI slice. Four of the thirteen harnesses have never run in CI, and the reason was never that they were hard. CI seeds the catalogue, the review claims, one wedding and its guests, and no marketplace rows at all — so verify:shortlist exits 2, *could not run*, against a database with no venue in it. That is honest and it is not coverage, and the same gap is why verify:signup, verify:placements and verify:lead-metering sit outside the job too. seed/marketplace.ts closes it for the cheapest of the four, which is also the only one needing the database rather than the server — so it runs beside the seeds rather than beside the sweeps. Every row it writes is demo by construction: demo- slug prefix, unclaimed status, no real business behind any of it. Measured at one second, and idempotent. Rehearsed on three throwaway databases rather than reasoned about, because a workflow edit is only testable in the environment it runs in and the alternative is pushing to find out. One: a fresh database migrated and seeded in CI's exact order plus the new step — 2 weddings, 6 venues, 14 suppliers, and the harness passes its 13 assertions. Two: a bare migrated database with no seeds at all, which is how CI runs verify:money *before* any of them — 40 rules hold, including the six shortlist probes V72 added, so widening that harness did not quietly make it depend on a seed. Three, and it is the one that matters: the same database seeded without the new step, to prove the step is load-bearing rather than decorative — the harness reports *could not run: found wedding=true venue=false supplier=false* and exits 2. A seed step nothing would notice the absence of is a seed step that gets deleted. The three server-dependent siblings still do not run in CI, and that is stated rather than left implied: each needs fixtures beyond this one — an enquiry for lead-metering, placements for placements — and they belong in their own commit rather than in the one proving the pattern works
V74The signup journey runs in CI, and the last two are waiting on one fixturecompleteNo deploy and no migration — a CI slice, and half of it is a measurement rather than a change. V73 took CI from 6 harnesses to 7. This adds verify:signup, the journey harness: a stranger signs up, creates a wedding and lands on a plan of 44 tasks, 43 rendered on their own page, with five refusals creating nothing — measured on a throwaway database seeded exactly the way the job seeds one, with a server started against it. It inserts one magic link, the single thing a script cannot obtain because the product mails it, and every step after that is a request a browser could have made. The other two are blocked on one thing, and finding out which took the rehearsal rather than reading. verify:lead-metering and verify:placements both wait on a supplier whose organisation holds a vendor account, and no seed in the repository creates one. seed/marketplace.ts makes suppliers under demo-* organisations with no vendor account; seed/vendor.ts makes a vendor account on its own organisation, vela-test-vendor, which has 0 suppliers. So seed/enquiry.ts refuses by name — *no supplier holding a vendor account* — and lead-metering exits 2, could not run. seed/plans.ts creates no placement for the same reason, which is the more interesting half: verify:placements exits 0 over an empty set, printing *no live placements today, so nothing can be paying for silence*. It was not added to CI for exactly that reason — a green line about nothing is the shape this repository has removed checks for before. The pairing exists on the development laptop only because somebody made it by hand through the vendor screens, which is why every harness that depends on it has always passed there and never run anywhere else. The bounded next slice is named in the handout: a seed that grants a vendor account to one demo supplier's organisation, additive, so seed/vendor.ts keeps its fixture and the vendor portal's expectations do not move
V75VELA can take moneycompleteDeployed 2026-08-30 as Worker 5faa83ff, with migration 0025_payment_refs on Neon first. The owner chose Stripe over Barion that day, and the whole integration is testable before a Stripe account exists — which is why it is shaped the way it is rather than as one file. events.ts has no imports at all: pure functions from a provider event to a decision, eighteen unit tests. stripe.ts is thin and does three things — a Workers-capable client, a signature check, a narrowing. apply.ts writes. Only the middle file knows Stripe exists, so the file deciding what a payment *means* is provable with no network. Three Workers requirements, checked against current guidance rather than assumed: createFetchHttpClient because the SDK reaches for Node's http; constructEventAsync with createSubtleCryptoProvider because WebCrypto is async and the synchronous constructEvent throws on this runtime; and the raw body read exactly once, because a signature is over the bytes Stripe sent and a Request body can only be consumed once. The second is the one that looks fine in a Node test and fails on deploy. verify:payments is the fourteenth harness and it guards the security property. The webhook is the only unauthenticated write in the product — anyone can POST and the signature check *is* the authorisation model. It signs payloads with a secret it invents, then proves a correct one verifies and that a wrong secret, a missing header, and a valid signature over a tampered body are all refused. Then it applies one payment three times and asserts one row. Four things the database corrected, each a runtime error on the first real payment. revenue_kind is subscription_charge, not subscription. source_id is a uuid pointing at our own row — the one revenue event already on a dev database is a commission keyed quote_acceptance:<uuid>, which is what said so. pgEnum creates types in public, so billing.subscription_status does not exist and the cast is public.. And Stripe spells a trial trialing with one l while the enum spells it trialling with two, so the mapping is a translation, not a pass-through. Also learned twice now: a backtick inside a SQL template literal ends it *even inside a comment*; the explanation moved above the query. Proven able to fail three ways. Making the signature check always pass turns two security assertions red by name. Making the idempotency key vary turns the count assertion red — *three deliveries booked 0 rows*. Removing the on conflict makes the harness report the constraint violation loudly rather than green, which is a weaker but honest failure. Switched off, deliberately. Creating the account and holding the keys are the owner's, so /api/stripe/webhook answers 503 with the exact wrangler secret put command, probed on production against a control — a nonexistent sibling route 404s, so the 503 means something. docs/OWNER-ACTION-stripe-setup.md is the fifteen minutes it takes. Not built, and said so rather than implied: no checkout screen, because shipping one would quote a price the ledger still calls provisional; no VAT split, because inferring 27% from a gross figure puts a guess in a table an accountant reads; and no refund handling, worth adding before the first refund rather than during it
V76Something finally watches the running sitecompleteNo deploy and no migration — a harness and a schedule, both live on main and proven by dispatching them rather than by reading them. **Fourteen harnesses could not answer one question: is the site working *now*. Every one of them runs before** a deploy and proves a build, which is right for catching what you are about to ship and structurally unable to notice an expired certificate, an exhausted database, an upstream that stopped answering, or a version somebody rolled back. verify:deployed comes closest and answers something else — *is production running this build* — and can pass while every page 500s. verify:live asserts content, never status codes alone, because on this stack a missing asset answers 200 carrying index.html and a degraded upstream answers 200 carrying an empty list. The front door must contain the Hungarian for venues; the 404 must carry the sentence V69 put there; and the Stripe webhook must answer 200 or 503 and never 500, because a 500 on that one route means money cannot be recorded. Hourly, and the reason is arithmetic rather than taste. The probe has no dependencies at all — Node's own fetch and nothing else — so the workflow runs it under --experimental-strip-types and skips pnpm install entirely: about twenty seconds a run, roughly six minutes of Actions time a day. Installing the workspace first would have been fifteen times that and forced a much coarser schedule. Three outcomes, not two. Exit 2 means every failing probe depends on event.clinic answering — VELA is up, and failing on somebody else's outage is how a job gets muted, which is the rule directory-watch already carries. Said plainly in the workflow rather than implied: this is not an uptime monitor. GitHub's scheduler is best-effort and delays of many minutes are ordinary, so it is a rot detector with a short fuse. The failure mode of believing otherwise is hearing about an outage from a customer while a green badge sits on the repository. The first dispatch failed before probing anything, and that is the entry worth keeping: setup-node defaults package-manager-cache to true, which makes it look for the repository's package manager to prime a cache — and this workflow deliberately does not install pnpm, so it died with *Unable to locate executable file: pnpm*. Reading the file would never have shown that; a workflow is only testable in the environment it runs in, which is the same lesson V73 and V74 recorded about rehearsing on a throwaway database. Proven able to fail three ways against live production: pointed at a host that is not VELA, 0 of 10; an unsatisfiable body assertion, reporting *answered 200 but the body does not contain*; and the slow bar dropped to 1ms, flagging all ten. Green and byte-identical on restore. Fifteenth harness CORRECTED the same night: it is six-hourly, not hourly. It ran hourly for about two hours and not one cron-triggered run appeared — two scheduled slots missed. The repository's own history explains it: Directory watch is scheduled for 06:00Z and its last run started at 06:59Z. GitHub delivers scheduled runs on this private repo roughly an hour late, and documents that frequently-scheduled workflows are the first dropped under load. An hourly job delivered an hour late is not hourly, it is a job that sometimes runs — so the claim was wrong before the schedule was. Six-hourly is a cadence GitHub is far more likely to honour, and with zero users on production a detection window of a few hours costs nobody anything. Faster than that needs a real monitoring service, which needs an account. Still unconfirmed at the time of writing: the workflow is proven by two manual dispatches and no cron-triggered run has been observed. The handout says to check rather than assume CORRECTED 2026-08-31. This row said six-hourly was chosen because *hourly produced no runs at all*. That reason was wrong. The file only reached main at 01:26Z on 31 August, so the two "missed" slots belonged to a scheduled workflow less than two hours old, and GitHub does not promise to start running one immediately. The supporting citation was worse: Directory watch is **0 6 * * 1 — weekly, Mondays only**, so its 06:59Z run on Monday 24 August is not a *last* run standing in for a pattern, it is the only scheduled run this repository has ever had, and it succeeded. One data point an hour late shows the scheduler works here and characterises nothing. Hourly was never shown not to work. Six-hourly is kept because a few hours' detection window costs nothing with zero users on production — the reason that already followed it in the file, which stands on its own. Also found: gh run list's default page does not reach back far enough to see the 24 August run, which is why the first look reported zero scheduled runs ever; gh api "repos/:owner/:repo/actions/runs?event=schedule" reports 1. The next honest measurement is Monday 31 August, 06:00Z and 06:17Z
V77The inventory a privacy notice is written from, and the Article 9 data in itcompleteNo deploy and no migration — a generator and the document it produces. velawel.com is live, indexed, and holds personal data about people who never signed up for it: a couple enters their guests, and wedding.guest then holds names, emails, phones and addresses belonging to third parties who agreed to nothing. Writing the notice is the owner's, with a lawyer — this is the input they need, and it is the half nobody else can produce, because it is a property of the schema rather than of an intention. Generated, never written. A hand-kept inventory is wrong the first time a migration lands, and being quietly wrong is the one failure a privacy notice cannot afford. pnpm --filter @vela/db data-inventory reads information_schema and reports 80 columns across 32 tables. It found Article 9 data that a column-name scan had missed, and that is the entry worth keeping. The first version classified by column name and reported wedding.guest_requirement as holding one column of *free text*. That table's requirement_type enum is dietary, allergy, accessibility, medical_note and its severity runs to life_threatening — so VELA stores health data about wedding guests, by design, belonging to people who never signed up, and the inventory said nothing about it. Enums are read now: a column name can be anything, but somebody wrote the enum values down on purpose, and a column whose values include allergy and medical_note is health data whatever it is called. It raises the notice from *should have* to must have before launch. A narrowing pass in the other direction, on the same run. It also reported content.locale.fallback_locale and content.message.locale as personal — translation attributes, a property of a *string* rather than of anybody, and a lawyer reading that would be misled about what the product holds. A preference-class column now counts only when its table also holds a name, an address or a contact detail. Broad patterns with a narrowing pass, rather than narrow patterns: a false positive costs a reader ten seconds, a false negative costs a data subject their notice. What only a query can say is in there too: the 15 tables that cascade when a wedding is deleted, the 67 that are soft-delete only, and the five processors personal data reaches — with event.clinic receiving nothing, because that flow is inbound only. Six questions are listed as questions rather than answered, because none is in the database and inferring them would be a confident description of decisions nobody has made. The first is the one a lawyer asks first: *how long does a wedding's data live after the wedding?* Nothing deletes anything today CORRECTED 2026-08-31 by V84. This row's headline — *the Article 9 data in it* — described the schema, not the database. wedding.guest_requirement has no write path anywhere in the application, its only reader is a count(*) in pressure.ts that never touches a value by explicit design, and it held 0 rows on production and 0 locally; so did wedding_brief.religion_note. The enum-aware classification below was a real improvement and stands — a column named guest_requirement really is shaped to hold a medical note — but *shaped to hold* was read as *holds*, and that sentence reached three documents and an owner decision before anything counted the rows. V84 made the generator count them
V78CI can finally charge a suppliercompleteNo deploy and no migration — a seed and two CI steps, rehearsed on a throwaway database before landing. verify:lead-metering had never run in CI, and the reason was not that it is hard. V74 found it by rehearsing rather than by reading: no seed in this repository produces a supplier whose organisation holds a vendor account. seed/marketplace.ts makes fourteen suppliers, all under demo- organisations, none billable; seed/vendor.ts makes a vendor account on its own organisation, which has zero suppliers. So seed/enquiry.ts refused by name and the harness exited 2. The pairing exists on the development laptop only because somebody built it by hand through the vendor screens months ago — which is exactly why anything depending on it passed there and nowhere else. The fixture is its own organisation, and the cheaper line would have been wrong. Granting a vendor account to demo-aranymetszes-foto is one statement; V29 records why not — *a sample listing must never be billable* — and is_demo enforces it in two places, vendorOptions and sellSlot. Contradicting a decision that is written down and enforced twice is not a shortcut. The label lives in the name a screen renders, where nothing can filter it away. Local only, and that is the difference from seed/vendor.ts, which has a :prod twin and runs on production deliberately: a vendor account is invisible to the public, but a supplier listing appears in the marketplace, so running this on production would publish a business that does not exist. The guard refuses a non-local URL. Proven on a fresh database seeded in CI's exact order, not on the laptop where it already worked: the seed pairs one supplier with one vendor account, seed/enquiry.ts then produces an enquiry, and verify:lead-metering reports *opened the reply link 3 times; charged exactly once, to the right vendor*. Run twice, it still reports one — idempotent. CI now runs nine harness invocations. verify:placements is still absent and that is a different gap, named rather than half-solved: it compares a bought rank against the position a venue actually occupies on a live directory page, so it needs a real event.clinic venue id and a directory that answers — neither of which a seed can manufacture
V79Deleting a wedding now actually deletes itcompleteMigration 0026_wedding_cascade applied to production; no Worker deploy — a schema fix and a harness. V77's inventory reported fifteen tables cascading from wedding.wedding_project. It could not report the six that hold a wedding_id and no foreign key at all, because an absent constraint leaves no trace in pg_constraint — finding them means asking which columns exist and diffing that against which are enforced. Four of the six hold personal data. An Article 17 erasure carried out by deleting the wedding would have left behind identity.invitation.target_email — an address somebody asked to be forgotten — a wedding.seating_table named in a couple's own words, free-text note on every wedding.spend line, and a guest's accommodation details in marketplace.stay_block. The other two are records of the wedding rather than of a person, so orphaning those is integrity rather than privacy. It had already happened. 38 orphaned wedding.spend rows and 37 orphaned workflow.project rows on the development database, left by ordinary re-seeding, pointing at weddings that no longer exist. V59 exists because enquiry.wedding_id had no foreign key and four orphans turned up; this is the same defect in six more places, found by asking the database which of its references it actually enforces. Applied validated, because production carried 0 rows in all six — measured immediately before writing the migration rather than assumed. Locally it refused until the orphans were cleared, which is the cheapest possible proof that the constraint is real rather than decorative. verify:erasure is the sixteenth harness and proves what a migration cannot claim about itself: that the list is complete. It asks the schema which tables carry a wedding_id — 21 — deletes a wedding, and asserts every one is empty of it. A twenty-second added next year with no foreign key turns it red without anybody remembering, which is the guard verify:couple-lens learned in V42 and verify:isolation had to learn again in V72. It builds the wedding it destroys — a harness that deletes real data to prove deletion works has understood the assignment backwards. The positive control is load-bearing, because every assertion after the delete is *this table is empty*, which passes trivially if nothing was ever written; so the two tables that had no foreign key until this migration must be shown to hold a row before anything is deleted. Proven able to fail by dropping the seating_table constraint, which turns two independent assertions red — the structural one naming the unenforced table and the behavioural one naming the row that survived. Restored and re-read with pg_get_constraintdef, because a constraint with the right name is not a constraint with the right definition. Verified on production afterwards: 21 tables, 0 unenforced
V80A leaked fixture made two other harnesses liecompleteNo deploy and no migration — three fixes, and the first is the finding. **V79's harness failed on a not-null column *after* creating its probe wedding, and left it behind — owned by a real user, with no workflow.project, so its plan page 404s. verify:isolation then failed its positive control** on it: *marikpeter@gmail.com could not read their own plan page*, which is exactly the alarm that control exists to raise, pointed at a mess a sibling harness had made. verify:couple-lens went red for the same row. Two harnesses lied about the product because a third leaked a fixture — and the positive control did its job perfectly, which is the reassuring half. The probe wedding now cannot outlive the process: a finally deletes it however the run ends, and a sweep at the top removes anything an earlier run left. Proven by throwing deliberately after creation — the database holds two weddings afterwards, not three. A harness that can only recover on its next *successful* run stays broken after the run that mattered. Second: migration 0026 turned four verify:money probes red, and correctly. They passed a gen_random_uuid() as quote_acceptance.wedding_id — a wedding that cannot exist — harmless while nothing enforced the reference. That file's own fixture comment already warned about this exact class, about the wrong table, after V59 did the same thing to enquiry. Every probe now names the fixture wedding, so a probe fails for the rule it is written for or not at all. Third, and it is a control rather than a note. Writing that comment put a backtick inside a SQL template literal for the third time in one day, which closes the JavaScript string and turns the query into a syntax error. There was already a note about it in two places. A note ignored three times is not a control, so sql-templates.test.ts now fails at pnpm verify when a line inside a template literal carries both a SQL comment marker and a backtick. Narrow on purpose — backticks in ordinary JSDoc are legal and common, and a rule broad enough to catch those is a rule somebody turns off. Proven against the real mistake, which it reports as verify-money.ts:83, and against a JSDoc fixture it must leave alone. 16 of 16 harnesses green afterwards
V81An erasure request has an answer that is not a psql sessioncompleteNo deploy and no migration — the command V79's cascade needed. V79 made delete from wedding.wedding_project complete: 21 tables carry a wedding_id and all 21 cascade. What it did not provide was a way for a person to do it. An Article 17 request arrives as an email, and the answer to it should not be somebody typing a hard delete against production at the end of a long day. Dry by default, reporting exactly what would go table by table and changing nothing. The confirm flag is --yes-permanently-delete rather than --confirm, because --confirm sits too comfortably beside --id and this is the one operation in the repository with no undo — every other table is soft-delete, this is a hard delete of a person's entire record. A flag you have to mean is worth four extra words. It refuses rather than warns when any table carrying a wedding_id has lost its foreign key: the cascade would not reach it, so the command would report a complete erasure while leaving personal data behind — the exact failure V79 closed, and the worst possible thing to be wrong about in a reply to a data subject. Proven by dropping the seating_table constraint: even with the confirm flag it refuses, names the table, and the wedding survives. It counts again after deleting, because a delete that reported success proves nothing on its own, and says INCOMPLETE rather than partial success if anything survived — with an instruction not to tell the data subject their data is gone. billing is untouched by construction rather than by choice: a revenue_event is a financial record with a statutory retention floor, carries a vendor_account_id and no wedding_id, and cannot be reached from here. If a request ever needs those anonymised rather than deleted, that is a legal question before it is a technical one. The table list is asked of the schema, like verify:erasure, so one added next year appears in the report a lawyer reads without anybody remembering. Tested end to end on a fixture built for the purpose: the dry run reported 3 rows across 3 tables and changed nothing, the confirmed run removed them, and an independent re-count found nothing left with the two real fixture weddings untouched
V82The placement money check had never checked a placementcompleteNo deploy and no migration — a harness that could not have caught the thing it exists for. verify:placements has run clean since V30 and has never checked a placement: production has carried zero live slots the whole time, so every run took the *no live placements* branch and printed *nothing can be paying for silence* — true, and a green that measured nothing. The comparison at the centre of a money check had never executed against a real page in any environment, which made the first sale also the first run of the code — the worst possible moment to discover the page stopped emitting the attribute it matches on. --rehearse runs the whole judgement against production with nothing sold: it invents slots in memory for venues whose position on the live page is already known, reads no database at all, and writes and bills nothing. Four controls, and the negative ones are the point — a rehearsal proving only the happy path is a positive control with no coverage. A venue rendering first is quiet; a venue absent from the page is reported; a venue rendering last is called under-delivered at the right position, which is the branch a presence check cannot see and the one a vendor notices first; and a page repeating every venue accuses nobody. All four hold against the 24 venues production renders today. The fourth is a defect, not a hypothetical. The comparison read order.slice(0, featured.size) off ids scraped from HTML, trusting the page to name each venue once — and that same page carries 24 data-venue-id attributes and 48 /directory/venue/ links, so it is already perfectly willing to repeat a venue. A card that grew a second copy of the attribute would make the paid block for two sold slots read [A, A], and the vendor holding rank 2 would be reported as under-delivering while rendering exactly where they paid to — a false accusation against a paying customer, produced by the check whose entire purpose is their trust, failing loudly rather than safely. Deduplicated now, which is the correct reading rather than a workaround: a venue listed twice appears at the earlier of the two. Both duplication patterns were measured, because only one is harmful — the whole list repeated is benign, each venue repeated adjacently is not — and the first version of the regression test used the benign one, passing against the unfixed function on a length difference rather than on the verdict. A test that goes green for a reason other than the one it claims is the failure this repository keeps finding, so it was retargeted at the pattern that actually produces the accusation. All three exit codes proven rather than asserted: 0 against production, 1 with the dedupe removed and again with the paid block broken, 2 against a base URL whose directory 404s. Registered in verify:all as its own entry with needs: 'production' — safe to point at production precisely because it reads no database, so the mismatched pair that once made this harness cry wolf about money cannot happen. 17 of 17 checks ran and passed, nothing skipped
V83A shared string is undecidable only when its keys disagreecompleteNo deploy and no migration — a rule that was true about the wrong question. verify:copy-reach declined every private string whose value more than one key holds, on the grounds that it *could not be traced to the key that produced it*. True of the key, and beside the point. The harness does not need to know which key put a string on a public page; it needs to know whether a string meant to be private is on one — so if every key holding that value is private, finding it is a leak whoever produced it. Attribution changes the name in the report, not the verdict. Measured before the rule was relaxed rather than after: 8 shared private rows, 6 held only by private keys — ui.guests.household.people with seating.board.heads, the two dependency.build_guest_list__* reminders, and market.keep.failed with shortlist.error.failed — and 2 sharing their value with plan.days_left, which is public, so that string legitimately appears on a public page and stays declined. That pair is the entire reason this is a disagreement test rather than a count. Two of the three all-private pairs are real prose that could genuinely leak; the third is an ICU plural template that never renders literally, and looking for it costs nothing. Declined is now 243 of 620 where it was 249. A finding on a shared value names every key that holds it — the harness does not know which one rendered it, and naming one would be a guess printed as a fact in the single sentence a reader acts on. Both directions proven against the live local page. Pointing the two private keys at a substring of what /plan actually renders turns the run red, reporting market.keep.failed or shortlist.error.failed. Giving a public key that same value moves it straight back to declined, 2 to 4, exit 0 — not a leak, correctly. Restored, and 17 of 17 checks ran and passed afterwards, which is what proves the sabotage left nothing behind. The floor of twenty characters is untouched and must stay: a short value can appear on a public page by coincidence, and no key-set reasoning rescues that
V84The inventory reported what the schema is shaped to hold, and it was read as what it holdscompleteNo deploy and no migration — a correction, a counter, and the drafted wording D-76 was blocked on. V77's inventory reads information_schema, so it can only ever report a column's shape. Read as a report of holdings, it produced a sentence that reached the ledger, the handout, an owner decision and the readiness page: *the site is live and holds health data about wedding guests*. It does not, and never has. wedding.guest_requirement has no write path anywhere in the application — the only reference that touches it is a count(*) in pressure.ts, which never reads a value, by a design rule written in that file — and on 31 August 2026 it held 0 rows on production and 0 locally. wedding.wedding_brief.religion_note is 0 of 0 too. The generator now counts. Every table it lists carries its row count, and a summary at the top says how many of them hold anything at all — because *shaped to hold a medical note* and *holds a medical note* are different questions and this file answers both, having once been read as answering only the first. Shape still matters and is still printed: a table that gains a writer tomorrow needs its notice today. What production actually holds: 736 rows across 11 of 32 tables, and in personal-data terms that is one account holder — the owner — and two waitlist signups, plus one vendor account. Zero guests, zero households, zero RSVPs, zero special-category anything. The remainder is the copy catalogue and its review workflow (508), the audit log (158), organisations and locations. The urgency was real and I had the reason wrong. Two people have given VELA an email address and are owed a notice today; the signup path has been open since V65, so the first couple to enter a guest list must find the notice already there. That is blocking a launch. It is not an ongoing breach about third parties' medical data. And with zero guest rows this is the cheapest moment the problem will ever have — nothing to reconcile, nobody to re-inform. docs/LEGAL-01-privacy-notice-DRAFT.md is the wording, which the standing instruction asks be drafted into a numbered file rather than blocked on. Written from measured facts and citing them: one cookie (vela_session, 14 days, strictly necessary — so no consent banner is required, and adding one would ask for consent VELA does not need), no analytics of any kind, five real recipients, and two that must not be listed — Booking.com and RateHawk ship as a seam with no contract, so naming them would describe processing that does not happen. event.clinic is a source, not a recipient. Seven [[DÖNTÉS]] markers where the answer is the owner's or a lawyer's, nothing guessed behind any of them, because a notice inventing a retention period is a published promise nobody is keeping — and today nothing purges anything on a schedule, which is the widest gap between what a notice would promise and what the code does. The hardest question is named and left open: controller or processor for guest data, with one product observation that makes it cheaper — /rsvp/[token] already exists, and a fact a guest enters about themselves has a far simpler basis than the same fact typed by the couple. Recommendation recorded: drop guest_requirement or build it deliberately, because an empty table shaped for life_threatening notes with no writer and no reader frightens every auditor, forces the hardest paragraph in the notice, and buys nothing RE-MEASURED 2026-09-05 and the numbers have moved. Production now holds 745 rows across 15 of 32 tables, up from 736 across 11. VELA has its first wedding: *Ági és Peti*, created 2026-09-02 by a second account holder, with 44 tasks, a brief, one spend line and three workflow warnings. The privacy analysis is unchanged and that is the point of having measured it: wedding.guest, guest_household, rsvp and identity.invitation are all still 0, so there is still no personal data about anybody who did not sign up, and guest_requirement and religion_note remain empty. What changed is the count of people owed a notice — two account holders and two waitlist signups — and that the signup path has now been walked end to end on production by a real account rather than only by a harness. The inventory is regenerated rather than amended, because it is generated
V85A directory 404 says something, for anyone running JavaScriptcompleteDeployed 2026-08-31 as Worker version ed8527ac, no migration. global-not-found.tsx covers every URL matching no route; it does not cover a notFound() raised inside a page that matched, and five public directory routes do exactly that when a slug or a page number does not resolve. Those served 404 with an empty body — 10KB of (hu)/layout.tsx and not one word for the reader, under the wrong title. A stranger following a link to a venue that has since been delisted got a blank page, which is the most common way anybody meets a 404 on this site and was the one path with nothing on it. V69 deleted app/not-found.tsx and was right to: with two root layouts and no app/layout.tsx there was nothing to wrap it, so it rendered its own <html> which Next then nested inside its __next_error__ shell, shipping two documents and two contradicting robots tags. **That argument is about a *root* not-found and does not reach one inside (hu)**, which already has a document to sit in — precisely the case not-found-body.tsx left the door open for in its own comment, *a second caller, should a route ever be able to render a 404 inside a layout*. The words are reused rather than copied, so the two 404s cannot drift into saying different things. The limit is real and measured, not hedged. Next replaces the whole document with <html id="__next_error__"> and serves a body of <div hidden><!--$--><!--/$--></div>. The words reach the RSC payload and render on hydration, not in the markup — a browser shows the page, curl still sees an empty body. Proven against a control build with the file removed, where the message appears 0 times anywhere in the response. So this rules out the entire family of fixes attempts one to six have drawn from: no boundary anywhere can put words into the served HTML for these routes. The only family left is not calling notFound() at all — deciding above the page — which is a much larger change. The first production probe used a control that proved nothing: grepping the response for the message matches on every (hu) page, because Next ships the group's boundary in every payload. Re-verified in a browser against production instead — the 404 URL renders the Hungarian page with two ways onward, and /directory renders 490 Budapest venues with 0 occurrences in its markup, so the front door is untouched. The title stays wrong and in two different ways, both measured: /directory/city/<unknown> is served under the index's Esküvői helyszínek, /directory/all/9999 keeps its own route's title with the out-of-range number in it. Neither is fixable here — V70 established that a notFound() makes Next discard the page's generateMetadata, and a not-found.tsx cannot set metadata of its own. A page with the wrong title and the right words beats one with both wrong and no words. robots was already noindex on all five from V70 and is unaffected. 17 of 17 checks green
V86V85 was a fix no check could see breakcompleteNo deploy and no migration — a harness change, and the harness caught the mistake in it. The five blank 404 routes still cannot pass inspect404 and still should not: Next replaces the document with its __next_error__ shell, the body stays empty, and the body rule — *the words may still be in the flight payload, which is exactly the shape that read as fixed for four months* — is right to fail there. What V85 makes newly assertable is exactly one bit: the not-found boundary is wired, so the sentence is in the response *somewhere* and a browser renders a real page. That bit is worth asserting only because it was proven to go to zero. A control build with (hu)/not-found.tsx removed carries the sentence 0 times anywhere in the response, so deleting that file now turns the sweep red — and it is proven end to end rather than from a fixture: file removed, rebuilt, sweep run, exit 1 naming all five routes, restored, green. The rule's own message says what it is worth: *it says the boundary exists, never that the served body does.* Searching raw HTML for that sentence is precisely the mistake inspect404 records as having read as fixed for four months, so if these URLs ever serve a real body this rule must be deleted rather than kept as reassurance — the full inspector would then apply and say something stronger. The bodiless fixture was out of date the moment V85 shipped, and the self-test is what noticed. It modelled the empty body *without* the flight payload, so the new rule fired on arrival and the run refused itself with *the blank-route sweep would be red on arrival* — a harness declining a rule its own fixture cannot pass, which is the guard V70 wrote for exactly this and the reason a reader did not have to catch it. The fixture now holds both halves at once, because that pair is what production actually serves; a second fixture without the payload is the positive control. 25 planted defects trip their own rule first, up from 24. 17 of 17 checks green
V87The venue descriptions are in English, and nothing was measuring itcompleteDeployed 2026-08-31 as Worker version 7d0c222a, no migration. /directory/venue/<slug> is 1 228 of VELA's 1 269 indexed URLs — the entire organic acquisition surface and the page a Hungarian couple most often meets VELA on. A sweep of 46 venue pages across 44 cities: 36 carry an overview, and 35 of those are detectably not Hungarian. A separate 30-page sample the same day split 20 English and 10 with no description at all. Not one confirmed Hungarian description was found by either method. verify:directory has asserted since V41 that these pages declare lang="hu". They do — over English prose, which is a document whose declared language contradicts its content, and the one signal a search engine is least forgiving about. The description now carries data-field="overview" so the prose can be read on its own: the page's own chrome is Hungarian throughout, so a whole-page check would have reported everything fine, which is the same reason V30 put data-venue-id on a directory row. Reported, never failed. overview is event.clinic's text and VELA renders what it is given; a harness that goes red for a sibling's data gets muted within the week, and this repository has that lesson written down twice. Filed as ACCEPTED-sibling-rulings.md §7, asking in order: a Hungarian overview; failing that a language tag alongside the provenance and confidence maps R1 already ships, which is the cheap answer and unblocks VELA without anybody rewriting 1 228 descriptions; failing both, a decision between suppressing the description and labelling it. The classifier is deliberately sound in one direction only: a description counts as *not Hungarian* only when it holds zero Hungarian function words and at least four English ones, so the printed number is a floor and the line says so. Under-reporting a problem somebody else has to fix is the right direction to be wrong in; naming a venue's Hungarian description as English is not. Three self-test cases and the third earns its place — English carrying a single Hungarian word must be left *unclassified* rather than counted, which is what keeps the number a floor instead of a guess. Proven by sabotage: a classifier that never fires and one that calls Hungarian English both exit 1 naming the case they broke. No machine translation and no language guessing in the page, both deliberately: a description a model rewrote is one nobody can attribute and would make the problem invisible rather than fixed, and a floor good enough for a report is not good enough to drive a user-visible lang attribute, which would then be wrong for exactly the borderline cases. Verified on production against a control: the anchor is present on /directory/venue/a38 and absent from /directory. Two upstream data defects noticed in passing and not acted on — +52 Event & Gastro Hall carries a Budapest address under a description placing it in Etyek, and Baghy-Szinyei Merse Kúria is filed under Cserkeszőlő with an Abádszalók address; both are the same class as the 181 null-city finding already on V-EC. 17 of 17 checks green CORRECTED 2026-09-02 by V95. This row said *not one confirmed Hungarian description was found by either method*. There are 63 in the corpus of 20 781, and one of them was in the sample. Baghy-Szinyei Merse Kúria was read from a truncated 120-character excerpt whose visible tail was Kúria a — taken for a venue-type label plus page chrome — and recorded as having no description. It is fluent Hungarian prose. Sampling was not the error: at 63 in 20 781, finding none in 76 rows is the expected outcome. Reading a truncation as an absence was, and it is the third time in a week this repository has made that shape of mistake. The finding stands and is stronger for it: had the field been uniformly English the cheap fix would have been to hardcode lang="en", and because it is mixed that would have been wrong for those rows — invisibly, since a Hungarian paragraph labelled English reads perfectly to a person
V88Half the venue descriptions opened with a model's replycompleteDeployed 2026-08-31 as Worker version 30ecfc1e, no migration. Measured on production: 28 of 56 sampled descriptions — exactly half — open a dumped record with a sentence like *Here is a detailed description of their function rooms and services:*. What follows is not prose; it is the record the model was asked to produce, flattened into one field. A couple reading a wedding page was told about a 50-square-metre conference room, after being addressed as somebody who asked a question they never asked. summariseOverview now cuts at that framing, on exactly the argument that function already makes about markdown: ## headings are *a model's idea of structure rather than the venue's* and on a public page read as a bug in the product — a preamble is that one level up, the model's idea of a reply. The first sentence is genuinely useful and everything after the colon is worse than nothing. Sound in the safe direction, which is the half worth arguing about. The pattern needs *Here is/are* within a short reach of a record noun, so ordinary prose that happens to say *here is* survives untouched and a description VELA cannot improve comes back exactly as it was — a rule that over-fired would silently delete the good half of a good description, a worse defect than the one it was written for and invisible on a page that renders fine with no description at all. Where the cut leaves under sixty characters it returns null, which is a state 10 of 30 sampled venues are already in and the layout handles. The two fixtures are the exact strings production served, kept verbatim rather than paraphrased — a fixture that tidies its input tests a defect nobody had. Four of the seven new tests fail when the cut is removed; the two guarding the safe direction pass either way, which is the point of them. Verified on production after deploying: 28 preambles → 0, with the same 56 of 75 pages still carrying a description, so nothing was lost wholesale — 1552 Boutique Hotel now reads as one clean sentence where it used to end mid-record. A patch, not a fix, and recorded as one: the prose is still English and still written for corporate event planners rather than couples, so V87's §7 ask stands undiminished. One shape is deliberately left alone — A38 Hajó … Location: … Description: …, where the good sentence sits *after* a field label — because handling it means parsing a generator's output format, which is a guess about somebody else's data and exactly what R6 forbids. 17 of 17 checks green
V89The payment probe passed whether or not payments workedcompleteNo deploy and no migration — a probe that could not fail, found on the day before it would have mattered. verify:live accepted [200, 503] on /api/stripe/webhook with mustContain: 'configured', and both bodies satisfy that: the GET answers {"configured":true} or {"configured":false,"missing":[…]}. The word is the JSON key, present either way, so the probe passed on the configured state and the unconfigured one, identically and for ever. Harmless today, because payments really are off. The damage is on the far side of the owner setting the two secrets: from that moment a lost or rotated-away secret puts the endpoint back to 503, and the live watch would go on reporting all ten probes healthy — the one probe whose stated purpose is *money cannot be recorded* would be the one saying nothing. PAYMENTS_EXPECTED_LIVE writes the intended state down and the probe asserts exactly one of them: false means it must answer 503, so a 200 says somebody switched payments on without recording it; true means it must answer 200, so a 503 says the secrets are gone. Flipping it is a deliberate, reviewed commit rather than something the harness infers, because nothing on the wire distinguishes *not set up yet* from *set up and broken*, and choosing between those is a judgement a person should make once. Proven by flipping it to true while payments are off: exit 1, expected 200, got 503; restored, exit 0, 10 of 10. A second defect, found by reading that failure line rather than the code. Probe.why is documented in its own interface as *printed on failure so the reader knows what broke for whom*, and it was not — the formatter read r.problem ?? r.probe.why, so a red line said expected 200, got 503 and dropped the sentence explaining what it costs. That is the line somebody reads at three in the morning, and it was the one that lost the context. Both are printed now, the reason indented beneath the problem. 17 of 17 checks green
V90The front door's headline was clipped on a phone, in HungariancompleteDeployed 2026-08-31 as Worker version d57de780, no migration. --v-text-4xl was 3.75rem at every viewport and home.css had no width breakpoint at all — its only @media was prefers-reduced-motion. On a 375px phone the front door's <h1> rendered at 60px with 58px clipped: scrollWidth 385 against clientWidth 327, so ***Esküvőtervező,* lost its final letter and its comma, and the document did not scroll, so the text was cut off rather than reachable. That is on the page whose own first paragraph says most planners were written for one country and translated everywhere, and that VELA is built the other way round. An English design would never surface it**: *wedding planner* breaks at the space, *Esküvőtervező* is one word of thirteen characters, and Hungarian produces those constantly — *anyakönyvvezető*, *rendezvényközpont*. A type scale chosen against shorter words looks fine until the language it was built for arrives. Two changes, and only one of them is the guarantee. The display sizes are clamp()ed, which is what makes the page *look* right — 8.5vw reaches the old 60px at a 706px viewport, so nothing above that changes at all, measured: still exactly 60px at 1280px. But a floor is still a fixed size and a longer compound would clip again, so every rule using a display token now sets overflow-wrap, which is what actually guarantees no word can clip. display-type.test.ts holds both, needs no browser, no viewport and no font metrics, and runs in pnpm verify on every commit rather than in a harness somebody remembers to point at a phone. Proven by removing each half separately: without overflow-wrap one test fails naming the rule, without the clamp another does. The /plan labels the sweep flagged are not defects and were nearly "fixed". .mk__field-l is a visually-hidden screen-reader label — width: 1px, clip-path: inset(50%), white-space: nowrap — so its 139px of overflow is by design, and changing it would have broken a label for the readers who need it most. The scan ignores clipped elements now, which is the second false positive this session's measurements produced and the second one caught by reading the source rather than the number. Verified on production at 375px: h1 36px, 0px overflow, full text, document width equal to the viewport, nothing else overflowing. 17 of 17 checks green
V91Production is watched by Cloudflare now, because GitHub's cron does not runcompleteDeployed 2026-08-31 as Worker version a51ec419, no migration; a new KV namespace and a Cron Trigger. Measured first: this repository has had exactly one scheduled GitHub Actions run in its life — Directory watch, Monday 24 August, 59 minutes late — and on Monday 31 August both slots, 06:00Z and 06:17Z, were still silent at 10:28Z. Two workflows, two slots, both dropped. GitHub documents that scheduled runs are dropped rather than queued under load, and a private repository is not where that budget goes. The live watch was never a monitor. A Cloudflare Cron Trigger every 30 minutes runs four probes and emails OPS_NOTIFY_EMAIL only when the answer changes — no new account and no new secret, because the Worker already held BREVO_API_KEY, MAIL_FROM and OPS_NOTIFY_EMAIL, which is what made it a decision the owner could simply say yes to. Only transitions: a four-hour outage is one email and one recovery notice, not eight, because the failure mode being designed against is not silence but an inbox nobody opens. A first run that finds everything healthy says nothing — announcing normality to introduce yourself trains a reader to ignore the sender. An upstream failure never emails and never overwrites the stored state: if it did, a directory blip mid-outage would swallow the recovery message, because the next healthy run would see no transition worth announcing. The probe list is deliberately not verify:live's — that asks whether everything is correct, this asks whether a customer is being turned away badly enough to justify waking somebody, and a stale sitemap is not. main now points at worker-entry.ts, which hands Next's fetch handler through untouched and re-exports the three Durable Object classes; it is excluded from tsc because it imports a gitignored build artifact, so all the logic lives in lib/ops/ under 37 unit tests that need no network and no KV. Three things went wrong and all three are in the code as comments. (1) I sent the owner a false alarm. wrangler dev --local reads .env.local, which holds a real Brevo key, so driving the schedule against a deliberately broken URL mailed a genuine *velawel.com is not answering*. The recovery notice went out immediately. (2) The first guard written for that did not work, and it is recorded: it refused when APP_URL was *unset*, but the offending run passed APP_URL explicitly — a guard has to cover the case that actually happened, not the neighbouring one that is easier to check. Alerts now fire only when watching the production origin exactly; the offending command was replayed and refused. wrangler dev --remote is not covered and cannot be, because it is production by design. (3) The first deployed run reported HTTP 522 on all four probes and sent a second false alarm. A Worker cannot fetch the hostname it itself serves — the subrequest loops back and times out — so the monitor was asking a question that could only ever fail, about a site answering perfectly from outside. It probes through worker.fetch now, running the identical Next handler in-process: routing, rendering, the database behind Hyperdrive, the assets binding and the real bytes of every page. What that gives up is stated rather than hidden: DNS, TLS and Cloudflare's own edge, because if those fail the handler never runs and the silence is the signal — a gap that belongs to a monitoring service with an account. The log line names the failing probes, added because the first real run said broken and nothing else, and a monitor that will not say what broke is one nobody can act on. The cron itself is proven: the first slot fired within two minutes of deploy, which is the thing GitHub never once managed. 17 of 17 checks green Observed running, which is the whole point. wrangler tail across the 11:30Z slot captured vela-watch healthy (was healthy) → stay-quiet — the second consecutive slot since deploy, probing through the Worker's own handler, and correctly silent because nothing had changed. Two slots in an hour against one scheduled run in this repository's entire GitHub history CORRECTED 2026-09-01: "GitHub's cron does not run" was wrong, and is withdrawn. By 1 September the repository had eight scheduled runs, six of them Live watch. GitHub's scheduler does run here — it does not run *on time*: slots delivered +1h28, +4h32, +4h48, +5h20, +4h20, +2h43, and Directory watch's Monday 06:00Z arrived at 12:57Z, nearly seven hours late. The two slots seen on 31 August were dropped, which is what the original measurement caught — a real observation generalised into a wrong claim. The decision stands and the reason changes: an outage you hear about five hours later is one your customers found first, so a schedule that lands somewhere in a five-hour window is a rot detector and not an alarm. The Cloudflare trigger fired within two minutes of deploy and has kept to its half-hour since. The GitHub workflow remains a second opinion, and a green badge there is not evidence anything is being watched
V92Payments are live, and a 503 now means the opposite of what it meantcompleteDeployed 2026-08-31 as Worker version c04291f0, no migration. The owner set STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET. Verified without ever seeing either: the endpoint answers 200 {"configured":true}; a forged invoice.payment_succeeded claiming 999999 is refused 400 by signature verification on Workers' async crypto path; a request with no stripe-signature header is refused too; and billing.revenue_event still holds 0 rows — which is the part that matters, because *a 400 is a claim and an empty table is the evidence*. Re-proved after the deploy with a second forgery. Both PAYMENTS_EXPECTED_LIVE flags flip to true. Before the secrets existed, a 503 was the correct answer and waking somebody over it would have been noise; from now on it means Stripe's deliveries are being rejected, it retries for a while and then gives up, and money is taken and never recorded. Same status code, opposite meaning, and nothing on the wire distinguishes them — which is exactly why the intent is written down rather than inferred. The flag exists twice because verify-live.ts runs with no install at all and therefore cannot import anything, so payments-flag.test.ts compares them as source text and fails on disagreement. The drift it guards is expensive in one specific direction: if the script said *live* while the Worker's watch still said *off*, the check a person runs by hand would go red while the one that emails at 3am stayed quiet — precisely the wrong way round. The run.test.ts fixture answered 503 for the webhook and now answers 200, and four tests went red on the flip: the fixture correctly following reality rather than a test needing a nudge. The Cloudflare watch is strict now too — V91's judge accepted both codes deliberately so a secret set an hour earlier could not wake anybody; with payments live it accepts only 200, so a lost secret is an alert rather than a shrug. 17 of 17 green, and verify:live 10 of 10 against production with the webhook asserted at 200. What is still not possible: charging anybody. There is no checkout screen, deliberately, and the two prices in billing.plan — 9 900 and 24 900 HUF monthly — are still marked provisional as they have been since V27. The plumbing records money; nothing yet asks for it
V93A vendor can buy a plan, and a hand-written migration nearly took that page downcompleteDeployed 2026-09-02 as Worker version 7cb55120; migration 0027_plan_price_ref applied to production first. The owner agreed the prices on 2026-09-02 — 9 900 and 24 900 HUF a month — so the provisional wording is gone and should not return unless the prices change. payment_price_ref on billing.plan, provider-neutral like V75's pair, for the reason that row gives verbatim: a column called stripe_price_id makes a change of provider a migration instead of a config change. Null means unbuyable, not free — hu_free will never have one, and a paid plan without one is refused by checkoutDecision in those words rather than falling back to a default amount, because the fallback is how somebody is billed a number nobody agreed and it is discovered on an invoice. checkoutDecision is pure with eleven tests, and refuses a demo listing before it looks at the plan: a sample business must not reach /admin/revenue as a real commercial relationship through a door selectPlan already closed, and telling it *that plan is unpriced* would send somebody to fix the wrong thing. The customer is created and recorded before the session, and that is not an optimisation. apply.ts resolves an incoming payment by vendor_account.payment_customer_ref, and checkout.session.completed is deliberately ignored because revenue is booked on the invoice — so nothing downstream will ever report the customer id. Letting Stripe create it at checkout means the first real invoice is refused with *no vendor account for customer …*, and the money is taken by Stripe and booked by nobody. The write is coalesced and conditional, so two clicks in two tabs cannot orphan the customer holding the subscription. stripe-plans.ts creates the products and prices from billing.plan, so the amount lives in one place and Stripe is a copy of it rather than a second opinion; dry by default, idempotent by the database rather than by asking Stripe, and it refuses without a key so the secret never leaves the owner's machine. It says LIVE — real money or test before it creates anything. The picker shows different words for the two paths — a button reading the same either way is how somebody presses *Choose* expecting a record and lands on a payment page, or presses it expecting to pay and quietly does not. And the near miss, which is the most useful thing here. 0027 was written by hand, so drizzle's meta/_journal.json never learned about it, and migrate reported "Migrations applied. 27 in the record" against both databases while applying nothing. tsc was happy because the *schema file* had the column. The first request to /vendor would have selected a column that does not exist and 500'd the page where money changes hands. The failure is silent in the reassuring direction: the run says *applied*, exits 0, and what it skipped leaves no trace. Counting catches it — migrate now refuses when the folder and the journal disagree and names the file it would have skipped; proven by hiding the entry again. Verified on production by running the exact plansFor query against it, since /vendor 307s when signed out and would not have exercised the new column. All three plans read (none yet), so buyable is false everywhere and the picker behaves exactly as before until the owner runs stripe:plans --create — which is the correct safe state for a checkout whose provider prices do not exist. 17 of 17 checks green
V95event.clinic shipped the language tag, and it corrected uscompleteThe ask in §7 was answered and built the same day it was sent. Both /directory/v1/venues and /directory/v1/venues/detail now serve a per-field language map in the shape of provenance and confidence. Verified from this side before anything was built on it: across 600 published Hungarian rows, 367 en, 18 hu, 215 with no key — exactly the distribution they describe. The rule they led with is the one that matters, and it is encoded rather than remembered: an absent key means undetermined, never English. It is their largest bucket — 1 535 of 20 781 — because the detector abstains (hu needs two Hungarian markers and zero English; en needs four English and zero Hungarian; everything else gets no key). languageOf returns null for an absent key, for an unrecognised value, and for a payload carrying no map at all, so an older response cannot lie either; four unit tests hold it, and defaulting to en would reintroduce the exact defect §7 was written to report. The page renders three states and the third is the considered one: lang="en", lang="hu", and **lang="" for undetermined — HTML's own encoding of *unknown*. Inheriting would claim Hungarian over English text, which is what the document did before and what the whole exchange was about. All three verified on real venues: a38 → en, Baghy-Szinyei Merse Kúria → hu, Alföld Gyöngye → "", with the document still lang="hu". V87's heuristic is deleted rather than kept as a cross-check. It existed because nobody published the answer; somebody does now, so the harness asserts what the page rendered instead of guessing what the text is. The new failure is unmarked — a description paragraph with no lang at all — because that is precisely what a regression looks like: the text silently goes back to inheriting hu and nothing changes visibly. Proven end to end by removing the attribute and rebuilding: exit 1 naming all 36 described venues. And they corrected us.** V87 reported *not one confirmed Hungarian description*; there are 63, and one was in VELA's own sample — read from a truncated excerpt and recorded as an absence. The correction strengthens the ask: had the field been uniformly English the cheap fix would have been to hardcode lang="en" and never write §7, and because it is mixed that would have been wrong for those rows and wrong invisibly. §8 closed too, and their answer beat the ask: Koós was *given* and simply wrong, corrected on production; but provenance: "city": "derived" already exists on 176 venues, and sweeping the remaining disagreements would make several worse — Puchner Kastélyszálló is filed Bikal with a Budapest address whose postcode 7346 is Bikal's own, so there the address is the broken field. Their never-overwrite rule stands and 19 rows are logged as their Q-537. Not a Hungarian problem either: DE 93%, FR 98%, IT 99%, ES 98%, PT 100%, NL 86% English. Every consumer of that catalogue in every locale has it, and VELA was the first to report it
V97A supplier can find out what they would be paying forcompleteThe owner tried to subscribe and reported two things: *I cannot start a subscription*, and *there is no indication of what a subscription package contains*. The second was plainly true — /vendor showed Directory listing · 20 photos · 10 lead credits a month in one table cell — and the first was true in the way that mattered: a grep of the whole app found no link to /vendor outside /admin. The button worked; nobody could reach it. (Minting a session and fetching the page showed Subscribe rendering correctly, so the button itself was never the defect.) /szolgaltatoknak is the page a business reads before paying: the three plans, what each includes as a list rather than a run-on cell, and plain sentences for the two things nothing had ever explained — what a lead is (*you are charged when you open an enquiry, once per enquiry however many times you read it, and one you never open costs nothing*) and what a featured place is, including that it is labelled as paid, because a couple who finds that out later trusts nothing else on the page. The prices are read from billing.plan, never written here — the same rows stripe-plans.ts created the Stripe prices from, so the number on a marketing page cannot drift from the number charged. Copy is catalogue keys, not literals, seeded en and hu with the Hungarian marked machine: it was written by an agent and must reach /admin/copy rather than pass as approved. Two defects found while building it. billing.plan.name_key has pointed at keys that never existed since V20 — a dangling reference no database could check, invisible for as long as nothing dereferenced it. This page did, and printed plan.hu_free.name on screen. /vendor looked correct only because it hardcodes the names in English and ignores the column, so a Hungarian supplier reads *Free listing* about their own business. Seeded in both languages. And the canonical URL was missing, caught the moment the route was added to verify:public-html. Three separate harnesses refused the page in the hour it was written, and each was right: no canonical; a revalidate window on a route not on the cacheable allowlist; and public strings still classified private, which is exactly the leak verify:copy-reach exists for. All three were satisfied by declaring, never by lowering a check — and the allowlist entry was added only after confirming the page reads no session, which is the control that file runs independently. Linked from the front door and listed in the sitemap at priority 0.7, because the defect being fixed was discoverability: a page reachable only by typing it is not reachable, and one search cannot find is the same thing one step further out. Still English-only: /vendor itself, named rather than quietly left
V100A sitemap is not a link, and V97 had only linked it oncecompleteCaught by verify:directory, not by a reader. V97 put /szolgáltatóknak in the marketing sitemap and linked it from the front door alone, so the check failed six public page shapes in its own words: *a sitemap is not a link, and this page is one a reader arrives on from a search.* The rule is that everything in the marketing sitemap must be reachable from every public shape, and adding a page to the sitemap silently made it required in six places. It belongs on the directory pages more than on the front door. A venue owner googling their own venue lands on /directory/venue/<theirs> — that is where a supplier actually arrives, and where the page telling them what a listing costs should be one click away. Added to DirectoryGuides, beside /plan and the market guide, so the three travel together. 7 of 7 shapes reachable, from 1
V99A couple can tick a task off, and the tick can be heardcompleteStanding brief, streams 1 and 3. A walkthrough of all twelve couple screens found /plan — the page the product is named after — with zero buttons, zero forms and zero fields. workflow.task has carried status and completed_at since V1 and nothing in the product could ever set one: the only code that has written done is quote-acceptance.ts, as a side effect of accepting a supplier's quote. So a couple who booked a photographer outside VELA, or did any task with no quote attached — *give notice at the registry office* among them — watched it sit outstanding for ever. V23's own ledger row assumed otherwise: *an already-ticked or not_applicable item is a couple's decision an acceptance has no business overruling.* There was no way to make that decision; the record described a product one affordance ahead of the one that shipped. A second defect in the same element. The tick was <span aria-hidden="true">✓</span> — decorative — so the only signal a task was finished was a hidden character and a CSS class, and a screen-reader user could not tell a done task from an outstanding one anywhere on the plan. It is a button now with aria-pressed and a real label, and the read-only variant announces the state where the control announces the action, because one string for both makes one of them a lie. A <form> rather than a click handler, so TaskCard stays a server component and ticking works with no JavaScript. The wedding is in the SQL predicate, not only in the authorisation guard: allowed() proves the actor may write to *this* wedding and says nothing about the task id, which also arrives from the caller — without project_id in the where, an authorised couple could tick a task on somebody else's plan by changing one uuid. Verified: a task id from one wedding paired with the other wedding's id updates 0 rows. not_applicable is left alone, because that is the plan saying a task never has to happen rather than a couple saying it is unfinished, and un-ticking it would silently restore an obligation nobody agreed to. Three mistakes of my own on the way. Backticks inside a sql template, the fifth occurrence — V80's guard was widened to cover apps/app (it scanned only packages/db, which is why the fourth slipped past), and then the mistake was made again twenty minutes later by running the build before the test. The guard was right; it was bypassed. workflow.task_status does not exist: pgEnum creates the type in public whatever schema its table lives in, exactly as V75 recorded about billing.subscription_status, and the qualified name that reads correctly is the one that fails. And the page rendered 43 buttons with correct aria-pressed and labels while every press failed with 42704 — only clicking one found it, which is the whole argument for driving a control rather than asserting it exists. Proven end to end in a browser: tick 6 → 7 with completed_at stamped, un-tick 7 → 6
V96A Hungarian visitor gets a Hungarian page and a Hungarian email, and can leavecompleteDeployed 2026-09-02 as Worker version f5a9…; catalogue seeded to production first so no reader got the English fallback in between. Two things the owner reported, and they are different defects. The page was locale negotiation. /sign-in negotiated on accept-language alone, so a couple in Budapest on an English-language laptop read a Hungarian front door — one arguing that most planners are *written for one country and translated everywhere* — clicked its call to action, and landed on an English form. The front door has always used Cloudflare's country header; its own destination did not. The email was worse: it had no localisation path at all. Subject and body were string literals in session.ts, so every reader in every market got English regardless. Both are catalogue keys now, en and hu, the Hungarian seeded machine because an agent wrote it — it renders immediately, which is the point, and still reaches the reviewer rather than passing as approved. A value that comes back as its own key falls through to the English literal, because the seed inserts and never renames, and this is the one email nobody can sign in without. The first fix introduced a regression, and only running the unreported cases caught it. It took whatever localeForVisitor returned, which defaults to hu for a country that is not an open market — so a British reader got a Hungarian sign-in page where they had been getting English. Hungary is currently the only open market, which is why a German visitor correctly gets en: there is no German copy, and offering a lang the catalogue cannot render is the defect V95 spent an exchange with event.clinic fixing. The rule is one tested function used by both callers instead of the same logic written twice. Then the escape hatch, because making the country authoritative removed the only lever an English speaker in Hungary had. There was no language selector anywhere in the product — the sole locale <select> was an operator tool for inviting reviewers, and wedding.locale is not editable on any screen. /lang/<locale> sets a preference cookie that beats every inference, as a plain link needing no JavaScript, with endonyms rather than flags because a flag names a country and this reader's country and language disagree by definition. The to parameter is validated: //evil.example and https://evil.example both fall back to /, since a naive *starts with a slash* test lets a protocol-relative URL straight through. A switch was added to the front door and removed the same hour, because it did nothing — that page resolves its locale directly and never reads the cookie, so pressing *English* set a preference, redirected back, and rendered identical Hungarian. A switch that does not switch is worse than none. Everything under (hu) is Hungarian by construction — hardcoded loadCatalogue('hu') beneath a layout fixing <html lang="hu"> — which is what keeps those pages statically renderable, so making them switchable is a change to that arrangement rather than a component drop-in. Recorded in the page rather than quietly skipped. Verified on production: Accept-Language: en with CF-IPCountry: HU renders *Belépés vagy kezdés*, with Magyar · English beneath it
V101A help page, in Hungarian, answering what the product actually confuses people aboutcompleteDeployed 2026-09-05 as Worker version 73156fb5, no migration. Standing brief, stream 2. The brief asked for a manual and a help system, and they are different deliverables: one is a document read once, the other is inside the product at the moment of confusion. /wedding/<id>/help is the pressure diagnosis, a different thing that shares the word, so until now there was no help anywhere. Seven questions, each a confusion the product actually causes rather than an invented FAQ. *Nincs jelszavam* is first because every user meets it: there is no password field, and a sign-in page that does not say so reads as broken. Nothing on it answers a data-protection question — retention, erasure and the lawful basis belong in the privacy notice, which is drafted and waiting on four facts only the owner has, and answering *can you delete my data* in a help page would be publishing a legal statement. Two mistakes found by reading the rendered page rather than the diff. help.title and help.lede already belong to the pressure diagnosis; the seed inserts and never renames, so it silently kept the existing rows and the new page rendered *Hol tartotok* — the diagnosis's own heading — under its own URL. Nothing failed. Everything moved to support.*. Then the rename itself was half-done: the regex required en('help.a. on one line and the answers are multi-line, so seven questions renamed and seven answers did not, and the page showed each question with support.a.password underneath it. The 28 mis-prefixed rows are soft-deleted locally and never reached production. Verified on production: HTTP 200, lang="hu", seven questions rendering Hungarian sentences, 0 raw keys, and /sugo/nem-letezik still 404. **The commit message's claim of *all seven public page shapes* was wrong** — six of ten. V102 is that correction
V102Four public pages had no route to help, and now a check says socompleteStanding brief, streams 1 and 3 — found by measuring V101 rather than by trusting it. V101 put /súgó in the sitemap and linked it from the six directory shapes through DirectoryGuides. The four public pages that do not render DirectoryGuides — /, /plan, /szolgáltatóknak and /weddings-in/<market> — linked to it from nowhere. That is the V71 defect one ring further out: reachable by URL, reachable from nothing, and the reader most likely to need *nincs jelszavam* is the one who landed on the front door. The shape of the gap explains itself: /plan and /weddings-in carried *identical* footers, copied, and the other two carried none — four inline footers is how the fifth spelling appears. One PublicFooter replaces them, bringing its own stylesheet because the four sit on three different sheets and adding the same rules to three sheets is the version that drifts. The help link is conditional and the condition is honesty: /súgó exists only in Hungarian, so the link renders only when the reader is actually being served Hungarian — the rule /sign-in already follows. Three callers pass the literal 'hu' because they are Hungarian by construction; the front door passes the locale it negotiated, and a British reader gets no link rather than a Hungarian page. Forced red against production before the fix: the new verify:public-html rule fired on exactly those four paths and nothing else, which is stronger evidence than any fixture — the check was proven against the defect it was written for, in the wild. The self-test plants the missing link and asserts the rule names it, and asserts /súgó is exempt from linking to itself and from nothing else
V103Every couple screen explains itself, keyed to the link it highlightscompleteStanding brief, stream 2 — the half of it the manual has been listing as a known limit since it was written. The brief asked for a manual and a help system; V101 built the public one at /súgó, which answers what the *public* surface confuses people about. None of that is what a couple is stuck on once they are inside. Behind the sign-in the confusions are structural: why the plan already has dates nobody typed, why a household rather than a person is the unit an invitation goes to, why *Keep* and *Ask if they are free* are two buttons. Twelve paragraphs under couple_help.*, private copy, deliberately outside PUBLIC_KEY_PREFIXES and outside help.* — three namespaces, three audiences, no overlap, which is the rule V101 learned by rendering the pressure diagnosis's heading on the help page. Rendered from CoupleNav rather than from twelve pages. The obvious build drops a component into each page file, which is twelve chances to pass the wrong screen name — and a wrong one renders a confident, well-formatted explanation of a *different* page, which is worse than none because nothing about it looks broken. current is already required, already covers all twelve, and already drives aria-current, so a screen cannot be mis-explained without also highlighting the wrong link. A closed <details> with no open prop, because a derived one is re-applied every render and fights the reader's own click. The check is uniqueness, not presence, for the same reason: twelve raw keys are twelve distinct strings, so the raw-key case is asserted separately. Both branches forced red against the running server: soft-deleting couple_help.legal produced *renders the raw key*, and copying plan's text onto budget produced *budget shows the same help text as the plan screen*, each naming the screen. Restored, then 24 of 24 screens carrying the menu explain themselves, all distinct — counted and printed, so a run that never found the menu cannot report the rule as passed. Correction, 2026-09-06: a third paragraph was wrong, and uniqueness could never have caught it. couple_help.booking described *what you sent and what came back*; /booking is nothing of the kind — its own first line reads *the people you have to book here, and by when*, the market-specific roles a Hungarian wedding needs that no international checklist mentions, each with a date. The paragraph was unique, confident, well formed and about a screen that does not exist, so the uniqueness rule passed it: that rule catches text *swapped* between screens and cannot catch text *invented*. Found by reading booking/page.tsx while counting affordances, which is the only thing that could have found it. Corrected in source and on both databases, and the manual's own row for that screen was wrong in the same way. Two other paragraphs were wrong, found by cross-checking them against the code rather than re-reading them — stream 3, applied to copy written the same hour. *Stays* said *what you see is rooms held*, on a screen whose own stays.no_provider string says VELA cannot search hotels at all: there is no booking partner, so they are hotels the couple adds by hand, and the help was describing rooms that are not there. *Booking* said accepting a quote *ticks the task on the plan* unconditionally; it does so only when taskCodeForCategory maps the category. Both rewritten in source and replaced on production. copy-set could not do it — it refuses an unattributable edit and there is no agent account, which is correct — so the rows were deleted under a reviewed_by is null guard and re-seeded, the seed being the table's only writer. The guard taught something: English rows seed as human, not machine, because English is the source rather than a translation, so a status = 'machine' guard silently skipped both English rows and only the Hungarian pair changed on the first pass. And the comment beside the map said seven; the map holds eight — band was added and the prose was not. The count is gone rather than corrected, because a number in prose is a second source of truth for the file next door
V104The supplier lens speaks Hungarian, and two pages stop describing the same plans twicecompleteThe owner's 2026-09-02 report, second half. V96 fixed the localisation in front of the sign-in; this is behind it. A Hungarian venue owner arrives from /szolgáltatóknak, which is Hungarian, presses the one button on it, and lands on an English page about their own business, their own money and their own invoices. The V97 seed comment had already recorded a symptom and left it — *a Hungarian supplier reads Free listing on a page about their own business* — and recorded is not fixed. It was worse than the name. /szolgáltatóknak and /vendor each carried their own describe(), one writing the entitlement bullets in Hungarian and the other in English, and /vendor kept a hardcoded map of three English plan names beside the nameKey the row has carried since V20. Entitlements are jsonb so that changing what a plan includes is data rather than a migration; two hand-written descriptions put that decision back into code, in two places, in two languages, with nothing able to say they disagree. planLines is the only writer now and both pages call it, with ICU for every counted line — ${n} photos is English grammar in a template literal, and Hungarian does not inflect the noun after a number at all. The locale is negotiated exactly as /sign-in negotiates it: an explicit choice, then the country, then the browser. Correction to the first version of this row, which said no row knows a supplier's language. identity."user".preferred_locale exists and has since V1 — and holds a constant: session.ts wrote 'en' for every account created through sign-in and vendor-accounts.ts writes 'hu' for every account an operator creates, so on production every user is en, Hungarian ones included. Two writers, two different constants, one column that claims to be a choice; reading it would have given roughly half of them the wrong language, which is worse than the negotiation, not better. Sign-up now stamps the negotiated locale instead, using the same call and the same list as the sign-in email two functions above. Nothing reads it yet — it is written honestly first, because a column full of a constant cannot be read later without a backfill nobody can compute after the fact. Verified in all three cases rather than the reported one: a Hungarian cookie beats an English browser, an English browser with no cookie still gets English (the V96 regression), and a Hungarian browser gets Hungarian. Errors are copy the moment something renders them — refusalMessage returned English sentences the page printed into a callout, and returns catalogue keys now; validate() returns a union with two total renderings, validationMessage for the English-by-design operator lens and vendor.error.validate.* for the supplier, both switch with no default. Three checks, each forced red. Deleting the Hungarian payments_off row named the reason, the key and the locale. The third caught itself: planting a sentence proved the source scan could not fail, because the line landed just after {plan.buyable ? copy.subscribe : copy.choose} and sat between a closing brace and the next tag rather than between two tags — the commonest shape a leaked string actually takes. Widened to open on } as well as >, then forced red in both shapes. Listed rather than papered over: <html lang> is still en on this route while the page inside declares the real language, the same state /sign-in has held since V96, and a refusal only an operator can provoke still shows English
V105The retention job the notice promises, and the rule proved on the case it would get wrongcompleteThe privacy draft's own words: *There is no automatic purge job. Nothing deletes an abandoned account, an expired session row, or a soft-deleted record on a schedule. That is the single biggest gap between what a notice would promise and what the code does.* The owner settled the period on 2026-09-02 at 24 months, and a published retention period nobody keeps is a promise rather than a policy — so the notice could not go out until this existed. Dry by default, and deliberately not scheduled. Permanently deleting data that cannot be reproduced is one of the four things reserved for the owner, so retention reports and --yes-permanently-delete acts — the same four-word flag erase-wedding uses, one flag to mean rather than two spellings of one intent. Three periods, not one: a wedding at 24 months after the day *or* the last sign-in whichever is later, a waitlist signup at 24 months of silence, and a credential 30 days after it expires — the grace exists so *I could not sign in yesterday* still has something to look at. verify:retention proves the rule on eight weddings, four that must go and four that must not, inside a rolled-back transaction, localhost-only, with a 23-month and 25-month pair as the boundary control and a count assertion so a predicate that selects everything cannot pass the positive cases. The harness corrected me rather than confirming me. The rationale first written said a two-value greatest(wedding_date, last_seen_at) would purge a brand-new account on day one; sabotaging the clause to prove the harness could fail tripped a different case and showed it is backwards — greatest of two nulls is null, null < now() - interval is null rather than true, so that row is never selected at all. The real failure is quieter and is the one the job exists to prevent: every account that never chose a date and never signed in again is exempt for ever, the run prints a smaller number, and nothing looks broken. Both cases are in the harness now, because one of them is the failure and the other is the one it is easy to believe is. The predicate lives in one file, shared by the job and the harness — they were two copies for one afternoon with a comment claiming they matched, which is how a rule stays green in a harness and drifts in the job. billing is unreachable and that is asked, not assumed: the run refuses if any billing table ever grows a wedding_id, because the eight-year invoice floor outlives an erasure request. Measured against production 2026-09-06: 0 weddings, 0 waitlist signups and 0 sessions past retention, 15 spent magic links past their sweep. Nothing was deleted — the list is the deliverable and the button is the owner's. And the operations index was mislabelling nine procedures: procedures.mjs classified by name, so ^verify meant *reads*, while nine of them plant fixtures on a real schema. All nine carry a localhost guard, so nothing was at risk — which is exactly why it was worth fixing rather than leaving, since the document is right by coincidence and the tenth inherits the wrong label for free. It reads the entry point's source now, and a third value, fixtures, says *writes, but refuses production* — forced red by removing one guard, which moved that row from 9 to 8 and relabelled it writes. Correction, V109: that 9 was wrong, and in both directions. The detector tested for the string localhost anywhere in the file, so it counted a script whose only mention is a *default* — VELA_BASE_URL ?? 'http://localhost:3100' — and, once V109 extended it to declared writers, it labelled stripe:plans:prod *refuses a database that is not localhost* and labelled itself one, because procedures.mjs contains both patterns as regex literals. It now requires the shape of an actual refusal, a negated test of the URL, which every real guard here is written as and which a default cannot match; and a production-reaching command can never be a fixture writer whatever its source says. 11, each verified by hand against the guard on its own line
V106Three pages declared English while speaking Hungarian, and the check found it in the wildcompleteThe V33 defect surviving in the one place V36 could not reach. /sign-in, /welcome and /vendor resolve their words with translatorForReader and each already put the answer on its own shell as <div lang> — but localeSourceFor only knew how to ask a row, and none of these has one: sign-in is the page before there is an account, /welcome is an account attached to nothing, and a supplier's language lives in a cookie rather than a column. So all three served <html lang="en"> wrapped around Hungarian text. V104 made it matter more: /vendor's title is now *A VELA-fiókotok*, and the document language is what decides how a screen reader announces a title — English phonemes over Hungarian words, which is unintelligible and inaudible to everyone not using one. A third LocaleSource, and no query behind it. { kind: 'reader' } resolves from the cookie and two request headers, so it costs nothing in a root layout — which was V33's entire objection, that reading anything per-request there made every route dynamic. It negotiates against ['hu', 'en'] rather than the catalogue's own locales on purpose: asking the catalogue means a full read of content.message in a layout, on every route in the group, to decide one attribute, and it is the same pair session.ts already hardcodes two functions from the sign-in email. The rule is agreement, not a constant. A check expecting hu passes on a page that is Hungarian for the wrong reason and fails the moment an English reader is served correctly; one expecting en reinstates the bug. What must hold is that <html lang> and the shell's <div lang> do not contradict each other, in both languages — and that is also the only form that can catch the original defect. Forced red by the world rather than by a fixture: run against the server still holding the previous build it failed four times, naming both routes and the Hungarian case only, because English passed while en was the constant. Green after the rebuild, and /vendor verified separately under a minted session: hu and en each agree across <html>, the shell and the title
V107A hospital, an airport and six gyms are in the wedding directory, and now somebody has counted themcompleteStanding brief, stream 3 — found by walking the surface rather than by reading the limits list. Eight production venue pages sampled at random on 2026-09-06 turned up *Airsoft Ring Kft.* and *Art Pub*, both rendering one heading and a link back. Every other check in this repository asks whether the 1 228 pages render; none asks what is on them. report:directory reads all 1 228 rows and samples 60 detail pages. **1 223 carry a town (99.6%), 1 031 a photograph (84.0%), all 1 228 a slug — and 241 (19.6%) sit in a category whose place in a *wedding* directory nobody has decided: 96 sports venues, 52 other, 32 with no type at all, 25 amusement parks, 11 stadiums, 6 gyms, and one each of a hospital, a cinema, a shopping centre and a zoo. Nobody upstream has erred — event.clinic is a general event platform and VELA points its taxonomy at a narrower question than it was built for. Two mistakes of my own, both caught before the number left the building.** The first version read a description field off the *list* endpoint, found it absent on all 1 228 and printed 0.0% carry a description — a catastrophe that was an artefact of asking the wrong endpoint for a key that does not exist under that name. @vela/directory states both facts in a table twelve lines long: the field is overview, *null everywhere* on the list and *populated on roughly two thirds* on the detail. Sampled properly it is 44 of 60 (73%), which agrees with verify:directory's 36 of 46 and with that table — three ways of asking, one answer. The second was the judgement itself: a first draft of the questionable list held church, temple and place_of_worship, the three most obviously correct wedding venues there are, and community_centre and school_university beside them — a *művelődési ház* wedding is ordinary in a Hungarian town. A list of types that look wrong to me is a judgement that reads like a measurement. Nothing was filtered. Excluding a category changes the offer, moves the front door's headline from 1 228 to about 987, and is not reversible from the reader's side — a couple who would have married in their town's *sportcsarnok* never sees it and never tells us. Recorded as /Users/petermarik/Documents/event-clinic-suite/wedding/vela/docs/OWNER-DECISION-01-directory-scope.md with three options and a recommendation to rank rather than hide. Two floors, both forced red: below 1 000 venues or below half carrying a description, exit 1 — raised above reality it named both and exited 1; left alone it exits 0. Deliberately not in the battery, because it costs a sibling's API 60 detail requests a run and verify:directory already samples 106
V108The couple-lens sweep was reporting 65 000 characters of empty pagescompleteFound by counting what a couple can actually do. A census of forms, buttons and fields across all twelve screens showed /shortlist with zero of each — which looked exactly like V99's defect, the page whose capability exists and whose control does not. Reading the file first is what stopped it being reported: the page renders a KeepButton and an EnquiryForm per card, and this couple has no cards. The count was of an empty page. Which is the real finding. verify:couple-lens prints *65 216 characters swept clean* while 8 of 24 screen renders carry an empty-state message — market, stays, budget and shortlist, in both locales. Every assertion in that file passes on them and exercises none: the 300-character floor is deliberately low and an empty state clears it comfortably. This repository already knows the lesson — *a render check proves little; it passes on a page of dashes* — and it was true of the harness that holds it. Named rather than failed. An empty section is a normal state for a new couple, so the sweep lists them by name every run and a green result can no longer be read as coverage. Detected from the catalogue rather than a marker list: every empty state in the product is a *.empty key, so a fifth added next year is covered without touching the file. It reports a message, not a screen, and the difference showed up at once: /market came back in the list while rendering forty venues, because its *supplier* half is empty and says so — worth knowing rather than worth hiding, so the wording says what was actually found. The one case that does fail is all of them, which is not a new couple but a database the seeds never filled; forced red by making every page read as empty, and it named itself
V109Two screens no seed had ever filled, and the fifth backtick the guard could not seecompleteThe half of V108 a seed can close. Nothing in this repository had ever rendered /shortlist or /stays with a row in it: no seed writes marketplace.shortlist_entry or marketplace.stay_block, verify:shortlist exercises the shortlist *functions* against a real schema with rolled-back fixtures and never goes near the page, and verify:couple-lens fetched both, found their empty states, counted six hundred characters each and called them swept clean. seed:lens keeps two venues and a supplier per wedding and adds two room blocks — one held with a release date, the one figure on that screen that costs money when it is missed and that nothing had ever rendered. Deterministic by slug and offset per wedding, so two couples do not keep an identical list: a sweep in which every wedding shows the same three names cannot tell a per-wedding query from a global one. Localhost-only, unlike its neighbours — seed:budget and the rest take whatever DATABASE_URL says, and there is no version of a good day that ends with three invented keeps on the owner's real wedding. The empty-state census fell from 8 of 24 to 2, and the two that remain are /market's supplier half, which is genuinely empty and says so. Then the file would not parse, for the fifth time in this repository's life. A note written inside the query — /* … current_date … */ — and a backtick inside a sql template ends the string, comment or not. sql-templates.test.ts exists for exactly this and 147 tests passed over the broken file: the rule fired only on --, and the fifth occurrence used a block comment. A check named for a trap it cannot catch is worse than none, because it is the reason nobody looks. Widened to both comment shapes — and the first widening immediately flagged font-coverage.ts:54, a JSDoc line reading *U+00E9-style codepoints, which is what pyftsubset --unicodes wants*, which is nothing to do with SQL and would have been the line that got the rule deleted. JSDoc is now excluded by its own punctuation: it opens /** and continues *, a note inside a query opens /*. Two fixtures added, one for the block-comment shape and one asserting the same note is fine once moved above the query — because a rule with no compliant alternative is one somebody deletes. Forced red on the real file: it named seed/lens.ts:174. And the label itself was wrong before it was extended. Promoting declared writers to fixtures immediately produced three absurd rows — stripe:plans:prod described as *refuses a database that is not localhost*, and procedures.mjs labelling itself one because it holds both detection patterns as regex literals — which is the loose detector V105 shipped, seen under load. The test is now the shape of a refusal, if (!/…localhost…/.test(, which every real guard here is written as and a default value cannot match; and a production reach overrides the label outright, because the two are contradictory claims about one command. 11 fixture writers, each checked by hand against the guard on its own line, and the V105 row carries the correction
V110The couple who signed in saw six sample venues; the stranger who did not saw 1 228completeFound by asking where the couple's marketplace gets its venues. /market called directory.venues({ country, limit: 6 }) on every render and rendered none of them — only market.venues.status, to choose a message when VELA's own table was empty. Which on production it effectively is: marketplace.venue holds six rows and all six are demo, as does marketplace.supplier with fourteen, while the front door advertises 1 228 helyszín. So the couple lens showed six sample listings and the public /directory showed the whole catalogue, and nothing in the couple lens linked to /directory at all — measured, not assumed: zero matches for href="/directory under app/(en)/wedding. The page's own note says it is *built from what we hold, and the directory fills the categories we hold nothing for*; venues are such a category and were the one place that rule was not applied, with the fetch already in place. Read-only by necessity: shortlist_entry.venue_id is a foreign key into VELA's own table and a catalogue venue has no row there, so keeping one would mean creating that row on the couple's behalf — a decision about what VELA holds, not a button. The cards link out and the copy says why, rather than showing a control that cannot work. The link goes to a Hungarian page and the lede says so, rather than being withheld from an English-reading couple the way /súgó is in V102: a venue's name, its town and its photographs read in any language, and withholding the product's main asset to avoid a language notice is the worse trade
V111Five hundreds behind five guards that existed and did not holdcompleteStanding brief, stream 3 — found by posting at a form instead of reading it. Twenty server actions write to this database and nothing had ever posted a hostile value at one of them: verify:isolation asks whether the poster is entitled to the wedding, verify:couple-lens whether the page speaks the right language, verify:signup whether the journey completes. Every one submits well-formed input, because every one was written to prove something else. <br><br>The five, each reproduced before it was believed. (1) Seventeen digits in the budget amount box — 22003, value out of range for type bigint. (2) 1e30 — 22P02, invalid input syntax for type bigint: "1e+32", because String(1e21) is exponential and PostgreSQL cannot read it. Both passed Number.isFinite(value) && value >= 0, which was the whole guard. (3) 9999-99-99 as a due date and (4) 0000-01-01 — 22008. Both passed /^\d{4}-\d{2}-\d{2}$/, which is a check on shape and never was one on validity; the comment above it named 22007 as the thing it prevented. (5) A tampered taskId on any of /plan's 43 ticks — 22P02 on ${taskId}::uuid, on a page whose own comment promised it would *simply re-render unchanged*. Corrected by V112: the sweep for this fifth defect was wrong. It grepped for ::uuid and found four places; where id = ${tableId} has no cast to grep for, because PostgreSQL infers the parameter type from the column and raises 22P02 anyway. Eight more of the same defect were sitting in five other files, and the search that was supposed to find them could not see them. A sixth was closed by reading rather than by probing: acceptQuote bounds the amount against the quote range and both bounds are nullable, so a supplier answering *from 450 000* with no ceiling left every larger number accepted. <br><br>Both fixes already existed in this repository. isValidDate in apps/app/lib/plan-dates.ts round-trips the parse and catches every rollover — the budget action was reimplementing its weaker half one directory away. isId in @vela/domain is the predicate that decides what a primary key looks like everywhere else. Neither was reached for. The money parser had been written twice, differently: the supplier reply stripped every non-digit, so 1200,50 became 120 050 Ft — a hundred times the quote — 1e6 became 16 Ft and -5 became 5 Ft, all silently. One parseMinorUnits now, in budget-maths.ts beside the arithmetic it feeds, doing the sum in BigInt so the bound is asserted on the real value rather than on a float that has already lost it, and accepting 450 000,- because that is how a Hungarian price list writes *and no fillér*. <br><br>The ceiling is a fact, not an opinion. MAX_MINOR_UNITS is Number.MAX_SAFE_INTEGER fillér — the point above which the value read back is not the value stored, since the driver returns int8 as text and every reader converts with Number(). A *product* limit is a different and much lower number and is the owner's; this one refuses only what cannot be held faithfully. <br><br>Year zero is the finding worth keeping. Date.parse('0000-01-01') succeeds and formats back byte-identically, so a round-trip check against JavaScript passes it; PostgreSQL follows the historical calendar where 1 BC is followed directly by AD 1 and raises 22008. Reusing the correct existing helper would still have shipped that one. <br><br>And a refused write was a 500 in English. The app has no error boundary, so throw new Error('Could not add that budget line: amount') reached a couple as Next's unstyled 500: Internal Server Error, in English, with the page they were on gone — the normal outcome of any rejected budget write, not an edge case. Post-redirect-get now, the arrangement /welcome/start has used since V60, carrying an allow-listed code rather than a message, because ?error= is attacker-chosen text rendered at our own address. Plus maxLength on the two money boxes, so a held-down key is refused by the browser before anything is sent. <br><br>verify:inputs is the 20th check, and it discovers its own targets. It fetches every couple screen, parses every <form> out of the served HTML with its Next action id, and posts 11 shapes into every field and every bound argument — 49 forms, 1 122 probes. The bound-argument half is what found defect 5: /plan's 43 ticks have no input boxes at all and everything they know travels in $ACTION_n:1. Its first summary read *49 forms found, 66 values posted* and called itself clean; found and probed are counted separately now and a gap fails the run. It cleans up after itself — proved by running it twice against a row count, and by disabling the delete to watch the guard name the table. Nine of twelve screens are printed as no reach, not as clean: they drive their actions from client components, whose action ids live only in the JS bundle. That is the next increment, said out loud rather than hidden. <br><br>No CHECK constraint, deliberately. All three writers of wedding.spend.amount_minor are bounded now, so a constraint would guard a fourth that does not exist. pnpm --filter @vela/db spend-integrity[:prod] is what makes that decision re-checkable — a single row it reports is the fourth writer announcing itself. Measured on production 2026-09-06: 1 spend row, 1 budget set, nothing out of range and no orphan category. (The row above claimed 0 of each before it was run; corrected against the measurement, which is the rule this ledger keeps.) <br><br>20 of 20 checks ran and passed, in one run, against a production serving the same build the local server was. Deployed 2026-09-06 as Worker version fa4a1d33, no migration — 53dc5fa7 first, then fa4a1d33 from an identical tree, and the second deploy is the finding rather than a mistake: the three chunks carrying server actions are not byte-reproducible, because their filename hash covers ids Next mints per build. Two builds of the same commit rename them, so any local rebuild turns verify:deployed red for ever after and verify:all can only be whole in the window between a deploy and the next build. The way through is to build once, deploy that artefact, and serve the same .next locally — opennextjs-cloudflare build leaves one next start can use. Recorded in DEPLOY.md step 5.
V112Nine screens the form sweep printed as no reach, and the eighteen 500s waiting on themcompleteThe next increment of V111, named in V111's own output rather than discovered later. verify:inputs parsed <form> elements out of the served HTML, which reaches three screens of twelve: only a form renders its Next action id into the page. The other nine drive their actions from an onClick, and those ids live in the JavaScript bundle. They were printed as *no reach* — honest, and still nine untested screens. <br><br>.next/server/server-reference-manifest.json is Next's own record of every server action — id, route and source file — so it reaches all of them and cannot go stale, being written by the build under test. 17 couple-lens actions, all probed. <br><br>The discriminator is what makes it usable. A server action is a plain function, so calling it with the wrong arity or the wrong types raises a TypeError — a hand-built POST getting what it deserves, not a defect. Reporting those would leave the check permanently red for a reason nobody should fix. So every probe is a pair: a control carrying a syntactically valid uuid nobody owns, then the same array with that argument malformed. A finding needs the control to succeed and the probe to fail, which can only mean the action took the argument and its *shape* is what broke it. Arity mismatches fail both halves and are ignored. <br><br>18 findings, in five files: seating (seatGuest, seatHousehold, removeTable), guests/invite-actions (sendHouseholdInvitation), market (submitEnquiry, toggleShortlist, submitAcceptQuote), help/request (cancelRequest), budget (removeSpend). Every one the same defect V111 fixed in setTaskDone, and V111's sweep for it was wrong: it grepped for ::uuid and found four places. where id = ${tableId} has no cast to grep for — PostgreSQL infers the parameter's type from the column, so it raises 22P02 just the same. The dangerous ones were exactly the ones with nothing to search for. Corrected in the V111 row too. **And corrected by V114: all eight of these guards used isId, which checks the version and variant nibbles and so asks *did we mint this* rather than *will PostgreSQL read this*. Nothing was refused wrongly here — every id these actions take is either newId's or an event.clinic v5 — but the predicate was the wrong one, and one lens over it told an operator that a real vendor account no longer existed. All sixteen guards are on isUuidShaped now. <br><br>The harness was mutating the fixture, and its own guard caught it.** Driving actions directly reaches addTable, whose second argument is a *name*, so the control filled it with a uuid and created 8 seating tables per run. The cleanup now snapshots the ids of every table in the wedding schema and removes exactly what the run created; the controls use uuids that belong to nobody, so an action that updates matches nothing. Proved by a row count either side, and the guard proved by disabling the delete. <br><br>Forced red before believed green: one guard removed from removeTable, and the check named seating/actions.ts on three of its four argument shapes and nothing else. <br><br>20 of 20 checks ran and passed, in one run, against a production serving the same build the local server was — the order V111 recorded in DEPLOY.md working as written. Deployed 2026-09-06 as Worker version e5244b12 (100%, confirmed with wrangler deployments list rather than assumed), no migration. Production probed after it: /, /sugo and /directory 200, an invented URL 404, /dev 404.
V113The page a couple sees when the product breaks was black, English, and about server logscompleteFound by V111 and left standing until it could be measured. V111 fixed the *deliberate* case — a refused budget write — with post-redirect-get. This is the one underneath it: the application had no error boundary at all, no error.tsx and no global-error.tsx, so every unexpected failure fell through to React's own page. <br><br>Measured in a browser, both ways, on a couple screen made to throw. Without a boundary: a black page, no title, and one line of English — *"Application error: a server-side exception has occurred while loading 127.0.0.1 (see the server logs for more information)"*. With it: *Valami elromlott nálunk*, Canela at 44px on the warm ground, a lede in Hungarian and two ways onward. The A/B was run rather than reasoned about, because the first measurement said the opposite of what the second one showed. <br><br>The server response is byte-for-byte identical either way, and that is the finding. global-error.tsx renders client-side: the server streams an empty 8.5 KB shell titled *VELA* whether the boundary exists or not, and React swaps it in after hydration. So no harness in verify:all can see this — every check that fetches HTML gets the same bytes before and after. A browser can, and did. <br><br>Which is why the guard is a source test. error-boundary-is-styled.test.ts holds the seven properties an ordinary edit would silently drop, following the pattern private-routes-are-dynamic.test.ts set: the boundary exists, it is a client component, it imports the stylesheet its class names are written against, it wraps itself in rsvp — load-bearing, because almost every rule in public-surfaces.css hangs off it and V69 found every 404 rendering unstyled Times on white for exactly that reason — the document says lang="hu", the reader is never shown error.digest, and nothing is read at render time, because an outage is one of the two things that brings somebody here and the database may be what failed. <br><br>Each of the seven forced red on its own: the import removed, the wrapper renamed, the language switched to en, the digest printed, and the whole file deleted — one failure each, four for the deletion, and nothing else moved. <br><br>Hungarian, hardcoded, and the trade is stated rather than assumed. A client boundary cannot read the row that holds a couple's language. The alternative is Next's English default, so an English-speaking couple is no worse off than today while every Hungarian one is better off. If that stops being the right trade, the fix is an error.tsx inside a layout that already knows the locale — not a lookup in a table that may be the thing that failed. <br><br>Not fixed, and recorded rather than tidied away: the two footnote links render in the browser's default blue on this page and on the 404, which shares the markup. Pre-existing, cosmetic, and the same on both. <br><br>20 of 20 checks ran and passed, in one run. Deployed 2026-09-07 as Worker version eddfb464 (100%, confirmed with wrangler deployments list), no migration. The boundary's own chunk was fetched from velawel.com and is byte-identical to the built one, 961 bytes — and the probe that proved it taught something: two of four greps returned 0 because the minifier escapes non-ASCII, so Nem a ti hibátok is Nem a ti hib\xe1tok in the bundle and a grep for the Hungarian finds nothing. On a Hungarian-first product that is a false negative waiting to be read as a failed deploy. Compare the SHA, or probe an ASCII substring.
V114The twenty-four actions nothing had ever posted at, and the guard that told an operator a real account was gonecompleteThe increment V112 named in its own output. The build manifest holds 41 server actions; seventeen are the couple's and were swept by V111 and V112. The other twenty-four sell paid positions on public pages, suspend a business's account, mint an organisation, edit the product's own copy, start a subscription, and take the answers of guests and suppliers who hold a link and no account at all — and nothing had ever posted a hostile value at one of them. <br><br>A status below 500 is not evidence here, and that is the whole design. Every one of the fourteen admin actions answers 200 {ok:false} to an unauthenticated caller — *"Selling a placement is internal staff work."* A harness whose credential did not take would post nineteen hundred hostile values, receive 200 to every one, and report a clean sweep of twenty-four actions it never entered. Measured, not imagined: the first sweep walked the manifest in order and reached signOutAction third. It revoked its own session, and the twenty-one actions after it all reported *correctly refused* — a total false green produced by the product working perfectly. <br><br>So every action is called twice, and the second call is a deliberately invalid control. Same sound arguments; the credential broken — a session token matching no session, a link token matching no row, or, for the two actions that take no credential at all, an empty argument list. If the two answers are identical the credential bought nothing and the code behind it was never entered, so the action is not counted as probed. Sessions are minted per recipe rather than per run, and the credential is proved again after the probes, so anything else that revokes one is reported rather than discovered the same way twice. 24 of 24 entered, and an action neither sweep enters fails the check instead of passing quietly. <br><br>A FormData action can only be called in React's own reply encoding, and getting it wrong is silent. The fields must be named _1_<field> and the root 0 must be appended last: Next parses a fetch action's multipart body with busboy and resolves the root model as it streams, so a $K1 reference arriving before its fields binds an empty FormData. Three plausible encodings all answer 200 with a domain refusal — indistinguishable from a reached probe — while binding nothing. Measured against createVendor until one of them created a vendor. <br><br>Eighteen findings, nine defects, every one reproduced before it was believed. cancelSlot, confirmVendor, setSuspended, editVendorIdentity, recordCallOutcome, saveVerdict and selectPlan all handed a primary key straight to PostgreSQL — 22P02 and an error page where a sentence belonged. Two enum columns took arbitrary text: saveVerdict's verdict and saveMessage's status, the second worse than a fallen-over screen because an unrecognised status takes the *stricter* permission branch and then breaks, which reads like an authorisation problem and is not one. And sellSlot — the screen that sells positions on public pages — had four guards, three of which did not hold: Number.isInteger is true of 1e30 and the column is integer; Number.isFinite is true of 1e30 and the price becomes a bigint; the dates had a presence check and a string comparison, so 9999-99-99 and 0000-01-01 reached date and raised 22008. isValidDate and MAX_MINOR_UNITS already existed — V111 wrote both for the couple's budget one directory away — which is the mistake that slice recorded, repeated one lens over. <br><br>The correction that matters is to a guard this slice shipped. The first version used isId, which checks the version and variant nibbles and therefore answers *did we mint this* — a **stricter question than *will PostgreSQL read this*. The development database holds a vendor account under the hand-written fixture id 33333333-3333-3333-3333-333333333333, documented as one in lib/vendor-accounts.ts, and it is a perfectly good uuid and not a v1-8 id. So the new guard told an operator selling that account a placement that the account no longer exists — a refusal that is a lie, which is worse than the 22P02 it replaced, because the fault at least looked like one. Twenty-one hard-coded ids in this repository fail isId; the patterned fixture id is a convention here. isUuidShaped is now in @vela/domain beside it with the distinction stated, all sixteen guards moved onto it including V112's eight, and a test pins both directions, forced red by making the two predicates identical. <br><br>Reach is a weaker claim than carrying out, and the gap is how that regression got past a green run.** The reach comparison proves the credential was consumed; a control that gets past the door and is refused at a *later* gate still differs from a refusal at the door. With the too-strict guard, sellSlot answered *that vendor account no longer exists* to every call including its own control, all sixty-five of its probes ran against that refusal, and the harness was green — twice, including after the fixture ids were changed to the patterned shape specifically to catch this. controlMustSucceed is what closes it: fourteen recipes have arguments complete enough to be accepted, and at least one action sharing each file must report having carried the request out. Forced red against the previous build it named exactly five actions and nothing else — the five the too-strict predicate refuses. <br><br>That assertion immediately found three collisions of the harness with itself, none of which any earlier check could see. inviteAction upserts on reviewer address and locale, so its probe re-issued the writer lens's own token and left two actions unreachable. createVendor's owner address was the fixture vendor's owner, who already runs an account, so its control could never succeed. submitAction closes the assignment it is handed, so saveAnswerAction answered *this has already been sent back* to every call including its control; each now gets an assignment of its own. A fourth: the invalid control broke only the token spelled token, leaving submitToken sound and the two answers identical. <br><br>A row-count guard that watches only for growth cannot see a deletion. undecideAction deletes the decision it names and recordCallOutcome cancels an engagement, so the recipes point at a locale nothing real is decided in — and the guard now fails on a table that shrank as well as one that grew. It found one on its first run: identity.session came back a row lighter, because the snapshot was taken after the couple's session had already been minted. Moved to the true before. <br><br>Each leaf is given values of its own type, and that is a stated limit. An early run reported ninety TypeError: value.trim is not a function findings from numbers posted where the contract says string. They are real 500s and they are not this check's subject — no client component can produce one, only a hand-built POST — and the couple sweep already draws the line in the same place. What is left is what a tampered value of the right type does, which is the question with a database at the end of it. <br><br>Nothing here touches a row it did not create. The run makes its own vendor account, RSVP link, supplier reply link and two writers' links, and every id argument that can reach the code with a valid uuid belonging to nobody uses one — cancelSlot answers *that slot is already cancelled* and writes nothing. Cleanup now covers every table in all ten application schemas, asked of the catalogue rather than listed, retried until the foreign keys allow it. Verified by row counts either side and by the guard reporting a table it could not clean. <br><br>20 of 20 checks ran and passed, in one run, against a production serving the same build the local server was. Deployed 2026-09-07 as Worker version 0dceedb0, no migration. Each of the nine guards was then probed one at a time against the deployed artefact and every one refused with a sentence rather than a 500, beside two positive controls that carried their request out.
V115Thirteen of twenty-one needs showed a couple invented businesses, and hid the real ones behind themcompleteThe supplier half of what V110 fixed for venues, and the same shape exactly. /market is built from two sources: marketplace.supplier, which VELA holds, and event.clinic's company catalogue, fetched on every render. The page chose between them rather than ordering them — a need with any local supplier rendered it and discarded listing.companies entirely, though they had already been fetched and paid for. <br><br>Measured on production: fourteen supplier rows, and seed:marketplace marks all fourteen demo-. They cover thirteen of the twenty-one needs, and they are the thirteen that matter: caterers, photographers, videographers, florists, bands, DJs, cake, bar, hair and make-up, styling, sound, transport, the MC. So a couple who signed in read invented businesses for all of them while a stranger on /directory read event.clinic's real companies for the same trades. Asked of the directory, need by need: 19 of the 21 have companies listed, between four and eleven each. Only video and vofely have none. <br><br>The page's own opening comment had already argued against this, before the seed existed: *"An empty shelf that says it is empty keeps its credibility; a stocked shelf of fictional suppliers loses it permanently, and at the worst possible moment."* <br><br>One rule, written down where it can be tested. lib/listing-order.ts: an invented listing is never rendered above a real one. Real local suppliers first, then the catalogue, then the samples — kept rather than dropped, because they were seeded deliberately so that screens built on an empty marketplace could be looked at, and removing them is a decision about what the product shows rather than a defect to fix. What is a defect is their standing in front of real inventory. Five tests, forced red by restoring the old choice: two of the five fail and the other three do not move. <br><br>Measured before and after against the same page, not reasoned about. Before: 6 needs showed the catalogue and 13 showed only a sample. After: 18 show the catalogue, one shows only a sample (video, which event.clinic has nothing for), one points at the venue list and one is genuinely empty. The rendered page went from 81 582 to 100 056 bytes. No new upstream requests — every company on it was already being fetched and thrown away. <br><br>No new copy keys, which is the reason to check first rather than write first. market.catalogue.title already reads *From the public catalogue* and *A nyilvános katalógusból*, seeded in both languages since V110. So this slice adds nothing to the Hungarian review queue, which is the third of the four things still waiting on the owner. <br><br>report:market is the check, and it reads the rendered page rather than the data. The data was never wrong: the catalogue was fetched every time and discarded in the markup, so counting rows would have reported a healthy shelf throughout — and every harness that fetches this page got a 200 with a list of suppliers on it, because a list of invented suppliers is a list. It prints one line per need and the number that must be zero. <br><br>Two discriminators were wrong before the right one, and printing the list is what caught it. day-item__name is the heading of an enquiry as well as of a need, so the first run reported a supplier the couple had written to as a twenty-second need; day-item__needs and day-item__tag are on both as well. The enquiry section now carries id="enquiries", the report stops there, and it refuses to run rather than guess if that id is ever removed. A count alone would have shown 22 and looked fine. <br><br>20 of 20 checks ran and passed, in one run, against a production serving the same build the local server was. Deployed 2026-09-07 as Worker version d27d8d7f, no migration. report:market is the 114th procedure in docs/OPERATIONS.md.
V116An open redirect on velawel.com, four spellings past a guard that named the attack it missedcompleteV114 covered the 41 server actions and left the HTTP routes, which are more exposed than any of them. Eight route handlers exist. Three answer with no input, /welcome/start is already driven end to end by verify:signup and the Stripe webhook by verify:payments — which leaves three public, unauthenticated routes that anything on the internet can post at and nothing ever had: /api/subscribe, /sign-in/verify?token= and /lang/[locale]?to=. <br><br>Two of the three held up, and that is worth recording so nobody probes them twice. /api/subscribe was given eleven shapes in each of its three fields and six malformed bodies — not-JSON under a JSON header, a bare array, null, no content type, an empty body — and answered 400 to every one, never a 500; its no-JavaScript path still 303s to the thank-you. /sign-in/verify was given the same eleven as a token and answered 302 to the sign-in page every time. <br><br>The third was an open redirect, and it was live. /lang/[locale]?to= takes a redirect target from a stranger and tested it with /^\\/(?!\\/)/. That is a real check written against a real attack — the route's own comment names it: *"//evil.example is a protocol-relative URL that a naive 'starts with /' test lets straight through."* It stopped one spelling and let four relatives past: a backslash, a backslash and a slash, two backslashes, and a tab. Every one resolves to https://evil.example/ in the WHATWG URL parser, which is the parser every browser uses. Reproduced on production before anything was changed: https://velawel.com/lang/hu?to=/\\example.com answered 307 Location: /\\example.com, and https://www.velawel.com did the same. A link on VELA's own domain, over TLS, that lands a reader somewhere else — and this product sends sign-in links by email, so a lookalike reached from a genuine velawel.com address is the attack that actually works. <br><br>The fix asks the parser instead of imitating it. sameSitePath in lib/hosts.ts resolves the candidate against a base and compares the origin, so it runs the same algorithm the client will run and the two cannot disagree. javascript: and data: fall out for nothing — they parse to a null origin — and the path is returned normalised, so what reaches the Location header is what the parser made of it rather than what somebody typed. A pattern that decides what a URL means is a second URL parser, and the only question about a second parser is which character finds the gap. <br><br>Forced red twice, at two levels. Putting the old regex back into sameSitePath fails two of its five tests and names /\\evil.example first. And the end-to-end sweep was run against the still-running vulnerable build before rebuilding — free, because the server was already there — and reported all four. <br><br>My own test data was wrong first, and the count is what caught it. The off-site list was written as string literals, and a backslash in a TypeScript string is an escape: the literal '/\evil.example' is /evil.example, an ordinary same-site path, because \e is not an escape anybody wrote on purpose and JavaScript quietly drops the backslash. The entry meant to carry two backslashes was the only one that survived. The first sweep reported two findings where a hand measurement minutes earlier had found four, and reported the other two as passes. The list is built from String.fromCharCode(92) now, with the reason written above it. <br><br>The guard cannot be a wall, so the control is checked beside it. /lang/hu?to=/sugo must still answer Location: /sugo; a route that refuses its own paths refuses an attacker by accident and would pass a check that only looked for refusals. <br><br>The check lives in verify:public-html, which fetches the bytes a reader is served rather than an artefact resembling them — and a unit test could not have caught what actually ships, which is a caller that stops asking the helper. 20 of 20 checks ran and passed in one run. Deployed 2026-09-07 as Worker version 68888589, no migration, and the sweep was then run against production itself on velawel.com and www.velawel.com: eleven public pages clean, seven redirect targets refused, /sugo honoured. my.velawel.com checked by hand as well — all three now answer 307 to the site's own root.
V117A public site sending no security headers at all, and the value that would have broken a screencompleteMeasured on production before anything was written: /, /sign-in and /directory carried no Strict-Transport-Security, no X-Content-Type-Options, no Referrer-Policy, no X-Frame-Options and no Content-Security-Policy. Not a weak policy — no policy. Found by carrying on down the public surface after V116, which is the same reason V116 existed. <br><br>The framing value is the finding worth keeping. DENY is the reflex, and it would have shipped a broken screen in silence: /pro/[id]/preview renders /wedding/[id]/plan in an iframe, same-origin, so a professional can see what a couple sees. A refused frame draws an empty box, logs nothing a reader would look at and fails no request a harness would notice — the page would simply have looked blank. SAMEORIGIN and frame-ancestors 'self' keep it working and still stop a third party framing the sign-in page. Both are sent, because they are different headers read by different browsers, and sending only the modern one leaves the older reader unprotected for nothing. A test reads the preview page's source and fails if it stops framing anything, so the reason survives the comment. <br><br>Referrer-Policy is not boilerplate on this product. Three surfaces carry a bearer token in the URL path — /rsvp/<token>, /reply/<token>, /write/<token> — and those are pages a guest or a supplier opens from an email. strict-origin-when-cross-origin is the modern browser default, and it is stated here rather than inherited, because a default is a thing that can change under you. <br><br>HSTS with neither clause that makes it hard to take back. 180 days, no preload and no includeSubDomains. Everything already answers over TLS behind Cloudflare, so this closes the first-request downgrade and nothing else; to reverse it, send max-age=0 and each browser clears it on its next visit. preload would mean asking a browser vendor to take VELA off a list on their schedule, which is somebody's decision rather than a default. A test asserts both clauses are absent. <br><br>What is deliberately absent, said rather than omitted. A full Content-Security-Policy: script-src, style-src and font-src need measuring per page against self-hosted fonts and imagedelivery.net, and a CSP that is wrong breaks the page instead of warning about it. frame-ancestors is the one directive that ships alone with no risk and it is the one that matters for a page carrying a sign-in. And Permissions-Policy: nothing here asks for a camera, a microphone or a location, so a policy would be a list nobody maintains that goes stale the day something does. <br><br>Declared in next.config.ts, not in the Worker wrapper, and that is a testability decision. worker-entry.ts only exists on Cloudflare, so a policy applied there is invisible to next start — every local harness would read a bare response and the battery could assert only what it cannot reach. Next's headers() is honoured by next start and by OpenNext, which was verified on both surfaces after the deploy rather than assumed: five headers, identical values, locally and on velawel.com. public/ holds no HTML — fonts, a favicon, robots.txt, the manifest — so no document reaches a reader before the Worker and misses them. <br><br>verify:public-html asserts the exact values on all eleven public pages, not merely their presence: a check that accepted any X-Frame-Options would have called DENY a pass. Forced red against the previous build, which reported all five missing on every page. 20 of 20 checks ran and passed. Deployed 2026-09-07 as Worker version 899f13be, no migration, then swept against production itself — eleven pages clean, seven redirect targets refused, five headers exact, and the 404 carries them too.
V118Twenty how-do-I answers, and the three server actions they found with no control anywherecompleteComplete on 2026-09-14: one full pnpm verify:all ran clean — 20 of 20 checks ran and passed, nothing skipped, exit 0 — on 0154878 plus the two harness corrections below, at load 11 rising to 80. verify:deployed matched 12 of 83 client chunks against production byte for byte, so the run measured the build Worker 51a6ff75 serves; 1 131 unit tests passed across eight packages; verify:couple-lens read 16 of 18 how-do-I answers rendered, 2 by source only and 2 absent as claimed; verify:inputs entered 43 actions and left the database as it found it. The session's first battery, on 2026-09-13, was 18 of 20, and both reds were real. verify:isolation failed its own coverage guard: /wedding/<id>/howto had shipped without ever being requested with another couple's session. The page asks the same can() as the screens beside it, so nothing leaked, but it had been live since 2026-09-12 as a route nothing had asked to refuse. Added to ROUTES in /Users/petermarik/Documents/event-clinic-suite/wedding/vela/apps/app/scripts/verify-isolation.ts: 26 cross-couple requests refused across 13 routes, and forced red by a copy that treated each couple's own wedding as the other's, which named both couples' /howto (6 391 and 6 024 characters) among 26 failures. verify:inputs failed on enquiry_wedding_venue_uq, and that was two defects stacked. The run killed on 2026-09-12 had left its whole fixture set — a supplier user, organisation, membership and vendor account, an RSVP link, two writer assignments, and the input probe enquiry that holds that key — and the harness cleaned up only at the end of a run that reached the end, so the 2026-09-13 run threw on the enquiry and left a second set of seven rows for the next one. The cleanup now also runs from finally, in /Users/petermarik/Documents/event-clinic-suite/wedding/vela/apps/app/scripts/verify-inputs.ts. Run with the 2026-09-12 enquiry still in place it failed on the same key and a sweep of every table carrying created_at found no row left behind, against seven before the change; both sets were then deleted by their own ids and markers, and the next run passed with the sweep again empty. A killed process still skips finally; its rows carry input-probe- and VELA input probe, which is how these were found. The handover's next feature, and writing it was a census. The product had two help surfaces and neither took a verb: /sugo is public and answers what a *stranger* is confused about, and couple_help.* explains, per screen, what that page is for. *How do I move a guest to another table* was answered by nothing. /wedding/<id>/howto holds twenty questions, verb first, grouped under the screen that answers each, with a search box over all of them, reached from the disclosure already on every couple screen with the screen as a fragment. Every entry names the control its answer points at, which is what makes it checkable and what made it impossible to write from memory: V103 wrote twelve per-screen explanations from knowledge and shipped eleven right ones plus a confident paragraph about a screen that does not exist. So the list was written by reading all twelve couple screens and asking, for each task, *where is the button* — and three tasks had a button that existed and was wired to nothing. removeSpend, seatHousehold and cancelRequest were exported, authorised, isUuidShaped-guarded and swept by verify:inputs, and no control in the product called any of them: a budget was append-only so a mistyped figure was permanent, a family of six was seated one person at a time on a board whose own comment promises *one action, on the family's own heading*, and a booked call could not be cancelled so the consult slot stayed held and off sale. All three are wired; none is new code. Two answers say the product cannot do the thing, because nothing it runs creates a guest, a household or a hotel: wedding.guest and wedding.guest_household are written only by seed/guests.ts and the verify-erasure.ts fixture, marketplace.stay_block only by seed/lens.ts. Three sentences told a couple to *add the hotels you are considering* on a screen with no control that adds one; stays.empty, stays.no_provider and couple_help.stays are corrected in both languages, and /Users/petermarik/Documents/event-clinic-suite/wedding/vela/docs/FINDING-01-a-couple-cannot-fill-their-own-lists.md holds the measurement and what closing it would take. The check is in verify:couple-lens, which already signs in and fetches the twelve screens: each task's data-howto marker must be on its control, found on the screen it names or at least in the source — the weaker tier exists because a cancel button needs an open request and a quote form needs a quote, and failing those for being correct teaches everybody to ignore a check — and the run names which got only the weaker answer. The two negative answers are held in reverse, markers absent from source and screen alike, so the day somebody builds either control the run goes red and names the sentence that has become a lie. Measured: 16 of 18 rendered, 2 by source only (quote, cancel), 2 absent as claimed. The search was wrong in the way that matters most here, and typing into the running page found it. It folded accents off both sides and asked whether the text contained the term, and failed on the one example the page exists for: a Hungarian types *ültetés*, the answers say *ültetek*, *ültetünk*, *ültetettek*, and those diverge at the sixth character — *nincs találat* over an answer a centimetre below. It now matches a whole-term substring or a shared five-character stem per word, so nothing previously found stops being found; deliberately not a stemmer. And the English half of the copy seed could never correct a string. It guarded every existing row as a human edit and every English row seeds as human, English being the source — so no English source correction could reach a seeded database, which is why renaming task.book_music.title landed nowhere on 2026-08-15. The guard now asks the exact question, *has anyone ever written a message_revision for this row*, which every human edit writes from /admin/copy and copy-set alike; the on conflict do update … where subquery was run against PostgreSQL 17 before being written. D-10 is unchanged. The widened seed was measured against production before it was allowed to write there, with copy-pending[:prod], new here, read-only and running the seed's own predicate. It found four rows: the three stays sentences and home.title, the front-door headline, which read *Every wedding venue in Hungary, in one place* on production while the source has read *A wedding plan that knows where it is* since V62 on 2026-08-30 — the English guard kept that rewrite from ever landing while the Hungarian half of the same commit landed normally. The two languages had been making different promises for thirteen days, and no visitor could see it: marketForCountry returns only reviewed markets, localeForVisitor falls back to Hungarian, and England and Wales is not reviewed (/weddings-in/england-and-wales is a 404). After the seed, copy-pending:prod reads *nothing would change*. The catalogue was seeded after the deploy rather than before it, which is the wrong order and is said plainly: /howto is behind a session and production holds no couples, so the window had nobody in it. Probed on production against a deliberately invalid control: the new page's client chunk is served byte for byte, 1 969 bytes with a matching sha256, while a chunk name that was never built returns 404 with an HTML error page rather than a silent 200. Copy cost, measured: 54 new Hungarian machine rows (49 howto.* plus budget.remove, seating.board.move_family, help.booked.cancel, help.booked.uncancellable, couple_help.howto), none public — howto.* stays out of PUBLIC_KEY_PREFIXES, so the public half of the review queue is untouched. lib/howto.test.ts holds 14 assertions, and nine mutations of lib/howto.ts were each run against it and each turned it red — dropping a screen's only task, a task on a screen that does not exist, a task with no copy, two tasks sharing a control, deleting both honest answers, breaking the accent fold, folding letters away, removing stem matching, a one-character stem; a tenth, an empty search matching nothing, was cut short when the machine stalled and was not observed. lib/screens.ts is now the single list of the twelve screens and CoupleNav reads it, so the menu and the index cannot disagree about which screens exist or what they are called
V119A full Content-Security-Policy, report-only, and the enforced header that hid its nonce on productioncompleteDeployed 2026-09-14 as Worker ae026d6b, no migration, after a first deploy, b276ae86, that production itself proved wrong. One full pnpm verify:all on this deploy ran clean: 20 of 20 checks ran and passed, nothing skipped, exit 0, on 87507df between 08:37 and 08:40 at load 79 rising to 298. verify:deployed matched 12 of 84 client chunks against production byte for byte; 1141 unit tests passed across 8 packages; verify:public-html found every script nonced on 2 pages rendered per request and no nonce on 10 served from cache; verify:live read 10 of 10 probes healthy on production with the nonce check among them. Production after the fix: the header's nonce on 11 of 11 scripts on /sign-in and 14 of 14 on /, a new nonce per response, no nonce on the cached /directory and /plan, a forged policy header on the request choosing nothing, the intake answering 204, 400 and 413 to a report, to junk and to 20 000 bytes, no enforced CSP header, and verify:live 10 of 10. Chrome loaded /sign-in, /, /directory and /plan on production and logged no violation on any of them, and wrangler tail saw no browser report at all — only the probe's own, its fake token cut to /rsvp/:id. Two policies, because Next can only put a nonce on a page it renders for the request. Measured on this build by sending a nonce in the request header Next reads it from: every script on a page rendered per request carried it — / 21 of 21, /sign-in 16 of 16, the 404 /rsvp/<token> renders for a bad token 14 of 14 — and every page served from the prerender cache carried none: /directory 0 of 20, /directory/city/budapest 0 of 119, and the _not-found document every unknown URL is given, 0 of 12. Next's CSP guide says nonces need dynamic rendering, and that is the claim measured; rendering the directory per request would give up the KV cache V35 exists for. So the front door and every page under /admin, /dev, /pro, /reply, /rsvp, /sign-in, /vendor, /wedding, /welcome and /write get script-src 'self' 'nonce-…' 'strict-dynamic', and everything else gets 'self' 'unsafe-inline', which still refuses a script from any other host, eval, a plugin, a <base> and a form posting anywhere else. The list names the per-request pages, not the cached ones, because middleware cannot know that a URL will 404 and the 404 document is cached; /Users/petermarik/Documents/event-clinic-suite/wedding/vela/apps/app/lib/security-headers.test.ts reads every page's own dynamic or revalidate declaration and fails in both directions. One mismatch is known and left for the enforcement decision: a mistyped URL under a per-request prefix, such as /wedding/<id>/nincs, is given the cached 404 and the nonce policy, and its 12 scripts would report with status 404. The rest of the policy was measured on production before it was written, across twelve public pages: every script and stylesheet same-origin, imagedelivery.net the only other origin anything loads, nothing framed, the four @font-face rules all self, and no script injected by Cloudflare. report-uri rather than report-to, because Cloudflare already sends a Report-To header of its own for network error logging. Reports are logged, not stored. /api/csp-report cuts every URL to its origin and path and anything shaped like a token or an id to :id, drops the policy text, and writes one JSON line to Workers Logs; a stranger posting invented reports can fill neither a table nor a KV quota that NEXT_INC_CACHE_KV and VELA_WATCH_STATE depend on. Verified with a real browser on production: reloading /sign-in in Chrome sent 9 reports, every one answered 204, every one logged in wrangler tail with the chunk names and the page cut as designed. How to read them is in /Users/petermarik/Documents/event-clinic-suite/wedding/vela/DEPLOY.md. The first deploy was right everywhere but production, and that is the finding worth keeping. Locally every script was nonced; on b276ae86 none was — /sign-in 0 of 11, / 0 of 14 — while the response header named a nonce, and Chrome reported every script on the page. OpenNext copies each response header it is about to send onto the request it hands to Next (headers[key] = value in @opennextjs/aws 4.1.0 core/requestHandler.js, so that cookies() works on a first render), and Next reads a nonce from the request's content-security-policy before its content-security-policy-report-only. V117's enforced frame-ancestors 'self' from next.config.ts became that request header, carried no script-src, and hid the nonce. So V117's enforced CSP header is gone: framing is still refused by X-Frame-Options: SAMEORIGIN, which every browser in use honours, frame-ancestors 'self' rides in the report-only policy, and the policy that is eventually enforced carries it itself — being the header Next reads first. Middleware now sets the request's content-security-policy to its own policy instead of deleting a client's, because OpenNext lays middleware's request headers over the original ones rather than replacing them, so on production a deletion never reached the page and a caller's own header could have chosen the nonce. No check in the battery could see this, because every harness that renders pages runs next start. verify:live, the one that probes production, now fails when a script on / or /sign-in lacks its response's nonce, and it was forced red against b276ae86: 14 and 11 scripts. Tests: 13 assertions across lib/security-headers.test.ts and lib/csp-report.test.ts; twelve mutations of the policy and the intake each turned their test red, and a thirteenth, putting the enforced header back into SECURITY_HEADERS, turned the new test red by name. verify:public-html — every script nonced on the pages rendered per request, no nonce on the cached ones, a new nonce per response, and its report-uri taking a report and refusing junk — and verify:couple-lens — every couple screen nonced — both went red against the V118 build before either passed.
V120The procedure index called five read-only copy reports writescompleteOne full pnpm verify:all ran clean on 4b24ec6: 20 of 20 checks ran and passed, nothing skipped, exit 0, between 08:45 and 08:48 at load 76 rising to 274, with procedures running the new pins over 116 procedures in 5 packages and verify:deployed still matching 12 of 84 client chunks, since nothing here ships. The index exists so nobody has to read a script to know whether it is safe against production, and it was wrong in the careful direction for twelve entries. /Users/petermarik/Documents/event-clinic-suite/wedding/vela/scripts/procedures.mjs judged a procedure's effect by the first word of its name, so copy-values, copy-drift, copy-progress, copy-priority and copy-pending — read-only reports whose second word says so, and whose sources change no row — were indexed as writes, locally and in their :prod forms, and review-status the same way. The read vocabulary already held values, drift and progress: the intent was there and the anchor on the first word defeated it. It now reads every word of the name before its first colon, and only whole words, so a stripe-checkout could never pass for a check; pending and priority joined the vocabulary. A declared read is still demoted by its own source if that source changes rows, which is why deploy-check-rows stays a writer although it contains check. Measured before it was written: the old and new rules were run side by side over all six manifests, and exactly twelve labels move, every one from writes to reads; /Users/petermarik/Documents/event-clinic-suite/wedding/vela/docs/OPERATIONS.md is regenerated with those twelve and no other change. copy-set is the entry that mattered most and it does not move: it writes through a helper, so its own file holds no SQL the MUTATES pattern could find, and its name is the only thing keeping it a write. The labels it got wrong are pinned in the script itself, so the procedures check in verify:all now fails on a regression rather than a reader trusting it: copy-values and copy-pending:prod must read reads, copy-set and copy-set:prod must read writes. Forced red twice: putting the first-word rule back failed the check naming copy-values and copy-pending:prod, and adding set to the read vocabulary failed it naming copy-set and copy-set:prod; the file was restored by content and the check passed again. Nothing ships: the index and the script live in the repository, not in the Worker.
V121A couple can put people on their own guest list, and take them off againcompleteDeployed 2026-09-27 as Worker 1a84c2cc, no migration. Probed on production against a deliberately invalid control: the new page's client chunk is served byte for byte — 1 782 bytes, matching sha256, reached through production's own 307 to the canonical spelling of [id] — while a chunk name that was never built returns 404 under either spelling. Production holds no couples, so the forms themselves are exercised locally by verify:signup and verify:inputs; on production the route answers 404 for a wedding it does not have, and both new keys read back in both languages. One full pnpm verify:all ran clean on 6a17d7a: 20 of 20 checks ran and passed, nothing skipped, exit 0, between 19:10 and 19:16 at load 182 rising to 323. verify:deployed matched 12 of 84 client chunks byte for byte; 1 146 unit tests passed across 8 packages; verify:couple-lens read 17 of 19 answers pointing at a control it had rendered, with the one remaining *cannot yet* absent as claimed, and fetched the Hungarian guest list at 3 154 characters; verify:isolation refused 26 cross-couple requests across 13 routes; verify:inputs found 85 forms across 4 of 13 couple screens and posted 363 values into fields and 1 837 into bound arguments across 51 actions, leaving the database as it found it; verify:live read 10 of 10 probes healthy on production. The gap FINDING-01 measured is closed for guests. Nothing in the product created a household or a person — every row on /guests, and so on /seating, was a seed's — so for a real couple the guest list could only ever be empty, and V118's help index had to answer *you cannot yet*. /wedding/<id>/guests now takes a household at the foot of the list (the name on the invitation, a town, a note), a person inside each household (first name and surname — surname first on a Hungarian wedding, the way an envelope is written there — an email, who they are to the couple, and whether they must be there), and takes either off again. Four server actions, each re-checking the couple's right to change the wedding, in /Users/petermarik/Documents/event-clinic-suite/wedding/vela/apps/app/app/(en)/wedding/[id]/guests/actions.ts. A person is added in the same statement that finds their household, scoped to this wedding and to rows not taken off: the foreign key only proves a household exists somewhere, and without the scope another couple's household id would put a stranger on their list. Removing a household takes its people with it in one statement, because guest.household_id is ON DELETE SET NULL — right for a hard delete and silent for a soft one, which would have left the family on the list under *not in a household yet*, still counted and still invitable. Removal is soft everywhere, and four readers learned it: the guest list, the invitation page (a guest taken off has no invitation left to answer), the household signal in the pressure diagnosis, and the seating board. What a couple typed is read in one pure module, /Users/petermarik/Documents/event-clinic-suite/wedding/vela/apps/app/lib/guest-entry.ts: text is trimmed and cut to its column, an email address is refused rather than cut, because a cut address is a different address an invitation would silently go to, and the pattern is deliberately loose because the mailed invitation is what proves an address works. There is no edit form, and that is a decision: a typo comes off and goes back on, the way a budget line does, and verify:inputs posts hostile values into every form it finds — an edit form bound to a real household is one that harness would write straight into that household. Dietary requirements are deliberately not here: wedding.guest_requirement is marked restricted, has no encryption helper and no read audit in the app, and giving it a write path is its own decision. The how-to held its promise. With the guests.add marker in the source and addguest still answering *you cannot yet*, verify:couple-lens went red with *addguest says the product cannot do this, and data-howto="guests.add" exists in the source. Rewrite howto.a.addguest — it has become a lie.* The answer is rewritten in both languages and the entry no longer claims the product cannot do it; addhotel still does, and still holds. verify:signup now walks the rest of the journey: the stranger who became a couple posts the forms /guests renders, has a household with no name and a person with the email anna refused with nothing written, adds a household and a person, sees both on the page, takes the person off and then the household with a second person still in it, and reads the database and the page after every step. Before the deploy, on the same build served locally: the unit tests, verify:couple-lens, verify:signup and verify:inputs were all green — and verify:couple-lens had been red on addguest minutes earlier, which is the only reason its green means anything. Tests: lib/guest-entry.test.ts holds 5 tests, and six mutations of the reader each turned it red — a nameless household taken, a long address cut to fit, any address taken, any value ticking the must-be-there box, blanks not trimmed, and an empty field kept as an empty string. Copy cost: 18 new keys in each language, the Hungarian as machine drafts, none public, and howto.a.addguest corrected in both. The catalogue reached production before the code, which is the order DEPLOY.md states and V118 got backwards. copy-pending:prod previewed exactly one English correction — the rewritten addguest answer — the seed then wrote 19 English rows, those 18 new keys plus that correction, and a second copy-pending:prod read *nothing would change*. Hungarian drafts on production went from 912 to 930, which is these 18 and nothing else.
V-ECevent.clinic directorypartialRe-measured again 2026-08-24, across all 1 228 Hungarian venues as the live pages render them. *The Igen / nem junk is gone* — 0 occurrences where 28 rows carried it on 13 August, so that data defect was fixed upstream and the denylist in verify:directory is now a tripwire rather than a live guard; keep it, because it can recur and a denylist is exactly what a later refactor drops. *A new one in its place:* 181 of 1 228 venues have a null city, and in a 40-venue sample half of them name a town inside streetAddress — so roughly ninety venues sit in a known town while being invisible to its city page and to the related-venues section, because the town is in free text rather than in the field. Not parsed here on purpose: inferring a city from an address line is the kind of guess this product's provenance discipline exists to refuse, and R6 forbids treating an upstream label as a key. It is a question for the sibling. Their JSON-LD is unaffected — 0 of 40 emitted an empty PostalAddress, every one had a street address. Re-measured 2026-08-13 and the 3 Aug reading is superseded. venue_type is populated on ~24 of 25 rows in every country sampled, overview on roughly two thirds, and a photo field now exists on 24 of 25. R1 shipped: /venues/detail carries per-field provenance and confidence, with R2's consumer mapping already applied at their end. Still open: capacity_max_pax is null on every row, so the ACCEPTED-sibling-rulings.md §4 finding is unanswered rather than resolved — when R11a lands it as derived, R2 puts derived in the trusted half, which was the whole point of filing it. Provenance is on detail only; the list endpoint carries none, so nothing that filters across venues can see what it is sorting. Two data defects found: 28 HU venues carry city: "Igen" / region: "nem", and limit is silently capped at 100. A capacity field arrived unannounced, 2026-08-28: seated_max_pax, on the list endpoint, populated on 15 of 1 228 — and absent from /venues/detail and from both the provenance and confidence maps, so it is the one capacity figure carrying real numbers for which no provenance can be published at all. That is the exact inversion of the §4.1a complaint, which was that provenance lives on detail while filtering happens on the list. It is also not the banquet number despite the name: Müpa Budapest reads 150 against a main hall seating about 1 700, and the Aria Hotel reads 40 — plainly a room's figure, R11a restated in a second field with the ruling left off. Nothing in VELA reads it and the type deliberately omits it, so reading it is a compile error rather than a judgement call. Filed as §4.1b in ACCEPTED-sibling-rulings.md with the ask: classify it, and say which layout it means. region and star_rating are null on all 1 228 too — measured, not assumed — so region renders nothing on every venue page that lists it. A street name became a town, 2026-08-31. Anonymus Étterem is filed under the city Koós with the address Budapest, Kós Károly stny. 1, 1146 — *Kós Károly sétány*, a Budapest street named after the architect, whose surname became a Hungarian town. VELA renders what it is given, so /directory/city/koos answers 200 with one venue under *Esküvői helyszínek Koós · 1 helyszín* — a live page offering wedding venues in a place that does not exist. Not in the sitemap, which has a venue threshold, but the venue page's breadcrumb links to it so a crawler arrives anyway. One clear case in 75 sampled venues, and the second candidate was the probe being wrong rather than the data — Aranypart filed under Heves at Heves, Gyöngyösi u. is correct, *Gyöngyösi utca* being a street named after Gyöngyös. Recorded because a finding whose measurement produced a false positive should say so. Not denylisted: the existing Igen tripwire is right for words that are not names at all, and extending it to real surnames would mean VELA maintaining a list of which Hungarian words are places — encoding a vocabulary from the outside, which R6 forbids, and the next street-derived town would not be on the list either. Filed as ACCEPTED-sibling-rulings.md §8, asking for provenance: derived on city if it is derived.
V-SITEMarketing site — retiredcompleteDeleted on 2026-08-30 by V67, its two pages rebuilt as Hungarian Next routes and its two guards carried into apps/app first. Kept below as the record of what it cost and what it taught, because both outlived it. The last line of the note was its own standing defect — *the page is lang="en" throughout and describes Hungary to Hungarians in English* — and that is what D-64b closed. Astro, 78 tests, font-coverage and HTML checks in verify. Deployed 2026-08-24, Worker e60474b4. /weddings-in/hungary told a crawler you can marry after 0 days. The legal notice period is a count-up animation and its start value was baked into the markup, so the *served* HTML read "You cannot marry until 0 days after declaring your intent" — what a crawler indexes, what anyone with JavaScript blocked reads, and what stays on screen if GSAP fails. A false statement about marriage law on a public, indexed page, and it looked perfect to everyone whose browser ran the script. The markup now renders the real figure and counts to it from 0 at runtime, writing 0 only once JavaScript is there to finish the job. The target moved onto the element too: it was const target = 30 in the script while the sentence beside it read c.noticePeriodDays from the reviewed module — two sources of truth for a legal figure, the silent one still saying 30 after a reviewer changed it. The home page had it too, found by sweeping for the pattern rather than by anybody reporting it — data-ribbon-count served 0 beside the same claim about Hungarian marriage law, and there the figure existed only as literals in the animation script, in three places, with nothing tying it to the review at all; it now reads noticePeriodDays from the same content module, with no ?? 30 default because that would put the number back into the file it is being removed from, and the ribbon simply does not render for a market with no such rule. Deployed 2026-08-24 as Worker 4a0cc3bb. check-html.mjs now takes a list of animated figures — the same defect appearing twice is the argument for a list, since a third added without an entry would ship the same bug — and compares the rendered number against the element's own target rather than a literal, so it fails both on a hardcoded start value and on a reintroduced second source; proven by regressing the built file, exit 1 naming both problems, exit 0 on restore. The content test already asserted the figure was 30 — nothing asserted the page rendered it, which is the whole distance between a value being right and a reader being told the truth. And the home page broke its own stated rule. index.astro says in its own comments that every element starts readable in HTML and CSS, because a page whose text is opacity: 0 until JavaScript runs is blank when the bundle fails — and it is the only thing a stranger sees. Two rules broke it: .review__tick and .review__fix were hidden in CSS and revealed only by a GSAP timeline, so the evidence for the "every statement was reviewed by someone who grew up with it" claim was invisible to anyone whose bundle did not load. Reduced motion was already handled, which is exactly why it survived — the branch people test was fine. An inline js marker now goes on <html> before the first paint and the hiding is gated on html.js, so no-JS reads it and JS users still get the reveal without a flash; is:inline and the absence of defer are both load-bearing and neither is obvious in a diff. check-html.mjs asserts the marker on every page, because losing it makes every html.js rule stop matching silently and safely, which is the kind of failure nobody learns from the page. Deployed as Worker e041b8af. Still partial: the page is lang="en" throughout and describes Hungary to Hungarians in English, which is the offer the (marketing) README made on 2 August and nobody has taken up; and the ledger's old "15 pages" is wrong — the build emits 3
V-MKTGIn-app marketing routesnothing to buildThe owner lifted the hands-off rule on 2026-08-24 (D-33a), and the folder turned out to hold one README and no code. The routes it describes moved to the Astro site months ago — *Hand the front door to the static site*, *Move the market view out of the app*. Building marketing pages here now would duplicate what already serves velawel.com, so the honest state is nothing to build, not *not started*. What the README actually pointed at was real and was done instead: /weddings-in/hungary is the page a Hungarian couple most likely meets VELA on first, and it still renders English framing — see V-SITE

What “done” means here

Done means a verification script exited zero on the committed tree. Not that somebody opened the screen and it looked right, and not that an agent reported success. Every slice marked complete above has a script in scripts/ that exits 0 against the code as committed; a slice whose script has not exited 0 in a single run is not complete here, whatever else was built. pnpm verify does NOT run the couple-lens leak sweep — that needs a running server and a local database, and it refuses a remote URL because it mints a session.

Run every gate at once:
pnpm verify

Run it locally

# 1 · the app (Next dev; pick a free port)
cd ~/Documents/event-clinic-suite/wedding/vela/apps/app && DATABASE_URL="postgresql://localhost:5433/vela_dev" pnpm exec next dev -p 5185
# prove the tree before believing any status above
cd ~/Documents/event-clinic-suite/wedding/vela && pnpm verify
# sweep the couple lens for English leaks and non-200s
DATABASE_URL="postgresql://localhost:5433/vela_dev" VELA_BASE_URL="http://localhost:5185" pnpm --filter @vela/app verify:couple-lens

Every couple route needs a signed-in session AND a wedding id, which is exactly the problem V21 exists to fix. Reach them by seeding (pnpm db:seed) and signing in as couple@vela.example; the ids below come from wedding.wedding_project.

Screens with data

Screen
Open at
What is on it
Overview
landing
/wedding/<id>
Countdown, health signals, what needs attention
Plan
list
/wedding/<id>/plan
The task timeline
★ Budget
signature
/wedding/<id>/budget
Four kinds of money kept apart; rows always add
Guests
list + detail
/wedding/<id>/guests
Households that split, plus-ones, dietary
★ Seating
signature
/wedding/<id>/seating
Drag-and-drop, per-guest so a household can split, printable
Stays
list
/wedding/<id>/stays
Room blocks held not booked; release date is the deadline that costs money
Market
list
/wedding/<id>/market
Venues and suppliers — session-gated, which V21 fixes
Booking
list
/wedding/<id>/booking
Enquiries sent and their replies
Day / Legal / Help
list
/wedding/<id>/day
Running order, Hungarian legal prose, ask a question
RSVP
needs a token
in-app navigation
Tokenised, no login. Open from a household in Guests
Supplier reply
needs a token
/reply/<token>
Tokenised, no login. Arrives by email when an enquiry is sent