Appearance
Journal — 2026-09-09
Application build, round two — the surfaces the ADRs already settle
Actor: Proposer (claude-opus-5) + three general-purpose subagents Artifact: api 9447763, frontend deaef95, gitlinks advanced Coverage check: 67 before, 67 after.
What was built, and what was refused
Sync endpoint, Chemical Record surface, manual search, QR identification, PWA shell. Refused: camera OCR (Risks item 1's spike needs photographs that do not exist), and the Conversational surface, First Aid and Spill Response (three open map tickets govern them). The refusals were decided before spawning rather than discovered during review.
A spec claim that was false when it was written
PRD §3 asserts twice, and Risks item 1 once, that jsqr "is already a dependency". It was not. The frontend's own AGENTS.md said so plainly; the spec and the repository disagreed and nothing noticed, because no gate reads either.
Risks item 1 is where the spec reasons that camera Identification is the largest cost of the platform decision, and half its mitigation is that the QR route is cheap and already there. That was reasoning on an unchecked premise. It is true now only because an agent installed the package.
Same shape as D-69's zero-retention requirement — a statement about the world written without looking at the world. Two instances is a pattern, so it is a ticket rather than a correction.
Two agents built the same ruling twice
Running the record surface and the identification surface in parallel produced two D-46 choosers: one in Identification, one on the record page behind a ?candidates= query nothing ever wrote. The record agent invented that contract and flagged that the other agent had to honour it; the other agent never saw the message and shipped its own chooser inline.
Removed rather than wired. D-46 attaches the chooser to Identification — an ambiguous search presents it — and the record page is only ever reached with one id. Two components rendering one ruling is how they drift apart.
A correction I made mid-review. I claimed the surviving chooser violated D-51 by hiding an undated record's absence, on a grep that matched revisionDate and so missed the v-else on the next line. It handles it correctly. The claim was wrong and the near-miss was deleting the better implementation on the strength of an incomplete search.
What each agent did better than asked
Sync: asserted completeness behaviourally — every document handed in comes out, and the serialiser takes one argument so there is no options bag to narrow through. I mutation-tested it; a .slice(0, 2) fails the suite. It also refused to trace /version to D-88, on the grounds that the sentinel is a lookup in the device's own store and a network route cannot be it. Getting that wrong would have put a network call on the path ADR-0032 exists to keep local.
Record surface: mutation-checked both required assertions rather than running them green, and restored the file it mutated byte-identically, verified by md5 and an empty diff. It reaches the gateway directly rather than through the store because the surface needs one readonly transaction — four store calls would let an eviction land between two fields of one screen.
Identification: caught that a Vue multi-argument emit reaches the template as $event = the first argument only, and reshaped its events to carry one object. It also declined a hand-written service worker, because one cannot precache Vite's hashed output and would ship the appearance of a precached shell without the substance.
Housekeeping
The frontend's AGENTS.md had two claims falsified by the work and could not be edited by the agents that falsified them (unowned file). Corrected here, including the jsqr history.
2026-09-10 — Map ticket resolved: the second numbering tombstone
Actor: Proposer (claude-opus-5, session 01BXCqRu) Ticket: issues/07-tombstone-the-second-numbering-hole.md — Type: task, AFK, claimed then resolved Artifact: docs/adr/0038-second-decision-numbering-gap.md (D-92), spec numbering-gap section Coverage check: 67 before, 67 after. Six tickets on the frontier, two blocked, one resolved.
Re-verified rather than trusted
The ticket asserted D-47 and D-48 exist nowhere including git history. Checking again turned up two commits matching git log -S"D-47" — both mine, from 2026-09-08, and both merely writing about the hole in the documentation site and the map. Had I quoted the ticket instead of re-running the check, ADR-0038 would have carried a claim that was true when written and false by the time it was read.
Evidence, and its limit
ADR-0022 declares D-44 then jumps to D-49 and D-50; ADR-0023 takes D-45 and D-46 in between, both written 2026-09-02 in one batch. Ids were reserved out of order across two documents being drafted together — the mechanism ADR-0021 already records.
What ADR-0021 could do and ADR-0038 cannot is name what each void id became. Nothing on disk identifies what D-47 and D-48 were spoken for, so the ADR says so and reconstructs nothing. A plausible history recorded as fact would have been the third unchecked claim this repository has shipped, after jsqr and zero retention — and the tombstone for the second gap is a poor place to open a third.
Why a second document rather than an amendment
ADR-0021 is Accepted and D-83 permits editing only Coverage Note cells. A second tombstone is also the more honest shape: it records that this has happened twice, which one amended document would hide. Both gaps have the same cause — ids spoken aloud during grilling before any document existed — and ADR-0038's Consequences names the cheap mitigation without adopting it, because allocating an id only when it is written is a working practice, not a ruling.
Five ids are now void. The documentation site's decision index counts declarations from the filesystem rather than id ranges, which is why it reads 87 decisions across 38 ADRs and not 92.
2026-09-10 — Map ticket resolved: the three stale claims
Actor: Proposer (claude-opus-5, session 01BXCqRu), grilled with the owner Ticket: issues/01-correct-three-stale-claims.mdArtifact: docs/adr/0039-adr-0031-cites-a-deleted-study-objective.md (D-93), four spec edits Coverage check: 67 before, 67 after.
Re-verifying changed all three items
The ticket was three days old and wrong in shape on every item.
Item 1 was narrower. R-04.2 appears eight times, but ADR-0018's D-33 already named five and two more only mention it while correcting others. The genuinely new citations were two.
Item 3 was partly true. The risk's body is left above; only the Action needed sentence was deleted — the sentence ADR-0032 quotes. "The original wording is left above" was imprecise, not false, and the distinction changed the fix.
Only item 2 stood as written.
The fix that needed no ruling
Item 3 was fixed at the spec end: restore the deleted sentence with the CLOSED annotation after it. That is what the paragraph already claimed happens and what this repo does everywhere else — ADR-0021, the Contradictions block, Risks item 1 all leave the original and mark it closed. My earlier edit broke that pattern while claiming to follow it.
One edit made ADR-0032's quotation sourced again and the spec's claim true, and required no decision about either document. The alternative — declaring the quotation unsourced in an ADR — would have legislated around a breakage instead of undoing it.
Scope the ticket got wrong
It assumed a corrections ADR for all three items. Only ADR prose needed one: the spec is stage-2 and revisable, and recording "we fixed a stale sentence in a revisable document" as a ruling would inflate the sequence and teach that spec edits need ADRs, slowing every future correction.
The third instance of one pattern
ADR-0031 cited a deleted objective five days after ADR-0018 recorded it deleted, and ADR-0017 had already corrected ADR-0009 for the same citation before that. Counting the R-03 Coverage rows and the jsqr assertion, three claims have now shipped that were recorded as wrong somewhere nobody re-read.
ADR-0039 states plainly that this one produces no gate. IDSTATUS checks whether a cited R- id is current; this citation names an objective inside a finding, and no check reads prose for claims about a source's contents. Caught by adversarial review and by nothing else — which is an argument for running the review regularly rather than once.
What D-93 refuses to let the observation become
The clause was voided and re-landed rather than simply deleted, because the observation is load-bearing: it is why the spec can say a higher Not Stated rate is intended rather than a defect, and deleting it invites someone to tune it down later. But the re-landing is a different claim, and the ADR says so — the deleted objective was about AI efficacy, the surviving one about worker needs. A Not Stated rate is evidence about coverage, never that extraction works. Quoting it the old way would repeat the original error in a new costume.
2026-09-10 — Map ticket resolved: the offline contradiction, and a worse one found inside it
Actor: Proposer (claude-opus-5), grilled with the owner Ticket: issues/02-resolve-the-offline-contradiction.mdArtifact: docs/adr/0040-no-surface-may-require-a-network.md (D-94), four spec edits, one new ticket Coverage check: 67 before, 67 after.
The ticket understated the fix
It framed this as US-31 versus US-56 — a stale story to delete. Reading the accepted rulings together showed something larger: D-66 already had the Routing Floor answering with no signal, and ADR-0037's D-90 had moved term explanation onto the device. Nothing on the Conversational surface required a network any more. The spec's headline property did not merely lag the product; it understated it.
So the correction is a stronger claim, not a smaller one: no screen in this app requires a connection. That is a sentence a Safety Officer can act on, where "every safety surface works offline, and one other surface doesn't" invites the question of which and when, at the moment nobody wants to reason about it.
Ruled rather than observed, and why that mattered
The tempting close was four spec edits and no ADR. Rejected: D-06 protects Safety-Critical reads, the Conversational surface sat outside that boundary, and nothing forbade specifying it as online-only. Fixing the sentences without fixing the permission leaves the next surface free to drift the same way — and First Aid, Spill Response and the Curated Explanation lookup are all still unwritten.
D-94 is deliberately about surfaces, not the system. Sync needs a network; Curation tooling is curator-side. What is constrained is what a person can be shown.
The acceptance test that would have failed correct work
PRD's offline check asserted the Conversational surface "reports its own unavailability" — so a correct build fails it. The old wording is kept beside the new one in the spec, because it is the clearest illustration of what a stale acceptance criterion costs: when a suite fails correct work, the fix people reach for is to change the code.
The contradiction found inside the ticket
D-67 says a sub-threshold retrieval answers Not Stated. D-85 says Not Stated means a document was consulted and is silent. A sub-threshold retrieval means the router failed to find the span — the document may say it three lines away. That is ADR-0032's lie arriving through retrieval.
ADR-0036 revisited Routing's outcome set and never re-read the rule that produces one of those outcomes. Its own Context names the failure about a different document: "adding a term is not the same as re-reading the one it borders." Adding a definition is not the same as re-reading the rules that produce it. Fourth instance of the pattern.
Deliberately not folded into this ticket. It is a different subject — what the app says when retrieval fails, not what works without a signal — and burying a blocker-class finding inside a connectivity story is how the next reader fails to find it. New ticket 10, and it is the higher-value of the two: this one fixed a spec describing the wrong product; that one fixes an app that would tell a Handler a supplier is silent when it merely searched badly.
D-94 makes it worse, which is worth stating: with no signal, retrieval runs on the Floor alone, so sub-threshold results are most common exactly when the fabricated silence is most likely — and the Handler with no signal is usually the one furthest from a binder.
A small discipline that paid
US-31 was rewritten rather than deleted. Ids are permanent, deleting one needs a tombstone, five decision ids are already void from exactly this kind of tidying, and D-06's Coverage row cites it. Rewriting kept the id, kept the row valid, and kept the half of the story that survives.
2026-09-10 — Map ticket resolved: escalation text gets an author
Actor: Proposer (claude-opus-5), grilled with the owner Ticket: issues/03-escalation-text-provenance.md — the deepest on the map; unblocks 04 and 08 Artifact: docs/adr/0041-escalation-text-is-curated-and-has-an-author.md (D-95, D-96, D-97), CONTEXT.md (Curated Escalation), PRD §8 and Seam 2 Coverage check: 67 before, 67 after. Four tickets resolved, six on the frontier, none blocked.
The glossary made this airtight, not arguable
Safety-Critical Content is "any information that can change what a person wears, touches, or does in an emergency… It is always Extractive." Escalation is "directing the user out of the app to real help." So escalation text is Safety-Critical Content, must be Extractive, and comes from no SDS. The curation spike said exactly this and nothing acted on it for eight days.
What it lacked was an author, not a citation. Every other piece of Safety-Critical Content passes through Curation under review; this was the only text reaching a Handler mid-emergency with nobody's name on it. D-90 had built the shape three days earlier for a different purpose, so a Curated Escalation is a Curated Explanation pointed at an act instead of a word.
The loophole is closed in the ruling rather than left to good sense: the artifact carries only the enumerated acts. Without that clause, the next time a PPE span is missing someone curates an "escalation-like" artifact and D-02 is bypassed by the document that was supposed to reinforce it.
Two mechanisms bundled into one ruling, twice
D-08 bundled a persistent hotline with a gate before step 1. ADR-0006's Context argues entirely about a solvent pool in an enclosed space, and the condition it wrote — "large, enclosed, or you are unsure" — is not a question you ask someone with a chemical in their eye. The hotline blocks nothing and stays on both flows; the gate is spill-only. The ordering was reasoned from one case and applied to two, and nobody re-ran it for the second.
D-55 bundled the branch condition with the escalation gate. That works for a document stating a threshold. The spike found one of two documents with no branching at all — "both shapes are legitimate GHS" — so half the sample. Bundled, a document with no condition still needed a gate, so the app fell back to an authored question no document asked, and Seam 2 asserted nobody could bypass it.
That is the sharpest finding here: the gate was fabricating a document's content in order to keep itself alive. D-53 already forbids a curator supplying safety content the document does not carry, and this fabricated the single most consequential field, since D-54 says the branch condition decides which restriction applies.
The pattern, again
Both bundles are the same failure as every other finding this week: a ruling written for one case, applied to a second, never re-argued. ADR-0036 named it about a glossary entry — "adding a term is not the same as re-reading the one it borders." Here it is about rulings: bundling two mechanisms into one decision means the second one travels wherever the first goes, unexamined.
Seam 2 got stronger by splitting
Where an SDS states a condition, no branch is reachable without answering it — unchanged. Where it states none, no condition appears at all. That second assertion is new and it is the one that would have caught this.
Ticket 08 unblocked with an answer rather than a guess
Five once-per-Corpus artifacts, not four: Routing Threshold, Escalation Trigger, Routing Fixture, Curated Explanation glossary, Curated Escalation. That is what it was blocked on.
2026-09-10 — Map ticket resolved: escalation is always reachable
Actor: Proposer (claude-opus-5), grilled with the owner Ticket: issues/04-escalation-trigger-contract.mdArtifact: docs/adr/0042-escalation-is-always-reachable.md (D-98, D-99, D-100), glossary, PRD §8 and Seam 2 Coverage check: 67 before, 67 after. Five resolved, five on the frontier.
The first finding was mine, from hours earlier
D-74: escalation survives with no Corpus at all, "None of them is a Corpus read." True while escalation text was unspecified furniture. ADR-0041 gave it an author — correctly — and filed it as a Corpus artifact. The Corpus is evictable. So escalation would have been unavailable precisely in the case D-74 exists to guarantee: evicted store, no signal, during a spill.
Giving a thing an author is a question about accountability. Where it is stored is a question about availability. ADR-0041 was asked the first and silently answered the second, and here the two have opposite right answers. Fifth instance of that mechanism this week and the first one I authored rather than inherited.
The destination became answerable only after the previous ticket
D-70 said the trigger hands the user to "the surface" and never said which. From Home with nothing identified, First Aid is Section 4 of a record that does not exist — so the candidates were an identification prompt, an empty card, or the last record from history, which is the recency cache §4 rejected by name.
None of those is good, and that is because escalation had no content of its own until D-95 gave it some. The question was unanswerable rather than unanswered. Ticket 03 had to land first.
The third language: fix the load, not the list
A Handler typing an eye-exposure phrase in Burmese matched no trigger, hit nothing in the lexical Floor, and got Not Stated with no hotline in sight — the persistent 1669 was specified on two flows and the Conversational surface is not one of them.
Adding Burmese was rejected as a fix. Any phrase list is incomplete — another language tomorrow, a typo always, a Handler typing one-handed — and each addition creates the illusion the list is now sufficient. D-100 removes the load instead: escalation is reachable everywhere, so the trigger saves a tap. It is still worth adding Burmese as a should, contingent on who works in the Study Area, but it must never be why the design is safe.
The assertion worth keeping
Seam 2 gains two, and the second is the one that matters: with the Escalation Trigger disabled entirely, escalation is still reachable from every surface in one action. A test that passes only when a feature is switched off is the one that stops that feature quietly becoming load-bearing.
Four holes that closed together
An evicted Corpus, an unmatched phrase, an unanticipated language, and a surface nobody thought to put a hotline on. Each was filed as its own problem; one ruling closed all four, because they were all the same problem — escalation depending on something that can be absent.
2026-09-10 — Map ticket resolved: Query Intent is advisory
Actor: Proposer (claude-opus-5), grilled with the owner Ticket: issues/05-query-intent-trust.mdArtifact: docs/adr/0043-query-intent-is-advisory.md (D-101, D-102, D-103), glossary, PRD §2/§9, Seam 5 Coverage check: 67 before, 67 after. Six resolved, four on the frontier.
A rule that held on the surface and was bypassed underneath
D-68 forbids Routing from designating a Chemical Record. The Query Intent tier returns a product name. A Handler types น้ำยาล้างชิ้นงาน, the cloud returns Acetone, that hits one record, and the chooser never appears. D-68 was satisfied by every line of the routing code and defeated by the data flowing into it.
The ticket described this as "nothing validates the intent". That framing points at the wrong fix. Validation filters on confidence, and confidence is the single thing a model reports worst about its own errors — a confident wrong intent looks exactly like a confident right one. Substitution makes the model's answer win; addition makes it compete. D-101 chose addition, which makes the failure structurally impossible instead of filtered.
A second problem dissolved without being solved
With substitution, retrieval had two scoring modes — raw question and intent-expanded — so one Routing Threshold could not be correct for both. The ticket raised it as a separate unassigned decision. Under union there is one retrieval operation, and the question stops existing. Worth noting because the tempting fix was two thresholds, which would have been a new curated artifact and a new way to get it wrong.
The assertion had to be narrowed to be correct
The instinct was "a wrong intent must never change the outcome". That is wrong and would break the feature: making ถุงมือ reach the PPE field is the entire upgrade. Intent is allowed to change outcomes. The boundary is record selection, and that is what D-102 asserts.
Seam 5 previously exercised the tier recorded and the tier dead. Never wrong.
I had the rate-limit framing backwards in my own ticket
The ticket said per-address limiting is "either loose enough to be no control against an outside caller, or tight enough to disable the tier for the whole plant". Half wrong: an attacker gets their own bucket, so it does control them. What it actually does is make The Plant's users share one, because they sit behind a single NAT egress.
And the exposure was already bounded — D-77's cap disables the tier and Routing serves the Floor, which is specified and tested. Abuse costs money and quality; it does not break the app. D-103 names the cap as the control and the limit as a fairness and accident guard, because D-78 read as though the limit were the protection, which invites tightening the wrong knob.
A deploy-time shared secret was rejected in writing rather than silently: a secret shipped to every browser is not a secret, and ADR-0033 already forbids the provider key in the client for the same reason. Rejected options are where that reasoning survives someone's good idea next month.
2026-09-10 — Map ticket resolved: an empty Coverage table is correct
Actor: Proposer (claude-opus-5), grilled with the owner Ticket: issues/06-reground-two-coverage-rows.mdArtifact: docs/adr/0044-an-empty-coverage-table-is-correct.md (D-104), five row corrections, one sentence in docs/agents/templates/adr.mdCoverage check: 67 before, 67 after. Seven resolved, three on the frontier.
The sixth instance was written while fixing the fifth
The ticket named two bad rows. Checking turned up six, and the sixth was mine from an hour earlier: ADR-0043 citing R-19 for D-103, where D-103 names which control bounds exposure and R-19 concerns unquantified benefits.
That instance is the argument for the whole ADR. It was written by an author who had read the finding describing this defect, in a session whose immediately previous act was fixing an instance of it. It rules out carelessness about a particular id, and it rules out not knowing the pattern.
What remains is the pull of the empty cell. A Coverage table with no rows looks like unfinished work, so the author reaches for the nearest plausible finding. ADR-0036 and ADR-0038 had already shipped empty tables; the convention existed and had never been stated, so every author had to decide alone whether an empty table looked like a mistake.
One row survived, and checking it mattered
The instinct was to remove all five. ADR-0033's R-19 → D-77 turned out to be real once stated accurately: R-19's surviving half is that the claims carry no thresholds and "cannot be read off the source", so a number the spec needs comes from the owner — which is exactly what D-77 rules about the cap. The earlier wording had stretched it into a claim about benefit metrics, which is what made it look false.
Deleting it would have been the fourth time this week I nearly removed something correct on the strength of a first reading.
Two places on purpose
The ADR is where the rule binds and where the count of six survives. One sentence in docs/agents/templates/adr.md is where an author reads it, because that is the file open while the Coverage table is being filled in. An ADR alone gets read by people auditing, not by people writing — and the writing is where the row gets invented.
Rejected, and worth recording why
Marking the Evidence as an analogy rather than removing the row. It fails because a row saying "cited by analogy" still counts as coverage to anyone skimming the table, and no gate can tell the difference. It preserves precisely the appearance the row was added to create.
Building a gate. Not available, and ADR-0039 had already said so: the check would be whether a cited finding supports the claim made about it, which is a reading task rather than a parsing one. coverage_check.py checks presence, IDSTATUS checks currency, and truthfulness is caught by adversarial review or not at all.
The honest expectation
D-104's Consequences say it plainly: this rule is enforced by reading and it will be broken again. The count is what makes the seventh instance recognisable as the same defect rather than a fresh one.
2026-09-10 — Map ticket resolved: the once-per-Corpus bucket was mislabelled
Actor: Proposer (claude-opus-5), AFK — the map's only research ticket Ticket: issues/08-size-the-once-per-corpus-bucket.mdArtifact: .scratch/chemical-safety-assistant/once-per-corpus-sizing.md, PRD Declared Gaps Coverage check: 67 before, 67 after. Eight resolved, two on the frontier. No ADR.
The answer was a correction, not a number
The ticket asked whether ADR-0028's "corpus size is the schedule" still covered the whole cost, now that five artifacts had accumulated outside D-58's per-document figure.
Reading the five against each other — which nobody had done, because each was introduced by its own ADR — three of the five are not once-per-Corpus at all:
- The Routing Fixture scales directly. Its mandatory negative cases are about Corpus contents: a chemical outside the Corpus, a question the SDS never answers, a name held by two suppliers. More documents means more of each.
- The Curated Explanation glossary scales partly — GHS core vocabulary is shared, product-specific terms arrive per document.
- The Routing Threshold needs revalidation whenever the Corpus changes, being calibrated against retrieval over this Corpus.
Only the Escalation Trigger and the Curated Escalation are genuinely fixed.
So ADR-0028 is more right than the bucket suggested, not less. The suspicion was that its ruling had stopped covering the whole cost. Most of the bucket turned out to be corpus-size cost wearing a label that implied otherwise.
Same mechanism as everything else this week: each ADR was correct about the artifact it introduced and inherited a classification nobody re-read against the others.
Refusing to produce a number was the point
D-58 exists because a two-document spike was run and timed. No spike has been run for any artifact here. D-30 forbids inventing a figure the spec has no basis for, and this repository has a live example of what happens otherwise — jsqr was asserted to be a dependency three times, load-bearing in Risks item 1's mitigation, and was not one.
The note says what a measurement would take instead: time the two fixed artifacts, since those are on the critical path for emergency flows, and fold the three that scale into D-58's per-document figure rather than sizing them separately.
The constraint worth more than the hours
Two of the five need a site authority rather than a curator. Evacuate-and-call is The Plant's procedure, and D-95 makes a named reviewer the point of the artifact. Those two are gated on the same access as the binder and the Study Area, so they moved into that blocker rather than staying in a schedule this project controls.
2026-09-10 — Map ticket resolved: the spec claimed four packages and had three of them wrong
Actor: Proposer (claude-opus-5), grilled with the owner Ticket: issues/09-jsqr-was-never-a-dependency.mdArtifact: docs/adr/0045-...md (D-105), scripts/stack_claim_check.py, a STACKCLAIMS gate, @vue/test-utils installed, ticket 11 created Coverage check: 67 before, 67 after. Nine resolved, two on the frontier.
The ticket asked whether anything else was unverified. Three more things were.
Found by reading two package.json files:
- Seam 4's "Playwright is already in the stack and can assert this directly" — absent.
- Seam 2's "Vitest with
@vue/test-utilsis the existing home for this" — absent, and nothing in this repository has ever mounted a component. - "Zod is already in the stack" — absent.
The two that matter carry the verification strategy. The spec's most load-bearing tests named tools that did not exist, and one of them — Seam 4 — had been widened by D-94 the same week, which nobody could have executed.
The confusion had a shape, which is why the fix is a distinction and not a correction
D-38's stack list was verified against the sibling's source when ADR-0020 was written, and it holds. These four sit beyond it. The trap is that "Zod is in the chosen stack" and "Zod is already in the stack" read identically and mean different things — a plan versus a manifest entry. Only the second can mislead someone into planning work on a tool that is not there.
D-105 separates them: claimed-installed goes into a fenced block the gate reads, chosen-but-absent is worded as a plan and governed by D-38.
A gate here, where ADR-0044 refused one the day before
ADR-0044 declined to build a check for stretched Coverage rows: judging whether a finding supports a claim is a reading task. Whether a package is in a manifest is a parsing task. The two defects look alike — the document says something untrue — and only one is mechanically reachable.
Taking the reachable one is not inconsistency with ADR-0044; failing to notice the difference would have been. STACKCLAIMS is in GATES, its --demo runs under FIXTURE, and it was falsified against the real spec rather than only its fixture: adding playwright to the block fails the run and names it.
What was and was not installed, and why the distinction matters
@vue/test-utils installed — Seam 2 needs it, the components landed yesterday, one light dependency.
Playwright not installed. Seam 4 needs a running app, browser binaries and a harness; installing a package so a sentence becomes true is the original error running backwards. Seam 4 is now marked specified-but-not-executable, with ticket 11 to make it run — and that ticket also notes Seam 2 is now executable and still unwritten.
Zod left as chosen-and-absent, because nothing needs it yet.
What this gate cannot reach
Claims about the world that are not package names: a provider's retention policy, a finding's contents, what an SDS section says. Three separate records now say adversarial review is the only defence for those.
2026-09-10 — Map ticket resolved: a retrieval miss shows the field
Actor: Proposer (claude-opus-5), grilled with the owner Ticket: issues/10-a-retrieval-miss-is-not-silence.md — the last grilling ticket on the map Artifact: docs/adr/0046-a-retrieval-miss-shows-the-field.md (D-106, D-107), glossary, PRD §9, Seam 5 Coverage check: 67 before, 67 after. Ten resolved, one open.
The ticket's own framing was wrong, and correcting it produced the ruling
I wrote that the app "genuinely cannot tell 'the document is silent' from 'my lexical search was not good enough' without a coverage record", and that ADR-0036 had already rejected that bookkeeping on cost. Both halves are true — for a field the curator left empty.
They are false for a field that has spans. The app is holding them. resolveSafetyRead already encodes the distinction: "Record present, curated field, no Source Spans → Not Stated."
So the hard case and the dangerous case are not the same case. The dangerous one — claiming a supplier is silent about content sitting on the device — is decidable for free by asking whether the field has spans. The undecidable residue is the curator's unselected span, which ADR-0036 already accepted and which no naming fixes.
Had the ticket been implemented as written, the answer would have been a fifth state, or a costly coverage record, for a problem that needed neither.
Why showing the field is not what D-67 forbade
D-67 guards against a wrong span arriving verbatim, cited and Section-numbered, wearing authority it has not earned. That danger came from the span replacing everything else. A whole curated field singles nothing out and asserts nothing about which span answers — it is what a user reaches by opening the record. There is no wrong span to show when none is being chosen.
A fifth state was rejected on the same reasoning: its honest content would be "we hold this and could not match your question to it", and the useful response to that is to show what we hold. A state whose job is to explain why the app is withholding something it could show is worse than showing it.
A cost fell as a side effect
Yesterday's sizing note flagged the Routing Threshold as needing revalidation whenever the Corpus changes, because a mis-set threshold produced false silence. Under D-107 it produces more or less highlighting. The artifact stopped carrying a safety consequence — which is a better outcome than the note's own recommendation of measuring it more carefully.
Third route to the same false answer
Storage (ADR-0032), vocabulary (ADR-0036), retrieval (here). Each previous fix was correct and incomplete, because each was written about the route it was looking at. ADR-0046 records the general form: ask of any rule that produces a user-visible answer which other rules can produce the same one. That question would have found all three at once.
2026-09-10 — Map ticket resolved: Seam 4 runs, and Seam 2 exists
Actor: Proposer (claude-opus-5) + two subagents Ticket: issues/11-make-seam-4-executable.md — the map's last open ticket Artifact: frontend e518262; playwright.config.ts, e2e/, five component spec files, init.sh gaining a SEAM4 gate Coverage check: 67 before, 67 after (after one self-inflicted detour, below).
The two things that would have made this green and worthless
Seeding a Corpus without the sentinel. D-88's snapshot-version record is written out-of-line (SnapshotVersion has no keyPath). Get it wrong and every read resolves to Corpus Unavailable, so the harness drives an app that answers nothing, observes zero requests, and passes. The agent caught this and mutated the sentinel key deliberately to prove the seed-took guard bites.
Allowlisting a host for the Query Intent exception. The ticket asked for the exception expressed by disabling the tier, not by naming a host, because an allowlist is how a second exception arrives without anyone deciding to add one. The agent found there is nothing to disable — the Conversational surface is a placeholder — and rather than shipping VITE_QUERY_INTENT_ENABLED=false that nothing reads, it asserted an unconditional zero with no allowlist anywhere. A flag nothing reads looks like a control and is not one. That is the better answer to the ticket than the ticket's own wording.
Verified four ways, one of them mine
A permanent self-test that fires one fetch and requires the collector to catch it — without which a silent collector is indistinguishable from a silent app. A sentinel-key mutation. An injected fetch in a built chunk. And independently: I added fetch('/api/deliberate-defect-check') to HomePage.vue and the gate went red naming the URL.
The agent also settled the service-worker boundary empirically instead of reasoning about it: SW-originated fetches are routed, so adding the runtimeCaching that vite.config.ts omits cannot make the gate go quiet.
Seam 2: the first component tests in this repository
Nothing here had ever mounted a component; vitest.config.ts had no Vue plugin. 130 tests, up from 102. The negative assertions are the ones that matter — no digit reaches an undated identity, no glove-material word reaches a generic PPE span, each no-answer state renders its own component while the other three do not.
Two agent claims I checked and one was wrong
The Seam 2 agent reported that ChemicalRecord.router.ts was not merged, so identification pushed to an unregistered route. It read a stale docblock instead of index.ts, where the router is imported and mounted. The docblock was stale — that part was right — and is now removed.
Its PPE finding was real but characterised backwards. It said the absence note wrongly claims absence on a field naming nitrile. Reading the Thai shows the note is conditional — "ถ้าเอกสารไม่ได้ระบุ…", "if the document does not specify" — so it is never false. The actual defect is the inverse and worse: D-52 requires the absence to be stated, and a conditional never states it. The Seam 2 test asserting the note's presence passed against a span naming nitrile, so the test was asserting the wrong thing and so is the component. That is now ticket 12.
I broke my own rule and the gate caught it
Ticket 12's first draft was titled # D-52's absence is explained, never stated. A heading beginning with an id is a declaration to coverage_check.py, so it redeclared D-52 and the count went to 68. The map's own Notes carry that rule, in the words "never start a line with an id", written by me four days ago after the same mistake. Renamed, back to 67.
Every ticket on the map is now resolved, and two new ones stand open from what the work found.
2026-09-10 — Map ticket resolved: a missing PPE material is data now
Actor: Proposer (claude-opus-5), grilled with the owner Ticket: issues/12-d-52-never-states-the-absence.mdArtifact: docs/adr/0047-a-ppe-material-absence-is-data-not-prose.md (D-108, D-109), frontend cfac4a4Coverage check: 67 before, 67 after. Every ticket on the map resolved.
D-52 was never satisfied, and the note was never false
The requirement: where an SDS names no PPE material, state the absence. What rendered was a conditional note on every PPE field — "if the document does not specify the material…" — which is true whatever the document says. Never false, never a statement, and so the rule went unmet for nine days while looking met.
The reason it matters is R-12 again: a Handler reading "wear gloves", not told the document named no material, assumes any glove does. For acetone the wrong glove fails. It is ADR-0002's failure arriving by omission rather than by paraphrase.
The gap was in transit
The API's PpeItem has carried material String? since the Corpus was modelled, with both guards in the schema — never approximated, no field for an external standard — and the serialiser emits it. The client had no per-item PPE structure at all. So the absence was representable, crossed the wire, and was thrown away before anything could render it. Prose was what remained, and conditional prose was the only honest prose available to a component that cannot know.
A correction I made mid-grill, and what survived it
I first recommended a boolean rather than the material name, arguing that storing "nitrile" puts a derived assertion into a structured field. I argued that without reading the API model, which had already chosen the nullable string and reasoned it better: material is set by a curator while reading the span they are selecting (D-50) — Curation-time judgement under review, not runtime derivation, which is the line D-02 draws. A boolean would also have discarded the reason span.
D-109 is what survived being wrong. The worry behind the boolean was real — a field describing a span can drift from it — and the right answer is to say which is authoritative rather than to refuse the field. The span is what a person reads; material exists only so absence is representable.
The test was asserting the wrong thing and passing
The old Seam 2 assertion checked the note was present. Because the note was conditional it was present regardless, so the test passed against a fixture span naming nitrile. A green assertion, about the wrong property, for as long as anyone cared to look.
The new test asserts the inverse — a named material produces no absence statement — and I falsified it by reverting the component to the unconditional note, which fails it by name. That is the shape worth carrying: assert what must not appear, because presence assertions pass for reasons you did not intend.
2026-09-10 — Stage 3: the spec becomes twelve tickets
Actor: Proposer (claude-opus-5), /to-ticketsArtifact: .scratch/chemical-safety-assistant/build-tickets.md, issues/13–issues/24Coverage check: 67 before, 67 after.
What exploring the codebase turned up
The server serves a Corpus snapshot and nothing pulls it. The endpoint exists, the on-device store exists, the sentinel logic exists — and no client connects them, so every surface answers from an empty device today. That became ticket 13 and four others are blocked behind it.
This is the value of the skill's explore step over working from the spec alone: the spec describes a sync and reads as though it were built, because both ends of it are.
Numbering, and why it continues rather than restarts
issue-tracker.md overrides /to-tickets's single-tickets.md convention: one file each under .scratch/<slug>/issues/. The wayfinder children already occupy 01–12, so the build tickets are 13–24. Restarting at 01 would have reused ids in a repository whose rule is that ids are never reused — and this repo already carries two tombstones for numbering holes.
What is deliberately not a ticket, and why that list matters
Curating documents is excluded because the spec says tickets filling the Corpus "cannot be sized as engineering work at all", and the Study Area is still unchosen. Camera OCR is excluded pending the Risks item 1 spike. Accounts are excluded by D-60.
Curation tooling was the judgement call. It is real scope — page-image verification, the cross-section detector, span selection, QR label generation — and it is curator-side rather than app-side. Mixing it into this breakdown would make the frontier misleading: an agent taking "the next ticket" would move between building the product and building an internal tool, with no signal that the audience changed. It gets its own chart.
The acceptance criteria are where the week's work landed
Each ticket's criteria are written from the rulings rather than from the surface. Ticket 16 asserts that where an SDS states no branch condition, no condition appears at all — the assertion that would have caught the app inventing a threshold to keep a gate alive. Ticket 14 asserts escalation is reachable with the Escalation Trigger disabled, which is what stops a phrase list becoming a person's route to a phone number. Ticket 17 asserts a sub-threshold match in a populated field shows the field, never Not Stated.
Those three sentences are the difference between tickets derived from this spec today and tickets derived from it a week ago.
2026-09-10 — Build ticket 13: the Corpus reaches the device
Actor: Proposer (claude-opus-5) Ticket: issues/13-pull-the-corpus-onto-the-device.md — the first stage-3 ticket Artifact: frontend f8806bfCoverage check: 67 before, 67 after.
Wiring one gap found a second
verifyCorpus — D-73's startup verification — existed in the store and was called by nothing. A device holding a Corpus reported having none until some read happened to check the sentinel. Both ends of that were built and reviewed; nobody had asked who calls it.
That is the same shape as the ticket itself: the sync endpoint and the sync-capable store were both built, both correct, and unconnected. Two subsystems being individually complete is not evidence that anything joins them, and neither gap was visible from either side.
The design consequence I did not expect to have to defend
The pull cannot happen on mount. Seam 4 asserts zero network requests while surfaces are exercised, and a sync on mount fires during exactly that exercise. So the pull is a person's action.
But §2 says the snapshot is pulled "opportunistically when a connection exists" — and that half cannot coexist with Seam 4 as written without deciding whether a background sync counts as "exercising a surface". I implemented the on-demand half and left the opportunistic half unbuilt with the tension written into the module, rather than either silently dropping it or quietly weakening the seam to fit. It is the open question this ticket hands back.
Deviating from AGENTS.md, using AGENTS.md
The file says new API surfaces extend HttpRequest. It also lists that class's timeout: 120000 under what not to carry over — "unacceptable for a Handler at the point of exposure" — and extending it inherits exactly that. The instruction and the warning point opposite ways, so the module says which one it followed and why, with a note to revisit as a shared provider when the Query Intent tier needs the second network surface.
The test worth keeping
A payload the client cannot trust never reaches replaceCorpus. Replacement is destructive — it swaps the whole Corpus — so a shapeless response that got as far as the store would leave a device that verifies, answers safety questions, and is answering from nothing. Falsified by making the guard permissive; two tests go red.
The other half: a failed pull leaves the previous Corpus intact. Stale is visible and a Handler can judge it. Empty is indistinguishable from never-synced.
2026-09-10 — Build ticket 14: escalation is one action from anywhere
Actor: Proposer (claude-opus-5) Ticket: issues/14-escalation-present-everywhere.md — unblocks 15, 16 and 18 Artifact: frontend e74d76eCoverage check: 67 before, 67 after.
The assertions are about what escalation does not touch
D-74 promises escalation survives an evicted Corpus, and ADR-0041 broke that promise the day it was written by filing the escalation text as a Corpus artifact. So the unit test spies getCorpusGateway and requires it never to be called, rather than checking that something rendered — which would pass just as well for a component that reads the Corpus and happens to succeed.
Seam 4 gained the browser half: delete the Corpus database, assert the app reports having no Corpus, then reach escalation in one click with zero requests observed. That middle assertion is the whole difference between a real test and a vacuous one. Without it the test passes against a device that still holds a Corpus — the same shape as seeding a sentinel under the wrong key and watching every read return Corpus Unavailable while the suite goes green.
The test that keeps the trigger honest carries no trigger
D-100's point is that escalation is reachable with the Escalation Trigger disabled entirely. Any phrase list is incomplete — another language, a typo, a Handler typing one-handed — so the list must never be the path. The layout test navigates from an unrelated surface with no trigger in the picture at all.
Three choices worth recording
The control lives in DefaultLayout rather than on each page, so a surface built later cannot forget it — 15 and 16 are unbuilt and inherit it for free.
The route is eagerly imported, alone among the routes here. A lazy chunk is a precache hit until the day it is not, and this is the surface that must render when everything else has failed.
The acts are a closed union type. Without that, the next time a PPE span is missing someone curates an "escalation-like" artifact and D-02 is bypassed by the document meant to reinforce it.
A comment that contradicted its own code
The first version of the router module documented an eager import and then wrote a lazy one. Caught on re-reading, and worth noting because it is the same defect class as every stale claim found this week — a document asserting something the thing beside it does not do.
What is placeholder, and says so
The escalation text is not real. D-95 requires a named reviewer and evacuate-and-call is The Plant's own procedure, so a real Curated Escalation needs a site authority and is gated on the same access as the binder. The reviewer field is visibly unfilled rather than plausibly filled, because placeholder text under a real-looking name is worse than placeholder text that admits it.
2026-09-10 — Build ticket 17: the Routing Floor
Actor: Proposer (claude-opus-5) Ticket: issues/17-the-routing-floor.md — unblocks 19 and 24; nothing is blocked now Artifact: frontend be90f4cCoverage check: 67 before, 67 after.
A reuse that looked obvious and was pointing the wrong way
matchChemicalRecords exists, is tested, and matches a chemical. Routing needs to match a chemical. So Routing called it — and every test returned Not In Corpus.
It asks whether a product name contains a search term, which is exactly right for a search box. A question is the reverse: the product name sits inside a longer sentence. Two functions that both "match a chemical" are not the same function, and the direction is invisible from the call site.
Routing got its own matcher rather than widening one whose contract its own tests assert. That is the third time this week that two individually-correct pieces did not compose, after the sync endpoint nothing pulled and verifyCorpus nobody called.
The threshold is a placeholder, and D-107 is why that is tolerable
The glossary calls the Routing Threshold a Corpus artifact fixed at Curation, and the snapshot carries no such field. It is a module constant with a TODO(spec).
Worth noting why that is acceptable now and would not have been last week: D-107 moved the threshold from deciding whether to answer to deciding whether to point. A wrong value shows a whole field where it might have highlighted one span — a usability defect. Before D-107 the same wrong value produced a false claim that a supplier was silent.
Seam 4 caught the surface change within seconds
/ask stopped being a placeholder and the harness failed on the old heading. That is the gate doing precisely its job on the first change that touched it.
Rewriting the harness to exercise the surface for real then found a second thing: visitFromHome navigates and returns, which is right for a placeholder you only need to render and wrong for a surface you must be standing on to use. My added assertions were running on Home.
Falsification
Restoring the pre-D-106 behaviour — sub-threshold answers Not Stated — fails the first test by name. The assertion is not that the router is good at retrieval; it is that a failure to retrieve is never reported as a supplier's silence.
2026-09-10 — Build ticket 22: Seam 1, the one the spec calls the most valuable
Actor: Proposer (claude-opus-5) Ticket: issues/22-seam-1-the-corpus-artifact.mdArtifact: scripts/corpus_check.py, scripts/corpus-check.sh, a CORPUS gate Coverage check: 67 before, 67 after. Six gates now, all but COVERAGE green.
The spec told me where to put it, and it was right
"coverage_check.py and adr_status_check.py are exactly this shape — data-level assertions with a self-test, wired into init.sh as named gates. A Corpus checker belongs beside them as a further gate." So it is a parent-repo gate rather than app code, and it inherits the shape three other checkers already use: rules, a --demo, a wrapper, an entry in GATES.
Falsified against the real case rather than only its fixture
Fed a snapshot with a PPE span in Section 8 and the contradicting Restriction in Section 7 — the acetone case, three pages away — the gate fails and names the document, the Section and the text.
That is exactly the shape the within-Section form would have passed, which is why ADR-0015 and ADR-0022 exist. Running the checker against its own demo would not have shown that; constructing the motivating document did.
What it deliberately cannot do
It asserts a Restriction was considered, never that it was resolved correctly. That limit is not a shortcoming to fix later — it is the reason Reconciliation is human (D-49). Judgement is not mechanically checkable; its presence is. Writing that into the module docstring matters, because the next reader's instinct will be to make the checker smarter.
The carve-out that took the most thought
A document with no Chemical Record displays nothing, so it has nothing to reconcile against and must not fail. Without that, the gate would fail every uncurated document in a partially-curated Corpus — which is the state this project will actually be in for its entire life. A gate that fails on the normal state is a gate that gets switched off.
It reports "nothing curated yet", and that is not a pass
There is no Corpus, because the Study Area is unchosen. The message says which blocker that is rather than printing a green tick over an empty check. The --demo under FIXTURE is what stops it rotting into a gate that cannot fail while it waits.
A spec suggestion I did not follow
The spec proposes the checker share the client's Zod schema. Zod is chosen and not installed (D-105), and the spec's own prior-art paragraph points at two Python gates. Python matches the prior art, the gate shape and init.sh. Recorded rather than silently diverged from.
2026-09-10 — Build tickets 15 and 16: First Aid and Spill Response
Prompt: "go" — authorising the next build ticket, worked one at a time in order.
Frontend 72329ca, API 46f532e, parent 9363fc0. Frontend LINT/TYPECHECK/TEST/SEAM4: PASS (180 unit tests, 3 Seam 4 tests). API LINT/TYPECHECK/TEST: PASS (103 tests). Parent FIXTURE/ADRSTATUS/IDSTATUS/STACKCLAIMS/CORPUS: PASS; COVERAGE: FAIL at 67 problems, the standing red ADR-0035 records, unchanged by this work — every one of them a stage-1 stack-conventions finding.
The defect shape showed up twice more, and the second one was worse
firstAidMeasures and spillResponseBranches were emitted by the server into a client model with no home for them, so both crossed the wire and were discarded. That is the third instance after ppeItems and after the sync endpoint nothing pulled: two individually-correct subsystems that do not compose, which is now this project's dominant defect shape and is found only by looking at the join.
The cross-repo half was worse than a dropped field. The server serialiser handed conditionSourceSpan to a function whose first guard throws on a missing span, and the response schema required one. D-97 says a document may state no branch condition at all and the curation spike found half its sample does — so the whole snapshot failed to serialise for such a document and no device could sync a Corpus containing one. A guard that is right for Safety-Critical Content (never render a span that is not there) was wrong one level up, where null is a legitimate shape rather than a missing value.
Falsified by restoring the un-guarded call: the regression fails by name.
Two routes claiming one screen, and a route nothing linked to
The placeholder /first-aid and /spill-response entries had to go, or each surface would have had two routes. Both real routes take a :chemicalRecordId, and wiring them with nothing linking to them would have been the same defect shape a fourth time — correct routes, correct surfaces, no path. The Chemical Record surface is the screen that has an id, so it carries the link, and a test asserts the link exists rather than trusting that someone will notice.
Home's First Aid tile therefore lands on manual search. US-26 wanted it at Home depth, and this costs one extra tap on a first-aid path. Recorded as TODO(spec) on Home rather than closed by invention: no ADR settles it and it is an owner call.
Seam 4 exercises them rather than visiting them
Visiting would have been worthless. A surface that renders nothing makes no request and passes the zero-request assertion for the wrong reason, so the harness seeds Section 4 and Section 6 content and asserts it is on screen, and answers the spill branch condition — under D-55 no step renders until it is, so an unanswered branch exercises half the surface.
One assertion was wrong before it was right: getByText('1669') matched both the layout's escalation link and the panel's own hotline. The panel's tel:1669 is what D-08 puts on this surface ahead of step 1, and the loose matcher would have passed on the layout alone.
2026-09-10 — Build ticket 18: the Escalation Trigger
Prompt: "go".
Frontend 34d6a4e. LINT/TYPECHECK/TEST/SEAM4: PASS (186 unit tests, 3 Seam 4 tests).
The criterion an obvious implementation fails silently
"Routing continues: any span found appears beneath the escalation, never displacing it. The trigger short-circuits blocking, not answering." The natural code is if (escalated) return, and it passes every test of the form trigger phrase → panel appears. The discriminating case is a question that fires the trigger and resolves to a span. Falsified by adding the early return; it fails by name. This is the second ticket in a row where the test that mattered was the one distinguishing a correct implementation from a plausible one, rather than from a broken one.
An acceptance criterion that could not be tested as written
"Evaluated before any network call" — the surface makes no network call, so the criterion was unfalsifiable. Rewritten as two structural assertions: the matcher takes the question and nothing else, and the panel renders with the Corpus read hanging forever. Both keep their meaning when ticket 19 puts the Query Intent tier behind that read, which is the moment the literal wording starts to bite. Restating an untestable criterion as a testable one is not the same as dropping it, and the answer says which was done.
A criterion satisfied by construction, and deliberately not built for
"Pre-fills nothing — not spill volume, not exposure route." TRoutingOutcome has no field to carry a pre-fill in. Writing a pre-fill mechanism in order to leave it unused would be the defect; the docblock records why there is no code.
The asymmetry that justifies a dumb matcher
A phrase the list misses costs a tap D-100 guarantees exists. A phrase it matches spuriously costs a panel nobody needed. Neither is a safety failure, so the list is generous and the match is substring — and anything cleverer would be the "model judgement" the glossary rules this is not.
Thai fragments ('เข้าตา') are the correct shape rather than a shortcut: Thai is not word-segmented, so substring matching is what word matching would be in English.
Same trap as yesterday, caught before it was written
Seam 4 asserts a[href="tel:1669"] rather than the text 1669, because the layout's escalation link carries that text too and the loose matcher passes on the layout alone. That is the assertion that had to be corrected on Spill Response the round before.
2026-09-10 — Build ticket 19: Query Intent, the half that could be built
Prompt: "go".
Frontend d8090b7. LINT/TYPECHECK/TEST/SEAM4: PASS (193 unit tests). Ticket 19 stays open.
The first ticket in this run that cannot be finished
Three of its six criteria need things this session does not have: a provider key, a spend cap number the owner sets, and a per-address bucket sized for a shared egress. gemini-3.8-flash was chosen from a pricing page read on 2026-09-08, after this model's knowledge cutoff, so the model id and the SDK shape are both unverifiable from here. Writing the call anyway would be reasoning on an unchecked premise — the jsqr failure exactly, where the spec asserted a dependency three times that was not installed and used that to call a route "cheap".
A fake provider returning plausible intent was the tempting alternative and was not built. A subsystem nothing real feeds is this project's dominant defect shape and has already been found four times; deliberately creating a fifth to make a ticket look finished is worse than an open ticket.
The half that was built is the half carrying the risk
The tier returns a product name, and that is the D-68 bypass: a Handler types น้ำยาล้างชิ้นงาน, the cloud returns Acetone, it hits exactly one record, and the chooser never appears. The rule holds on the surface and is bypassed underneath it. A record the person's own words did not name now reaches the chooser even as the only match.
Fields are widened, never replaced. ADR-0043 names the worse-than-a-wrong-record case: a spill question whose intent names Section 4 retrieves a verbatim, correctly cited first-aid span, wearing all the authority of the right document for the wrong situation.
Deep equality, not "an answer still appears"
D-102 case 2 says malformed intent produces the identical outcome to the tier being dead, so the assertion is toEqual against the same call with no intent. An implementation that discarded the intent but took a different path through the router would satisfy the weaker reading. Seven malformed shapes, including a well-typed object with the wrong field types.
What made the design safe was refusing to validate
The obvious control is checking the intent and rejecting bad ones, and ADR-0043 rejected it: a confident wrong intent is indistinguishable from a confident right one. So there is no repair, no partial acceptance and no confidence check — the payload either parses into the shape or becomes null, and null is the no-signal path already specified by D-66.
2026-09-10 — Build ticket 20: Curated Explanation lookup
Prompt: "go".
Frontend 431c301. LINT/TYPECHECK/TEST/SEAM4: PASS (201 unit tests, 3 Seam 4 tests).
A store with a writer and no reader
replaceCorpus has been writing the Curated Explanation store since the Corpus was modelled, and nothing had ever read it. That is the same defect shape as the sync endpoint nothing pulled and the two curated fields discarded in transit, arriving from the other side: not a reader with no data, a writer with no reader. Five instances now, and the tell is always the same — each side is correct.
The assertion is the absence, not the presence
ADR-0037 names the repair it forbids: "do not restore generation because the Curated Explanation glossary is incomplete — an absent explanation is an absence, and inventing one is the failure this ADR removes." So the test that matters is that an uncurated term yields nothing, falsified by an implementation returning a placeholder sentence. A test asserting only that a curated term renders would pass the version that invents.
The one read that carries no sentinel
Every safety read in this app pairs the record with the snapshot sentinel, so an evicted store cannot be reported as a supplier's silence (D-88). The glossary read deliberately does not, and that is the correct call because an explanation is not Safety-Critical Content: an evicted store means there is no explanation, which is a plain absence rather than Corpus Unavailable. Pairing them would turn a missing word into a statement about the Corpus — the collapse ADR-0032 and ADR-0036 prevent, from a third direction.
Styling is not the control, and saying so in the docblock is the point
The panel is dashed, muted, headed "not text from an SDS", and renders no SourceSpanView — an explanation has no SDS Section, and dressing one in a citation lends it a supplier's authority. But ADR-0012 and ADR-0037 both rejected visual distinction as a defence: the readable block is what people actually read. What makes this safe is that a person wrote the sentence and reviewed it before it shipped. A future reader who mistakes the styling for the safeguard is the one this comment is for.
The interface did its job
Adding readCuratedExplanations to ICorpusGateway made the typechecker name all six fake gateways in the suite. A capability that cannot be half-adopted is a capability whose absence cannot ship.
2026-09-10 — Build ticket 21: History and Settings
Prompt: "go".
Frontend 0df7342. LINT/TYPECHECK/TEST/SEAM4: PASS (210 unit tests, 3 Seam 4 tests).
The criterion that asked for a ruling and did not need one
"A history entry pointing at a record that no longer exists resolves to the correct no-answer state rather than a blank — decide which, and record it if no ruling covers it."
It resolves itself, because a history entry is a link into the Chemical Record route rather than a second reader of the Corpus. The record surface answers it through the same resolveSafetyRead as every other read: Not In Corpus with the sentinel present, Corpus Unavailable without it. The temptation was to give History its own lookup and its own empty state, and that is precisely how a blank ships — a second implementation of the four states drifts from the first, which is the collapse ADR-0036 exists to prevent. The right answer to "which no-answer state" was "not yours to decide".
Written where a record was shown, and nowhere else
A Corpus Unavailable screen is not a drum this person looked at, so the trail is written only on kind === 'Record'. Falsified by recording unconditionally.
Why not the Corpus database
History must survive a Corpus replacement and must be erasable without touching curated documents. In IndexedDB beside the records, each would hold the other hostage. localStorage also makes the "never sent" property structural rather than a promise: there is no sync path to accidentally include it in.
Every read is defensive. A corrupt value is an empty history, never a throw — history is the least important thing on the screen and must not take the screen down.
Asserting an absence without matching your own promise
The first version of the no-account assertion scanned the rendered text for "เข้าสู่ระบบ" and failed against the page's own sentence saying no sign-in is needed. Rewritten as the absence of anything to fill in: no inputs, no route matching login. An assertion that a promise is stated is not an assertion that the thing promised is true.
2026-09-10 — Build ticket 23: Seam 3, Identification
Prompt: "go".
Frontend ec4750a. LINT/TYPECHECK/TEST/SEAM4: PASS (224 unit tests, 3 Seam 4 tests).
Fourteen assertions passing on the first run was the warning
They did, and one of them was worthless. The near-miss case used Acetonitrile, which misses under every containment rule — so a deliberately broken matchChemicalRecords (matching a term that contains a product name as well as the reverse) sailed past it. The fixture proved the rule could not fail rather than that it did not.
Replaced with "Acetone Plus 500": a formulated product whose label contains the name of a pure substance the Corpus holds. Matching in that direction hands a Handler acetone's record for a drum of something else, and it is the read a camera or a hurried typist actually produces. It fails the broken version by name.
The general lesson is not "write better fixtures". It is that a passing assertion is evidence of nothing until the implementation it guards has been broken against it — and that a fixture chosen to look adversarial (six shared characters!) can be adversarial in the wrong dimension.
An outcome that looked wrong and is right
A printed CAS Number reaches the chooser here, because two suppliers' documents carry 67-64-1. A CAS Number identifies a substance; a Chemical Record is a document (D-45). D-64 makes the CAS Number the preferred target of a label read, not a key that resolves to one record. Writing the assertion the other way would have encoded a real defect as the expected behaviour.
The criterion left uncovered, and why that is not laziness
D-64's camera read order — CAS Number first, then product name — belongs to a route that does not exist, blocked on the Risks item 1 spike. A pure text decoder could have been written and tested today, and would have been a subsystem with no caller: the shape found five times already in this project. What the seam asserts instead is the half that route will depend on — a CAS-shaped term is matched exactly and never as a product-name substring, so a record named "Cleaner 67-64-1 Plus" is not a match for 67-64-1.
2026-09-10 — Build ticket 24: Seam 5, the Routing Fixture
Prompt: "go".
Frontend e673346. LINT/TYPECHECK/TEST/SEAM4: PASS (257 unit tests, 3 Seam 4 tests). The last ticket.
The falsification that failed, and found the fixture rather than the code
Five deliberate defects. Four failed by name. The fifth — checking the sentinel after the record lookup — passed every case, and that was correct: the fixture's evicted Corpus held no records, so every ordering produced Corpus Unavailable. The case asserted a property the code could not violate.
Fixed by giving the evicted Corpus its records back with the sentinel gone, which is D-72's actual shape: a store that was reclaimed while records remain readable. A router that trusts that store now fails four assertions. The general form of this mistake is worth naming — a negative fixture whose inputs cannot reach the code path it guards passes forever — and it has now appeared twice in two tickets, in Seam 3's near-miss case and here.
Running every case twice is the ruling, not thoroughness
Recorded intent and tier dead. Where intent legitimately changes an outcome the case states both, because D-102 permits intent to change outcomes and forbids exactly one: selecting a Chemical Record the person's own words did not name. The pair is D-66's upgrade-never-a-gate made mechanical.
D-30 asserted against the fixture's own text
No case may state a percentage, threshold, score, confidence or probability. The runner raw-imports the fixture source and scans it, falsified by putting the word "threshold" in a case name. The reason is not tidiness: a fixture that encoded how sure Routing is would freeze a tuning constant into the definition of correct, and D-107 has already moved that constant once.
Three placeholders of the same shape is a design gap
The Routing Fixture, the Routing Threshold and the Escalation Trigger's phrase list are all "Corpus artifacts" the Corpus has no field for. One is a placeholder; three is the snapshot schema missing a home for curated Routing configuration, or the glossary calling something a Corpus artifact that is not one. Raised as TODO(spec) rather than resolved by making the code match the words.