European event procurement · build status
From a request, through quotes and a comparison, to an award and somebody turning up. The ledger is the authority; this page is generated from it and from nothing else.
P1 – P186 · 187 slices
187 complete
| Slice | Name | Status | The ledger’s note |
|---|---|---|---|
| Slices | |||
| P1 | Schema foundation and enums | complete | 0001. Types, uuidv7, the forbid_mutation trigger that makes five tables append-only. |
| P2 | Identity and tenancy | complete | 0002. Organisations, workspaces, users, memberships, roles. db/test/isolation-test.sh, 18 assertions. |
| P3 | Federation and taxonomy | complete | 0003. Every federated table carries its own uuid plus a (source_id, external_id) natural key — the shape event.clinic's R6 later required, built five days earlier. D-112. |
| P4 | Suppliers and compliance | complete | 0004. |
| P5 | Events and requests | complete | 0005. |
| P6 | Clarifications and submissions | complete | 0006. |
| P7 | Evaluation, approval, award tables | complete | 0007. awards, award_snapshots, award_line_items, award_notifications. |
| P8 | Handoff, catalog, notifications, audit | complete | 0008. Tables only; the handoff feature is P24. |
| P9 | Row-level security | complete | 0009, 0010. Every table forced. Never tested as the database owner — BYPASSRLS would make the suite pass against no security at all. |
| P10 | The privilege model | complete | 0021. Both databases fail closed: a table with no grant produces permission denied naming it, loudly, on the developer's machine. ./db/privilege-audit.sh. |
| P11 | Migration governance | complete | migration-ledger.sh, apply-migrations.sh. Migration state is measured, never recorded. |
| P12 | Sign-up and provisioning | complete | Clerk, db/test/provisioning-test.sh and api/test/provisioning-api-test.sh, 50 assertions between them. |
| P13 | The request builder | complete | Ten chapters, lots, criteria. api/test/workspace-api-test.sh, 141 assertions — the largest suite in the repository. |
| P14 | Budget, scope and timeline | complete | 0017. db/test/budget-timeline-test.sh, 18. |
| P15 | Issuing a request | complete | 0022, and 0042 which moved the per-lot budget out of the snapshot after measuring that an invited supplier could read it. db/test/issue-request-test.sh, 42. |
| P16 | Inviting suppliers | complete | db/test/invitation-target-test.sh, 17. |
| P17 | The supplier answers | complete | Interested or declined, with a reason the buyer reads. api/test/supplier-api-test.sh, 50. |
| P18 | Clarifications and notes | complete | 0016. db/test/note-visibility-test.sh, 12. |
| P19 | Bid fees | complete | 0031. db/test/bid-fee-test.sh, 29. |
| P20 | The supplier's quote | complete | 25 fields, two-press send, everything locks on submit. api/test/quote-api-test.sh, 63. |
| P21 | Submission deadlines | complete | 0036. db/test/submission-deadline-test.sh, 16. |
| P22 | Comparison | complete | Lot by lot, with exclusions. 0051 keeps every verdict rather than overwriting it, and records whether the prices were already visible when each was made (D-120). On production 2026-08-14. api/test/compare-api-test.sh 29 and db/test/exclusion-verdict-test.sh 32. |
| P23 | Awarding and issuing | complete | Stages 9 and 10. Split awards, two-press confirm, email notices. On production 2026-08-13. award-api-test.sh 33, issue-api-test.sh 39, award-reference-test.sh 12. |
| P24 | The handoff | complete | Stage 11. On production 2026-08-13. Scope, build-up and strike windows, site address, the person each side phones, a checklist. 0049 closed a defect that let a supplier rewrite the buyer's terms and delete the record. A list at /handoffs for both sides — without it the only route in was one tender's comparison screen. 0050 added the supplier's own tick (D-118) and the delete guard (D-119). handoff-api-test.sh 75, handoff-ownership-test.sh 20. |
| P32 | Approvals | complete | D-122, D-123. A threshold, a queue, sign-off, delegation, and a trigger that refuses to issue an unapproved award. 0052 also closed a defect that would have made the module decorative: any member of the workspace could approve a step assigned to somebody else, and force the instance outright. approval-authority-test.sh 23, approval-api-test.sh 32. On production 2026-08-14 — measured, not inherited: migration-ledger.sh prod reports 0052 present, and the deployed Worker answers /v1/approvals 401 while a deliberately invalid control path answers 404. |
| P25 | Supplier discoverability | complete | 0038, 0039. Being findable by a signed-in buyer and being readable by anyone are different permissions. db/test/supplier-discoverability-test.sh, 27. |
| P26 | The public supplier directory | complete | api/test/public-directory-api-test.sh, 23. Q-047: a supplier opts in through its own control, and the copy states the consequence beside it. |
| P27 | Directory sync from event.clinic | complete | Q-054 closed: the per-venue detail request is opt-in, so a full live run went from never finishing to 1m34s, 30 845 venues. D-116. Run against production on 2026-08-22: 30 845 venues in 3m42s, then 743 hotel chains backfilled after D-171. api/test/sync-test.sh, 161. |
| P28 | Billing and subscriptions | complete | Stripe, 0040 and 0041. The webhook re-fetches the event from Stripe rather than trusting the posted body. api/test/billing-api-test.sh, 35. |
| P29 | The marketing site | complete | Astro, static, nightly rebuild. Never renders fabricated records as real. |
| P30 | The platform console | complete | api/test/platform-api-test.sh, 19. |
| P36 | Events | complete | On production 2026-08-13. List, create, edit, and a request may name one — scoped so it cannot name a rival's. D-117. Closes the gap that made the handoff's date seeding dead code. api/test/events-api-test.sh, 26. |
| P31 | Immutable personal data | complete | D-016. IDs in append-only records, never names. db/test/immutable-personal-data-test.sh discovers the tables from the catalogue on every run, 11 assertions with 4 controls. |
| P34 | Supplier performance | complete | D-129 to D-133. A directory with no performance history is a list; this makes it a record. 0054 closed the eleventh instance of the recurring defect — a buyer could score a supplier it had never worked with, rewrite 1/5 to 5/5 after the supplier had read it, delete a shared review outright, and leave four reviews of one supplier. The READ half of that table was already careful, which is what made the write half easy to miss. Deliberately NOT append-only: comment is free prose that will name a person, and D-016's tables have no erasure path at all. review-authority-test.sh 26, review-api-test.sh 36 — both raised from 20 and 32 by D-142, which made a shared review correctable with the correction visible. On production 2026-08-15. |
| P33 | Purchase orders | complete | D-124 to D-128. What an accepted award becomes: a reference the supplier invoices against, and a record that they agreed to fill it. 0053 closed the tenth instance of this project's recurring defect — the buyer could mark its own order confirmed and delivered, and rewrite the total after delivery, while the supplier those words are about could not write the row at all. Two lanes, enforced by a trigger and a narrow function rather than by the routes. Q3 (D-143) then reversed D-128: an award may be called off more than once, capped at its own value and scaled to what is left, with the remainder derived rather than stored. order-authority-test.sh 38, order-api-test.sh 36. 0055 then fixed a defect found the same night: 0053 built the order from award_line_items, a table nothing in this application has ever written, so every real order would have arrived with no lines and a 0.00 total — both suites were green because both seeded that table themselves. Now built from submission_line_items, the same rows awardLot values the award from, with a refusal when the two disagree (D-134, closes Q-057). On production 2026-08-15. |
| P35a | Documents | complete | D-136. Every file the caller can reach in one place, with what each file's visibility actually means spelled out beside it — until now a file was reachable only from inside the one tender it hung off. The list scopes to the caller's own files and the tenders they can see: trusting attachments_read alone returned 113 other organisations' public company logos to every caller, including a supplier with no relationship to anybody. documents-api-test.sh 10, forced red by removing the scope. On production 2026-08-15. |
| P35b | Reports | complete | D-144, D-145. The retrospective questions Home does not answer: who we award to (awarded value beside what has actually been called off), who replies (four counts, never a bare percentage), how long we take from issue to award, and month by month. No SECURITY DEFINER in it — an aggregate counting unreadable rows leaks the size of everyone else's business without showing a row of it. reports-api-test.sh 16, forced red by disabling RLS on awards. On production 2026-08-15. |
| P37 | The audit log | complete | D-137 to D-141. An MVP requirement in the brief that had never recorded a row: audit_events existed since 0001 with policies, a privilege-audit entry and an immutability suite, and held 0 rows on local and 0 on production. Now written by record_audit() triggers on requests, invitations, awards, orders and reviews — triggers and not routes, because an API-written log misses set_order_status(), psql, and the next route somebody forgets. Owner decisions of 2026-08-15: erased with its tenant, buyer-only readership, amounts recorded, no screen yet. 0056 also closed a defect in 0009 that let any tenant forge events into its own trail. 0057 and 0058 fixed six foreign keys — two of mine and four older — that made teardowns and user deletion impossible. audit-log-test.sh 19, each guarantee forced red separately. On production 2026-08-15, and already recording rows there. |
| P38 | In-app notifications | complete | D-146, D-147. The other half of an MVP "Yes". Written by notify_on_change() triggers on awards, orders and reviews, addressed to the party on the OTHER side of the act, and PERSONAL — a colleague marking theirs read does not clear yours. A bell with an unread count in the header, and a feed that does not clear itself on sight. 0061 also closed a defect in 0009 that let a user insert into their own feed. notifications-api-test.sh 25, forced red by opening the insert policy. On production 2026-08-15. |
| P39 | Compliance documents | complete | D-148 to D-152, owner-confirmed on 2026-08-15, and the last of Q-058 — which is now closed. A supplier files what it holds; a buyer that has invited it verifies or sends it back. 0062 closed the twelfth instance of the recurring defect and the second inversion of it: compliance_documents_write named which row and never asked what or by whom, so a supplier could stamp its own insurance certificate verified while the buyer doing the verifying could not write the row at all. All four controls passed before the fix, which is what made it look finished for eleven migrations. Verification carries its author (D-150) because the status column is shared by every buyer — no screen renders a bare "verified". The types vocabulary was seeded as part of the work: both databases held 0 rows, and an upload screen with an empty dropdown is the same dead module in different clothes. Two defects found only by opening the screen, with 1473 assertions green: a test fixture that had leaked 96 rows into the dropdown (D-151), and every date rendering a day early (D-152). compliance-authority-test.sh 20, forced red four separate ways; compliance-api-test.sh 37, forced red by disabling RLS and by dropping the refusal translation. compliance-attachment-test.sh 11, forced red four ways including WIDENING the branch. On production 2026-08-16. |
| P40 | The supplier directory | complete | D-158. The last nav item a buyer could click and be told it did not exist — and every piece it needed was already built with no page to live on: the search (P25), the performance projection (P34) and compliance (P39). So it composes rather than adds: no new route, no new policy. Opening it found a defect a suite could not: serviceCountries left /v1/suppliers as the STRING {HU,AT} while its type said string[], because that projection never cast ::text[] — invisible for as long as nothing rendered the field, and an instant crash once something did. suppliers-api-test.sh 23, asserting the JSON type rather than the printed value. On production 2026-08-16. |
| P41 | The organisation and its people | complete | D-154 to D-157. An organisation could not acquire a second person: POST /v1/invitations had existed since 0024 with nothing in the application calling it, and /v1/invitations/accept had no route in the app at all — so the link Settings hands out would have 404'd. Now: a members list, an invitation, the one-time link shown with what it is, and a screen that accepts it. 0065 closed instance fourteen — the authorisation check asked whether you were a member and never which role you held, so a viewer could mint a buyer_admin. 0066 then made an invited colleague visible to the organisation that invited them, because the membership was legible and the person it named was not. invite-authority-test.sh 9 and provisioning-api-test.sh 37, both forced red. On production 2026-08-16. |
| P42 | Navigation that matches the organisation | complete | D-159. Every signed-in person was served the buyer's menu, so a supplier was offered Requests, Approvals, Reports and the supplier directory. Those screens do not error — RLS returns nothing — which is worse than an error, because an empty screen reads as "you have no work". Suppliers now have their own seven items. Five NotBuilt routes were also deleted: they had been unreachable since the screens behind them shipped, because React Router takes the first match, and the comment above them promised a deletion that never happened. No suite covers a menu; both were read from a browser as each kind of organisation. On production 2026-08-16. |
| P43 | Creating and issuing a request | complete | D-162, D-165, D-166. The application could not create a request, and could not issue one. Both routes existed, both were covered by suites — requests-api-test.sh and issue-request-test.sh's 42 assertions — and neither had a caller. The intake screens wrote a draft into localStorage and stopped; a buyer could answer every question, press the only button, and nothing would exist outside their own browser. Found by walking the flow a new buyer walks first, which no fixture does: stage10-browser.sh seeds a tender that is already issued. Also fixed: inviting the only match said it had not been found, because inviting removes a supplier from the results and the empty state only knew how to say "nobody registered". Walked end to end in a browser — create, invite, set the timeline, issue — ending status=issued number=REQ-2026-0001. The loop then closes the rest of the way: the supplier answers, prices and submits, and the buyer compares and awards — D-167 added the whole-request award, without which a request with no lots, which is what the RFQ route creates, could not be awarded at all. On production 2026-08-16. |
| P44 | Guards against shipping what nobody can reach | complete | D-163, D-164, D-166. Two scripts, both forced red before being believed. scripts/deploy.sh measures the production ledger and refuses rather than warns: 0067 adds a column the API now selects, so deploying ahead of it fails every authenticated request rather than losing a feature. scripts/surface-audit.sh reports API routes nothing calls — the shape behind three defects in two days — and now runs inside verify-all with --strict, because a guard nobody runs is the same defect one level up. The audit was wrong four separate ways first, each found by sabotage and each recorded in D-166. On production 2026-08-16. |
| P45 | A supplier asks a question | complete | D-169, D-170. Brief §7.1 gives the supplier responder *"view invitations, ask questions, submit and revise quotes"* — quoted in SupplierHome.tsx's own header, and never built at any layer. The route answered 405, and nothing in the application had ever written clarifications; the buyer could answer questions that could not be created. 0068's INSERT policy answers all four questions the recurring defect keeps splitting apart — which request, whose name, what state, what scope — and a supplier files supplier_private because broadcasting its own question to every rival is not the asker's decision. clarification_deadline_at is now enforced rather than drawn on a timeline. clarification-authority-test.sh 13, forced red three ways; supplier-api-test.sh 57. On production 2026-08-16. |
| P46 | Companies from the event.clinic directory | complete | D-176, and D-173's reference only. directory_companies held 0 rows on production while 27 284 companies sat upstream — not a failed run, but a resource the sync did not implement, so --resources companies was not an argument you could pass. The header said companies were "blocked by the schema", true of organizations and no longer the reason after 0013 added a table that is not a tenant: the third time in this one file a true statement quietly became false and went on telling readers not to look (see P27's D-171). Field coverage was measured across 1 000 rows sampled at 25/50/75% and the tail rather than page one, which is alphabetical and markedly less complete — trading_name and sectors read 0% and are mirrored anyway, because dropping a declared field because it is empty today is exactly the D-171 defect. organization_id is absent from the mirrored list on purpose: it is the claim a buyer makes, and the sabotage that adds it fails one assertion by name. sync-test.sh 183 → 205. On production 2026-08-22: 27 284 companies, 69% with a country, 0 named (unnamed). |
| P47 | Venues a buyer can browse | complete | D-177, D-178, and D-173's browse plus buyer notes. 30 845 venues were on production and no route or screen could reach one. The handover for this slice specified the wrong storage and I wrote it: venues has RLS disabled, no policies and no organization_id, so a note on venues.internal_notes would have been readable and overwritable by every rival buyer — 0003's "LOCAL ENRICHMENT" means *not overwritten by the sync*, not private. 0069 reuses 0015's override pattern; production carried 0 notes, so it arrived before the first one. Two rationales I wrote into that migration were then disproved by sabotage and corrected in the file (D-178) — a wrong reason in a comment is worse than none. Capacity, star rating and street address are deliberately absent: each is populated 0 times across all 30 845, and a column of dashes reads as a bug here rather than an absence upstream. Four of my own bugs found on the way, two of them browser-only and invisible to every suite. sync-test.sh 205 → 226, venues-api-test.sh 55 new. On production 2026-08-22. |
| P48 | A scheduled sync, and something that shouts | complete | D-181. The Worker WATCHES the sync; it does not run it. Measured before building: one full run is ~919 subrequests against Cloudflare's 1 000 ceiling, and 0014's RLS on categories needs a real session that the HTTP driver cannot hold. More to the point, a cron that runs the sync cannot report that it never ran — when it does not fire, nothing fires. So sync-watchdog.ts reads directory_sync_runs daily at 06:00 UTC and emails when a resource is stale, failed, or left a run open; silence means healthy, because a daily "all fine" mail is unread within a week. WATCHED_RESOURCES is named rather than discovered: a never-synced directory has no rows, so a discovered list would report perfect health. Verified end to end against production data and the real Brevo key via wrangler dev --remote --test-scheduled — healthy first, then forced unhealthy locally to watch the mail actually send. watchdog-test.sh 21, forced red three ways. On production 2026-08-22. Completed 2026-08-26, and the last step was not the one this row predicted. The owner ran the two documented commands and the agent loaded, listed, and could not execute: Operation not permitted, because ~/Documents is TCC-protected and a launchd agent has no Files-and-Folders grant. Nothing about the plist or the script was wrong and the install looked entirely successful — it would have failed identically at 03:15 with nobody watching, leaving one line in /tmp and a watchdog mail three days later about the directory being old. scripts/deploy-sync.sh builds ~/procuvent-live from git archive HEAD and points the plist there: outside the protected folders so launchd can reach it, and built from HEAD so uncommitted work cannot run on a schedule — the second property is why the NRÜ planner already works this way. Granting Full Disk Access to /bin/bash was the alternative and is far broader than this job needs. scripts/verify-sync-agent.sh is the other half: it checks the path, the credential and node_modules, then starts it, because launchctl list showing a job is exactly the evidence that misled here. Measured after: two full runs, finished ok in 310s and 300s, and the census now reads all three resources succeeded 0h ago against a 36h staleness limit. |
| P49 | Claiming a directory listing | complete | D-182 to D-184. The dead end D-173 accepted, closed. 27 284 companies a buyer could see and act on none of. A supplier now claims its listing at sign-up: approved automatically when its provider-verified email domain equals the listing's website host — 85% of the directory, measured, across 22 801 distinct domains with zero free-mail among them — and queued for the platform console otherwise. Two things almost shipped wrong and both were found before production. directory_companies has no RLS, so any tenant could have taken any listing with one UPDATE while the two doors stood by (D-183) — and the assertion covering it passed vacuously, because it grepped for a psql tag -q suppresses. And 0070 checked only is_platform_actor(), which reads a GUC no signed-in person sets, so the review queue would have been unworkable by any human being while every test passed (D-184). A buyer may note an unclaimed company but not mail one; Procurevent does not contact companies that never signed up. claim-authority-test.sh 66, forced red four ways including reverting each near-miss. P49b then added what a browser walk showed was missing: a supplier that already existed had no way to claim at all, so everything built for it was unreachable — the seventh instance of "exists, tested, no caller" in this product and the first I built myself. 0071 also lets Procurevent link one by hand, as a CLAIM carrying the decider and a required reason rather than a column write, so the field that decides who owns a listing never holds a value nothing explains (D-185). And all three doors were called as SELECT (f()).*, which runs a function once per output column — 13 times, measured — so the first call linked a listing and the second reported it as already claimed; scripts/side-effect-call-audit.sh now refuses the shape (D-186). On production 2026-08-22. |
| P50 | An agency sells as well as buys | complete | D-228, Q-063 owner-confirmed. 0075. An agency could not be invited to quote and could not be found in the directory — is_active_supplier_org and supplier_is_discoverable both required type = 'supplier', and 0030 excluded agencies by name. Zero agencies exist on either database, so the rule was never exercised and never contradicted. Both widened to IN ('supplier','agency'); the three properties 0030 protects — a rival buyer, the platform tenant and an archived company — still refuse, asserted. App: AGENCY_NAV is DERIVED from both menus, and /invitations mounts SupplierHome a second time because an agency landing on the buyer Dashboard had no reachable invitation queue. db/test/invitation-target-test.sh, 20, forced red first. | Completed 2026-08-23: an agency may hold a supplier profile (no policy or API gate stops it — measured, not assumed), supplier_is_discoverable asserted for an agency, and the sign-up label now states both halves. Found while doing it: REFUSED: a rival BUYER is not discoverable was VACUOUS — sd-rival held no profile, so it was refused for having nothing to discover and would have passed against a policy with the type check deleted. sd-rival and sd-agency now hold IDENTICAL tender_ready profiles and differ only in type, so the pair proves the rule rather than asserting it. |
| P51 | A founded organisation can actually trade | complete | D-232, owner-confirmed. 0076. Self-service onboarding dead-ended at its last step: every organisation founded through sign-up was pending, and nothing anywhere moved it on — measured live, 0 functions, 0 triggers, 0 API lines, 0 screens. Both supply-side helpers require active, so a supplier that signed up was permanently uninvitable and invisible. Found by writing the FIRST end-to-end agency test over HTTP; the database suites could not see it because they call found_organisation and assert the rules, not the journey. api/test/provisioning-api-test.sh now founds an agency, saves its profile through the real route and asserts it becomes discoverable — 45, deterministic over three runs. |
| P52 | The MVP promise, walked | complete | D-234, D-235. api/test/journey-api-test.sh seeds NOTHING: both parties sign up, the supplier publishes a profile, the buyer creates a request, sets a deadline through the chapter that owns it, issues it, invites the supplier, and the supplier reads the invitation addressed to it. 25 assertions, deterministic over three runs, covering all of brief §6.1 — create, invite, issue, respond, quote, submit, compare, award. It stops at the award and SAYS so: issuing notifies the supplier by email and this harness starts the API with no mailer, so the 503 is asserted rather than skipped — if a mailer is ever wired in, that assertion fails and whoever did it is told to walk the last two steps. It found P51 on its first run and then found two more steps a real person must take that no seeded suite exercises — a request cannot be issued without a submission deadline, an invitation on a DRAFT is correctly invisible to the supplier, and a quote cannot be SUBMITTED until a draft exists — the route being listed in the API's own route index was not the same as the action being available. verify-all.sh also gained a floor: every suite on disk must be named in API_SUITES, which is now defined once and shared by the check and the loop. |
| P53 | Measuring rows, and enforcing what 0074 assumes | complete | D-237 to D-239. db/census.sh — read-only, a FIXED set of questions rather than a query runner. Built because 0076 carried a data-modifying UPDATE and, once applied, nothing could say whether it touched 0 rows or 2: for a SCHEMA migration the after-state suffices, for a DATA migration it is compatible with several befores. Five scripts reach prod and none answered a question about rows. 0077 then makes is_system = (organization_id IS NULL) a rule rather than a data fact — 0074 ranks on that correlation and nothing enforced it. The ledger gained a check probe kind, because the existing constraint kind tests a FOREIGN KEY's ON DELETE action and would have called every applied CHECK migration MISSING. |
| P54 | Fixtures that clean up after themselves | complete | D-241, owner-confirmed. The local database grew on every test run and nobody could stop it: 4 974 users, 3 781 organisations, 2 237 requests and 1 239 fixture-minted system roles. The reason it was never fixed is that DELETE FROM organizations DOES NOT WORK — the cascade reaches clarification_messages, which is append-only, and forbid_mutation refuses it. db/test/lib/reset-fixtures.sh earns the D-115 exemption (test tree AND cannot be pointed off localhost, both forced red), suspends the triggers inside ONE transaction so a rollback restores them, and is called by 13 suites before they seed. Orphaned test users are swept too — deleting an organisation cascades to memberships but NOT to users, which is why 3 310 survived a run that took organisations from 3 781 to 89. db/verify-reset-precedes-seed.sh guards the ordering, which was wrong in three suites and failed with messages naming a 403 rather than the reset. A full battery run now leaves the row counts unchanged. |
| P55 | Certificates get cheap, honest checks | complete | D-243, owner-confirmed, Q5 closed. Measured first: SIZE was already checked (413 at 25 MB), expires_on and a computed expired already existed. Three gaps. requires_expiry described a rule nothing applied — projected to the client since P39, never read on the write path, so 8 of 10 types require an expiry and all 10 accepted a document without one; enforced in the route rather than by a constraint so the refusal can name the document a person is looking at. Content type was unchecked: a narrow allowlist, the only file-type restriction in the product, and an ABSENT type is refused rather than defaulted to application/octet-stream. expiringSoon and daysToExpiry computed in the API so list, detail and dashboard cannot disagree about "soon"; the screen renders a day count only when actionable and never recomputes it from the browser's clock. Claims nothing about authenticity — no byte sniffing, no verification. compliance-api-test.sh 55, six new with controls. |
| P56 | The catalog storefront | complete | D-240, D-244, D-245. Phase 2, module 1 of 3. catalogs and catalog_items existed since 0008 with a policy each, ZERO rows on both databases and no caller anywhere — schema waiting for a feature. 0078 added the piece that was missing rather than merely unbuilt: buyer_specific was permitted by a CHECK constraint and satisfiable by NOBODY, because no table named a buyer. Supplier side manages catalogs, items and named buyers; buyer side browses what reached it and is never told WHY it can see a catalog. Prices are strings end to end — numeric(_,4) keeps its scale and a JSON number would drop it. db/test/catalog-visibility-test.sh 20 (every mode from three sides: owner, named buyer, stranger), api/test/catalog-api-test.sh 43, seeding nothing. On production. AND THE ORDER FLOW, which brief §6.2 names in the same sentence: orders.catalog_id has existed since 0008 and NOTHING EVER WROTE IT, because orders.award_id was NOT NULL — every order needed a tender, and a catalog order is the case where there was none. 0079 replaces the NOT NULL with num_nonnulls(award_id, catalog_id) = 1: exactly one origin, never both, because an order carrying both would claim a tender awarded it AND a catalog priced it. 0080 is the one door that writes it, and prices come from catalog_items INSIDE the transaction — a route that accepted a unit price would produce a row that is well formed, internally consistent and wrong, which no policy can detect. 0081 widened two triggers that still refused an award-less order: relaxing a column is not relaxing the rules ABOUT that column, and CLAUDE.md predicts exactly this. |
| P57 | Contract management | complete | D-240, D-248, D-249. Phase 2, module 2 of 3. No groundwork existed — no tables, no enums. 0082 adds a clause library the buyer owns alone, templates, contracts with two sides, and contract_assents, which is append-only and joins the six: the whole value of the record is that it cannot be revised, so it carries IDs and never names (D-016). Brief §6.3 excludes "complex contract lifecycle management" in the same section that asks for contracts, so there are no approval chains, no negotiation rounds and no amendment trees: drafted, agreed, superseded, and a renewal is a NEW contract pointing at what it replaces rather than an edit of the old. An agreed contract is not editable — changing text under an assent already given is the one thing this module exists to prevent. body_sha256 is computed by the server on every write, so an assent given to earlier wording shows as staleText rather than silently looking current. Nothing is called a signature and an assertion guards it. contract-boundaries-test.sh 18, contract-api-test.sh 31. On production. |
| P58 | Invoice and payment | complete | D-240, D-250, D-251. Phase 2, module 3 of 3 — Phase 2 is complete. Brief §6.2 asks for capture, matching and payment status; §6.3 excludes payment processing and escrow, and the second governs: paid means a person at the buyer said it was paid, elsewhere. There is no pay route because the product cannot pay, and an assertion checks the API's own route index for one. The SUPPLIER raises (a buyer inventing a debt to itself is refused); the BUYER sets the state (a supplier marking its own invoice paid is refused by a trigger, not a route). invoice_column_guard decides which COLUMN each side may touch, which a policy cannot express. Matching is computed — matched, over, under — with over named separately because it is the one that costs money. invoice_payment_events is append-only: the invoice may be corrected, what the buyer decided may not. 0084 closed a hole 0083 left: WITH CHECK (supplier_org_id = current_org_id()) reads like "only the supplier may raise one" and means "only the supplier named on the row, which the inserter chooses" — anybody could invoice anybody. Caught by a control written for a different rule. invoice-authority-test.sh 22, invoice-api-test.sh 24. On production. |
| P59 | The journey walks Phase 2 too | complete | D-252. journey-api-test.sh now covers brief §6.1 AND all of §6.2, on the SAME two organisations — a catalog, a contract and an invoice between parties who had never dealt with each other would prove the tables work and nothing about whether the product hangs together. 44 assertions, seeding nothing. It re-proves 0079's catalog order carries no award and that the buyer can READ it, which is the INNER JOIN that cost a 404. db/verify-no-inline-json-bodies.sh came out of it: an inline JSON body containing a comma is BRACE-EXPANDED by bash into separate words, and the API then answers 400 naming a missing field. It found one real instance — a contract assertion expecting 400 for a date order that was passing because the mangled body was missing a required field instead. |
| P60 | Reverse auctions | complete | D-253 to D-255. Brief §6.2 Future, module 1 of 3 attempted. §6.3 excluded reverse auctions from the FIRST release; the MVP and all of Phase 2 are complete, so that expired rather than was overridden. Public tender publication is untouched on purpose — *"Future, legally reviewed"* is not something to build unilaterally. A BID IS A SUBMISSION. The obvious auction_bids table is wrong here and the schema said so: submissions_version_key and submissions_one_live already mean "a supplier may offer again and the new offer replaces the old". A parallel table would need teaching, separately, everything submissions know — and awards.submission_id is NOT NULL, so a winning bid that was not a submission could never be awarded without a second award path. 0087 adds the WINDOW and the RULES and no new kind of bid, so comparison, evaluation, approval, award and handoff are untouched. And the assumption underneath it was wrong, which the first test run found: the versioning is a SHAPE WITH NO REACHABLE TRANSITION — submission_status_owner_guard refuses a supplier setting its own row to superseded, and 0006 permits revision only when the buyer reopens by hand, which is unusable at auction pace. 0036 wrote *"supersession is the system's when a revision replaces a version"* and nothing could ever produce the case. The exemption is a fact, not a flag: permitted only when submission_open_auction() finds a genuinely open auction over that lot, so there is no setting to spoof and no is_platform handed out; disqualification stays the buyer's. Bid privacy resolved deliberately — §3.3.4 asks for it and a bidder who learns nothing cannot know whether to improve, so auction_rank() returns the caller's OWN position and nothing else, and the refusal message compares against the supplier's own previous bid because comparing to the leader would hand back the leading price. The cost is stated rather than glossed: a rank of 4 reveals four bids exist. No auto-award (§6.3), and closing is the clock, not a job — no cron whose failure silently leaves an auction open, the reasoning P48 already applied. record_audit() raises for an unknown table so it had to learn auctions; derived via pg_get_functiondef with the insertion asserted once, and all six branches verified live afterwards — 0074's stub is what that check is for. db/test/auction-test.sh 23, sabotaged by removing the monotonic rule: 4 failed including the row COUNT, because a rule that raises and a rule that quietly permits look identical if you only assert the exception. On production 2026-08-25. P60b closed that gap 2026-08-26: routes, client functions and two screens, with surface-audit --strict passing and no new expected entries. Five defects surfaced that the database suite structurally could not see, every one needing a real request. Refusals answered 500 — check_violation is unmapped by asGuardRefusal, so a supplier failing to improve its bid was told the SERVER had broken (0088; the count was the tell — 63 uses of insufficient_privilege in this schema against 3 of check_violation, all three mine). A closed auction blamed the bidder, superseding before checking the window, so bidding late was refused with "a supplier cannot set its own quote to superseded" (0089; 0087's own comment argued against checking there, and the reasoning was sound while the ORDER was wrong). The buyer saw zero bids on an auction with two — JOIN organizations is a filter under RLS, D-251's fourth instance and the first caught by a suite rather than a person; now supplier_nameplate(). isOpen read false on an auction just opened, because auction_is_open() is STABLE and inside UPDATE ... RETURNING sees the pre-update snapshot. A supplier could open the buyer's view — nothing leaked, the submissions policy still applied, but "it happened to return nothing sensitive" is not "it refused". Two guards were also broken: verify-no-inline-json-bodies.sh had bad uninitialised under set -u and aborted the first time it ever found a real violation, having only ever been green; and 0089's probe matched text 0087 had put in the same function hours earlier, so the ledger read it as applied and skipped it. api/test/auction-api-test.sh 34 over real HTTP seeding nothing. On production 2026-08-26. |
| P61 | A supplier's answer only moves forward | complete | Q-042, and the question was already answered. The log listed it Open with the note that a buyer could still push an invitation back to draft or re-issue it as invited; 0035 answered it on the owner's decision the day it was raised — disqualify only, with a reason — and carries the full who-may-write matrix in its own header. The first cut of 0090 therefore closed a gap that did not exist: two of its three rules were redundant with 0035 and were removed rather than shipped. Found by sabotage, not by reading — deleting the no-return-to-draft rule changed nothing and every assertion stayed green, because 0035 was refusing the whole time. The gap that IS real: 0035 governs WHO may write each status and says nothing about the ORDER of the ones a supplier owns. Measured with the trigger dropped, a supplier setting submitted -> viewed SUCCEEDED — rewriting what it did and when, on the trail §7.2 wants reconstructable. 0090 is now that one rule; statuses off the ladder stay entirely 0035's, so declined -> interested still works because a supplier changing its mind is real. Then 0090 stole a rule from 0035 and only the battery noticed. It ranked invited, which 0035 governs, so 0090 answered first with a different error and invitation-target-test.sh broke — *expected refused, got error*. 0090's own suite could not see it and never could: it asserts a psql exit code, and every refusal is exit 3 whatever raised it. 0091 unranks invited, and the suite now asserts the refusal's WORDS so the theft cannot recur silently. Three more question-log rows were stale and are corrected in place with the measurement: Q-062 (High) closed by 0074, Q-039 by verify-all.sh, Q-064 by the app's verify chain. The log's header now carries the failure mode. db/test/invitation-transition-test.sh 17, every assertion naming the guard that owns it. On production 2026-08-26. |
| P62 | The concept drafter | complete | D-027, owner-confirmed 2026-07-30. Brief §6.2's last buildable Future module — the AI copilot's "brief generation" half. The other two Future modules stay untouched: *public tender publication* is marked "legally reviewed" and *reverse auctions* shipped as P60. D-027's three constraints are structural, not advisory, because its recorded risk is specific: *"Generated drafts prove more trouble to correct than to write, or a buyer issues one unreviewed."* Nothing writes a tender — 0092 stores drafts in ai_drafts, apart from the document, and no parameter would make it touch request_document_sections. There is deliberately no "use this draft" button: pasting the text in would satisfy the letter of the constraint and defeat it, because editing would become the step nobody took. The buyer copies. The friction is the feature. Append-only, so "did this paragraph come from a model" is answered by looking; a boolean on the section was the obvious alternative and goes stale the moment the buyer edits. Prompts carry almost nothing — a note the buyer wrote for this plus the category label from the closed vocabulary, never the request row, the title, or anything from users. redactForPrompt is a backstop whose own header says it cannot remove a name; the mechanism is what is sent. prompt_sent stores the redacted string and the screen shows it, so the privacy claim is readable rather than asserted. It refuses rather than degrading, the opposite of classify: D-055 makes classify degrade because a ranker works without a model, and prose has no ranker — a template presented as a draft is worse than an empty box because it looks finished. Controlled rollout, two switches: the AI binding AND AI_COPILOT=1. The binding alone is not consent to spend on prose. Shipped OFF on production 2026-08-26, switched ON 2026-08-27 behind a daily ceiling. api/test/copilot-api-test.sh 22 — the dev port ECHOES its prompt, which is what makes the redaction assertion mean anything; sabotaged to a passthrough, 5 fail including the control proving it redacts rather than deletes. db/test/ai-drafts-test.sh 18, because the API suite proves the ROUTE refuses and only the policy suite proves the POLICY does. A daily ceiling, 0094. Workers AI has no spend limit of its own — 10 000 free Neurons a day and then billing, with nothing that stops — so 60 drafts a day platform-wide, counted since midnight UTC on Cloudflare's clock rather than Budapest's. A SECURITY DEFINER function and not a count, because ai_drafts is buyer-scoped under FORCE RLS: a tenant-scoped count is a fairness control, and the bill is one account's. Returns a boolean, so no tenant learns how busy the platform is; fails closed on a NULL or non-positive ceiling. Building it surfaced an OLDER hole — ownership was proven at the INSERT, after the model call, so any authenticated caller could spend Neurons against a request they could not read and get a 404 for the money. Ownership now precedes both the ceiling and the model, which is why that category join is now a LEFT join: all 35 local requests carry a NULL category_id, and an inner join would have 404'd every draft. privilege-audit.sh gained the guard that the definer's owner really does bypass RLS, since if that stops being true the ceiling and lead_rate_exceeded both go inert with every suite still green. |
| P63 | The nightly rebuild, and three things it was doing wrong | complete | Q-048, and the row was stale in the way its own header warns about. The question said *nothing rebuilds it*; a nightly workflow had existed for weeks. What was actually wrong was three things downstream of the answer, none of them in the question. (1) The job never finished. Every venue page fetched its own detail and Astro renders pages sequentially, so the build was 2 000 round trips at ~1.55s from a GitHub runner — gh run list shows six of the last eight runs cancelled at 30m20s against a timeout-minutes: 30 ceiling. The site did not rebuild at all on most nights. Details are now fetched once in getStaticPaths through a worker pool (src/lib/concurrency.ts, 8 in flight, measured 5x against the live API with every response 200). Measured after: 7 000 details in 3.5 minutes, 0 missing, and pages that took +1.55s each now take +1ms. (2) It published the wrong 2 000 venues. getAllPages capped at 2 000, which over a 30 845-venue directory is alphabetical order: amahoro-stadium in Rwanda had a page and 1 147 of Hungary's 1 228 venues did not — 93% of the wedge missing, including audi-arena and bok-sports-and-conference-centre. This was not a new product call: docs/32-go-to-market-strategy.md §3 already said *Hungary first, Central Europe second*, and the same document argues venues are the asset being undersold. getAllVenues now fetches the §4.2 wedge complete and lets a stated budget fill the tail — 6 137 wedge + 863 tail, and 6 137 matches the strategy's own measurement exactly. The tail is still published, so pages that exist today do not become 404s. (3) It shipped a site that could not take a lead. The job ran npm run build, but PUBLIC_FORM_ENDPOINT is set only on package.json's deploy script — deliberately, so a local build exercises the unwired fallback. This job built and then deployed separately, so it inherited the fallback: measured on the live site, /contact carried zero forms and one mailto:. Every lead since the first green nightly run went to a mail client instead of POST /v1/leads, which is the whole first slice of the funnel. Fixed, and the deploy now fails if the built contact page has no form, because that absence is silent and free. A pagination guard that disagreed with its own cap was found on the way: guard < 50 at 100 rows a page silently stopped every caller at 5 000, so a 7 000 budget would have quietly returned 5 000. The guard is now derived from the cap. scripts/verify-concurrency.mjs 12, forced red with --sabotage (a pool that drops one item — the off-by-one that would silently cost one venue its spaces section and still report success), and wired into npm run verify. |
| P64 | The directory pages are page-weight bound | complete | Q-065 and Q-066, and the second one was opened by nearly shipping the bug. P63 raised the venue budget to 7 000 and that was right for detail pages and wrong for the browse page. /venues renders every row it is handed into one document and filters client-side: measured live, 2 523 999 bytes for 2 000 cards, ~1.26 KB each, so 7 000 is roughly 8.8 MB — on a site whose static architecture astro.config.mjs justifies by first-load speed, aimed at a mobile-heavy first market. The CI run that would have deployed it was cancelled mid-build once the number was measured rather than assumed. The fix is a separation, not a smaller catalogue. [slug].astro still builds all 7 000, so every venue keeps an indexable page and stays in the sitemap — which is what a search engine actually reads. venues.astro renders LISTING_MAX = 1500, and because getAllVenues returns wedge-first with Hungary at the front, that cap keeps all 1 228 Hungarian venues and fills the rest from the wedge. 1 500 is *below* today's 2 000, so this is a weight improvement rather than a regression held level — measured on the built output: 1 933 831 bytes against the live page's 2 523 999, a 24% reduction, and against the ~8.8 MB the uncapped version would have shipped. The page states the gap instead of hiding it — *"N venues browsable here; 7 000 venues have a page on this site"* — because a browse list quietly showing a fraction of the catalogue is the number somebody later quotes as the directory's size. Companies got the ordering and not the budget. getEventClinicCompanies read /companies in upstream order and cut at 2 000, which over 27 284 is alphabetical; it now takes the §4.2 wedge first, Hungary first within it. The budget stays 2 000 *because of* the weight lesson — /suppliers is 593 KB for 1 931 cards. The build now reports 2000 in the wedge, 0 from the tail, and Hungary's 1 738 companies fit inside it. That answers Q-065. A number corrected in public. The suppliers page was first read as 292 cards; it has 1 931. 292 is how many carry a *country* — Astro omits an empty attribute and 85% of these companies have none. Both numbers are in Q-066, the wrong one beside the right one, because a silently corrected measurement teaches nothing. Coverage checked rather than assumed: only four pages fetch directory data at build, and the two that render a whole dataset are now both bounded. |
| P65 | The screens say what they are | complete | An accessibility and correctness pass over all 36 screens, run as four parallel audits on disjoint file sets, each required to prove itself with tsc and npm run verify. Verified: five screens had no <h1> a user could land on — SupplierHome (575 lines, no heading of ANY level, and it serves both the supplier landing page and /invitations), PlatformConsole and PlatformElsewhere (each rendered as the whole of / by organisation type), FoundOrganisation (its title was a styled <p>, and App.tsx renders it outside AppShell so nothing above supplied one), and Compare's no-quotes branch. Suppliers.tsx jumped h1 to h3 where Companies.tsx renders the identical card at h2. Nine table header rows given scope="col"; no <th> without scope remains in src/. Four form controls given names. Loading made distinguishable from empty on Suppliers and Venues, where the region was simply blank while a search was in flight. Two correctness defects, not cosmetic: SupplierCatalogs held the "name a buyer" input in ONE string for the whole table while rendering it once per buyer-specific catalog, so typing in one row filled the other and Share sent the id typed against the first — a price list shared with the wrong buyer. FoundOrganisation re-enabled its submit button before the session reloaded, so a second click answered a successful sign-up with a 409. Dates: Contracts sliced ten characters off a timestamptz, so an assent given at 00:30 in Budapest was shown a day early — on the record whose entire value is when a party agreed. Rejected on evidence: SupplierCompliance has no <h1> and correctly has none; App.tsx renders it inside /profile beside SupplierProfile, which owns the heading. Not checked: no screen reader was actually run; this is markup structure, not a usability test. |
| P66 | Two currencies are not one number | complete | A supplier holding 5 000 EUR and 800 000 HUF was reported as 805000.00 EUR. Measured, not inferred — that is what the previous query returns from the new fixture in reports-api-test.sh section 2b. spendBySupplier summed value_net grouped by supplier alone and labelled the total with MIN(a.currency), which is alphabetical order; its orderedNet subquery summed every order in every currency and borrowed the awards row's label. byMonth carried no currency at all, so the screen printed a hard-coded 'EUR'. Both now group and match on currency; the month stays one row (adding currency there would multiply it through the FULL OUTER JOIN and double-count requestsIssued) and its money became a list. currency_code accepts any ISO 4217 code and this repository's own fixtures already use both, so this was never hypothetical. Verified: all six new assertions fail against the previous query and pass against this one, including one naming 805000.00 directly. The fixture needed a second lot — awards_one_live_per_lot means a supplier cannot hold two live awards on one lot — and the suite BLOCKED rather than measuring nothing when that constraint first refused the row. Deployed to production api and app, in that order, and the served bundle was diffed to confirm the edge carries it. |
| P67 | The guards that were not running | complete | Three guard defects, each found by using the suite rather than reading it. verify-modules-load.mjs existed for the exact bug I then shipped — a backtick inside a SQL template literal, which ends the string and stops the module parsing — written 2026-08-24 for the same trap. A correction, recorded rather than quietly fixed: I first wrote that nothing ran it. That was false — db/verify-all.sh:194 runs it, and always did. It was absent only from api's own npm test, and the real failure was that I pushed without running the battery. Adding it to npm test buys faster feedback; it did not fix a missing check. The reports suite reported twelve assertions as 000, which reads like a harness fault rather than a syntax error. Sabotage-checked: restore one backtick and it exits 1 naming three files; remove it and it exits 0. It now runs FIRST in api's npm test, prepended rather than reordered because the two suites after it are order-dependent. verify-styled-controls.mjs read raw source, so a doc comment containing a control tag was reported as an unstyled control; three separate authors hit it in one night and one reworded their prose to get past it, which is the real cost. Comments are now blanked one space per character, because the reported line number derives from the match offset. A new guard, verify-screen-has-h1.mjs, asks whether each ROUTE has exactly one heading — catching both a route with none and a route with two. Two coverage holes were found and closed while writing it, both of the kind where a check passes by never looking: scoping to a bare element={<X />} covered 29 of 36 screens and skipped all four targets, and matching <Route lazily to the first /> ended the block at <SupplierProfile /> so the screen beside it was never read. Now 31 routes, sabotage-tested in both directions. |
| P68 | The site's card groups have a heading | complete | Five marketing pages jumped from the page h1 straight to h3, including the homepage: each opens with a row of cards whose titles are h3 and only reaches an h2 further down, so a reader moving by headings met three to six of them with nothing saying what they were a list of. Not fixed by promoting the cards — site.css:25 puts the display face on h1, h2, and both card rules already carried a comment saying these headings must stay on the sans; that decision is sound and this keeps it. Each group instead gets the section heading it was missing, marked visually-hidden, a class the site already defines and uses. Verified in a browser on the built output: the element is 1×1, position: absolute and clipped, so it takes no layout space, and stays display: block so it remains in the accessibility tree. Re-audited all 21 distinct page templates of the 7020 built pages: no heading findings. Copy checked against brief §13.1. Deployed through npm run deploy — NOT by shipping the local dist, which a plain npm run build leaves with the mailto: fallback instead of the wired contact form; the live form action was confirmed afterwards. |
| P69 | A keyboard user can reach the page | complete | Measured by tabbing the running app: a keyboard user passed the notifications bell and NINETEEN navigation links before reaching page content, on every screen, every time. WCAG 2.4.1 is Level A. A skip link is now the first tab stop. Verified end to end in the browser: it rests at -1440px and returns to 8px on focus; Enter moves focus to <main id="app-main">, which is why main needs tabIndex={-1} — without it the browser scrolls there but leaves focus behind and the next Tab returns to the navigation; the Tab after that lands on "New request", inside main. Positioned off-screen rather than hidden, because display: none and visibility: hidden both remove an element from the tab order and would leave the link decorative. The same shell serves the supplier side. Also verified and clean: all 24 tab stops on /requests change their outline or shadow on focus, compared against each element's resting style with real key presses rather than programmatic .focus(), which does not trigger :focus-visible. A note on method: clicking the page to give it focus before tabbing put Chrome's sequential-focus starting point on the clicked element, so Tab skipped the link and the test said the implementation was broken when it was not. |
| P70 | Nothing scrolls the page sideways on a phone | complete | Measured at 375px: /reports and /documents scrolled the whole DOCUMENT horizontally, 575px and 527px against a 375px viewport, which moves the navigation and the heading too — it reads as a broken screen rather than a wide table. Four more tables had no wrapper either and were not failing only because three of them render no rows in the current fixtures. All 15 tables in the app now scroll inside a wrapper. Verified: both routes report a document scroll width of exactly 375 while their tables stay 535px and 512px; at 1280px the wrapper is inert — no border, transparent, does not scroll, exactly as wide as the table. tablewrap and table-wrap are different classes and not interchangeable; the first is bare because the card supplies the frame, the second brings its own border for a standalone table. Requests uses the second correctly, which is why the widest table in the product (1180px) never broke the page. Three new guards wired into npm run verify, now 14: verify-tables-scroll.mjs, verify-token-contrast.mjs and verify-screen-has-h1.mjs. The contrast one exists because CLAUDE.md records --text-tertiary shipping below AA while its own comment claimed it passed; measured now, every text token clears AA on all six surfaces, and every ratio stated in a comment matches to two decimals. |
| P71 | The intake keeps what it collects | complete | D-253 and D-257, both owner-confirmed 2026-08-29. The request intake asked six questions and sent two. Proven on the wire before the fix: with a category chosen and shown on screen as "AV › Light › Lighting Auxiliary Equipment", the create body was {"title":…,"route":"rfq"} and the row held category_id NULL, submission_deadline_at NULL, zero lots. Nothing in the API had ever written procurement_requests.category_id. Measured after: category Light Strips, deadline 2026-09-20 17:30 Budapest — the wall clock the buyer typed — and one lot carrying qty 12, unit each, 2026-10-01 to 2026-10-03. D-257 changed the question rather than answering it: the deadline field was a type="date" feeding a timestamptz, so somebody had to invent the hour — midnight closes a tender a day early, end-of-day needs a timezone nobody chose, and there was NO precedent for that conversion anywhere in the codebase. It now asks for a time, and the browser converts to an absolute instant so the same tender cannot close at different moments depending on where the Worker is deployed. Refusals are loud: an unknown category is a 404 and not a silent drop — it is validated rather than resolved by a subselect, because a subselect that finds nothing yields NULL, which is this exact defect moved one layer down; an unparseable deadline is a 400 and not the 500 the untranslated invalid_datetime_format would have produced. No new endpoint for the lot: saveChapterData already wrote those columns for the workspace, so the intake calls it. A second call rather than one transaction, deliberately — if it fails the request exists, the draft is still in localStorage, and nothing typed is lost. 12 new assertions, requests-api-test 55 → 67, each checking the COLUMN rather than the 201. A 201 proves acceptance, not storage: one run showed the deadline in the POST body and a 201 back with the column still empty, because the dev server was running pre-change code. |
| P72 | Standing in front of the screens | complete | Three screens had never been seen with data on them. db/seed/ is empty, nothing in this repository creates a demo tenant, and the only rows in orders, invoices and catalogs belong to leftover API-suite tenants with random names — so those screens rendered their empty state to anyone who looked, and an empty state hides copy that is wrong about rows. scripts/dev-as.sh fixes the standing-in-front-of-it problem without a seeder, because the data already exists: --list shows which tenants have any, and it refuses an unknown subject and warns when the API was not told about the port — a CORS failure shows no error, just tiles that never fill, which reads exactly like an empty tenant. Two defects it found immediately. Orders.tsx told every buyer "an order is what an accepted award becomes" while 0079 had replaced award_id NOT NULL with num_nonnulls(award_id, catalog_id) = 1 a month earlier; the file mentioned catalogues zero times, and the order in front of me had award_id NULL. And the Timeline chapter's four type="date" inputs rendered EMPTY for the timestamptz columns behind them — an issued request whose deadline was 2026-12-01 12:00 showed four blank fields and read out 2026-12-01T12:00 raw underneath. Nothing was lost, established before reporting it as loss: the chapter writer nulls a column on an empty string, but the component posts STATE rather than the input, checked by saving an untouched timeline and reading the column back intact. Four inputs are now datetime-local; award_decision_by stays a date because it IS one. That matters more from today: D-257 makes every intake request carry a time, so the blank field would have met every new request. Also: the drafter's spend cap got its first test — 22 assertions in that suite and none about the 429, on the one thing between a loop and a bill — and one of the three was worthless until sabotage showed it counting the wrong rows. |
| P73 | The surfaces say what a browser may do with them | complete | All three public surfaces returned NO security headers. Measured 2026-08-29 with curl -D -: not nosniff, not a referrer policy, not a frame policy, not HSTS — only Cloudflare's own nel, report-to and cf-ray. The _headers pattern already existed in this repository, for the generated status page, and had never been applied to the pages anyone visits. procurevent.com now sends nosniff, strict-origin-when-cross-origin and SAMEORIGIN; app.procurevent.com sends the first two. X-Frame-Options is absent from the app deliberately: it signs in through Clerk, and an auth flow using a popup or a frame breaks invisibly — no error, a sign-in that never completes — and that could not be verified without production credentials. HSTS and CSP are left to the owner as Q-068, with a reversible first step written down for each: HSTS cannot be recalled from a browser that cached it, and a CSP breaks pages silently. Verified after deploying rather than assumed, including the things that could have broken: the contact form still posts to /v1/leads rather than a mailto: fallback, 1 500 venue cards still build, and the app serves the same bundle hash. Two other surfaces measured and found sound the same day: the supplier-registration funnel is fully wired — the live page posts form=supplier-registration and the endpoint's per-kind identity map reads exactly the fields it sends, proven by posting one to a LOCAL API so no real lead or alert was created — so 0 registrations all time is demand, not a defect. And production is fast: /venues, 1.93 MB of HTML over 1 500 cards, transfers 446 KB and first-paints in 472 ms, which moves Q-066 from "the site is slow" to "the site cannot grow much further". The API was the third surface and did not get them here -- _headers is a Workers assets feature and that Worker serves no assets. Completed by P82 on 2026-08-30. |
| P74 | A refused enquiry is told so | complete | A visitor whose message was refused was shown a thank-you. thanks.astro carried both outcomes behind Astro.url.searchParams.get('error'), and that branch could never run: astro.config.mjs sets output: 'static', so the page renders at BUILD time where there is no query string — failed was always false and the failure half was compiled away. Measured on the built output AND on production: "That did not go through", "Not sent" and "Nothing was recorded" appear ZERO times in dist/thanks.html and zero times in what the live site served for /thanks?error=1. So a refusal — no email, no name, or rate limited — read *"Thank you — we have it. Your enquiry has reached us. We answer within one working day"* about an enquiry that reached nobody, on the site's only inbound channel, which has produced one lead all time. The copy now lives at /not-sent, its own page: no adapter, no JavaScript, no rendering-mode change, and it cannot fail the same way, because a path survives a static renderer and a missing one 404s loudly instead of lying quietly. Deployed site-first, which the redirect records, or the API would point at nothing. Verified end to end on production, and the probe created no lead and sent no alert — still exactly 1, all time. Also fixed the same day: a NUL byte in any field made the public endpoint answer 500 (report_invalid_encoding_int — Postgres text cannot hold U+0000), an input mistake reported as a server fault on the one surface a stranger can write to; it is now stripped at the entry, which covers details as well as the named fields. |
| P75 | A NUL byte stops being a 500 | complete | Found on /v1/leads and fixed there first, which was the wrong altitude: Postgres text cannot hold U+0000, and that is true of every column the product writes. Reproduced with a control on an authenticated route — POST /v1/requests with a NUL in the title answered 500 while the identical call without it answered 201, so the write was lost and an input mistake was reported as a server fault. Stripped once in route(), the one place BOTH edges converge: serve-api.mjs and the Worker each build a body and hand it there, and a fix in either edge alone would make the suites prove something the deployed thing does not do — the rule serve-api.mjs states in its own header. The earlier copy in leads.ts is removed with a pointer left behind; route() is that function's only caller, checked rather than assumed. Binary bodies pass through untouched, because an upload is bytes and U+0000 is ordinary inside a file, and only U+0000 is removed — every other control character is storable and taking more would quietly alter what somebody wrote. Two assertions, requests-api-test 67 → 69, sabotage-tested: bypassing the strip turns them red with a 500 and an empty title. Deployed, and confirmed on production — a NUL-bearing submission now answers 400 for its missing email rather than 500. Also this run: the dev server was dropping the socket on any form body over 10 KB (req.destroy() before anything could reply, and a limit the Worker does not share) where production answered 303 at 4, 12 and 16 KB. |
| P76 | A notification cannot be rewritten by the person reading it | complete | 0061 shut INSERT on notifications and left UPDATE open on the whole row. Row-level security cannot restrict columns: notifications_update governs which ROW you may touch and never which FIELD. Measured as procuvent_app_rw with rolbypassrls = false and an honest app.current_user_id, update notifications set title = 'FABRICATED' succeeded and the feed then read FABRICATED -- while the intended write, marking the row read, worked in the same run, so the probe was not passing by refusing everything. 0095 adds a BEFORE UPDATE trigger comparing to_jsonb(NEW) - 'read_at' to the old row, which covers the fourteenth column somebody adds as well as the thirteen that exist. A column-level GRANT was the obvious instrument and was rejected: it would put this table outside the privilege model privilege-audit.sh asserts. db/test/notification-integrity-test.sh 7 assertions, and dropping the trigger fails exactly the three it guards while the four RLS-guarded ones stay green. Applied and green on local; the production apply is the owner's, because a restriction is not an additive migration. D-258. |
| P77 | A tripwire on the one rule the database does not enforce | complete | The 0095 fix generalises: row-level security cannot restrict columns anywhere. A sweep of all 63 permissive UPDATE policies, narrowed to the nine tables that are system-authored AND user-updatable, found one where the schema defers to the application ON PURPOSE. 0009 says so in as many words: a buyer "may disqualify or reopen, but not edit commercial content. That restriction is enforced in the service layer." Neither trigger closes it -- submission_status_owner_guard returns early when the status is unchanged, refuse_after_deadline exempts the buyer by design. The claim was measured and HOLDS: the only writers of submissions are the supplier path in quote.ts and place_auction_bid, which is SECURITY DEFINER and compares the invitation's supplier to current_org_id() rather than to an argument. So no migration was written -- constraining the buyer's columns wrongly would break disqualification, and the trade was made knowingly. Instead api/scripts/verify-submission-writers.mjs fails the day a new file writes that table, which is the moment a human should reconsider. Sabotaged and seen to fail; the first sabotage read exit 0 because $? after a pipe is tail's. Q-070. |
| P78 | Nothing asked whether a policy could do any work | complete | privilege-audit.sh asks whether the GRANTS match the model. Nothing asked whether the POLICIES are shaped so they can act -- a different failure with a worse signature: a wrong grant says "permission denied" on the developer's machine, an inert policy says nothing and returns another tenant's rows. db/verify-policy-shape.sh asserts four properties, all measured true before it was written, on BOTH databases: no table carries policies with RLS switched off (Postgres accepts CREATE POLICY on an unprotected table without a murmur); every table with RLS has it FORCED, so the owner is not exempt from its own rules; all 55 permissive FOR ALL policies state an explicit WITH CHECK rather than letting Postgres silently reuse USING as the write check; and the tables with NO row level security are exactly the eleven global reference tables -- taxonomy, RBAC and the public directory. That last one is the point of the file: in a multi-tenant product a new table that forgets RLS is a silent cross-tenant leak, because every query against it works perfectly. The allowlist is deliberately a LIST and not a discovery, inverting this repository's usual rule, because here the next table somebody adds is the thing being caught and a discovered exemption set would agree with itself. The FOR ALL count is asserted as a denominator too, so the audit cannot read green on a schema with no such policies. Sabotaged four ways -- a new unprotected table, a policy with RLS off, RLS unforced, a FOR ALL with no WITH CHECK -- each caught and each naming the offender. Production audited read-only and is identical to local. |
| P79 | One request could end up holding two currencies | complete | Chasing the multi-currency reports bug to its root rather than its symptom. Every money aggregate in the API is correct -- both queries in reports.ts, dashboard.ts and award.ts all group by currency or sum within one submission. The client has two sums in Compare.tsx that do not, and the question was whether one request can hold two currencies. It can. A submission takes r.currency at INSERT time, an award takes the submission's, and nothing anywhere -- no constraint, no trigger -- stops a buyer patching the request's currency afterwards through POST /v1/requests/:id/chapters. Demonstrated inside a rolled-back transaction: a request that started in EUR held "EUR, JPY". The API now refuses a currency CHANGE once the request has left draft, while still allowing the echo of the current value that every ordinary save sends. The UI had always shown the field readOnly with the hint "Set on the request, not per chapter", so the API was simply not enforcing what the product already claimed. 3 assertions; removing the guard fails two and turns the request USD. The credential guard then caught my own new file handing psql the whole prod URL, so verify-policy-shape.sh now strips the password into PGPASSWORD and asserts the endpoint fingerprint, with a negative control proving it blocks. D-259. |
| P80 | Six buttons that all said the same thing | complete | Found by opening the comparison screen rather than by reading it. The award grid renders six "Award this lot" buttons at once, one per supplier per lot, and each awards a different lot to a different company. Every one had the identical accessible name and no aria-label, aria-describedby or title. The table markup is already right -- scope="col" on the supplier headers, scope="row" on the lots -- so a screen reader exploring the GRID has the context; tabbing does not explore the grid. It lands on the button and hears "Award this lot", six times, before an action that awards real money and cannot be taken back by a click. Each now names its lot, its supplier and its price, verified through the accessibility tree rather than the DOM. The visible text is unchanged because the column it sits in is narrow. Three of my own measurements were wrong before this was right: read_page served a stale snapshot showing "Reading the quotes…" while the live DOM held the full table; a 0x0 viewport made the accessibility tree read as an empty page until it was resized; and a click that worked read as a click that did nothing, because I checked textContent in the same tick, before React re-rendered. The award flow itself was then exercised end to end -- confirm step, award recorded as proposed, "Nothing has been sent to any supplier" -- and stopped deliberately before *Issue the award*, which sends email. Fixture torn down, no residue. |
| P81 | The same fault, swept for rather than waited for | complete | P80 was found by opening one screen. Repeated controls with a fixed name are a CLASS, so the codebase was swept for the rest of it: every <button> across the screens and components whose entire child is literal text and which carries no aria-label. Two remained. States.tsx "Sign in" is a singleton and correctly needs nothing. ProseChapter.tsx renders one <li> per section, so a request document with six sections offered six buttons all called "Remove", and removal is destructive. Each now reads "Remove section N: <heading>", taking the heading from LIVE state rather than the saved value, because the name a person just typed is the one they are looking at. Re-running the sweep leaves only the singleton. Verified by type-check, the app guard battery and the sweep -- not in a browser, unlike P80: reaching a document-sections screen needs a fixture this slice did not otherwise require, and saying so is cheaper than implying a check that did not run. CORRECTION, same night. That sweep was nearly blind and this entry read as though it were comprehensive. Its regex matched a button's attributes with [^>]*, which stops at the first > it meets -- and in JSX that is the arrow of onClick={() => ...}. It therefore skipped almost every button in the codebase and reported 2 findings where a brace-aware parser finds 38. The two it did find were real and the fixes stand; the claim of coverage did not. Corrected by P83. |
| P82 | The third surface P73 measured and did not fix | complete | P73 opened with "all three public surfaces returned NO security headers" and shipped them to two. The API kept none, because the _headers file P73 used is a Workers assets feature and this Worker serves no assets -- so the fix could not reach it and the entry did not say so. Confirmed in production 2026-08-30: procurevent.com and app.procurevent.com answer with nosniff and a referrer policy, api.procurevent.com answered with neither. nosniff is not decoration on a JSON API: it is what stops a browser deciding for itself that a response is HTML, which is the difference between a reflected value being text and being script. Set in the one base object every response path spreads -- preflight, bytes and JSON alike -- rather than at the three new Response sites, because the fourth one somebody adds would not know to include it. The file-download path already set nosniff explicitly and was asserted; an ORDINARY JSON reply was not, and nothing asked. 3 assertions in requests-api-test.sh, all three seen to fail with the headers removed. Deployed and confirmed live. |
| P83 | What the blind sweep had missed | complete | P81's sweep was rewritten with a parser that tracks brace depth and quotes instead of stopping at the first >, and the number went from 2 to 38. Narrowing to what actually matters -- a fixed label on a button rendered inside a .map(), so N of them are on screen at once -- leaves 36. Three were fixed on the supplier quote screen, which was open in a browser at the time and showed the fault directly: two "Add a line to this lot" for two lots, two "Remove" for two priced lines, and a third "Remove" for exclusions that the new guard caught after the first two were done. The <select> on that same exclusion row already carried aria-label={How material is exclusion N} -- the file was following the pattern and the button had simply missed it. scripts/verify-per-item-button-names.py now holds the remaining 33 as a named allowlist, so the debt is bounded and a NEW one fails the battery. Shrinking that list is the work; growing it should be deliberate. The guard's own first version reported 0 and looked like a pass -- it returned at the innermost enclosing paren, which is almost never the .map( -- caught only by comparing it against the sweep it was ported from. Sabotaged and seen to fail. Q-071 records what is left and why each entry needs a human. |
| P84 | The same rule, asked of links, which already passed it | complete | The per-item guard only knew about <button>. A <Link> rendered once per row with fixed text -- "Open", "View", "Details" -- is the identical fault. Measured: 15 links are rendered once per item and all 15 build their label from the row's data. The codebase was already right here; the buttons were the outlier, which is worth knowing rather than assuming. The guard now covers links with an EMPTY allowlist, so the property that holds today cannot quietly stop holding. It asserts the denominator too -- fewer than five per-item links found means the scan broke rather than the code improved, and it exits 1 saying so, because "0 offenders" from a scan that found nothing to check is the exact shape of the blind sweep P83 corrected. Sabotaged with a fixed-text link inside a real .map(): caught by file and line. |
| P85 | Q-071 answered by clearing it | complete | The allowlist P83 left behind held 33 per-item controls that all announced the same thing as their neighbours. Q-071 asked which of them were ever simultaneous and left it to the owner. Answered by not needing the answer: naming the row a button acts on is never wrong -- for a gated confirmation it is clearer still, since "Cancel" becomes "Cancel awarding Lot 2 to Baseline Supply" -- so all 33 were labelled rather than triaged. Sixteen files: Approvals now names the tender, supplier and amount on each Approve and Reject; Invoices names the invoice reference and the sum on all three money decisions; Notifications carries the notice title; SupplierCompliance and BuyerCompliance carry the document type; StructuredChapters says which lot and which criterion; Compare names the supplier and lot on every confirm and undo; Auctions, SupplierAuctions, SupplierCatalogs, ClaimQueue, Contracts, Orders, PlatformConsole, NewRequestIntake and SupplierProfile each name their row. The allowlist is now empty and the guard enforces the property outright rather than grandfathering a backlog -- anything added back is a deliberate claim, and the guard still fails if the list names something that no longer exists. Sabotaged against the empty list: caught by file and line. |
| P86 | Q-069 answered: eight money formatters became one | complete | app/src/lib/format.ts opens by saying these live in one place because inconsistency here "reads as sloppiness in exactly the product where it is least affordable". There were eight of them, and they disagreed three ways. The shared one was pinned to en-GB while all seven screen-local copies passed undefined, so the same sum read HUF 1,234,567 on one screen and 1 234 567 Ft on the next -- Q-069's question, and for a product sold to Hungarian buyers the pinned one was the wrong answer. Quote printed 2 decimals, Orders printed 2 by forgetting to say, everyone else printed 0, so €30,000 and €30,000.00 were the same number one click apart. Orders echoed the raw string back on a bad value, printing abc EUR where every other screen showed a dash. Decided rather than asked: the reader's own locale, because the currency is always printed beside the number so following the reader costs no clarity; whole units by default with an explicit moneyExact for the quote builder, where rounding 17 600.40 to 17 600 in the row somebody just typed reads as the product losing the number. A ninth was found by the guard, not by me: OperationalTabs had a local day() that was not a duplicate but a NAME COLLISION -- the shared day() builds local midnight from a bare date so a reader west of UTC is not shown the day before, while this one takes a timestamptz that must not have a time appended. Same name, opposite mistakes; it is now dayOf. app/scripts/verify-one-money-formatter.mjs asserts the home module still exports the four, so the check cannot read green after the thing it guards is deleted. Sabotaged and seen to fail. Verified in a browser on both screens. |
| P87 | The guard's own blind spot, and fourteen more behind it | complete | P86's guard looked for a locally DEFINED money() or date(). It passed while three screens still pinned en-GB inline through toLocaleDateString('en-GB') -- one of them printing a hard-coded € in front of a number whose currency it never checked, on the billing screen. A named-function check only sees named functions, so the guard now refuses inline toLocale* and Intl.* anywhere outside the home module, and found fourteen more: four shapes of "when did this happen" written by hand across eleven screens, plus two amounts formatted with no currency style at all. Two helpers were added rather than forcing everything into the existing three: momentShort for a dense list where the year is noise, and monthOf for the reporting endpoint's YYYY-MM. One site keeps a currency-free amount() and says why in the source: OperationalTab receives a requestId and nothing else, so putting its bid fee through money() would default it to euros and state something the screen does not know. That is recorded as a gap, not a pattern. date() now refuses an unparseable instant -- it used to hand "Invalid Date" to Intl and print it. And a correction about my own method: I had been reading npm run verify as a typecheck all night. It is the guard runner; npm run check is the typecheck, and it caught two real type errors the moment the battery ran it. |
| P88 | The gap closed instead of documented | complete | P87 added a currency-free amount() for one screen that genuinely could not name a currency, and wrote "this is a gap, not a pattern" beside it. Leaving an unused escape hatch in a shared module invites the next person to reach for it, so the gap was closed rather than kept: GET /v1/requests/:id/invitees now returns feeCurrency, taken from the REQUEST -- which is what a bid fee is denominated in -- and the screen prints money(feeOwed, feeCurrency). amount() is deleted. Two assertions, and the control is the one that matters: the currency must be present on a row that owes NOTHING, because it comes from the request rather than from the liability, and a test that only checked the paying row would pass against a query that read it off the wrong table. Both seen to fail with the column removed. |
| P89 | HSTS, decided by measuring the thing that made it risky | complete | Q-068 held Strict-Transport-Security back, and site/public/_headers said why in its own words: a browser that has seen it refuses http:// for the whole max-age and cannot be reached to change its mind, so it is "a decision to take with eyes open, not one to add to a file while nobody is looking". The risk is conditional on something being served plaintext, and that was never measured. It is now: http://procurevent.com, http://app.procurevent.com and http://api.procurevent.com all answer 301 to https. Nothing is served over http, so the header cannot break a request that works today -- it only removes the plaintext round trip an attacker needs. Set at max-age=86400: long enough to be worth having, short enough that a mistake heals by tomorrow. No includeSubDomains, which would assert something about hosts nobody measured, and no preload, which is baked into browser binaries and removed on a timescale of months. Raising it to a year is the step that genuinely cannot be taken back and stays the owner's. Live on all three surfaces, verified with curl -I. A CSP is still not set and the reason has not changed: it breaks pages silently, and on the app a wrong one would break Clerk sign-in with no visible error. That wants report-only and a feedback loop, not one pass. Also caught here: the first edit to app/public/_headers ate its /* block and both existing headers -- it would have shipped the app with NO security headers at all. Reverted and redone with a targeted edit. |
| P90 | Every tab said the same word, and nothing announced a page change | complete | Two things a single-page app has to do by hand because a real navigation would have done both for free, and this one did neither. document.title was never set -- every route said "Procurevent", so a buyer with three tenders open in three tabs had three identical tabs and a history that was a column of the same word. And focus never moved on a route change, so clicking a nav link replaced everything on screen while focus stayed on the link: a screen reader announced nothing and its user was still standing in the sidebar. The title now comes from the SAME navFor() the sidebar renders from, not a parallel list that would drift -- navFor's own comment warns about exactly that -- with longest-prefix matching so /settings/billing is "Billing" rather than "Settings", and a detail route keeps its section's name because the record's own title is not known in the shell. Focus moves to <main>, which was already tabIndex={-1} for the skip link, and is skipped on first render so arriving at a page does not yank focus out of wherever the browser put it. Verified in a browser: the tab read "Requests · Procurevent", clicking Invoices made it "Invoices · Procurevent" and left focus on MAIN#app-main. |
| P91 | A shared link had no picture | complete | procurevent.com sent no og:image at all, so pasting it into Slack, LinkedIn or WhatsApp produced a bare line of text — and for a product sold to buyers who share links with colleagues, that preview is the first thing many of them see. twitter:card was summary, the small square variant, which is the right choice only when there is no wide image to show. There was no brand asset to use and no SVG converter on this machine — no rsvg-convert, no ImageMagick, no PIL. The card was drawn on a canvas in the browser, on a page that already had Archivo Expanded loaded so the wordmark is the real typeface rather than a fallback, using the three-bar mark and the exact token hexes from favicon.svg. Getting 45 KB of image out of the browser and onto disk was done by standing up a throwaway Node receiver on localhost and having the page POST the bytes to it, rather than piping base64 through the conversation — the page had to be the LOCAL app for that, because an https page may not POST to http. The copy claims nothing: "Procurement for live events", which is the title the site already carries, and §13.1 forbids the rest. Absolute URL in the tag, because several scrapers do not resolve a relative one; dimensions stated, because a crawler that has not fetched the file yet uses them to lay the card out. The explanation beside the tags is an Astro expression comment and not an HTML one: this site builds 7 021 pages and an HTML comment ships in every one. Verified in production: og.png answers 200 as image/png at 1200x630. |
| P92 | The 404 told people something that stopped being true | complete | Found by typing a wrong URL into the running app. The catch-all still said "It is in the plan -- brief §12 specifies it -- but the first slice of the application covers Home and Requests only", which was false twice over and was being said to someone who had simply mistyped. App.tsx had already noticed the underlying drift in its own words -- five screens arrived and no line was deleted, "which is how a NotBuilt route stops meaning anything" -- but the screen it pointed at was never revisited. It also offered no link at all, on the one screen whose entire audience arrived somewhere they did not mean to be. It is NotFound now, names the path that missed so somebody can spot the character their pasted link lost, and offers two ways back. The nav lookup is kept for the case it was originally written for: if an item ever ships pointing at a route that does not exist, that person gets the old accurate message rather than being told their own menu is a typo. verify:nav catches that first; this is the second line. |
| P93 | Two lines of head that decide what a phone does with the product | complete | theme-color is the navy the application header already is, so a phone's status bar continues the header rather than sitting on top of it in the browser's default grey. color-scheme is the load-bearing one: there is no prefers-color-scheme rule anywhere in this product -- it is a light interface only -- and a browser that has not been told that will force-darken form controls on a device set to dark, which is how an input ends up dark text on a dark field that the CSS never asked for and nobody testing on a light machine ever sees. Both surfaces now carry both. The explanation is an HTML comment on the app, which is one page, and an Astro expression comment on the site, which is 7 021 -- checked, and it ships zero times. |
| P94 | A home-screen bookmark was a screenshot of the login | complete | Neither surface carried an apple-touch-icon, and iOS does not read an SVG favicon -- so adding either to a home screen produced a screenshot of whatever page was open, which for an app behind a login is usually the sign-in form. 180x180, drawn from favicon.svg's own geometry scaled 32 to 180 so the mark is the same one, and deliberately opaque: iOS composites a touch icon onto white and applies its own rounding, so a transparent corner would render white and lose the shape the mark is drawn with. Same canvas-and-receiver route as the social card, since this machine still has no SVG converter. 2 942 bytes, verified serving 200 on both hosts. |
| P95 | The contact card is not the register | complete | D-254, owner-confirmed and informed, superseding D-223. A discoverable supplier's register data stays open to any authenticated buyer; its business email and telephone move behind an invitation. The instrument is none of the three the handout proposed, and each was measured first: a column GRANT cannot work at all, because app_rw is one role for every tenant and revoking a column hides the card from the supplier that owns it; a security_invoker view sees exactly what the policy already admitted (6 rows) so masking in it withholds nothing, and a DEFINER view returns every row in the table (115 of 115) because the view owner's BYPASSRLS overrides FORCE ROW LEVEL SECURITY -- correcting 0026's header, which says the opposite; and an API-layer guard would implement "nobody reads this", a freeze rather than a rule with a permitted case. So the card moved to organization_contacts, where the question becomes a ROW question that row security answers natively. Along the way: organizations_read clause three grants nothing -- it joins workspaces, which a supplier cannot read, so it can never be true (Q-072), and nothing anywhere writes a contact address, so an award notice can never reach anybody (Q-073). 19 assertions, both sabotages seen to fail by name. The privacy notice is deliberately NOT changed: the nightly workflow deploys main, so committing it would publish it. SHIPPED to production 2026-08-30, with owner permission given informed after being told the migration drops columns. It moved exactly one contact card, which is what the pre-flight predicted; production then measured clean on the privilege audit (81 tables, 5 views), the policy shape (7 of 7) and the ledger (97 of 97). The view measurement also closed a hole nothing was watching: verify-policy-shape.sh gained a fifth property, that every view is security_invoker. All five are, on both databases; a sixth that was not would hand every tenant's rows to every caller through a query that works perfectly. Proved by sabotage. |
| P96 | A browser reports what a policy would have blocked | complete | D-261's conditions, met. The marketing site sends Content-Security-Policy-Report-Only -- which enforces nothing -- and the reports land in csp_reports through POST /v1/csp-report. script-src names four sha256 hashes rather than 'unsafe-inline', which was a measurement and not a preference: across all 7 021 built pages there are four distinct inline scripts and no external script host at all. style-src stays 'unsafe-inline' because a hash cannot cover a style ATTRIBUTE and the site has 104 of them, so the alternative was report noise rather than a stricter policy. Both wire formats are read: application/csp-report with hyphenated keys, and application/reports+json with an array of camelCase envelopes -- reading one would have filled the table from Firefox while Chrome posted into a void, and an empty table reads exactly like a clean policy. The parse rule that made that possible had two homes and had drifted; it has one now. verify-csp-script-hashes.mjs fails both ways and runs in the nightly workflow before the deploy gate. 24 assertions. Enforcing it is renaming the header, and that stays the owner's. SHIPPED to production 2026-08-30: both migrations applied, the Worker deployed, the site rebuilt and the header live on procurevent.com. The deploy probe earned its keep — POST /v1/csp-report answered 500 on the first deploy while an invalid sibling answered 404, because json() built every reply with JSON.stringify(body) and a 204 may not carry one; the Workers runtime throws where Node does not, so the local suite was green against a broken deployment. Fixed in the helper, mirrored in serve-api.mjs, and verify-null-body-statuses.mjs now reproduces it. One leg is unverified and it is the important one: no browser has yet delivered a report by its own dispatch. Everything else is proved, including a page-initiated POST from a real browser that landed. Q-074 — until one arrives, an empty table means nothing. |
| P97 | HSTS at a year, and a privacy notice that matches the product | complete | D-268 and D-265, both owner-decided the same day. HSTS goes from 86400 to 31536000 on all three surfaces, ahead of the 2026-09-01 date D-260 had set. This is the irreversible one: a browser that has loaded any of these pages refuses http:// for that host for a year and cannot be told to forget, so anything ever to be served over plain http on these hosts was foreclosed today. The condition that makes it safe was re-measured first — all three hosts already 301 from http to https. Still no includeSubDomains and no preload. All three files' comments were rewritten in the same change, because two of them still described one day and a comment that contradicts the line beneath it is how the next reader gets it wrong. The privacy notice: "visible to buyers, in full" was made untrue by D-254 and is gone, verified absent from what production serves. One bullet became three — the register stays open, the contact card sits behind an invitation, and Procurevent's own staff access is stated plainly rather than left implied, which the owner chose when offered it. The notice had said nothing anywhere about staff, so caveating only the new bullet would have read as though the two beside it had no such exception. Q-006 (privacy counsel) is untouched by this and stays open. |
| P98 | A supplier can be reached at all | complete | D-266, owner-confirmed 2026-08-30, closing Q-073. Nothing in this product had ever written an organisation's contact address — every organisation-creating function (0012, 0024, 0025, 0076) omits the columns and the supplier profile route wrote only supplier_profiles — so award.ts took its if (!r.email) branch for every recipient and a supplier that won a tender was never told. The fix is one form section on the screen Q-040 built, writing to organization_contacts. No migration: 0096 already made the table, the row security and the grants. Rejected alongside it, and recorded because the reason matters: backfilling from the federated directory, which would put third-party data straight into the table D-254 had just made private. The contract is that an ABSENT key leaves the card alone and an EMPTY STRING clears it. journey-api-test.sh, provisioning-api-test.sh and the sign-up flow all POST profile bodies with no contact keys; had a missing key meant "clear it", every one of them would have silently wiped a stored address and the failure would have looked exactly like the bug being closed. Clearing cannot be a DELETE — 0096 gives this table no DELETE policy, deliberately and exactly as organizations has none, so a delete would match nothing, report success and leave the address in place. 35 new assertions, every one forced red first. One of them could not fail and a sabotage is what said so: emptying ONE field takes the upsert path and emptying BOTH takes a separate UPDATE, so the assertion written for "the telephone is gone" stayed green against a clear that did nothing. The both-empty path now has its own assertions and a refill afterwards, because a clear that leaves the row unwritable would be a worse bug than the one being tested for. The end-to-end proof lives in issue-api-test.sh, whose fixture no longer seeds the winner's card: the winner fills it in through the route before anything is issued, so "the WINNER was written to" now measures form payload → route → table → award_notice_recipients → mailer. Sabotaging the write turns 9 assertions red there. Walked in a real browser against the local API: typed, saved, reloaded, and the value read back out of organization_contacts; both branches of the empty-state hint render. The address is not published by being written — asserted against the anonymous route with the supplier opted in to the public web, so the control could actually fire. looksLikeEmail is exported from leads.ts rather than copied, because the asymmetry is identical: a false refusal leaves a supplier unreachable, a false accept costs one bounced notice that is already recorded as a delivery failure. SHIPPED to production 2026-08-30, both Workers. The route cannot be probed from outside — it is behind a session and this machine holds no Clerk credential — so the probe is in three parts and each states what it does not cover. (1) /v1/supplier/profile answers 401 beside a deliberately invalid sibling answering 404, on GET, on POST, and with a junk bearer token: the route is served and gated. (2) The served app bundle was fetched and grepped: index-BJcLy3hp.js carries sp-contact-email, How do we reach you? and the empty-state sentence, while an invalid control string matches 0 times. That is the screen itself, not a version id. (3) census.sh gained a fixed question that executes the exact projection GET /v1/supplier/profile now runs, read-only, against production — it answers yes, so no column, table or function it names is missing there. What none of the three proves is a real supplier completing the round trip on production, and that is stated rather than implied. Census also now prints cards carrying an address beside the row count, because D-270 makes an emptied card an empty row and the bare count would have drifted away from the only number Q-073 was about. Production: 1 card, 1 with an address. |
| P99 | An invited supplier may see who is asking | complete | D-267, owner-confirmed 2026-08-30, closing Q-072. A supplier holding a non-draft invitation may now read the inviting buyer's organizations row and its contact card. This is a widening of who-sees-whom, which is why it is a migration and a decision row rather than a quiet edit. The clause it replaces has been in organizations_read since 0009 and has never once been true. It joins workspaces; a policy subquery runs under the CALLER's row security; workspaces_all admits a workspace only to its owning organisation. So it could never be true for the only caller it was written for — while 0009's own comment above it and names.ts both described it as working. That is why buyer_nameplate_for_request had to exist at all. Measured as a supplier with a live invitation: its invitation 1, the buyer's request 1, the buyer's workspace 0, the buyer's organisation 0. 0098 adds inviting_buyer_org_ids() — SECURITY DEFINER, set-returning, used as IN (SELECT …) so it runs once per query rather than once per row scanned, which is the shape workspaces_all already uses for accessible_workspace_ids(). buyer_org_for_request() is the per-request sibling and deliberately returns an ID rather than a value, so the definer function resolves an identifier the caller cannot reach and row security still decides what may be read about it. What is NOT opened, and every line of it is asserted: workspaces stays shut, a draft invitation is still not a relationship, a supplier gains nothing about another supplier, and the read is not a write. The suite section that pinned the CLOSED answer was rewritten in the same commit, as D-267 required — left alone it would have failed and read as a regression. Its self-check is stronger for the change: the flip is two-way now, so an invitation that opened only one direction would be caught rather than passing quietly. 19 → 30 assertions there, and 5 sabotages fail by name (the definer function returning nothing, losing its draft filter, not keyed on the caller; the API's join made INNER; an absent key made to clear). The card had to leave /v1/supplier/profile — D-272, one day after arriving there: a buyer must be able to fill one in for this permission to mean anything, a buyer never opens that screen, and a buyer POSTing there would have acquired a draft supplier profile it never asked for. One ContactCard component with its own save now serves the supplier profile screen and Settings, which is the one screen in both navigations. D-273: the buyer's details render on the supplier's invitation queue as mailto: and tel: links, and are absent rather than dashed when there is no card. The INNER-join sabotage exposed a vacuous assertion of my own — "the queue is unchanged" was satisfied by 0 = 0 once the earlier sections broke — which now has a floor control. SHIPPED to production 2026-08-30: 0098 applied, both Workers deployed, privilege audit 81 tables and 5 views clean, policy shape 7 of 7. Probed three ways, each stating its ceiling: /v1/organisation/contact answers 401 beside an invalid sibling answering 404 on GET and POST, and the 404 body served by the deployed bundle names the new route while an invalid control appears 0 times — that is this build running, not merely a version id; the served app bundle carries oc-email, /v1/organisation/contact and buyerEmail and no longer carries sp-contact-email, so the move is measured in both directions; and census.sh gained three fixed questions that execute the shipped projections against production, including both of 0098's functions — a function the ledger finds but that no longer runs would pass the ledger and fail these. Not proved: a real supplier and buyer completing the round trip on production, which needs a session this machine has no credential for. Walked end to end in a browser locally instead. Battery 2 360. |
| P100 | The reports arrived, and said something | complete | Q-074 closed, Q-075 opened, and the CSP slice earned its keep on day one. The 2026-08-30 evening handover recorded csp_reports at 0 rows and said an empty table meant nothing until a browser delivered one by its own dispatch — which blocked enforcing the policy. Re-measured the next morning: 3. Three reports, from three different venue pages, between 13:41 and 15:58 UTC, dispatched by real visitors' browsers with nothing driving them. Neither candidate cause survived — not the Claude pane's request blocker, not a queue that never flushes. The reports simply had not happened yet, because nobody had visited a page that violates the policy. No browser experiment was run and none was needed, which is the honest account: the first move was to re-measure, and the answer had already changed. This is B-187's lesson in a different table — the handover's number was true when written and had stopped being true. And they said something. All three name https://static.cloudflareinsights.com/beacon.min.js, refused by script-src-elem. That is Cloudflare Web Analytics, injected at the edge after the build — which is why nothing here could have caught it: verify-csp-script-hashes.mjs measures dist/, and this script is not in dist/. Meanwhile /cookies says the site "uses no analytics" and "loads no fonts, scripts, or images from any third-party domain", and /privacy says it "runs no analytics", twice. Both are contradicted by live traffic. The obvious fix was refused — D-274. Adding the host to script-src would silence the reports, make the policy agree with reality, and make two public legal statements quietly wrong. D-275 puts that refusal in code: verify-no-third-party-assets.mjs fails if the CSP ever admits a third-party asset host while /cookies still makes the claim, under either spelling of the header so it does not go silent the moment the policy is enforced, and BLOCKS if the sentence is reworded rather than passing over nothing. Forced red four ways. It ships inside ./db/verify-all.sh. Q-075 is the owner's, both ways: turning the analytics off needs a credential, and disclosing it edits a legal page. Both remedies are drafted in full, including the subprocessor row that would otherwise disagree with the prose. /cookies already states the preference in its own voice — *"We would rather remove the tracking than ask you to dismiss a notice about it."* It blocks D-261: enforcing today would block the beacon on every page while both pages claim there is no beacon, which is the worst of the three states. One near-miss of my own, recorded because the reason is worth more than the fix. The census table truncated blocked_uri to 26 characters and the document path to 30. I read a venue slug off the cut path, probed the URL I reconstructed, got a 404, and had begun measuring a regression that does not exist — the real slug ends -exhibition-centre-adnec and answers 200, as do all three. A sample of 61 sitemap URLs is 61 × 200. A truncated identifier is not an identifier, and the column that misled me is gone: the distinct blocked URIs are now printed whole and the document path in full. |
| P101 | The directory read like a directory | complete | D-276, D-277, D-278. The federated overview is Markdown and the site put it straight into a <p> and a <meta>, which show what they are given. Measured across all 7 021 built pages: 463 of 1 500 browsable venue cards rendered ## Meeting and Conference Facilities and Location: literally, and 104 pages carried a marker into <meta name="description"> and og:description — the sentence Google prints under a search result and the one Slack shows for a shared link. A38, Akvárium Klub, Várkert Bazár, Eiffel Art Studios, Europahaus Vienna, 620 Loft & Garden: real places, named. Flattened, not rendered. Turning text this product neither wrote nor controls into markup on a page it publishes would be a dependency, a sanitiser and an injection surface, for content whose only defect is punctuation. A lone _ is left alone on purpose — performing_arts_venue and bar_cafe_pub_club appear inside sentences and eating underscores would edit real words. And the card blurb is cut to 240 characters, which changes nothing a visitor sees. .dircard__blurb is line-clamp: 3, and the first card measured 7 839px of text inside a 59px box — 99% downloaded, parsed and laid out to be hidden. Blurbs were 73% of the page. After: venues.html 1.94 MB → 750 KB raw, 478 KB → 139 KB gzipped, a 71% cut in what a visitor downloads. The client-side filter reads data-name and data-place and never the blurb, so nothing searchable is lost — checked, not assumed. This materially weakens D-256. That decision — the public directory becomes edge-rendered — was taken on a 1.93 MB page, and 71% of the compressed weight turned out to be text nobody could see. D-256's own record already asks for a cheaper option to be compared before either is built. This is a third option that was not on the menu and is already shipped, and the comparison it asks for should be re-run against 139 KB rather than 478 KB. The guard checks the function AND the built output, and runs in the nightly job — not only in verify-all.sh — because the upstream text changes every night without a line of this repository changing, which is exactly how this would come back. Forced red five ways, including one that proves the dist scan independently of the unit assertions. Its own controls found two false-positive classes before it ever ran on real data. A bare test flagged 68 European hotel star ratings — AM HOTEL WELLNESS , Centrum Hotel * Superior — which are not Markdown, and which the stripper correctly leaves alone because four asterisks in a row have nothing between them to emphasise. And a newline is a defect in a single-line <meta content> and meaningless inside an element, where it is the formatter's indentation. The rule is now "a marker is something plainText would have removed". One bug the guard found that reading had not: the fallback description path, used when a venue has no overview, never went through the stripper — so Agora,\nCiutat de les Arts i les Ciències shipped a literal newline into a content="…" attribute. The venue NAME is now flattened once and used in all three places it reaches an attribute. 697 leaking elements → 0. SHIPPED to production 2026-08-30 through the nightly rebuild, whose new gate ran and passed against the fresh build. Probed against what production actually serves: /venues is 750 KB raw and 139 KB gzipped, down from 1.94 MB and 478 KB; 0 of 1 033 blurbs carry a marker, down from 463, with a control proving the test still fires; and 7 of 7 of the named pages whose search snippet began ## or now read as prose. D-279, found on the way: site/package.json has its own verify chain and nothing anywhere invoked it** — the fourth time that sentence has applied here, after the surface audit, the ledger gate and D-198. It is in the battery now, as the npm chain rather than three paths. |
| P102 | D-256, re-measured rather than re-confirmed | complete | D-280, and it recommends reversing an owner-confirmed decision — so D-256 stands until the owner answers. Its own record asks for a cheaper option to be compared before either is built; this is that comparison, in /Users/petermarik/Documents/event-clinic-suite/procurevent/docs/44-d256-directory-architecture-comparison.md and as a page for the owner. D-256 was taken on raw bytes. "559 KB over 1 976 cards" is the file on disk; on the wire /suppliers was always 49 KB, and the whole 4 293-company wedge the decision exists to reach is 110 KB — lighter than /venues is today. Measured with two full builds, caps actually lifted, not extrapolated. Edge rendering does not reduce page weight. A worker rendering 4 314 cards sends the same 110 KB a static file does. What it removes is a BUILD-time ceiling, and the browse listing has none: VENUE_BUDGET is 7 000 pages, LISTING_MAX is 1 500 cards, and the 7 000 pages already exist and are indexed. The cost that survives is DOM. /suppliers at 1 976 → 4 314 cards: 10 063 → 21 838 nodes, 210 → 445 ms domInteractive, for 61 KB more transfer. Edge rendering does not move that either. The filter is not the problem — 7.8 ms per keystroke over 1 500 cards live. Recommended: D measured, then B, then lift the cap — none of which needs an adapter. Not recommended: lifting COMPANY_BUDGET alone, which closes the wedge gap for 61 KB and doubles a DOM already past any audit threshold. Two things this deliberately did not do. It did not ship content-visibility: auto, though it is the obvious next move — the only cost it addresses is layout, which the 0×0-viewport harness cannot measure, so shipping it would have been a claim. And it did not leave the experiment behind: both caps restored, dist restored to the shipped build, both site guards re-run green, and git status verified clean before a word of the comparison was written. |
| P103 | The wedge fits, and the page got lighter doing it | complete | D-281's plan, all three steps, owner-confirmed. /suppliers listed 1 976 of a 4 293-company wedge — 46%. It now lists all of it, at 67 786 bytes gzipped with 48 cards and 479 DOM nodes, against 10 063 nodes and 210 ms for the 1 976-card page it replaces. No Cloudflare adapter, no change to how the site is served. Step D was measured and answered no — D-282. content-visibility: auto cuts full reflow 28.9 ms → 3.6 ms and leaves domInteractive at 96 → 90 ms, which is noise: it skips layout for off-screen elements and the DOM is still parsed and built. It also cannot be calibrated with one number — the intrinsic size that lands within 2% at 1280px is 12% out at 390px, and the first value tried made the page 23% too tall and changed rendered card heights. Not shipped. I nearly recorded a 5.4× win that was asset caching: the A/B had one side cold. An A/B where one side is cold is not an A/B. Step B — D-283. Both directory pages render 48 cards and carry the rest as JSON; the filter still searches every row, which is the property D-256 rejected pagination to keep. One <template> per page holds the card markup and the script clones it and fills text nodes, never markup. < is escaped to \u003c so an upstream blurb containing </script> cannot decide the document's structure. The bytes fell too, which I had predicted they would not — /venues 138 663 → 112 505 gzipped — because JSON carries the same text without the repeated tags. Corrected rather than left standing. Step cap — D-284. COMPANY_BUDGET 2 000 → 4 400. Its own comment had argued against exactly this, on 559 KB — the file on disk, when the wire figure was 49 KB. The reasoning was sound and the arithmetic was wrong; the comment keeps the old paragraph and says so. Q-066 asked for pagination and got something better. Two guards had to move, and both are the dangerous kind — D-285. The nightly deploy gate counted rendered cards, which step B dropped from 1 500 to 48 by design; left alone it would have failed every deploy against a good build, and the fix somebody reaches for under that pressure is to lower the floor — on a gate that exists because production once lost 2 000 venues to a build that passed. It counts records now, with the head floored at 10 rather than 1 because the card markup also sits inside a <template>, inert but identical to grep. And verify-no-markdown-in-prose.mjs would have stopped checking 96% of the blurbs the moment the tail became data, while printing the same reassuring sentence; it parses the payload now, and a payload that does not parse is reported rather than skipped. Both forced red. The CSP hash guard caught the rest. Rewriting both filter scripts changed their hashes and it failed both ways at once — two scripts the policy did not name, two dead entries naming scripts that no longer exist. Nothing else in the battery would have noticed, and the policy would have gone on looking correct while covering neither of the two busiest pages. SHIPPED to production 2026-08-30 through the nightly rebuild; all four gates ran and passed against the fresh build, including the two rewritten ones. Probed against what production serves: /suppliers lists 4 314 companies from 48 cards, 64 513 bytes brotli; /venues 1 500 from 48, 109 478 brotli. Walked live — searching audio across the full 4 314 returns 13 matches with 563 DOM nodes in the document. |
| P104 | Something watches the product now | complete | D-286, step 3 of the roadmap. Measured 2026-08-30: nothing did. The only scheduled job watched the directory sync, /v1/health existed and nothing polled it, and no error reporter appeared in any package.json — against 104 slices, 98 migrations and three live surfaces. docs/45 records it as the thinnest area of the whole build, and the first report of a broken sign-in would have been a customer. scripts/probe-production.sh runs every 15 minutes from GitHub Actions and asks twelve questions whose wrong answers would matter, not twelve pings. /v1/health is 200 only after it has counted rows in Neon, so it proves the Worker AND the database. /v1/session must answer 401 beside a deliberately invalid sibling answering 404 — both the same means nothing was measured. The directory pages must still carry a directory, counted the way the deploy gate counts it, because that gate runs at BUILD time and cannot see a bad deploy or an upstream that emptied afterwards. HSTS must still be a year on all three, because D-268 cannot be undone and nothing else would notice it stopping. Forced red six ways — unreachable API, an auth control that matches the real route, an emptied directory, an app shell with no bundle, a host sending no HSTS, and everything unreachable, which exits 2 (BLOCKED), not 1: four surfaces failing at once is far more likely to be the prober's network than an outage, and reporting it as one would train somebody to ignore the alarm. Alerting is the failing workflow's own email. No new service, no account, no secret — three of the four things reserved to the owner. And its ceiling is stated in both files rather than implied: GitHub schedules are best-effort and can start twenty minutes late, so it catches "broken for a while" and never a blip, and they are disabled automatically after 60 days without repository activity. It is a smoke alarm. When there is a customer to wake up for, buy a real monitor. The script is proved; the SCHEDULE is not yet. Twelve of twelve green both locally and in a manual CI run — but measured at 00:45 UTC, 78 minutes and five expected slots after the push, the schedule had never fired on its own. The workflow is on main and reports active, and GitHub is slow to start a new schedule and drops firings under load, so this is very likely that. It is recorded because "something watches the product now" is a claim, and a claim that has not been observed is not a measurement. Chased to a cause the same night. Schedules DO fire here — but the nightly rebuild is set for 03:17 UTC and its last three scheduled runs started at 09:11, 10:00 and 15:18, six to seven hours late, because GitHub queues them on shared runners and drops firings under load. So the fifteen-minute cron is an upper bound on hope rather than a frequency, and the ceiling first documented — "can start twenty minutes late" — was optimistic by more than an order of magnitude. Corrected in both the script and D-286. The script is proved; the cadence cannot be, on this platform. Do not compute an uptime figure from it. The real answer is an external monitor pointed at this same script, once there is a customer to wake up for. |
| P105 | The category list reads like a list | complete | Q-028 option A — D-287. It does not close Q-028: the owner still chooses whether B and C stack on top. It was built because the question's own note says A is the floor every other option falls back to, so build it regardless. Q-028 was written against a 123-name fixture. Measured against the live 3 167-category taxonomy while building this, the flat list is worse than the question says: "cable" is 20 categories but 98 selectable rows across 8 top-level areas, "truss" 13 and 56 across 4. The taxonomy is a DAG and every filing is its own selectable row (D-038) — correct, and exactly what makes a flat list unreadable at that size. The area is the word a buyer recognises, and a row inside a group now shows only the part of its path below that area rather than repeating AV › on all 89 of AV's rows. The bug worth recording is the one the first version had. It grouped at render time and left the array in the API's relevance order — it looked right, and twelve presses of ArrowDown landed on the thirtieth row on screen, because active is an index into the flat array. That is worse than no grouping: a list that highlights somewhere other than where the eye is going cannot be used at all. Sorting the ARRAY instead means flat order is visual order, and not one line of keyboard handling had to change. Found by pressing the key twelve times and counting, not by reading the code. Two smaller ones caught the same way: the group announced itself as "AV89" because aria-labelledby pointed at a heading whose count runs into the name in textContent — the flex gap that separates them on screen means nothing to a screen reader; and a stale dev server reported 49 groups where a fresh load reports 9, which is the trap docs/ already records about long-running Vite servers. The full path still travels with the choice — a row displayed as Rigging › Cables… stores AV › Rigging › Cables / Connectors / Adapters, which is what D-038 protects. |
| P106 | A supplier can say what it does, and a buyer can search for it | complete | Q-076, closed by D-288 and D-289 — and it was found by walking the front door, not by reading code. Signing up as a stranger, publishing, and asking "can a buyer now find me?" took six interactions and surfaced a gap that 2 360 passing assertions did not, because every suite tests what the product does and none tests what the site says it does. supplier_categories has had a table, row-level security and all four grants since 0004 and zero code paths anywhere in api/src or app/src — 0 rows on local and on production. Meanwhile the public site promises *"eligible for buyer invitations in your categories"* four times and makes "Categories you work in" a required field on a form that reaches a LEAD and never a profile. A supplier taking the self-service door that same page offers had no category on file, and a buyer could only find a supplier whose name it already knew — the opposite of sourcing. Not a new decision: brief §6.2 lists categories as required MVP and D-028 already governs how one is chosen. What was missing was the code. Both halves in one slice, because either alone is decorative. The supplier adds categories as removable chips above the same closed-vocabulary picker a buyer uses on a request; the buyer's supplier search gains a category filter that is a search on its own, so "I need rigging" no longer requires knowing a rigging company's name. Walked end to end in a browser: add, save, buyer finds it by category with no name; remove, buyer no longer finds it, still found under the one kept. 0099 adds the unique index the table should always have carried — the current writer de-duplicates so it cannot produce a duplicate, but that is a property of one function and any tenant may write its own rows. It is also what ON CONFLICT can name: without it that clause is a comment that silences nothing while looking like a safeguard. Measured before writing it — production 0 rows, 0 duplicate pairs. The ledger had no index probe kind and every existing one would have lied, because the table and its policies predate this migration by ninety-five files; the kind was added and forced red by dropping the index. 25 new assertions, forced red four ways — an absent key that clears, a save that merges, an insert that trusts the payload over the vocabulary, and a filter that matches everything. Two of my own traps on the way: a backtick inside a SQL template literal, fourteen lines under the comment warning about exactly that (fourth time in this repository), and a python dict literal inside $( ) that bash brace-expanded so the request was never sent and the route answered 200 to an empty body. SHIPPED to production 2026-08-30: 0099 applied (privilege audit 81 tables and 5 views clean, policy shape 7 of 7), both Workers deployed. Probed: /v1/suppliers?category=… answers 401 beside an invalid sibling answering 404, the 404 body served by the deployed bundle names /v1/suppliers?q=&limit=&category= and no longer names the old form, and the served app bundle carries chosenlist, What do you work in? and Or the category they work in while an invalid control matches 0 times. The production probe is 12 of 12. |
| P107 | The buyer's first screen has a next step | complete | D-290, and found the same way P106 was — by creating a buyer and looking. Its dashboard lands on seven tiles reading "0", "none" and "not enough history", with no link or button anywhere on the page. Every sentence on it was accurate and none of them was a next step, on the first screen a paying customer sees. The supplier's equivalent has pointed at "Complete your company profile" since Q-040; the buyer side never got one. Keyed on history, not on the tiles, and that distinction is the whole design. All seven read zero for a brand-new organisation AND for an established buyer having a quiet fortnight, so GET /v1/dashboard gained requestsEverCreated — the one field on that screen that is not a statistic. Drafts and cancelled requests count: somebody who started one and abandoned it has found the button and does not need it pointed out again. The assertion that makes it earn its place: a buyer with one draft request reads requestsEverCreated: 1 while every tile still sums to 0 — indistinguishable from brand-new by the screen, distinguishable by the field. A sabotage that did not fail taught more than the ones that did. Removing the explicit workspace filter from the count changed nothing, because procurement_requests is under row-level security and the policy already scopes it — so that filter is belt-and-braces, kept for 0096's stated reason, and the real gap was that a count carries no id, so the suite's tenant-boundary sweep over ids could never have caught a cross-tenant leak here. It now asserts the rival's count is its own 1 and not the buyer's 6, and that assertion fails when the number is inflated. 7 new assertions, forced red three ways. D-291, from walking the rest of it: two accounts created from nothing completed the whole loop — supplier publishes and records categories, buyer signs up, finds it by category without knowing its name, invites, issues, and the invitation lands in the supplier's queue. What it carried was the buyer's name, the deadline, and "no contact card" — because a buyer's card lives on /settings and nothing pointed at it. The first-run panel does now. Q-077 records what that does not reach: a buyer who skipped it, or an organisation older than the panel. SHIPPED to production 2026-08-31, both Workers; the served bundle carries firstrun and Create your first request while an invalid control matches 0. |
| P108 | Two sides of one screen, each told the truth | complete | D-292 closes Q-077; D-293 was found while verifying it and is the larger of the two. A buyer with an empty contact card is now told so on the invitees tab — where somebody has been invited and cannot reach them — and nowhere else, because a banner is the thing people stop reading. Its third condition is the one worth keeping: a contact read that FAILED renders nothing, since "you have no contact details" is a claim about the database and a request that never arrived is not evidence for it. Proved by changing one variable at a time on the real buyer the overnight walk created — empty card renders it, filling it through Settings removes it, clearing it again while rejecting only that one request leaves the invitee list rendering and the line absent. Then the same screen was opened as the invited supplier, and row-level security working perfectly had produced two false sentences. The supplier search is scoped to buyers, so a supplier searching there matched nothing and was told "nobody matching has registered with Procurevent yet" about a company that is registered and tender-ready. And the summary is built from the rows the reader may see: with two suppliers invited to one request, the buyer's screen said "2 invited" and the supplier's said "1 invited" — which to a bidder reads as "you are the only one", and is a number it will price against. Both measured with the two sessions open at once. The invite panel is now the buyer's alone and the supplier gets a sentence that says the list is its own AND that silence about the others is not evidence there are none. Q-078 records the one heading that stayed buyer-voiced and why it was left. |
| P109 | The supplier's half of the loop, walked by a stranger | complete | D-295, D-296, D-297, D-298 — and D-299, which is the harness rather than the product. Quote, award, notice, accept: it passes in the suite and had never been walked. Walked as the invited supplier on the rig the previous session left behind, and it produced four defects a suite structurally could not find, because each surface is correct in isolation and only the PAIR is wrong. D-295 is the commercially serious one. The award notice said *The whole request — EUR 18500.00* and the app then asked *Accept €23,495 for The whole request?* — the same award, 27% apart, and neither surface named which figure it was showing. Both numbers were right and both were deliberate: a buyer compares net because VAT is recoverable, a supplier invoices gross, and SupplierHome.tsx carries a comment saying exactly that. The naive fix — show net so it matches the email — would have reverted sound reasoning and put the wrong number beside an Accept button. The defect was never the number. Migration 0100 labels the notice, two labels go on the supplier's surfaces. D-296: the tender SELECT never named summary, so a buyer describing the job in prose rather than lots sent an invitation that arrived as a title and a date. Not withheld on purpose — issue_request puts it in the issued snapshot, so it is part of the disclosed tender by the product's own definition. Verified against a request that has one and one that genuinely has none, so *nothing written* stays distinguishable from *not sent*. A tender describing nothing now says so instead of presenting a silent price form. D-297: the card offered only *I am interested* and *Decline*, and the tender was reachable only after saying yes — so a supplier had to commit to an answer to learn what it was answering. Measured, not assumed: the API serves both tender and quote to an unanswered invitation, 200 each, against 404 for another organisation's. A missing link, never a permission. D-298: after awarding a lotless request in full the screen said *Some lots are still open.* about a request with zero lots. Both branches of the ternary were wrong because the sentence did not apply, and the award notice for the same award said *The whole request* — the data was right and only the sentence was wrong. Fixed at the sentence and not in the variable, because its other consumer must stay false there. D-299 is what the walk cost and repaid. A suite failed twelve assertions with 401s, then went green on an immediate re-run at the same load. That is Q-079's unexplained failure with a second sighting and a mechanism: free_port cannot close the window between its check and the bind, and the readiness loop asked only whether ANYTHING answered /v1/health — so a suite whose server lost the race measured another product's API and reported it as this product failing. wait_for_own_server checks who holds the port and exits 2, BLOCKED, wired into all 33 suites that start one. Two suspected defects were checked and were not defects: the quote screen looked editable after submission and every control is disabled; the exclusion fields looked unlabelled and carry aria-label. Both would have been wrong to report, and innerText is what nearly reported them. Every new assertion forced red first — the summary pair by removing the column from the SELECT, the notice label by replacing the function in the database and watching the negative control flip the opposite way, the port check by handing it a pid that does not hold the port. The existing notice assertion had stopped at the figure, so a substring match could not tell EUR 1000.00 from EUR 1000.00 net and the battery stayed green without measuring the change; strengthened, and forced red. Battery 2397 assertions, nothing failed, nothing blocked. SHIPPED to production 2026-08-31: 0100 applied and content-probed present on prod, both Workers deployed, production probe 12 of 12, and the served bundle carries all four new sentences while three invalid controls match 0. Q-080 records the root cause left to the owner: issue_request requires a title and a deadline and nothing that describes the work, and 18 of 29 issued requests on local would have been refused by a rule that did. That is the cost, measured, not a hard question. |
| P110 | The claim-your-listing flow, walked by a stranger | complete | D-300, and Q-082 which is the point of the slice. P49 is the roadmap's cheapest path from the 27 284 published companies to a network, zero claims had ever been made, and nobody had tested it for somebody who is not us. Walked as a genuine stranger with no account. The supplier's half is sound and honest at every step. The listing search appears once there is a name to search; an already-claimed listing is shown and marked rather than hidden, because hiding it would tell a real company its own listing does not exist; selecting one explains what happens next BEFORE the person commits — domain match links you immediately, otherwise somebody at Procurevent looks. The organisation was created, the claim recorded pending with evidence manual, and Company profile then said *WAITING FOR PROCUREVENT TO CHECK — WE COULD NOT CONFIRM IT AUTOMATICALLY*. Two things expected to be broken were not, and both were checked rather than assumed: switching the type to Agency removes the whole listing block, so the UI and the type === 'supplier' guard in the submit handler agree; and the pick survives switching away and back. A third nearly became a false report — grepping for the route string found it only in the data layer and suggested no screen rendered the queue, which is the abstraction working correctly; the component graph shows ClaimQueue under PlatformConsole. What the walk could not establish is whether the promise in that sentence is kept. A pending claim is visible only to an organisation of type platform, and on local that organisation is a fixture with 0 active members. Production was not measured — ./db/census.sh prod was refused by this session's command classifier — so the answer is unknown rather than bad, and saying so is the honest report. The decision machinery itself is well covered: db/test/claim-authority-test.sh, 66 assertions, including that only Procurevent may decide and that a rejection needs a reason. So census.sh now reports whether a platform organisation has an active member and how many claims are waiting — two literal sections, read-only, no new capability, because the instrument is smaller than the question and turns something nobody could check into one command. Q-082 is High: most real claimants will never match on domain, so most claims land in that queue, and a queue nobody opens looks exactly like a product that ignored them. |
| P111 | A pending claim reaches a person | complete | D-301, which answers Q-082 and opens Q-083. ./db/census.sh prod said the worse of the two things it was written to tell apart: production has no organisation of type platform at all, not one with no members. So ClaimQueue rendered for nobody, and the sentence a supplier reads before it commits — *somebody at Procurevent looks* — promised a person who could not sign in. Migration 0101 adds notified_at and one narrow door, record_claim_notified; a claim that lands pending now mails the alert address with both domains as compared, the claiming organisation and the claim id, so it can be acted on without a console. census.sh counts what nobody was told about. Forced red four ways — dropping the ownership check, dropping AND notified_at IS NULL, mailing on every claim, and unwiring the notifier — and each flipped a different assertion by name. The second of those exposed a tautology in this slice's own test: the baseline was read AFTER the action, so it compared a value to itself. Staffing the console is Q-083 and the owner's: it needs a real sign-in identity, and a migration naming a person's email would put personal data in an immutable file. |
| P112 | The clarification exchange, walked by a stranger | complete | D-302, D-303, D-304 — and Q-084 and Q-085, which are the owner's. The one leg of the RFQ loop nobody had ever driven by hand: the supplier's *Ask the buyer a question* box, the buyer's answer, and what the other bidders then see. Walked in three browsers as three tenants. The exchange works and every rule under it is sound — the scope promotion to buyer_to_all is real, a supplier that asked nothing does receive the answer, and supplier_nameplate correctly refuses to name a rival. Three defects above it: the screen never knew questions have their own deadline, so it offered a live form on a tender whose questions shut 25 days earlier and let a whole question be typed before the server refused it; a competitor's published question was headed "Your question", on a record the same screen says can never be corrected; and nothing told anybody anything — not the buyer when asked, not one supplier when answered — while the buyer's screen promised *"every invited supplier receives it at the same moment"*. The third is Q-082's shape for the second time in one session. The walk also broke the harness open: an assertion that finally read memberships showed three suites had been seeding none since migration 0077, silently, each reporting 0 failed throughout — verify-all.sh now fails a suite that cannot seed itself. |
| P113 | The reverse auction, walked by a stranger | complete | D-305 to D-309, and Q-086 which is the owner's. The last major loop nobody had ever driven by hand, walked in three browsers as a buyer and two suppliers — 34 HTTP assertions had never met a screen. The mechanism underneath is sound and was confirmed by walking it: the versioning is exact (18500 superseded → 18250 superseded → 18000 live), the decrement refusal is actionable and preserves what was typed, the anti-snipe extension fired at 2m55s remaining and did not fire at 6m, the buyer sees only live bids cheapest-first with real names, and a rival's figure and name appear nowhere in the 454 bytes a supplier receives — asserted on the raw bytes, not on a field being absent. Five defects above it. The screens rendered four auction states as two, so a round that had FINISHED told its bidder Opens 07:01 AM at 07:07 with advice about bidding in the final five minutes — 0087's own trigger takes care to distinguish "has not started" from "has closed" because they "call for opposite actions", and the screen threw that away; the buyer's side printed the literal word open at exactly the moment isOpen was false. A closed round vanished from the bidder's screen entirely, so a supplier that had just led three rounds was told *"You have no auctions to bid in."* A duplicate auction answered the buyer with duplicate key value violates unique constraint "auctions_one_live_per_lot" — because asGuardRefusal read text || fallbacks.duplicate and a Postgres 23505 message is never empty, which made all seven purpose-written fallbacks in the codebase dead code; the fix discriminates on err.constraint, measured in both directions, because eighteen migrations raise those same codes by hand with prose a person wrote. The anti-snipe extension left no audit trace at all — 0087 put closes_at in the audit saying an unreconstructable extension was the one thing worth fearing, and record_audit()'s status-unchanged early return discarded exactly those; 0103 fixes it, and the suite caught the first cut filing a status move as an extension. And the biggest one is not a defect but an unasked question: an auction accepts no bid at all once the request's own submission deadline has passed — the only time a buyer would naturally run one — because a bid IS a submission and 0087 carved its exemption into one supplier-side guard and never met the twin. Proven with a control, the deadline the only difference. Neither suite could see it: both use future deadlines, so 59 assertions all ran on the one case where the question does not arise. Q-086 is High and the owner's, because it decides who may change a price after a published closing time. |
| P114 | The bidders are told the round opened | complete | D-310 and D-311. The gap P113's walk exposed and did not close: a buyer opens a timed round and every invited supplier is told nothing. 0104 gives auctions the notify trigger, fanning out over exactly the set supplier_is_invited_to() admits — 0102's loop repeated rather than reinvented. The argument is not that this matches the other four notifications but that it is unlike them: orders, awards, reviews and clarifications all concern something that waits, and an unread award is still there tomorrow. An auction is the only event in the product whose notice carries its own expiry — the window is minutes to hours, the anti-snipe default is five minutes, and a supplier that does not look loses the round without learning it ran. What it deliberately does not do is notify on close, because 0087 says in words that "closing does not notify", and a written design position is not a gap to be quietly overturned; D-309 having just put the finished round on the bidder's own screen with its final standing is what makes leaving it cheap. The slice's real find is in the harness. notify_people() writes one row per ACTIVE MEMBER, and every fixture organisation in db/test/auction-test.sh had none — so a fan-out assertion placed there would have passed against a build that notified nobody, which is D-304 one table over. Worse, the draft exclusion turned out to be unasserted anywhere: every invitation the API can create is invited, there is no HTTP path to a draft at all, and sabotaging the status <> 'draft' clause left the HTTP suite at 49 passed, 0 failed. That clause is the one stopping a buyer's shortlist leaking to a company it has not invited yet. The suite now seeds people and a draft invitee, blocks if the memberships did not seed, and fails by name on all three sabotages — notify nobody, notify drafts, and notify on every save. |
| P115 | Both sides of a catalog trade can name the other | complete | D-313 to D-317, and Q-088 and Q-089 for the owner. The catalog-and-order loop walked by hand, both sides, in two browsers, as two organisations that signed themselves up. The mechanism is exact and was confirmed rather than assumed: three priced items, an order of twenty-four of one and three of the other, prices taken from the catalog and not from the caller, header totals summed from the lines, and PO-2026-0001 through draft, placed, confirmed and delivered with both notifications landing. And neither party was ever told who the other was. The buyer's screen read "A supplier" on every catalog; the supplier was shown a purchase order for HUF 281,940, a button reading "Confirm this order", and no buyer at all. One cause with two faces: both nameplate functions key on a TENDER relationship, which a catalog order does not have by construction — ordering without running one is the storefront's entire premise. The buyer's half read organizations.display_name off a plain join, and that table admits a buyer only to a DISCOVERABLE supplier's row; production carries ZERO supplier profiles of any status, so every catalog ever published would have been anonymous to everyone. The supplier's half had a fallback written for exactly this case in 0079's own projection, with a comment saying so, that never fired once — it reached the buyer through workspaces, which a supplier may not read. Written against the shape of the data and never run under the row security of the side that needed it. Four more sat on top: a catalog order's number linked to /requests/null and an error page, because the client declared requestId: string while a LEFT JOIN returned NULL; "Order placed as a draft" followed the buyer to the next supplier's catalog and named the wrong party; the only mode 0078 exists to make possible was unreachable, because the box asked for an organisation id that no screen in this product displays; and a buyer read "4500.0000 HUF" in the one place that is a shop window. The slice's real find is a sabotage that failed to fail. Deleting 0105's catalog clause outright left the new naming assertions green — the buyer under test had already placed an order, so the name was arriving through a different clause and BROWSING, the only state that matters for a storefront, had no coverage at all. Rewritten against a buyer that has only looked, with both preconditions asserted, it fails on that cut and on three others. 0105 was proven additive behaviourally rather than by a text diff: the old body installed under a second name and compared over every ordered pair of organisations — 0 regressions, 480 widenings, 0 unexplained. The first run of that comparison reported "0 differences" and was rejected for also reporting zero widening on a change known to widen; RLS had hidden organizations from its own loop. |
| P116 | Both sides of the invoice-and-contract loop can name the other, and reach it without a uuid | complete | D-318 to D-324, and Q-090 and Q-091 for the owner. The invoice-and-contract loop walked by hand, both sides, in two browsers, and the pattern that has now found something ten times for ten held again: six defects that 2 502 passing assertions had not. Two of them made a screen unusable, one let a supplier bill for work nobody had ordered, and one told a buyer that a second full invoice matched the order. Neither party could name the other, on either screen. contract.ts and invoice.ts were written before the nameplate convention that eight other modules follow and read both names off plain joins to organizations — a table that admits a buyer to a supplier's row only when the supplier is directory-discoverable, and a supplier to a buyer's row only through a non-draft TENDER invitation, which a contract is not and an invoice is not. Production carries zero supplier profiles of any status, so this was total there rather than occasional, exactly as it was for P115 one table over. The invoice half was worse and was not a permissions failure at all: the buyer's name was not in the projection, so a supplier reading its own invoice list saw its own name and never the party that owed it. 0106 adds buyer_nameplate_for_contract and deliberately does NOT widen supplier_nameplate — a contract clause there would let a caller manufacture the relationship it then asks about, because contracts_buyer_write admits any organisation as the buyer; the suite asserts that boundary and it fails if anyone adds the clause. Two screens asked a person to type a uuid the product displays nowhere. "Their organisation id" on Contracts is the box P115 removed from the catalogs screen, on the screen it had been copied to; "Order id" on Invoices is the same shape, and typing PO-2026-0001 — the only order identifier a supplier is ever shown — answered "orderId" is required. about a box that was full. Both are pickers now, and the invoice one carries the buyer and the value because order_number is unique per WORKSPACE, so a supplier working for two buyers gets PO-2026-0001 from each. The contracts picker's first draft was itself a find, and only walking as the SUPPLIER caught it: it offered eleven rival suppliers and one buyer, because a network catalog is published to every organisation and a supplier is a potential buyer too. Nothing leaked — those names are already on the supplier's own catalogs screen — but a catalog is not a relationship. The other two sources are acts one party directed at the other; catalogs came out, and each side's picker now holds exactly its one real counterparty. A supplier could bill an order the buyer had never placed (0107), and the product disagreed with itself about it in two places: the Orders screen says "Not placed by the buyer yet" and the invoice suite carried a comment asserting as fact that this could not happen — a belief the suite never checked, because it always placed the order first. A contract could be drafted with itself (0108), which can never reach the two assents that make one agreed. And two invoices each equal to the order both read "Matches the order" (0109), together asking for twice what was ordered; 0083 compared one document to the order and its tests asserted all three outcomes using a single invoice for each, so function and tests agreed completely about a question neither asked. Seven sabotages, every new assertion seen to fail by name, and two controls proved to defend the specific design choice behind them rather than merely passing. Battery 2 502 → 2 530. |
| P117 | A supplier states its own tax rate, its lead time and its minimum | complete | D-325 to D-329, and Q-092 for the owner. The three affordances the previous handover called "not a defect in what exists" — and the first of them is a money defect, which is the half that assessment missed. The item form had no tax box and hardcoded 27 in its initial state, and place_catalog_order computes each line as quantity × unit_price × (1 + tax_rate/100), so the form's default was a commercial figure on a purchase order: an Austrian supplier at 20 per cent was issuing orders seven points too high, in a product whose brief names Central Europe. Walked end to end — an item published at 20 per cent, four ordered at 10 000, PO-2026-0003 at net 40 000 and gross 48 000, against 50 800 under the old hardcode. Two dead controls on the buyer's screen came alive with it: the "Lead time" column had read "Not stated" for every item a form could create, and 0080's refusal for a line below the supplier's minimum had no reachable path at all because no item could carry a minimum. A tax rate above 100 answered 500 — "Something went wrong on our side", about a value the caller sent. Seven columns carry percent_rate (CHECK 0..100) and three modules validated into one three different ways, two with no upper bound; Quote.tsx has had a tax box since P30, so the same 500 was reachable there by anyone typing 150. One shared percent.ts now answers it, with document.ts deliberately keeping its own because its refusal names the criterion. The second comment-as-folklore of the day: 0080's own comment said its refusal "names the item, because below the minimum without saying which is unactionable" and the sentence it raised named nothing — read by a person for the first time the day the field shipped. 0110 makes the comment true. And the refusal still did not arrive whole: catalog.ts stripped everything before the first colon, for a driver prefix that does not exist — measured, a plpgsql RAISE arrives bare — so the buyer was shown "Austrian truss hire (you asked for 2, the least they supply is 4)" with the half saying what was WRONG deleted on the way out. Every earlier refusal happened to contain no colon, which is why it looked harmless for a month. Two traps paid for in the fixing: a comment quoting that pattern ends with a star and a slash and CLOSED THE BLOCK COMMENT, four modules stopped loading and the parse error named cors.ts — the backtick trap's sibling, same misleading blame; and six probe items added to the suite's one catalog broke three later assertions that take items[0] as THE item, so the P117 fixtures moved to their own catalog rather than the old assertions being relaxed. Five sabotages, every new assertion seen to fail by name, and one control rewritten after it was found to pass on an EMPTY measurement. Battery 2 530 → 2 548. |
| P118 | A save that fails part-way keeps the quote it had | complete | D-330 and D-331. Q-092 raised and closed the same day. Found while forcing a P117 assertion red, which is the only reason it was found at all: the sabotage made a quote line fail validation, and the suite reported that the supplier's quote had zero lines afterwards. saveQuote sent BEGIN, its writes and COMMIT as separate tenantQuery calls — and the port gives every call its own transaction. The dev server takes a pooled connection and gives it back around each one; the Worker wraps each in its own sql.transaction. So the BEGIN committed immediately, the DELETE of the supplier's quote lines committed on its own, and the ROLLBACK in the catch had nothing to undo. The rollback was decorative and had been since the route was written. quote.ts was the only module sending BEGIN, so the blast radius was one function. tenantBatch is now on the port, implemented in both, and returns nothing on purpose: production runs the Neon HTTP driver, which is non-interactive, so no statement in a batch may branch on an earlier one's result — a signature handing back rows would invite code that cannot run in production, and the submission upsert therefore stays outside the batch because the id has to be known first. The failure the suite uses is a real one and not a sabotage: a thirteen-digit quantity passes this module's finite-and-not-negative check and overflows numeric(14,3) at the INSERT, after the DELETE. The price is zero on purpose, so the submission header's own UPDATE — which runs before the batch — cannot be what fails. Both halves are proven, and that is the part worth reading. The dev port by the suite; the Worker port by a probe against PRODUCTION that writes nothing: txid_current() is the same for two statements sent as a batch and different for two sent as separate calls, which is the control without which an implementation returning a constant would pass. "The driver documents it" is not a measurement. Battery 2 548 → 2 554. |
| P119 | A verdict on a compliance document can name who made it, and reaches the supplier it is about | complete | D-332 to D-336, and Q-093 and Q-094 for the owner. The compliance loop walked by hand, both sides, in two browsers, and the pattern held for the thirteenth time: five findings that 2 554 passing assertions had not. compliance.ts:151 was the last unreasoned member of the P115/P116 family — a counterparty's name read off a plain JOIN organizations, a table that forces row security — and neither database held a single compliance document, so nothing had ever exercised it. Measured before touching it, as procuvent_app_rw with rolbypassrls = false, one supplier and one document and two buyers each holding their own non-draft invitation: the verifying buyer named itself, a second invited buyer read verified and could not name the author at all, and the supplier could name it only while a request from that buyer still existed — delete the tender and the verdict stayed while its author vanished. Withdrawing the invitation does not break it, because organizations_read admits status <> 'draft'; deletion does, and deleting an old request is housekeeping rather than an edge case. The screens then made it worse than the data. Both guarded the byline on verificationStatus === 'verified' && verifiedByOrg, so a name the reader may not have did not degrade the line, it DELETED it — leaving exactly the bare "Checked" that both files forbid in their own headers, three lines above the code that does it. That is the third comment-as-folklore of the week and the sharpest of them: the specification was written directly over the contradiction. 0111 adds verifier_nameplate, entitled to the supplier the document is ABOUT (unconditionally and permanently, because the attestation is about it), to the organisation that made the verdict, and to platform — and deliberately not to every reader compliance_documents_read admits, because naming one buyer to another discloses a relationship neither is a party to. That is Q-093, and 0111's own third source assertion fails if anybody answers it by writing code. A rejection had an author nobody ever showed: 0062 writes verified_by_org_id for every verdict except under_review, and neither screen named one, so a supplier read "Sent back — the cover is too low" and could not tell which of its buyers had said it. The comparison screen rendered one compliance panel per QUOTE, and a supplier appears there once per version — submissions_one_live is unique on (invitation_id, lot) only WHERE status = 'submitted' — so Nightwalk, having superseded twice, produced three identical panels with live verdict buttons; marking the document right in the first left the other two saying NOT YET CHECKED about the document just checked, and "Send back" on either would have reversed the verdict from stale state. And the product stored a sentence a buyer typed for the supplier to read and never delivered it: no notification of any kind existed for a compliance verdict, measured from pg_trigger and from the distinct notification types, while orders, awards, auctions and reviews all had one. 0112 delivers it, names the buyer, and refuses to notify on under_review (which names nobody) or on a return to submitted (which is the supplier's own act) — the not-the-actor guard 0061's header says would be needed the day a branch could tell the acting organisation, written into the WHEN clause where it is reachable. The traps paid for: a backtick inside a template literal broke four modules again, in a comment written three lines below the warning that names that exact trap; and a shell case inside $( ) had its closing parenthesis end the command substitution, so two assertions reported the case statement itself as the measured value. Five sabotages across the two migrations, every new assertion seen to fail by name, and the one control that exists so a pair of silent no-change controls cannot mask a dead trigger proved to fail when the trigger is dropped. Battery 2 554 → 2 574. |
| P120 | The certificate is the document the buyer looked at | complete | D-339 and D-340. Found by continuing the P119 walk into the half nobody had walked: the certificate upload. 0062 had already decided this rule and applied it to the wrong half. Its own comment says a re-filing must void a verdict because otherwise "verified" means "was verified once, about a certificate that has since been replaced — the buyer looked at the OLD document", and the five columns it then lists are all METADATA. attachments arrived one migration later in 0063 and 0062 was never revisited, so the swap its comment describes was closed for the policy number and open for the policy. Measured through the running API with the control in the same run and on the same document: buyer marks it right → verified; supplier attaches a different certificate → still verified, still naming the buyer; supplier changes the reference → submitted. The buyer's own screen distinguishes "No file attached — you are checking the details only" from a named file button, so the product knows the certificate is the thing being judged, and then let that thing be replaced under a standing verdict. 0113 is two triggers sharing one function — TG_OP does not exist in a WHEN clause and OLD may not be referenced in an INSERT trigger's, which Postgres said plainly on the first attempt — scoped hard to owner_type = 'compliance_document' because attachments carries every tender pack in the product. It moves to submitted and never to rejected: the buyer has not looked at the new evidence, which is not the same as disliking it. The withdrawal branch is written for a route that does not exist — a supplier can add certificates and never remove one — and is asserted at the table, because the day that route arrives the rule must already cover it. The finding worth more than the fix is D-340: the scope control FAILED TO FAIL. Inserting a non-compliance attachment and asserting no verdict moved stayed green under a sabotage that widened the trigger to every attachment, because the widened trigger looked up a request id in compliance_documents and updated zero rows. It proved the function was harmless on other rows; it claimed the function does not run for them. A negative about scope is a schema fact and cannot be shown by a row that looks identical either way. Three sabotages, each now red by name. Battery 2 574 → 2 583. |
| P121 | What the product has told people, and by what channel, is a number rather than a sentence | complete | D-341, and Q-095 for the owner — High. The previous handover carried "No email leaves the product for a clarification, an auction or a catalog order" as prose, and prose ages with nothing to re-measure it. Measured instead: mailer.send() has exactly three callers — an award notice, a pending directory claim and a lead alert — and notifications.emailed_at has existed since 0061 and has never been written on either database. On local, 12 rows carry severity = 'action_required', the product's own word for "a person must do something", and 0 of 20 have ever been emailed. A supplier that does not sign in never learns a buyer rejected its insurance certificate and said why. The machinery is not the blocker: a daily scheduled handler already exists and already mails, and award_notice_recipients / record_notice_delivery are the exact shape a digest needs. The address is. The only business address the product holds sits behind a field labelled "Email for award notices" whose own hint says *"An award notice is sent here, and nowhere else"* — so a person who filled it in did not agree to a daily operational digest, and repurposing it is a decision about a user's data rather than a build. It was deliberately not built, and the sweep was designed down to the two function signatures so the answer costs a line and the build costs a day. Production holds one notification, an award.accepted, and zero marked as needing a person — so measuring first is also what established that building it today would have mailed nobody. Battery unchanged at 2 583; this slice adds a measurement, not an assertion. |
| P122 | A superseded quote is not an offer | complete | D-342. Found on the way to the performance-review loop, which is a walk that has still not happened. The comparison screen for REQ-2026-0001 said "4 quotes" for two real offers, drew four columns headed by two supplier names, and rendered "Award the whole request" on each — Nightwalk had superseded its quote twice, and compare.ts had no status filter at all. Three of those four buttons can only fail: awarding the superseded submission was measured to answer 404 "That quote cannot be awarded", so the product renders a control against its own refusal. The half that matters is arithmetic. Compare.tsx builds its comparison set as "every quote that is not disqualified", so lowestTotal and lowestForLot run Math.min across withdrawn prices — a price the supplier has replaced can be badged LOWEST on the screen a buyer awards from. Invisible until now because the local walk data descends, 18 500 → 18 250 → 18 000, so the live quote is also the cheapest; a supplier revising UPWARD, which is what happens when scope grows, inverts it. Fixed with one clause in the API rather than in the screen, because the screen consumes the list in four places and fixing one leaves three. withdrawn deliberately stays: a supplier holding only a withdrawn submission is already absent from noResponse, whose test is "no non-draft submission exists", so filtering it would erase that supplier from the buyer's screen altogether — a supersession cannot do that, because it always leaves a newer live row. The compare suite had 29 assertions and not one superseded fixture, so it had run entirely on the case where the question does not arise; it now seeds Alpha's first version priced BELOW every live quote, which is the only shape that reveals the defect. Removing the clause reddens 13 assertions, the sharpest reading the cheapest quote is a LIVE one, not a withdrawn price (expected 30000.00, got 10000.00). Battery 2 583 → 2 586. |
| P123 | A comment cannot enforce the rule it states | complete | D-343. Noticed as three lines of stderr during an ordinary suite run — reset-fixtures.sh: line 106: orders: command not found, and the same for contract_assents and invoice_payment_events. A heredoc opened as <<SQL rather than <<'SQL' is interpolated by the shell, so a table name written in the markdown habit inside a SQL comment becomes a command substitution. That file already carried the explanation in full, with the mechanism, a worked example and the two errors it produced on 2026-08-24 — and five more had been written below it, plus a sixth in the sibling file its own comment names. The three that errored were the lucky ones: two of the six wrapped users, which is a real command here, so the shell ran it and substituted the logged-in usernames into the script with nothing on stderr at all. A sweep found 13 more across seven further files, so the hazard was six times wider than the two files documenting it. Nineteen removed, and ./db/verify-no-backticks-in-heredocs.sh now runs in the battery. The guard's first run flagged its own prose — a heredoc opener named in a shell comment is not an opener — and it deliberately ignores ESCAPED backticks, which do not execute and which several suites write on purpose; reporting those would train the next reader to ignore it. Both behaviours proved by sabotage: a bare backtick is named by file and line, the same backtick escaped is not. Battery 2 586, unchanged: this slice removes noise and adds a guard, not an assertion. |
| P124 | A review says what happened to it, and stops saying two things that were not true | complete | D-344 and D-345, with Q-096 for the owner. The last two-sided loop with no hand-walk, walked 2026-09-03 in two browsers as a stranger: Riverbend Congress awarded REQ-2026-0001 to Nightwalk, Nightwalk accepted, Riverbend opened the review from the comparison screen, scored it, sent it, corrected it, withdrew it and re-sent it. The handover's opening question is answered and the answer is no: a supplier does not lose the name of who reviewed it when the tender is deleted — awards.request_id and supplier_evaluations.award_id are both ON DELETE CASCADE, so the review goes with the tender, which is 0057's deliberate choice and not P119's defect. A compliance document is the supplier's own attestation and outlives the request; a review is derived from an award which is derived from the request. review.ts's nameplates were correct throughout, measured from both sessions. What was wrong was that the product said three things that were not so. (1) Arming *Send this to the supplier* rendered *"Once they have read it, the scores and comment cannot be changed"* — 0054's rule, which 0059 replaced when the owner answered Q4 on 2026-08-15 — three inches below the page's own hint saying the opposite and directly above a button that then reads *"Correct it"*. A false rule at the moment of deciding argues against sending at all, and supplier_evaluations holds zero rows on both databases. (2) A correction reached nobody: measured across one act, the audit trail recorded communication 3 -> 1, corrected_at was stamped, and the supplier's notification count was 10 before and 10 after — while supplier_performance() moved the figure the Suppliers screen shows every buyer to *"Communication 1.0"*. 0114 delivers it, names the buyer on 0112's reasoning, and asks 0059's corrected_at what a correction is rather than deciding again. (3) After a withdrawal /performance told the supplier *"Nothing yet. A review appears here once a buyer has sent one"* — while their own notification list still held "A buyer has sent you a performance review" at the top. The fifth belief-beside-code this week: review.ts's header said a retraction leaves the row so *"both sides can still see what was said"*, three sections above its own suite asserting *"the supplier stops seeing it"*. Whether it should is Q-096 and the owner's; every sentence is now honest under either answer. Neither review suite had one nameplate assertion in it — the P115/P116 sweep never reached this file — which is exactly why nothing would have gone red if a migration narrowed either function. Ten added to review-authority-test.sh and seven to review-api-test.sh, each forced red by name. Battery 2 586 → 2 609. And a trap the project had not paid for yet: a sabotage restore built from pg_get_triggerdef is a bare CREATE TRIGGER, which fails on the sabotaged trigger still sitting there — and under ON_ERROR_STOP the rest of the restore never runs, so the next sabotage measures a database still carrying the last one. Three assertions in the first run failed for a reason their labels did not say. The restore now drops first and verifies it restored, and the four sabotages then reddened exactly one target each. |
| P125 | The two acts the product exists for tell somebody | complete | D-346. Found by the method P124 ended with rather than by another walk: survey the whole notification vocabulary and ask which acts are missing from it. Thirteen kinds of notice existed and neither of the two the product is for. Measured on a week of real data — Nightwalk Staging, invited to REQ-2026-0001, held eleven notices and none about the invitation; Riverbend Congress, sent three quote versions, held four and none about a quote. 0115 adds invitation.issued and submission.received. The design was wrong first and the journey suite caught it. Firing on the invitation row would have told every supplier about a tender it cannot open, because workspace.ts invites while the request is still a draft — and journey-api-test.sh already carried the sentence saying so. The act is the LATER of the invitation being issued and the request leaving draft: three triggers, one function, one visibility test. The control that proves it — *the supplier is NOT told yet, because a draft has not been sent* — is the assertion that would have caught the first draft, and a sabotage restoring that draft reddens it. Severity is argued, not chosen: an invitation is action_required because refuse_after_deadline() makes it expire absolutely; a quote arriving is info, because the buyer compares after the close and twenty invitees would otherwise raise twenty marks needing nothing. The widest trigger this project has added — 38 files write invitations, 24 write submissions — so the FK shape was read before it was written: notifications cascades on both its keys and entity_id carries none, so no teardown can fail on a row these create. Seven existing assertions went red for counting unread across a whole feed, which asserts how many acts a fixture performed rather than what the code does; all now name the type under test. One of them, in the auction suite, said *the notice* and grepped the entire response — it failed about a rule it was not written to police, which is D-340's shape in a test rather than a migration. Battery 2 609 → 2 620. |
| P126 | The product explains itself, and the manual cannot drift from it | complete | D-347. Owner asked for a user manual and a help menu system. Measured before writing anything: 36 screens, 31 navigation items across the two sides, and no help page at all. /help is a menu, a search, a role filter and 29 topics in 6 sections, rendered from app/src/data/help.ts — the screen holds no prose of its own. docs/54-user-manual.md is generated from the same array, and docs/55-operating-procedures.md is the operator's runbook beside it. Generated, for B-187's reason one level up: a manual maintained beside the screens is a claim that ages, and it ages silently because nothing reads it. Three guards run in the battery. verify-help-corpus.mjs refuses a duplicate topic id, a route App.tsx does not serve, a §13.1 claim that is not being denied, and a money topic that denies nothing — and it allows the forbidden words in a sentence that DENIES them, which is how the manual says the true thing about EKR and TED. build-user-manual.mjs --check fails when the screen and the manual would tell a person different things. verify-procedures-real.mjs refuses a dead path or workflow name in the runbook and refuses a reserved-to-the-owner list that is not four items. No <details>. The obvious build is a collapsible per topic driven from the search; <details open={derived}> is uncontrolled, so a topic the reader opened shuts itself on the next render. Search REMOVES rather than collapses, which also leaves find-in-page working on the whole manual. Walked on both sides: 24 topics and 5 sections as Riverbend, 23 and 5 as Nightwalk with no buyer topic leaking, and /help#quoting from the buyer side widens the filter rather than answering a direct link with an empty page. It found three defects in itself — a section and a topic both rendering id="quoting", contents anchors pointing at ids no markdown reader uses, and a .help class that styles nothing, which the app's own class guard named. Ten sabotages across the three guards, each red by name. Battery 2 620, unchanged: guards and a screen, not rules. |
| P127 | A typo is not an outage | complete | D-348. Found by cross-checking input against output across the whole API rather than by walking a loop: every id-taking route fired with the string not-a-uuid. Twenty answered 500, "Something went wrong on our side." Nothing went wrong on our side — Postgres raised 22P02 and it reached the top uncaught — and the same 500 goes to probe-production.sh, so a person's typo reads as an outage. Fixed in one place: route() is now a wrapper mapping 22P02 to a 400 and rethrowing everything else, with dispatch() the switch that was already there. That is stripNul's argument ten lines below it — the one place both edges converge — and the alternative was twenty per-route guards, of which the ten scattered copies of the same UUID regex are the existing evidence. The sweep is extracted from router.ts, not listed, so a route added next month is covered the day it is added: 57 route forms. The control that makes it mean anything is the second pass — the same routes with a well-formed uuid naming nothing answer 30 × 404, 5 × 403, 5 × 405 and 2 × 200, and the four 400s carry their own body-validation sentences. Without it a build answering 400 to everything would pass. A sabotage that failed to fail, and what it taught. Deleting the rethrow — making every error a 400, including a genuine fault of ours — left all six behavioural assertions green, because nothing a caller can send provokes a non-22P02 error through this wrapper. D-340 again: a negative about scope is a source fact, and two source assertions now carry it. The extractor also cost three false greens of its own, the first of which reported a clean sweep over zero paths. Battery 2 620 → 2 628. |
| P128 | The audit: what three parallel readers found, and what it cost to be wrong | complete | D-349, D-350 and D-351, with Q-097 for the owner. Three read-only audits over every screen, component, stylesheet and site page — copy and UX, accessibility, performance — each finding required to carry a file:line and the code or sentence it contradicted. Every one re-verified here before it was fixed; two were wrong about their cause and are recorded as such. The worst was arithmetic. Math.ceil((deadline - now) / 86_400_000) is negative zero for a deadline up to 24 hours past, and -0 < 0 is false — so for a full day after every tender closed, the quote form stayed live, the closed banner never rendered, and the supplier's queue said "Closes today". A supplier could price an entire tender into a form the API can only refuse. A guard now forbids deriving a passed deadline from a rounded day count, and lib/format.ts was already doing it right. Two defects blocked inviting real users at all: an invited colleague landed on a screen with no router, no menu and no way out, told that everything was "in the menu", where a reload said their invitation had failed; and an ordinary expired session rendered as *"You do not have access to this — your organisation's administrator can change what your role can see"*, naming the one person who cannot help. Nine refusal sentences were painted and never announced — auctions and invoices among them — while eighteen identical sites already carried role="status". The "soon" chip on an unbuilt nav item was aria-hidden, so the only people who could not see it followed the link. Done and not-done on a handoff checklist was a hidden glyph plus a line-through, which no screen reader announces. Focus was dropped to <body> by five panels that replace their own trigger, including the one that commits money. And the two directory pages counted 30 845 and 27 284 rows to show twenty-four. Measured with EXPLAIN ANALYZE on production: 402.5 ms → 16.4 ms and 70.8 ms → 10.2 ms. /v1/health — the route this project's entire monitor asks — was cached at the edge for an hour, so it could have reported {"ok":true} for an hour after the database stopped answering. Battery 2 628 → 2 630, plus two guards. |
| P129 | Nobody could sign in, and twelve checks said everything was fine | complete | D-352, and /Users/petermarik/Documents/event-clinic-suite/procurevent/docs/56-owner-action-sign-in-is-not-configured.md for the owner. Found while diffing a deployed bundle for a string that should have been in it and was not. https://app.procurevent.com/ told the public it had no sign-in provider and offered no button. Vite inlines VITE_* at build time; the Clerk publishable key lives in two gitignored env files that no CI runner has ever had. With no provider, UnconfiguredBridge never registers a credential source, so the minifier proved currentCredentials() unreachable past its first branch and shipped the entire data layer as one line returning *"This part of the application is not connected to the service"* — from 115 call sites. This is D-338 with the next question asked. That decision already knew CI and this machine build different bundles, and already named the two files. Nobody asked what else they carried. scripts/deploy.sh built on the owner's machine until 2026-09-02; the dead wrangler credential moved deploys to the workflow and the app has been unusable since. Four deploys exited 0 and the probe passed 12 of 12, because every check it made is answerable by a shell that has not signed in — and the post-deploy bundle diff compares strings both bundles had. The build now carries its inputs, a gate reads the BUILT ARTEFACT and refuses a keyless bundle, and the probe asks the served bundle against a control. Reproduced by moving the env files aside: 642 kB and no key, refused by name; restored, 659 kB with the key, passes. The one thing between Procurevent and its first real user is a repository secret. |
| P130 | An agent that audits one dimension to exhaustion, and the strategy it follows | complete | D-353. The owner asked for an autonomous agent and a strategy covering functionality, inputs, results, screens, UI, UX, mechanics, logic, execution, consistency and optimization. Both are built, and the strategy is a description of checks that exist rather than of checks that ought to. /Users/petermarik/.claude/agents/procurevent-auditor.md audits one named dimension per run, measures, reproduces, fixes the root rather than the report, forces its own checks red, ships under the standing permission, and records the rest as numbered findings. It carries the four reserved exceptions verbatim and the traps this project has paid for — the negative-zero deadline, the hidden tab's one-second timer clamp, the restore that fails to restore, git add -A, a backtick in shell prose. /Users/petermarik/Documents/event-clinic-suite/procurevent/docs/57-test-strategy.md documents the seven layers and the blind spot in each, which is the half that matters: every defect this product has shipped got through a layer that was green. It names what is still not tested — malformed bodies, concurrency, load, mail delivery, restore, a real screen reader — rather than implying coverage. Both are guarded. verify-procedures-real.mjs now reads the strategy as well as the runbook: 37 paths, every workflow name, and the reserved list is still four items. Its first run reported three dead paths that were verify-*.mjs globs in prose — a truncated glob is not a path — and it is red by name when a guard is renamed out from under it. Battery 2 630, unchanged: this slice adds an agent, a reference and a guard, not a rule. |
| P131 | Every remaining finding, fixed | complete | D-354 and D-355. The 2026-09-05 audit's 59 findings closed out: thirty-one more fixed here, four drafted for the owner because they are public legal statements, and one found stale on re-verification. 0116 is the only new rule: a contract both parties have signed now says *agreed* whichever side signs second. The flip lived in contract.ts behind AND buyer_org_id = current_org_id(), and the policy admits only the buyer to UPDATE — so in the natural order, buyer drafts and supplier signs last, the row stayed Draft for ever on a contract both had signed. The rest are the product no longer saying things that are not so: an approvals threshold that said *over* where the database says >=; a review that claimed the supplier *had read* it; a handoff asserting a precondition whose column has no writer; an auction that said *Opens* about a time already past; a private catalog badged Live; a supplier served the buyer's uploader, answer form and internal-note box on every request it was invited to; five controls that could only fail; three empty pickers that reported a failed read as a fact about the business. On the marketing site: a heading offering 4 314 companies as invitable when none are; a security claim about short-lived access links that files.ts says do not exist; *capacities and bookable spaces* promised on four pages over data that has neither; a pricing calculator whose sliders moved freely while the price stayed hard-coded without JavaScript; and 75 policies across 46 tables where the schema now holds 143 across 70. The immutable set had drifted twice — five, then six, now eight — so CLAUDE.md is corrected and the count is gone from the site rather than restated. Battery 2 630 → 2 634. |
| P132 | The legal pages say what is true, and date themselves | complete | D-356, owner-instructed. The four corrections drafted in docs/60 are applied. The subprocessor register listed three live providers as "Planned — not yet in use" — Clerk, Cloudflare R2 and Brevo, all carrying data on every request — under a paragraph saying authentication was not live and no email was sent, and it omitted Stripe while cards were being taken through it. The draft's one open question was measured rather than guessed: api/wrangler.jsonc binds the bucket with "jurisdiction": "eu", so files are stored in the EEA. The terms described card-at-checkout as invoicing against an order form, and named five immutable tables where there are eight — understating a stated limit on erasure by three, including contract_assents, which records who agreed to what. And the dates are derived now. All three pages said *29 July 2026* while two had been substantively edited weeks later, on a page that promises *"the date at the top always reflects the current version"*. Legal.astro asks git for the last commit that touched each page; the page identifies itself, because a layout cannot know its caller; and the nightly rebuild checks out full history, because a shallow clone would date every page to the build and make an untouched page claim to be current. Battery 2 634, unchanged: this slice corrects prose and adds a derivation, not a rule. |
| P133 | Six silences | complete | D-357. The tail of the audit, and every one of them is the product failing to say something. A criterion weight of 5o counted as zero, so the screen said *"50% still to allocate"* with nothing pointing at the field. A refused section delete was discarded and the section reappeared on the next reload. A saved internal note rendered its confirmation inside the form it had just closed, so it could never be seen. *"Edited by the issuer"* could never fire, because editedNote is hard-coded null with no column behind it. A directory search returning nothing rendered nothing, on the screen that exists to decide whether a listing is there. And the claim queue named the narrower of two intake rules while its own header says the dominant case is the other one — 4 041 of 27 284 listings carry no website at all. Battery 2 634, unchanged. |
| P134 | A bare scalar is not a request body | complete | D-358, and the largest named gap in docs/57-test-strategy.md is closed. Every POST route fired with sixteen malformed bodies: "just a string", 123 and true answered 500 on three of them, because const input = (body ?? {}) as Record<string, unknown> is a cast and not a check and 'name' in "just a string" throws. Fixed in route() — the same one place P127 used, for the same reason. Scalars only, which is what lets it need no list of exceptions: /v1/csp-report genuinely takes an array and an upload is a Uint8Array. The sweep asserts 500, not 5xx, because /v1/requests/:id/concept/draft answers 503 to every body including a good one — drafting is off unless AI_COPILOT is set, and it says so. A sweep failing on any 5xx would have called a deliberate, well-worded refusal a defect. The control that matters is the last one: without *an object body still answers in its own words*, a build refusing every body would pass everything. Battery 2 634 → 2 640. |
| P135 | Broken, but not by this deploy | complete | D-359. The production probe gains a third verdict, BLOCKED ON THE OWNER, used at exactly one hand-written call site that must name the docs file carrying the owner action. Prints as loudly as a failure, repeated in the verdict under its own heading, and does not set the exit code. Prompted by run 33985573369: an API-only deploy that shipped P134 correctly, skipped the app build, and was reported failure by a check about the app bundle. Sabotaged both ways — a real failure beside it still exits 1; a present key turns it green and drops the blocked count to 0. The first sabotage of the second kind was a sed that did not match, so it changed nothing and read as a pass; it now asserts the substring before substituting. |
| P136 | Two people, one moment | complete | D-360, migration 0117, and api/test/concurrency-test.sh — the first suite in this repository that fires requests at the same instant instead of one after another. That distinction is the whole point: a sequential duplicate exercises the JS pre-check, a simultaneous one reaches the constraint handler, and only one of those had ever run. Found on the first attempt: two organisations signing the same contract at the same instant left it draft for ever with both assents recorded and no path back, because each trigger's count(DISTINCT org_id) ran under a snapshot that could not see the other's uncommitted row. Reproduced deterministically with two hand-interleaved transactions, fixed with a row lock taken in its own statement, applied to local and prod, and the racy duplicate of the same logic deleted from contract.ts. Section 0 proves the harness collides before anything below it is believed — and it failed on its first two runs for two different reasons of its own (it timed the arming sleep; then it compared six concurrent against three sequential, a built-in 2× handicap). Battery 2 640 → 2 651. |
| P137 | Can production tell anybody anything? | complete | D-361. /v1/health now reports mail and mailRedirected; scripts/probe-production.sh asserts both every fifteen minutes, with the field's own absence reported as *nothing was measured* rather than as an outage. Until now nothing could answer whether the product could send an invitation at all — the failure mode is a Worker that accepts everything, tells nobody, and logs one line. The first version of the redirect boolean was wrong about the empty string, which still rewrites every recipient to nowhere; caught by this module's own suite before it shipped. Battery 2 651 → 2 657. |
| P138 | Five controls a green guard could not see | complete | D-362. 829 controls measured across 35 screens — both sides — at 375 px with a coarse pointer; six below the 44 px floor, all fixed, re-measured to 0. The buyer-only first sweep reported zero and was half a measurement: the supplier's fifteen screens carried the sixth. The guard could never have found them — it only examines rules that STATE a too-small height, and these declared no height at all. It now prints that limit on every green run, and the sweep is written down as app/scripts/measure-screens-in-a-browser.md. Shipped to the repository, not to production: the app deploy is gated on the sign-in secret, so this queues behind the owner action in docs/56. |
| P139 | The first restore | complete | D-363, Q-098, runbook /Users/petermarik/Documents/event-clinic-suite/procurevent/docs/61-restoring-the-database.md. First point-in-time restore ever performed on this project: branch cut from 18:09Z, proved by a control it must LACK (main has migration 0117, the restore does not) and by matching row counts across seven tables. Ready in seconds. Found in the doing: --timestamp is not a flag, is accepted silently, and branches from *now* — in an emergency that is a copy of the corrupted database with no error. And the recovery window is six hours, which is now Q-098 in front of the owner. |
| P140 | Nothing is nameless | complete | D-364. 829 controls across 35 screens, both sides: zero without an accessible name, zero named only by a placeholder, zero duplicate ids, zero heading skips, zero images without alt. Sabotaged twice before the zero was believed. The focus-ring sweep's first version reported 72 of 72 broken — its own defect, since Chrome does not apply :focus-visible to a programmatic .focus(); verified with a real Tab key instead. What still needs a person: whether the announcements make sense, and anything inside a dialog these sweeps never open. |
| P141 | Seven panels that opened silently | complete | D-365. Every disclosure trigger now carries aria-expanded, enforced by app/scripts/verify-disclosures-announce.mjs, which discovers disclosures instead of listing them and so fails closed. Reading the source found four; the guard found seven — Contracts, Suppliers and Venues had the same defect unseen. The guard had two bugs of its own and its own controls caught both: a [^>]* tag match that stopped at the arrow in () => and found zero buttons while reporting green, and a first rule that counted pagination and cancel buttons as disclosures. Found while closing a gap written down twenty minutes earlier — *anything behind a dialog or a dropdown is unmeasured* — which is the first thing that gap produced. |
| P142 | The cap that nothing was watching | complete | D-366. /suppliers is 67.9 kB gzip and /venues 110.3 kB against 6-8 kB for every other page, because both carry the rest of the directory as JSON so the DOM stops scaling. That is a deliberate, documented trade — but the payload scales one-for-one, COMPANY_BUDGET is liftable by design, and nothing measured the consequence. site/scripts/verify-page-weight.mjs states a per-page gzipped budget and fails when one is exceeded. It adds no cap, because capping would hide suppliers who signed up to be found. Sabotaged with a 6x tail: 365 kB gzip, 2.8 MB raw, red by name. |
| P143 | Nothing falls over | complete | D-367, scripts/load-probe.py, and the last named gap in docs/57-test-strategy.md is closed. 810 read-only GETs against production at up to 60 concurrent: everything answered as expected, p50 186 → 304 ms, throughput to 146 req/s, no cliff. The finding is /venues — p50 475 ms, p95 2 423 ms, and a cache HIT on all 120, so the latency is payload rather than origin. It confirms P142 in milliseconds. The probe's own first version reported 0/120 on a route whose correct answer is 401. |
| P144 | A legal date that was never true | complete | D-368. All three legal pages dated themselves to the build, every build, since the derivation was written — import.meta.url after bundling is a generated chunk in dist/ that git has never tracked, so the lookup returned nothing and a silent catch printed today. Fixed with a repo-relative literal plus a cwd for git, and it now throws rather than guessing. site/scripts/verify-legal-dates.mjs compares the built date against git log — not against a date being *present*, which is what every existing check asked and why nothing caught it. I had already 'fixed' this once, with fetch-depth: 0, verified only by the page having a date. |
| P145 | A fault nobody can see is not reported | complete | D-369, migrations 0118/0119, suite api/test/faults-test.sh (25 assertions). An unhandled 500 is now recorded — one row per fingerprint per minute with a count, so a deploy failing at 146 req/s writes one row a minute rather than half a million an hour — and folded into the daily alert that already has an address. Carries no identifier of any kind: the route pattern, never the URL; no caller, org, IP or session; the message redacted first. The watchdog reads it through a SECURITY DEFINER aggregate rather than by widening the app role's policy, and the suite proves app_rw still cannot read a row. Closes the last item in docs/59 Gate 5 that was mine. Two of the three sabotages are visible only to source assertions — a deleted call leaves every behavioural assertion green. |
| P146 | The fault log's first catch | complete | D-370. Within an hour of shipping P145, server_faults held a real one: /v1/supplier/invitations/:id/quote answering 500 to a thirteen-digit quantity, because quantity is numeric(14,3) and is validated only as finite-and-not-negative. A supplier typing too many digits was told the server broke. 22003 now answers 400 in its own words, from the same one place 22P02 does. quote-api-test.sh had asserted the 500 — the status was taken as observed rather than intended, so the defect was written down as correct. Those assertions' real subject, that a failed save keeps the supplier's quote, is untouched. |
| P147 | The whole set, not the next one | complete | D-371. After 22003 turned up by accident, the remaining caller-data sqlstates were found deliberately: 22001 (a nine-character currency answered 500 — 22 columns are length-capped), plus 22007/22008, which were handled inside document.ts and nowhere else, so every other date-taking route 500'd on a mistyped year. All five answer 400 in their own words. Deliberately not the whole of class 22: 22012 is our division by zero, and a polite 400 would hide it. The suite asserts the set exactly, asserts 22012's absence, and asserts each has distinct wording — the last being the only thing that catches five identical sentences. Battery 2 686 → 2 690. (The commit message for this slice says 2 692; the measured number is 2 690, and this row is the one that was checked against the run.) |
| P148 | Behind the panels | complete | Four of the seven disclosures opened and measured at 375 px with a coarse pointer — Companies and Venues notes, Catalogs items (a money path: a quantity box and an Order button), Auctions bids. Zero too small, zero nameless. /contracts and /suppliers have no rows in the local fixture, so their panels never render; that is a data gap and it closes when the first real contract exists. Two traps recorded in the procedure: [aria-expanded] first matches a combobox input on /suppliers, and clicking an input opens nothing — it reported a clean screen. And the click must be split across two tool calls, because a hidden Browser pane throttles setTimeout and stops delivering requestAnimationFrame altogether. |
| P149 | An invitation to a tender that closed a month ago | complete | D-372, migration 0120. A request whose submission deadline was thirty days past issued with 200, identically to a future one. Every screen behaved correctly around it — a supplier would open the invitation and be told the tender was closed — so the defect was an invitation that should never have been sent. Found by probing plausible-but-wrong values, the one thing docs/57 says no sweep can do. That probe also confirmed the money paths are sound: negatives and >100% tax refused by field name, and 7 × 1.115 = 7.81 stored and returned, so the supplier sees what was recorded. |
| P150 | The deliverables form, twice | complete | D-373, migration 0121. (a) Replacing a deliverable on a live tender answered 500 — the DELETE spares non-draft lots by design and the re-insert then collides on request_lots_key. Every lot in production is open, so this fired the first time a buyer edited a live tender. Found by the fault log, its second real catch. (b) A delivery window ending before it starts was accepted; six tables hold an ordered date pair and only one enforced it. Constraint added — with its handler in the same slice, measured: constraint alone turned a silent 200 into a 500. |
| P151 | The fault log as a gate, not a diary | complete | D-374. The battery clears server_faults before the suites and fails after if anything was provoked that nothing asserted. Threshold zero, measured rather than assumed: after the day's fixes a full run provokes none, and no suite expects a 500 any more. Turns every future battery run into a 500-detector for free — and its own justification is that the first look found two real defects while every suite passed. |
| P152 | Numbers that land on a half | complete | D-375. The money arithmetic is correct — half-up rounding, gross as net plus tax, and a header total equal to the sum of the *rounded lines* so the document adds up. Nothing could have shown that before: every figure in the quote and compare suites was round, and round numbers pass whether the rounding is right or wrong. Twelve assertions added over numbers that land on a half. No defect found; the gap was coverage. |
| P153 | The rule was real, the reason was not | complete | D-376. approval.ts claimed the database refused self-approval. It does not: may_decide_step asks only who a step was *asked of*, never who raised it. Self-approval is unreachable only because the route is the sole writer of approval_steps and can name a user and nothing else — which the suite now asserts, so the protection cannot be removed silently. No behaviour changed. The comment now names where the check must go the day a role-named approver becomes creatable. |
| P154 | Thirty-three screens after a night of changes | complete | Ten screens were edited on 2026-09-05/06 — aria-expanded on seven, coarse-pointer heights on six selectors, the notifications copy — and the battery proves the API, not the rendering. Walked both sides in a browser: 20 buyer screens and 13 supplier screens, none broken, no console errors, aria-expanded present in the expected counts, and both variants of the new notifications sentence live on their screens. The sweep's own first run reported all twenty broken — the local API had stopped behind the app, which is the symptom app/scripts/measure-screens-in-a-browser.md documents and which this confirms is worth having written down. |
| P155 | Two quotes that cost the same | complete | D-377. Nothing had ever compared two quotes with equal totals — the case where a comparison product earns or loses trust. The ranking's third ORDER BY term is a deterministic tie-break and nothing asserted it. The sabotage is the finding: with the tie-break deleted, five identical requests still returned the same order and only the source assertion caught it. No finite number of calls can disprove nondeterminism. |
| P156 | The last mile of every refusal | complete | D-378. The API's chosen words only matter if the screen prints them, and nothing checked the join: API {error, hint} → client.ts reason → ErrorState. All three links are correct; a broken middle one would make every carefully worded refusal invisible while every API test still passed. Guarded, and sabotaged one link at a time. No defect. |
| P157 | Two minutes, verified | complete | Telling the owner an action takes two minutes is worth being certain of. Checked: the deploy workflow does pass secrets.VITE_CLERK_PUBLISHABLE_KEY into npm run build, and gh secret list shows the repository holds the other three secrets and not this one. The wiring is right; only the value is missing. verify-build-can-sign-in.mjs now asserts that workflow line too and refuses the build if it disappears — otherwise the secret could be set, the deploy could go green, and the bundle would still ship without a key, with the artefact gate noticing one step too late. Sabotaged by deleting the line: exit 1 with its own sentence. |
| P158 | The rule that only exists in production | complete | D-379. NEVER_CACHED and s-maxage live in worker.ts alone; every suite runs serve-api.mjs, which has no cache rule. So the behaviour was exercised only in production and measured nowhere — after already going wrong once, when /v1/health was cached for an hour in front of the entire monitor. Three checks now, every fifteen minutes, and the control is the route that IS cached — without it, no-store everywhere passes. Probe 13 → 16. |
| P159 | An alarm that named the wrong subsystem | complete | D-380. A fault-only day mailed *"directory sync needs attention"* and told the reader to re-run the sync — at 6am they would look at a healthy sync while the API 500'd. Mine, from merging faults into a report that had only ever described the sync. Found by asking what production does that no test can see: the scheduled handler runs nowhere, and its healthy signal is silence. Subject, opening line and instruction now come from sources; absent means the sync, so every existing caller is unchanged. Watchdog suite 21 → 28. |
| P160 | The upload that has never happened | complete | D-381. Production holds zero attachments; local holds fifteen. The R2 path has never moved a byte, and storage.put — the only call in the product reaching a service outside it — had no catch, so an R2 failure would have told a supplier the server broke rather than that their document was not saved. Now 503, in words, with no row left behind. Tested by making the local fake fail as R2 would; the sabotage needed three attempts to be real (a non-recursive chmod, then a filename that counted previous runs). |
| P161 | Could anybody become anybody? | complete | D-382. No. worker.ts has no dev-token path at all, so production cannot accept one — the code is not there, which beats a config check. But nothing asserted that: the existing guard covered the browser bundle only. Now both halves — the guard scans all 48 API files the Worker ships, and the probe asks the deployed Worker itself, because a deployed Worker is not its source. Nine malformed credentials probed on production: all 401, none 5xx, messages correctly distinct. Probe 16 → 17. |
| P162 | A loaded machine is not a defect | complete | D-383. The concurrency harness's collision check read 1.48× against a 1.5 threshold and failed the battery on a machine running batteries back to back. It now measures twice, and a genuine miss exits 2 (BLOCKED) rather than 1 — because its own message says *nothing below was measured*, which is what BLOCKED means here. It prints the load average beside the ratio so the reader can see whose fault it is. |
| P163 | Which pages may call the API | complete | D-384. The CORS policy runs only in worker.ts against a Worker variable, so it existed only in production and nothing watched it. Measured and correct — the app's origin echoed on tenant routes, no header at all for a foreign one, * only where nothing is protected, credentials never. Now three probe checks, the third being the control: *evil is refused* passes on an API that refuses everyone, which is the outage rather than the safe state. Probe 17 → 20. |
| P164 | Nobody hits a paywall, and that is correct | complete | Checked because it changes who the owner can comfortably invite: no feature is gated on a subscription in either the API or the app, and production holds zero. Not an unfinished gate — D-029 makes the paid surface *self-served discovery of open tenders*, which does not exist yet, so there is nothing to gate. No health check added for billing, deliberately: it is not on any first user's path, and a check for something nobody depends on is noise. Recorded in docs/59 because a supplier meeting an unexpected payment screen would be the owner's problem, not the product's. |
| P165 | The watchdog says it ran | complete | D-385, migration 0122. Silence was the healthy signal *and* the signal for a cron that had stopped — the handler wrote nothing to the database on a healthy run, so an unwatched product would have reported itself green everywhere. One heartbeat row per job, seeded fresh so it goes stale only if the cron genuinely stops; /v1/health publishes a boolean; the probe asserts it. The thing that watches everything else is now watched by the thing that runs most often. Watchdog suite 28 → 34. |
| P166 | A grant that reaches nobody | complete | D-386, which corrects D-369. 0118, 0119 and 0122 granted to a list of role names not including production's app_rw, so on production they granted nothing and said nothing — while their own assertions passed, because those run as the owner. The fault log had recorded nothing on production since it shipped. Found by /v1/health returning "watched": null, the third answer built in for exactly this. privilege-audit.sh was right all along; nothing ever pointed it at production, and verify-all.sh runs it against local where the roles differ. Now apply-migrations.sh audits the database it just changed, and 0123 refuses to finish if it granted to nobody. |
| P167 | An accented name belongs next to its letter | complete | D-387, Q-099. Production is C.UTF-8 and sorted every accented name after Z — 200 venues and 62 companies — while local is en_US.UTF-8 and no suite could see it. Three directory sorts now carry und-x-icu, guarded by scripts/verify-directory-sorts.mjs. The column-level fix was written and withdrawn: two views depend on those columns and rebuilding them risks the whole directory to reorder 262 rows. Whether Hungarian digraph rules should govern too is Q-099, with the measurement attached. The public site had the same defect by a different mechanism — getAllVenues never sorted at all, so procurevent.com/venues ran *taberna |
| P168 | October in, September out | complete | D-388, Q-100. to_char renders in the session's timezone; local is Budapest and production is GMT, so a buyer picking 1 October had the timeline chapter read back 30 September — on the field that governs when a tender closes. Invisible to every suite, which run on the agreeing zone. Five projections now declare their zone. The write path's own comment had already named this hazard and pinned the instant; the read path had not. Asserted in SQL with the zone forced both ways, because nothing else on this machine can see it. |
| P169 | The subrequest budget, counted | complete | Q-101. Every Neon HTTP call and every outbound fetch on a Worker is a subrequest, ceiling 1 000 — the number sync-watchdog.ts already designed around after one sync measured ~919. issueAwards spends four per recipient, so one issue can notify roughly 248 suppliers before the Worker is killed mid-loop: some told, some not, awards already marked issued, no record of where it stopped. No cap imposed. Nothing caps invitations today, the largest request has four, and the product has no users — a cap for a number nobody has approached would be a guess. The arithmetic is written into the loop where the next person to add a query will read it. |
| P170 | Yesterday's third item, and a stale question | complete | Reconciling docs/58's owner actions against docs/62 found Q-097 had been dropped — one of yesterday's three, still open, absent from today's report. A report that silently loses an item is worse than none, so docs/62 now carries every open question the owner actually needs. 33 questions are open, and one was worth re-measuring: Q-075 said the site runs analytics its own cookie page denies. Re-measured against the live site — no analytics script on any page and no Set-Cookie header anywhere — so the client-side half is now verifiably true. What remains is a Cloudflare dashboard setting only the owner can see. |
| P171 | A promise on seven thousand pages | complete | Every venue page on the public site told a visitor *"its overall capacity is shown alongside"* and showed no capacity. The aside it points at renders one only when capacity_max_pax is not null, and that column is null for every venue in the directory — the aside's <dt> appears on 0 of 7 000 built pages. So the sentence was false on all of them, live, on every venue page a search engine has indexed, and it pointed the reader at empty space on exactly the pages where they had come looking for a capacity. The branch now depends on the same value the aside renders from. The first reading of this said 6 993 and was wrong: it grepped for the bare phrase Maximum capacity, which matched seven venues whose own description says *"White Hall: Maximum capacity of 130 persons"*. The aside is markup; verify-venue-capacity-promise.mjs matches markup, and fails four ways — neither wording, both wordings, promise-without-render, and render-without-promise, the last of which guards the fix rather than the bug. All four forced red by name. Battery 2 745, nothing failed, nothing blocked. Shipped and verified against production, cache-busted, against a 404 control. D-389. |
| P172 | Two subprocessors that have received nothing | complete | The privacy page's table calls six providers "In use", and the paragraph under it promises that *"naming one that processes nothing would overstate what happens to your data as surely as omitting one would understate it"*. Measured on production: R2 holds 0 files, Stripe has 0 subscriptions and 0 invoices. The other four are true. A configured key is not processing — all six credentials are set, which is what made the claim easy to believe. I expected Clerk to be a third, because the served app bundle carries no publishable key; the database says one person has signed in, so the inference was wrong and the row is accurate. That is why the check reads rows and not config. ./db/census.sh prod now prints all six against the database on every run. The public page is untouched — it is a legal statement — and three wordings are drafted in docs/63 with a recommendation. Q-102, and it is in the owner report so it cannot be lost the way Q-097 was. Battery 2 745, nothing failed, nothing blocked. D-390. |
| P173 | The screen that told a visitor to run npm | complete | app.procurevent.com ships with no publishable key, so every visitor who clicked "Sign in" in the marketing header read *"This build has no sign-in provider configured. In development, run npm run dev:demo…"* — a prospective customer told the product is broken and handed a command they cannot run. Nothing was failing: the graceful path worked as designed and the words inside it were written for the developer who wrote them. The branch now splits on import.meta.env.DEV, so the production bundle does not carry the developer text at all, and a visitor gets *"Sign-in is not open yet"* with the public directory and a pilot enquiry. Guard extended, forced red both ways: developer copy present, and visitor copy missing. I caused a production regression doing this and rolled it back. ./scripts/deploy.sh app compiled a real pk_live_ key out of a gitignored .env.production.local and the app hung at "Signing you in…" forever; the keyless CI build was strictly better. deploy.sh now refuses to bake untracked env values in without being told to, listing exactly which file and variable, and treats a shell-set variable as stated. Q-103 corrects the owner's sign-in blocker: the key was never the missing piece. Battery 2 745. D-391. |
| P174 | Nothing checked that a link went anywhere | complete | 7 021 pages, 66 distinct internal link targets, and nothing had ever checked that any of them resolved — astro build does not, because an href is a string. Measured: all 66 resolve and not one link is relative, so this is a floor under a clean result rather than a fix. The failure it guards is a rename, and a rename is silent. verify-internal-links-resolve.mjs forced red three ways: a broken absolute target, a relative href, and a dist with pages but no links at all — the last is what stops it passing on a build where the scan simply stopped seeing links. Script content is stripped first, and the first version did not do that: the homepage's category finder builds results with href="${c.href}" inside a template literal, interpolated in the browser and entirely correct. Reporting it would have been crying wolf on the normal case, which is how a guard's failures come to be ignored. Battery 2 745. |
| P175 | The directory spoke in database keys | complete | 6 793 of the 7 000 venue pages led with the raw venue_type code under the venue's name, the listing cards did the same, and the type filter on /venues was a dropdown of fifty keys — bar_cafe_pub_club, hotel_resort, place_of_worship. The labels were never missing: getVenueTypes() was fetched on every build and read in one place only, the block that renders when rows.length === 0, which cannot execute while the directory has venues. The country filters were bare ISO codes on both pages and now say Austria, Croatia, Czechia. My first count of 2 697 was too low — it matched snake_case, and hotel, restaurant, stadium, theatre are codes that merely read as sloppy typography on another 4 096 pages. Two near-misses of my own: an uncached getVenueTypes() would have turned one request into 7 000 in a single build (the Q-101 ceiling, aimed at a sibling product), and the guard's first version swept in every <option> so the country codes joined the venue-type vocabulary and it reported AT as a leaked type. The CSP hash guard then caught the changed filter script, third time it has. Battery 2 745, deployed, verified on production against a 404 control. D-392, Q-104. |
| P176 | A card whose only detail was "SI" | complete | The supplier cards' meta line is often a card's only detail and read US, SI, MX on its own — the same failure as a venue type key, a value stored for machines shown to a person. Both surfaces now spell the country out, server-side through countryName() and client-side through the browser's own Intl.DisplayNames, so the codes do not have to travel in the payload twice and a browser too old for it shows the code rather than breaking. The venue cards' *"Budapest, HU"* is deliberately left alone and is Q-104: a country after a city is ordinary writing. Guard extended and forced red against the pre-fix build, where it named all seven cards. And it found a stray file: suppliers (1).html, 507 KB, dated 5 September, had survived six rebuilds in site/dist — caught only because the CSP guard counted 7 022 pages and an unnamed script. Removed; Q-105 asks whether a deploy should refuse to ship what the build did not produce. |
| P177 | Five columns that could never be filled | complete | The buyer's Requests table reads 13 fields per row; GET /v1/requests returned 10 and client.ts overwrote five with a hardcoded null, so Event, Category, Owner, Offers and Approval rendered an em dash on every row, for every tenant, always. All three layers had written the IOU down in comments. The route now computes all five as correlated subselects (every table behind them has RLS on, so a filtered subselect would yield NULL and look identical to the bug; a positive control on a request with an event proved RLS permits the reads) and the client maps them through. Offers counts submitted, unwithdrawn quotes, so zero means nobody replied. A sabotage caught a broken assertion of my own: "the list names the owner" passed with the field absent, because the harness's absence marker is non-empty; it now compares to users.full_name. requests api 82, journey 53, workspace 156, dashboard 43. Commit 632faac. Deployed 2026-09-13 with P178. The API answers 401 beside a 404 control; the new fields cannot be read on production until someone can sign in there (Q-103). D-393. |
| P178 | The chapter a supplier reads first, and nothing could write it | complete | quote.ts selects r.summary into the supplier's tender and Quote.tsx renders it, and no route wrote that column and no screen offered a field for it — NULL on every request ever created, so the paragraph a supplier decides whether to bid from was always blank. Of 12 document chapters, 8 are structured and 5 had editors. Introduction and Confidentiality are now writable end to end (route, client, editor); Contact information is the last with no editor. Verified by hand against a live tenant: both writes reach the column, a bad confidentiality level is refused 400 and changes nothing, the supplier's tender returns the buyer's words, and the Introduction editor round-trips through the browser. Suites run 2026-09-13 after a reboot: workspace 169 and journey 56, no failures, exactly 13 and 3 more than before. Forced red by making both writes no-ops while keeping $14 and $15 referenced, so the SQL stayed valid and nothing failed for the wrong reason: six went red by name, including the cross-party "the SUPPLIER's tender carries it, word for word", and the file was restored from git. One assertion, "the column is NULL, not an empty string", stays green under that sabotage because the column was never written; it is only meaningful beside the one before it, which failed. The full battery then refused three guards on my own editors: a number formatted inline instead of through lib/format.ts, a bare radio with no radio class (13px on a touch screen instead of 24), and .choice, a class defined nowhere. The battery's chained app-guard run reported only the first of the two app failures; running each guard on its own found the second. Fixed with the app's existing checkline and radio markup. Commits fa7e6d0 and 161d1c1. Deployed 2026-09-13: production's app bundle changed from index-g6lVW43L.js to index-BkRBaxzq.js and contains both editors, with no real Clerk key and no localhost:8792 in it. D-394. |
| P179 | The supplier's screens, walked | complete | Owner-directed 2026-09-13. Same method that found P177: what each supplier screen's types expect, against what its routes return. From source, every expected field of every supplier type is named in its own handler module; the one exception, Clarification.editedNote, is documented dead code the API says it does not return and no screen renders. Live, as a real supplier on the local API: invitations, tender, quote, catalogues and compliance each returned every expected field. Not walked live, and why: claims has zero rows on the local database; awards has one issued award, but its organisation has no user the dev login can act as, and the journey supplier's own award is proposed, which row-level security correctly hides. Issuing one honestly needs a mailer the local API lacks, and issuing one by hand was declined (D-395). No defect found on the supplier side, which is the opposite of the buyer's Requests table. One trap of my own on the way: a discovery script reported "no supplier on this database has any" compliance documents because it looked for supplier_org_id on a table keyed by organization_id; raw counts showed one row, and it compared clean. Walked live 2026-09-14, and the row is complete. /Users/petermarik/Documents/event-clinic-suite/procurevent/api/test/journey-api-test.sh now starts its API with a mailer that keeps what it sends, so its award is issued through the product: the notice reaches the supplier's own card, the supplier's award list returns it issued with every field SupplierAward names — read out of /Users/petermarik/Documents/event-clinic-suite/procurevent/app/src/data/client.ts at run time rather than copied — the supplier accepts, and the buyer opens the handoff while the supplier's own attempt is refused. A claim on a listing with no website is made through POST /v1/companies/claim and read back with every field ClaimRow names, and the buyer's claims list does not carry it. Both comparisons were forced red by renaming one key in each route's select, and each failed naming the field (declineReason, decidedAt). No defect in either route. Seen in the browser as that supplier: *"You have been awarded work · Journey stage build · €250,000 gross · You accepted this."*, and the listing *"Waiting for Procurevent to check"*. Two test defects found on the way: the journey's control "an UNISSUED award is not visible to the supplier" counted items against a route that answers { awards, notices }, so it could not fail; and /Users/petermarik/Documents/event-clinic-suite/procurevent/db/test/claim-authority-test.sh counted every pending claim in the database, so the journey's real claim turned it red in the battery — now scoped to its own suppliers, and green with the journey's claim still present. Journey 77, claim authority 81, battery 2 789, nothing failed, nothing blocked. D-397. |
| P180 | The last chapter with no editor, and a hint about a mode that does not exist | complete | Contact information was the last of the eight structured chapters still falling through to *"a later slice of the build"*. Measured before building: procurement_requests has no contact column and no table keys a contact to a request; the only contact data in the product is organization_contacts, one card per organisation, which D-267 already opens to an invited supplier. So the chapter mounts the existing ContactCard and says beside it that the card is the organisation's and changes on every request — no migration, no new route. Building it found a gap one screen along: the supplier's tender named the buyer but carried no card, while the invitation queue had carried the card since 2026-08-30. GET /v1/supplier/invitations/:id/tender now returns buyerEmail and buyerPhone through the same two joins, under the supplier's own row security, and the supplier's tender header renders them as mailto: and tel: links, absent when the buyer has no card. And the chapter's hint described a mode that does not exist: it said the organisation "may be shown or anonymised depending on how this request is published" and told the buyer to check before issuing. Nothing publishes a request, and buyer_nameplate_for_request names the buyer to every invited supplier; the hint now says that. /Users/petermarik/Documents/event-clinic-suite/procurevent/api/test/journey-api-test.sh gained 7 assertions — the buyer writes its card through the route and the SUPPLIER's tender carries it, a cleared card reads null rather than stale, and the same tender still names the buyer. Forced red two ways: the card nulled in the select turned 2 red by name; the join keyed on the reader instead of the buyer turned 3 red, one reading back the supplier's own address, which is why the fixture gives the supplier a card of its own first. Journey 63, battery 2 775, nothing failed, nothing blocked. Commit 193af92. Deployed 2026-09-14 from this machine, the local wrangler credential having answered again: API version 62032eae, and the app bundle changed from index-BkRBaxzq.js to index-BVQjpD4U.js, which carries the new chapter heading, the corrected hint and buyerEmail, does not carry the old hint, and holds no key-shaped Clerk string and no localhost:8792. The tender and contact routes answer 401 beside a 404 control; the production probe is 21 passed, 0 failed, 1 blocked on the owner (sign-in). The new tender fields cannot be read on production until someone can sign in there (Q-103). Typed contacts per request and the open-versus-invited mode are not built. D-396. |
| P181 | A buyer told its award notices would reach it | complete | Saving the organisation's contact card from the new Contact information chapter answered "Saved. Award notices will go to that address." to a buyer, measured in the browser on 2026-09-14. A buyer never receives an award notice — award.ts addresses only award_notice_recipients, and /Users/petermarik/Documents/event-clinic-suite/procurevent/app/src/components/ContactCard.tsx says so in its own header. Its labels and hints already branched on the organisation type; the save note did not, and P180 put a buyer in front of it. A buyer now reads *"Saved. Suppliers you invite will see that address."*, or that an invited supplier cannot reach it when the address is cleared; a supplier's wording is unchanged. Verified in the browser after the change. App typecheck and every app guard green; battery 2 789, nothing failed, nothing blocked. Commit f30d5b8. Deployed 2026-09-14: the app bundle changed from index-BVQjpD4U.js to index-aNjlLLjI.js, which carries both new buyer sentences and still carries the supplier's, with no key-shaped Clerk string and no localhost:8792; production probe 21 passed, 0 failed, 1 blocked on the owner (sign-in). |
| P182 | A deadline saved on production came back two hours later | complete | The Timeline chapter's date-time inputs post a wall-clock string with no zone, and Postgres reads such a string in the SESSION's zone, while the chapter has read every deadline back in Europe/Budapest since 2026-09-06 — a fix that stopped at the read. Measured 2026-09-14 with a new section in /Users/petermarik/Documents/event-clinic-suite/procurevent/db/census.sh: production sessions get GMT, with no role or database override, and local sessions get Europe/Budapest. So on production a buyer who saved midnight stored midnight GMT and read back 02:00, and because the form posts back what it read, every later save moved each date two hours further — clarification deadline, submission deadline and presentation alike. No local suite could see it. The three writes now read a zone-less time as Budapest time, and a string that states its own offset keeps its instant; the expression was run in psql under both zones for a zone-less time, Z, +02:00, a date alone and empty. /Users/petermarik/Documents/event-clinic-suite/procurevent/api/test/workspace-api-test.sh section 9 now starts a second API whose sessions are GMT — pg honours the connection string's options, and the suite checks that it does — and round-trips through the handler: typed 00:00, read back 00:00, stored as Budapest midnight. Forced red by restoring the naive cast: 3 red by name, reading back 02:00 and storing 00:00Z, which also proves that server's sessions really are GMT. Rows already stored are not rewritten, because a shifted value cannot be told apart from one typed that way. Workspace 176, battery 2 796, nothing failed, nothing blocked. Commit 5fdcd53. Deployed 2026-09-14: API version 9ce81e04, after the deploy's own gates confirmed all 123 migrations and the 4 system roles on production. The chapters route answers 401 beside a 404 control, and the production probe is 21 passed, 0 failed, 1 blocked on the owner (sign-in). The fixed write itself cannot be exercised on production until someone can sign in there (Q-103); on this machine it is exercised under production's measured zone. D-398. |
| P183 | Everyone invited is told, and nothing told them | complete | Two hints in the Timeline chapter promised that extending the submission deadline is recorded beside the original and that everyone invited is told. Measured 2026-09-14, none of it was true: no route or function wrote 0017's three extension columns, submission_is_past_deadline read only the original deadline, and the one notify trigger on requests fires on issue only. Worse, the chapter patch let a buyer overwrite the published closing date of an issued tender, telling nobody. Built end to end. POST /v1/requests/:id/deadline/extend records the new date beside the original and refuses a missing reason, a date not later than the one in force, and anyone but the buyer — all asserted — and a request not yet issued, from source. Migration 0124 makes the guard read the deadline in force through 0017's own view and adds a trigger that tells every invitation still in play, including suppliers that already quoted. The timeline patch refuses a changed closing date after issue and no longer writes the original at all. The six API readers show the deadline in force, and the Timeline chapter offers "Give suppliers more time" on an issued request. Walked in the suites: a closed tender reopens under an extension and closes again without it; the supplier that already quoted is told and the buyer is not; its tender, its invitation queue and the buyer's own list all show the same instant, stored as noon Budapest time. Forced red four ways — the old guard restored, the trigger dropped, the later-than-in-force rule removed, one reader reverted — and each failed by name. The suite's own control caught a defect of mine: comparing instants exactly refused the form's unchanged save with a 409 on a deadline stored with seconds; it now compares to the minute and leaves the column alone. Submission deadline 18, workspace 189, journey 84, battery 2 818, nothing failed, nothing blocked. Commit 6bdf9ae. On production 2026-09-14, in that order: 0124 applied — the ledger read it missing, the migration's own assertion passed on production, the ledger then read all 124 present, and the privilege audit on production reported every table matching the model; then the API (version 9ef36c47, its gate confirming 124 of 124) and the app (bundle index-DYXkfbF5.js). The extend route answers 401 beside a 404 control. The served bundle carries "Give suppliers more time" and the route, does not carry the removed "does not yet notify" paragraph, and holds no key-shaped Clerk string and no localhost:8792. Production probe 21 passed, 0 failed, 1 blocked on the owner (sign-in). The extension itself cannot be exercised on production until someone can sign in there (Q-103). D-399. |
| P184 | The sync that died every night, and the mail that never stopped | complete | The owner asked why the "directory sync needs attention" mail kept arriving, and whether to split the sync into chunks. Measured from /Users/petermarik/Library/Logs/procurevent-sync.log: the nightly sync failed on 8 of 10 nights, 2026-09-08 to 2026-09-17, each about 5.5 minutes in — seven with terminating connection due to administrator command, one with read ETIMEDOUT. Neon's documentation names the message: a connection idle long enough for the compute to scale to zero, 5 minutes on the Free plan and not configurable. The job downloads all 30 845 venues before writing any, so its one connection sat idle exactly that long; pg then raised an unhandled error event and Node exited before the run row was closed. No reboot and no sleep in those windows. The watchdog read every open row as "killed", and mailed the newest one per resource whatever came after it — a crash from 2026-09-11 every morning, in a mail whose own table showed that resource succeeding; production held 15 such rows. Chunking was rejected: the failure is the idle gap, not the volume. Now the runner sends a SELECT 1 once a minute and listens for connection errors, and the watchdog reports an open run only while no later run of that resource has finished, naming a dropped connection. Watchdog suite 37, forced red by removing the superseded condition (2 red by name); battery 2 821, nothing failed, nothing blocked. Commit 41be4bd. Shipped 2026-09-18: the API (version 6f61906a, its gate confirming all 124 migrations on production) carries the new watchdog, which takes effect at the next 06:00 UTC check; the live copy the Mac runs was rebuilt from 41be4bd by /Users/petermarik/Documents/event-clinic-suite/procurevent/scripts/deploy-sync.sh, and /Users/petermarik/Documents/event-clinic-suite/procurevent/scripts/verify-sync-agent.sh proved launchd can start it. The run it started finished ok in 337s at 06:34:43Z, all three resources succeeded, and 122 new companies arrived. One success proves little — the 01:15Z run the same morning also succeeded without the heartbeat — so the evidence is the nightly log from 2026-09-19 on. D-401. |
| P185 | The directory sync twice a month | complete | Owner, 2026-09-18: "We can schedule this update every 2 weeks instead of daily" — the directory barely changes; the same morning's run found 0 changed venues and categories and 122 new companies out of 27 406. launchd cannot count weeks, so the job now runs at 03:15 on the 1st and the 15th, 14 to 17 days apart. The watchdog's 36-hour staleness limit had to move with it, or it would have mailed between every pair of runs: it is now the longest normal gap plus a day, 18 days, and a failed run is still reported the next morning; ages in the mail read in days. The public venues page's "rebuilt daily" is the site's own rebuild and stays true. Watchdog 38, forced red by restoring the 36-hour limit (3 red by name); battery 2 822, nothing failed, nothing blocked. Commit 35ef692. Shipped 2026-09-18: the API (version 43a32b43) carries the new limit; the live copy was rebuilt from 35ef692, and launchd now holds two triggers — day 1 and day 15, at 03:15 — where it held one daily trigger. The first run on the new schedule is 2026-10-01. D-402. |
| P186 | Nothing here could create the database its 2 800 assertions need | complete | A cloud container was asked to build the next slice and could not. It had the Postgres binaries and no schema, and the cause was not the container: apply-migrations.sh measures before it applies, and on a virgin database the ledger's own probe for 0057 raised relation "public.orders" does not exist — reported as BLOCKED, so the applier stopped. The gate that keeps the applier honest was the reason no second machine could run the battery. Two probe kinds cast ::regclass; they now use to_regclass, so an absent object answers "missing". db/bootstrap-local.sh creates both application roles and the database and applies every migration through the committed applier, then leans on its ledger and privilege audit; it starts no server and says how to, including the LC_ALL a fresh macOS cluster needs. Proved from nothing on a throwaway cluster: 124 missing measured without raising, 124 applied, privilege audit clean, 88 tables. db/test/ledger-probe-test.sh asserts both directions and was forced red by restoring the cast — 4 red by name, the real-database checks green. The 2026-09-23 move had also left the sync plist and deploy-sync.sh naming the old folder, with install instructions pointing into ~/Documents, which launchd cannot read; both repointed. Then the production probe, run by hand because the scheduled one has not fired since 2026-09-22 (Q-106), printed a third stale path at the owner, and a grep found two more: the watchdog mail's own "run the sync by hand" recipe, the probe's owner-action pointer, and three harness commands in scripts/status.config.json. The mail no longer re-types the recipe — it names ~/procuvent-live/scripts/nightly-sync.sh, the script launchd runs, which deploy-sync.sh keeps outside the TCC-protected folders and which cannot drift with the repository. watchdog-test.sh gained the rule rather than the path — the mail must name no folder under ~/Documents, asserted that way because a path that exists on this Mac would not exist in a container — and its connection-string control moved onto the detector, since a mail that mentions no credential makes that negative vacuous. Forced red 3-by-name. Battery 2 830 — 2 829 when the bootstrap work was measured, plus the one rule assertion added with the path fixes; nothing failed, nothing blocked. Production measured 124 of 124 the same day. D-403. |
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.
Exit 2 means BLOCKED — a suite could not measure, which is not a pass. 2 392 assertions across 68 suites and 27 guards, measured 2026-08-31. All 99 migrations are on production. scripts/probe-production.sh makes twelve behavioural checks against production and is 12 of 12 (D-286). Its GitHub schedule is NOT proved and cannot be on that platform — the nightly rebuild set for 03:17 UTC last ran at 09:11, 10:00 and 15:18, so treat the probe as 'somebody looked today' and do not compute an uptime figure from it. Overnight, working the roadmap in docs/45: the category list groups by area (Q-028 option A); a supplier can finally record what it works in and a buyer can search by it (Q-076 — the table had row security and grants since 0004 and zero code paths, while the site promised exactly that four times); and a brand-new buyer's dashboard now has a next step instead of seven empty tiles and no link. TWO THINGS WAIT ON THE OWNER: Q-075, the site runs Cloudflare Web Analytics while /cookies and /privacy both say it runs none, which blocks enforcing the CSP; and Q-006, privacy counsel on the legal pages. docs/45 is the honest state: the software is not what is missing. Production still has 1 buyer, 0 published supplier profiles and 0 paid subscriptions.
./db/verify-all.sh
# 1 · seed a tender you can award and hand off cd ~/Documents/event-clinic-suite/procurevent && ./api/test/fixtures/stage10-browser.sh # 2 · the API, against local Postgres cd ~/Documents/event-clinic-suite/procurevent && PROCUVENT_API_DATABASE_URL=postgresql://procuvent_app_rw@localhost:5432/procuvent_dev PORT=8802 PROCUVENT_DEV_AUTH=1 PROCUVENT_DEV_MAIL=1 node api/scripts/serve-api.mjs # 3 · the app, signed in as the buyer cd ~/Documents/event-clinic-suite/procurevent/app && VITE_DEV_AUTH=1 VITE_DEV_AUTH_SUBJECT=stage10-buyer VITE_API_BASE=http://localhost:8802 npm run dev -- --port 5186