Appearance
Spec: Chemical Safety Assistant prototype
Problem Statement
A Handler standing in front of a drum cannot find out what is in it fast enough to act well. The SDS that answers the question exists — The Plant keeps it on file — but it is scattered across binders and folders, slow to search, and written at a length nobody reads while wearing gloves.
Three consequences follow, and the source names all three: unsuitable PPE gets chosen, chemicals get stored incorrectly, and emergency response is slow. The failure is not missing information. It is information that cannot be reached at the moment it decides what someone does next.
The moment of peak need is also the moment of worst conditions: a spill, indoors, urgent, frequently with no network signal, and never with time to create an account.
Solution
A web application that puts The Plant's own SDS in a Handler's hand in seconds, answering only from documents The Plant actually holds, and never inventing an answer it does not have.
Four properties define it, and each is a decision rather than a preference:
- It answers from The Plant's own SDS (D-14) — not from general information about a chemical elsewhere in the world. Where the Corpus is silent, the answer is Not Stated (D-03), which is a correct outcome and not a defect.
- Safety-Critical Content is Extractive (D-02). Every hazard, PPE item, storage rule, Spill Response step and First Aid instruction appears verbatim, showing its Source Span. Nothing that changes what a person wears or does is ever written by a model.
- It works with no network (D-06, D-94). No screen in this app requires a connection. Every surface renders and answers offline, the Conversational one included. A network improves how well a typed question is understood (D-66) and nothing else — it is an upgrade, never a gate.
- Escalation comes before procedure (D-08). In Spill Response and First Aid the app first tells the reader when to stop and call, and only then how to proceed.
The prototype is an academic deliverable whose retrieval path is built for real (D-01). Where something can only be supplied by a company, it is stubbed and named in Declared Gaps — never quietly faked.
Platform. The prototype is a web application on the work-permit stack, superseding the earlier native Android decision (D-36) on the owner's instruction. Two of the four properties above — offline operation and camera Identification — were chosen when the platform was assumed native, and both are materially harder on the web. Neither property is weakened here; the mechanisms change and the risks are recorded in Contradictions for the owner.
User Stories
Reaching the app and identifying a chemical
- US-01 As a Handler, I want the app to open directly on Home with no login, so that nothing stands between me and an answer during an incident.
- US-02 As a Handler, I want to point the camera at a container label and have the app identify the chemical, so that I do not have to type a name I cannot pronounce.
- US-03 As a Handler, I want Identification to read the CAS Number when the label prints one, so that identification is unambiguous for a Pure Substance.
- US-04 As a Store Officer, I want Identification to fall back to the product name when no CAS Number is printed, so that a Formulated Product such as a parts-cleaning solution can still be found.
- US-05 As a Handler, I want to identify a container by its QR code where The Plant has applied one, so that pre-labelled drums resolve without reading text.
- US-06 As a Store Officer, I want manual search present on Home as an equal route rather than a recovery path, so that a decanted, damaged or handwritten label — and a camera that has failed — never leaves me stuck.
- US-07 As a Handler, I want the app to tell me plainly when it cannot identify what I showed it, so that I do not mistake a wrong match for a right one.
- US-08 As a Safety Officer, I want Identification to show me which Chemical Record it matched and let me reject it, so that a near-miss on a similar product name is caught before it is acted on.
Reading a Chemical Record
- US-09 As a Handler, I want the hazard shown first and prominently, so that I learn the thing that changes my behaviour before anything else.
- US-10 As a Handler, I want each piece of Safety-Critical Content to show its Source Span, so that I can see the app is quoting a document rather than telling me a story.
- US-11 As a Safety Officer, I want to see which SDS Section each span came from, so that I can satisfy myself the app is reading the right part of the document.
- US-12 As a Safety Officer, I want the Provenance of the SDS behind a record — supplier, revision date, and how the copy was obtained from The Plant — so that I know whether I am reading a current document.
- US-13 As a Supervisor, I want to open the SDS Section a span came from in full, so that I can read the surrounding text before authorising a task.
- US-14 As a Handler, I want the app to say Not Stated when the SDS does not answer my question, so that I go and ask a person instead of trusting a guess.
- US-15 As a Safety Officer, I want a Chemical Record to be identified by product name, supplier and revision date, so that two revisions of the same product do not silently merge into one answer.
PPE
- US-16 As a Handler, I want PPE listed item by item for the chemical in front of me, so that I know that nitrile is required rather than that "gloves" are required.
- US-17 As a Handler, I want each PPE item to carry the reason it is required, so that I understand what I am protecting myself from and do not skip it.
- US-18 As a Store Officer, I want any restriction, exclusion or incompatibility in the PPE section carried through to what I see, so that "not compatible with nitrile" cannot be lost between the SDS and my hands.
- US-19 As a Supervisor, I want to check the PPE the app recommends against what a Handler is actually wearing, so that I can correct a mismatch before work starts.
Spill Response
- US-20 As a Handler, I want Escalation shown above the procedure — when to evacuate, and the hotline — so that I do not work through five steps in a situation that needed a phone call.
- US-21 As a Handler, I want the hotline reachable with one tap from the Spill Response surface, so that calling is not slower than reading.
- US-22 As a Handler, I want Spill Response steps presented in their original order, so that I do not ventilate before putting on PPE.
- US-23 As a Supervisor, I want Spill Response to name the chemical it applies to, so that I do not apply one product's procedure to another.
First Aid
- US-24 As a Handler, I want First Aid organised by exposure route — eye, skin, inhalation, ingestion — so that I can find the one that happened without reading the other three.
- US-25 As a Handler, I want First Aid to lead with when to seek medical attention, so that Escalation is not buried under immediate steps.
- US-26 As a Supervisor, I want First Aid reachable directly from Home, so that I am not navigating through a Chemical Record while someone is affected.
Storage
- US-27 As a Store Officer, I want storage guidance for each Chemical Record, so that I can place a delivery correctly when it arrives.
- US-28 As a Store Officer, I want incompatibility statements shown with storage guidance, so that I do not shelve two products that must be separated.
Working without a network
- US-29 As a Handler, I want every safety surface to work with no signal, so that the app is useful in the store room and on the floor rather than only in the office.
- US-30 As a Safety Officer, I want the Corpus to show when it was last updated, so that I can judge how stale the answers might be.
- US-31 As a Handler, I want to be told when a question was matched without the app's online help — at the moment it changes what I should do, not as a banner I stop seeing — so that I try search instead of assuming nothing exists, and never watch a spinner waiting for a network the screen does not need.
- US-32 As a Handler, I want to install the app to my device's home screen, so that I can reach it in one tap and it is present before I need it rather than after.
Language
- US-33 As a Handler, I want Safety-Critical Content in Thai, so that I can read it under pressure without translating technical English in my head.
- US-34 As a Safety Officer, I want the original-language span reachable from any Curated Translation, so that I can check the rendering when a decision depends on it.
- US-35 As a Handler, I want chemical identity — product name, formula, CAS Number — left in its original form, so that what I read matches what is printed on the drum.
Asking a question
- US-36 As a Handler, I want to ask a question in my own words and be taken to the Safety-Critical Content that answers it, so that I do not need to know which SDS Section to look in.
- US-37 As a Handler, I want an unfamiliar term explained, so that I understand what a span means without needing the span rewritten.
- US-38 As a Safety Officer, I want the Conversational surface to be visibly distinct from Safety-Critical Content, so that nobody mistakes a generated explanation for a quotation from an SDS.
- US-56 As a Handler, I want to ask a question and still be taken to the right span when there is no signal, so that the surface I reach for first is not the one that fails in a basement.
- US-57 As a Safety Officer, I want the app to say Not Stated rather than show me its best guess, so that a confidently cited span from the wrong document never reaches the floor.
- US-58 As a Handler, I want typing that chemical went in my eye to put hotline 1669 in front of me immediately, so that nothing waits on a network call.
- US-59 As a Safety Officer, I want nothing from our SDS to leave the device or the server, so that using the app is not a disclosure of documents I do not own.
- US-60 As a Handler, I want the app to tell me its own data is missing rather than tell me the SDS is silent, so that I go and find the binder instead of believing a wrong answer.
- US-61 As a Handler, I want hotline 1669 to still be there when the app has no data at all, so that the worst case still puts me one tap from help.
- US-62 As a Safety Officer, I want every chemical used in my area to be in the Corpus, so that a Not Stated tells me something about the SDS rather than about what got curated.
- US-63 As a Handler, I want to be told plainly that we hold no document for this chemical, rather than that the document is silent, so that I go and find the binder instead of believing the supplier said nothing.
- US-64 As a Safety Officer, I want a question outside what a record covers to say so and offer me the full SDS, so that a gap in curation never reads as a gap in the supplier's sheet.
- US-65 As a Handler, I want an unfamiliar term explained even with no signal, so that the screen I reach for when I do not understand something is not the one that stops working.
History and returning
- US-39 As a Handler, I want the chemicals I have looked at recently listed with when I looked at them, so that I can return to this morning's drum without identifying it again.
- US-40 As a Handler, I want history to work with no account and no registration prompt, so that the app never asks me for something it does not need.
- US-41 As a Supervisor, I want history held on the device rather than reported centrally, so that using the app does not feel like being monitored.
Being told what this is
- US-42 As a Safety Officer, I want the app to state that it retrieves SDS information and does not replace the SDS, site procedure or trained judgment, so that its standing is unambiguous to anyone who picks it up.
- US-43 As a Supervisor, I want that statement present on safety surfaces and not only at first run, so that someone who was handed an open app still sees it.
- US-66 As a Safety Officer, I want the statement shown on a device's first use before any surface is reachable, so that nobody can use the app without having been told what it is.
- US-67 As a Handler, I want that first screen to be passable in one tap, so that a first run that happens to be a spill costs me one action rather than a page of reading.
- US-68 As a Handler, I want the emergency control on that first screen too, so that the one screen I am guaranteed to meet is not the one without the number on it.
- US-69 As a Safety Officer, I want the statement shown again when the device no longer holds a record of having shown it, so that a cleared or unreadable store cannot silently suppress it.
- US-70 As a Handler, I want the app to show its own name while it checks what it holds, so that a slow start looks like a slow start rather than a crash.
- US-71 As a Safety Officer, I want that opening screen to offer nothing to sign in to and nothing to tap, so that the place an account would be added by habit has no room for one.
- US-72 As a Handler, I want the emergency colour to mean only emergency, so that I can find the one control that matters without reading the screen.
Choosing between products that share a name
- US-44 As a Handler, I want the app to show me which product and supplier it matched when a name fits more than one, so that I do not read advice written for a different drum.
- US-45 As a Store Officer, I want two suppliers' sheets for the same chemical kept as separate records, so that one supplier's guidance is never quietly blended with another's.
- US-46 As a Safety Officer, I want to see that two records for one chemical disagree, so that I can raise it rather than discover it after an incident.
Records the documents cannot fully support
- US-47 As a Store Officer, I want a record whose SDS states no revision date to say so plainly, so that I do not read a substituted date as evidence the sheet is current.
- US-48 As a Handler, I want a PPE Section that names no glove material to tell me that, rather than showing me a guess, so that I know to ask instead of assuming.
Responding to a spill of the size in front of me
- US-49 As a Handler, I want to be asked how large the spill is before I am shown any step, so that I get the procedure the supplier wrote for my situation.
- US-50 As a Handler, I want the size question to double as the point where I am told to evacuate and call, so that the decision to escalate is made before I start working.
- US-51 As a Supervisor, I want restrictions that apply only to the larger spill to be present when that branch is shown, so that the more dangerous case is not given the safer case's instructions.
Labelling containers so scanning works
- US-52 As a Store Officer, I want to print a QR label for a Chemical Record, so that containers in my store resolve instantly instead of depending on reading a scuffed label.
- US-53 As a Handler, I want a labelled container to identify from its QR code alone, so that identification does not depend on the camera reading text well.
Trusting what the record says
- US-54 As a Safety Officer, I want to know that a restriction elsewhere in the document was considered when a span was chosen, so that I can trust a PPE card is not silently missing a contradiction from another Section.
- US-55 As a Safety Officer, I want Thai text in a record to have been checked against the original page rather than taken from the document's text layer, so that a corrupted character has not changed what a span says.
Implementation Decisions
1. Platform and stack
The prototype is a web application on the work-permit stack, replacing the native Android decision (D-36) by owner override.
- Client: Vue 3.5 with Vite, Pinia for state, vue-router, vue-i18n for interface language, PrimeVue with Tailwind for components, Zod for schema validation,
@iconify/vuefor icons. - Server: ElysiaJS on Bun, Prisma against Postgres, with OpenAPI and CORS.
- TypeScript throughout; Bun as package manager and runtime.
- Tests: Vitest with
@vue/test-utils, and Playwright via@vitest/browser-playwright.
Two capabilities the design depends on are not present in that stack today and are new work: a service worker and precaching for offline operation, and text recognition for Identification. QR decoding is available: jsqr was installed on 2026-09-09. The spec previously said it was already a dependency and it was not (D-105).
vue-i18n localises the interface, never the Corpus. Curated Translation is Corpus data carrying its own Provenance and its own reachable original (D-11); it must not be folded into interface message catalogues, where it would lose both.
A design source is ranked, and it binds one layer only (D-142). Colour values, spacing, surface treatment, card, border and radius are taken from the design extract; everything else — wording, network behaviour, flow, what a screen may contain, and language — is settled by the rulings, which win on contact. The extract has lost to a ruling four times in practice: it links a font host, which the offline ruling refuses; it labels the Conversational surface online-only, which D-94 reversed; it glosses chrome strings in English, where English is preserved for chemical identity alone; and it routes two Home tiles straight to a record, where both routes require a Chemical Record id. A claim about the extract cites the file it was read from, the way a claim traced to the source PDF cites a page (D-147). One consequence is mechanical: a meaning has one palette, so the alarm and caveat colours are named tokens and an ad-hoc colour reaching a safety surface is a second palette (US-72).
The bound list grows by two, and both additions are token sets (D-158, D-159). The type scale — display, heading, subheading, body, caption — and motion — two durations, one easing curve, and three uses: hover, active, and the transition between routes — are defined once in @theme beside the palette, and a surface names a token rather than a raw Tailwind size or duration utility. The extract binds both the way it binds colour, and loses to a ruling on contact the same way; fonts stay self-hosted, which e2e/SelfHostedFont.spec.ts already asserts. Each set is policed by a source sweep in the shape of OnePalette.spec.ts, so a raw utility fails a build rather than a review.
Every interactive control comes from one ported set (D-160). src/volt/Button.vue and its variants are ported from the work-permit frontend, where this repository's Card.vue and Toast.vue already came from; src/components/base/BaseButton.vue wraps it with a variant of primary, secondary or danger and is what a surface calls. src/components/base/BaseTab.vue and BaseTabWindow.vue are ported beside it, ?tab= query sync included, so an open tab is linkable and survives a reload. Both ports are behavioural: their hardcoded bg-white, border-primary and --p-gray-4 are retinted to this application's tokens on arrival, because a second palette is what D-142 exists to prevent. A raw <button> or a hand-rolled tab strip on either build — the Handler's or the Curator's — is a defect, asserted by a source sweep rather than by review.
2. Client and server split
There is now a server, which there was not when D-06 was written. The split is fixed by the rule that no safety read touches the network.
Server (Elysia, Prisma, Postgres) holds:
- The Corpus of record — Chemical Records, Source Spans, SDS Sections, Provenance, Curated Translations, and the stored selections described in §5.
- The sync endpoint the client pulls a Corpus snapshot from, versioned so a client can tell whether its copy is current. A published snapshot names the documents it covers (D-122), carries the Curated Explanations that existed when it was published (D-123), and is serialised in the shape the device stores, with every per-field value — spans, caveats — decided on the server and none lost on the way (D-124). A check crosses the two repositories to hold that contract. A published version is the payload the gate passed, stored at publish and served as it is: the pull reads nothing live, so no write made after a publish reaches a device unchecked (D-127).
- The Conversational surface's backing service, which requires a network by definition (D-06). It receives Query Intent requests only (D-69): the user's question in, structured intent out. It is not built until the Study Area's Corpus exists (D-126): with no Corpus every question is Corpus Unavailable before intent is consulted, so an endpoint today would only spend and send questions away. Its price, cap and limit are taken from that day's facts. That "only" is now literal. Term explanation used to be an exception to it and is not one any more — an explanation is a Curated Explanation held on the device (D-90), so nothing but intent crosses and no text the app displays is generated by a model (D-91). It is not given Source Spans, SDS Sections, Curated Translations or documents to read, and history is never placed in a prompt (D-09). It calls
gemini-3.8-flashon the provider's paid tier (D-75), holds the provider key server-side and never in the client (D-78), and is bounded by a spend cap the owner sets (D-77) and a per-address rate limit (D-78) — the endpoint is unauthenticated by construction, because D-60 removed accounts. The cap is the control that bounds exposure, and it fails into a tested state: reaching it disables the tier and Routing serves the Floor. The per-address limit is a fairness and accident guard — it catches a client stuck in a retry loop, not an attacker — and its bucket is sized for a shared egress, because The Plant's devices sit behind one and a limit tuned as though one address meant one person would lock out the whole site during the needs study (D-103). Submitted text must not be used to improve the provider's products; zero retention was sought, found unavailable, and withdrawn (D-76) — see Declared Gaps for what remains exposed. - The original SDS documents, kept as files so a Source Span can always be traced to the document it was drawn from.
Client holds a complete copy of the Corpus in IndexedDB, with the application shell precached by a service worker.
What syncs: the Corpus snapshot, one direction, server to client, on demand and opportunistically when a connection exists. Nothing about a person's use is sent back — history is device-local (D-09).
What never crosses the network at read time: every safety surface. Identification, Chemical Record, PPE, Spill Response, First Aid and storage guidance are all served from IndexedDB. A request in any of those paths is a defect, and Testing Decisions treats it as one.
Cache correctness matters more here than in an ordinary web application. A stale Corpus is a wrong safety answer, so the last-updated indicator (US-30) is a safety feature rather than a convenience, and Corpus replacement is atomic — a partially applied sync must never be readable.
3. Identification
Identification has three peer routes (D-62). None is the fallback of another, and none is presented as what happens when something else failed. All three are reachable from Home at the same level:
- QR label, where The Plant has applied one. Available on this stack today via
jsqr. - Camera text recognition of the printed container label. It targets the CAS Number first, where one is printed, and the product name otherwise (D-64). Not "chemical name": a Formulated Product has no chemical name, and those are the majority case on a production floor.
- Manual search, always available, reachable without first attempting a camera read.
The earlier ranking that placed QR beneath camera text is superseded (ADR-0030). It ranked the free and reliable route below the one this platform performs worst, and the authoritative source itself treats them as peers — its caption reads วางฉลากหรือ QR Code ให้อยู่ในกรอบ, place the label or QR code in the frame.
Camera routes use getUserMedia. Text recognition in the browser is a WASM library and is materially less accurate than the on-device recognition assumed when the platform was native — see Risks item 1. The QR route is unaffected by that weakness, and manual search is unaffected by any of it; between them they mean no single fragile capability decides whether the product works.
Identification returns candidate Chemical Records for confirmation rather than selecting one silently, so that a near-miss between similar product names is caught by the person holding the container. Where nothing matches, the outcome is stated plainly rather than approximated.
Where a name matches several products, the app presents a chooser listing product and supplier, and never guesses (D-46). This is not a rare case: The Plant's binder holds several suppliers of the same solvent, and their SDS genuinely disagree — so choosing for the user would mean showing advice from a drum they are not holding.
Barcode resolution is not attempted; no public mapping from barcode to chemical exists.
4. Offline operation
D-06 stands unchanged: the whole safety Corpus is available with no network. On the web this requires three things, all new work:
- A service worker precaching the application shell, so the app starts with no connection.
- The Corpus in IndexedDB, complete rather than partial. A recency cache is explicitly not sufficient — the emergency case is the unfamiliar chemical, which is exactly what a recency cache does not hold.
- Installability, so the app can be added to a device's home screen (US-32) and is present before it is needed.
Offline reliability on the web is weaker than the native assumption ADR-0005 was written under: storage can be evicted under pressure, installation is a per-person action that may not happen, and a browser may discard a service worker. This does not weaken D-06 as a requirement; it means D-06 needs verifying rather than assuming. See Risks item 2.
When the Corpus is not there, the app says so — and Corpus Unavailable is not Not Stated (D-72). Corpus Unavailable is one of four reasons the app cannot answer, and it says which (D-85, D-86, D-87): the SDS was read and is silent (Not Stated); the Corpus holds no record for this chemical (Not In Corpus); a record exists but the question falls outside the fields it curates (Outside The Record); or this device holds no verifiably complete Corpus. They are four components and four sentences, never one. Collapsing any two is the defect ADR-0032 was written for, and it recurred in the vocabulary a day after that ADR landed.
A snapshot-version sentinel is read on every safety read (D-88). Version record present and the chemical absent means Not In Corpus; version record gone means the store was evicted underneath a running app and the answer is Corpus Unavailable. One key lookup, and the two stop being indistinguishable — startup verification alone left an evicted store answering for the rest of a session, which on a floor tablet left open for days is the realistic case rather than the exotic one.
The client verifies its Corpus against the snapshot version at startup, and anything that does not verify is treated as absent in full: not a smaller Corpus, not a Corpus with holes (D-73). Atomicity already prevents a half-applied sync from being readable; this covers eviction, which no atomicity rule reaches.
The distinction is the whole point. Not Stated says the SDS does not say. Corpus Unavailable says this device consulted no document. An evicted store that answers Not Stated is telling a Handler the supplier was silent on gloves when the truth is the document is gone, and it does so with the authority of a curated answer — the recency-cache failure above and the ADR-0002 failure arriving together through storage. Corpus Unavailable names what is missing, when the Corpus was last complete, and what to do.
Escalation survives an empty Corpus (D-74). The Curated Escalation and the D-07 standing statement are not Corpus reads, so they render on a device holding nothing. A phone number is more use during a spill than a blank screen.
The startup verification has a visible state, and its content is a closed set (D-141). While the app reads the snapshot-version sentinel it shows its own name rather than nothing, so a slow start on a floor tablet reads as a slow start rather than a crash (US-70). What may appear there is closed: the product's name, the ground colour, one non-interactive mark, and the line saying the app works offline and needs no account. What may never appear is equally closed — no spinner, dots, progress bar or percentage; no account affordance of any kind; no version, build id or third-party branding; nothing tappable at all (US-71). Adding a member is a change to the ruling, not a feature. The no-spinner member carries the same reasoning as D-03: an app that teaches people to wait for an animation has taught the wrong habit, and the habit would start on the first screen.
First Run is a fact about the device's own storage (D-140). The app records that it has stated the Standing Statement, and every failure to read or write that record — a cleared store, a private window, storage disabled, a call that throws — is treated as a First Run and the statement is stated again (US-69). The opposite default would let a browser API failure suppress a disclaimer, which is the failure D-07 exists to prevent arriving through the storage layer. Being told twice costs a tap. Nothing about First Run is sent anywhere and no safety read consults it.
5. The Corpus, and Curation as the place judgement happens
The Corpus is the set of SDS documents The Plant itself holds, obtained as files and admitted by Curation (D-14).
Curation is where every irreversible judgement in this product is made. That is the single most important structural fact in this spec. The runtime displays a curated record and decides nothing; anything requiring a human, a model, or a call that could be wrong happens once, at Curation, under review. The runtime is deliberately dumb, and that is what makes offline operation possible at all.
Curation performs the following, in order, for each document:
Provenance. Supplier, revision date, and how the copy was obtained from The Plant, recorded for every document.
Text verification against the rendered page. Extracted text is never trusted (D-56). A document is verified whole before anything is selected from it, and a span carries one page's verified text, never raw extraction (D-119). Thai SDS text layers are corrupt in practice — a spike measured one document extracting zero correct ำ against eighty-seven broken — and the corruption is silent: a dropped vowel changes a word without looking wrong. Every extraction is checked against the rendered page image before it can become a Source Span. This replaces the translation-review saving that preferring Thai documents was expected to deliver; the cost does not disappear, it changes shape (D-57).
Selection. Choosing which Source Spans a Chemical Record shows is itself a Curation act (D-50), and the record's identity is read from its document rather than supplied with the selection (D-120), not something the runtime does at the point of read. What ships in the Corpus snapshot is the selection already made and already reviewable.
Reconciliation. A Restriction anywhere in the document that bears on a span about to be shown must be resolved before that span enters a Chemical Record (D-44). The scope is the whole document, not one SDS Section. A cross-section detector supports this by flagging materials and entities named in a candidate span that also appear in restriction, exclusion, incompatibility or negation language elsewhere in the document — but it is a curator's tool and never runs at display time (D-49). It surfaces candidates; it does not resolve them. Resolution is a human judgement, and the reconciled content, including any caveat attached, is what the Corpus stores.
The reason this cannot be mechanical: a storage incompatibility is not automatically a prohibition on a splash glove. A real document recommends nitrile gloves in Section 8 and forbids storing with nitrile rubber in Section 7. Those may both be correct. Only a Safety Officer can say.
Curated Translation. Where an SDS exists only in English, Curation stores a translation reviewed by a person, and the original-language span remains reachable from it (D-11). No translation of Safety-Critical Content happens at runtime (D-12).
Where Curation runs (D-110, D-111, D-112). Only on the curator's own machine: the API mounts its Curation and upload routes solely on a loopback bind, and the curator's pages exist only in a separate curation build, never in the one that ships to Handlers. The bind address is the whole of the access control, so no credential exists to leak or rotate — which is what D-60 asks of a product with no accounts. Page images for text verification are rendered in the curator's browser with pdf.js and never stored. A refused admission reuses the file it already uploaded, for that picked file only, so no stored SDS is left without a document; no object delete is mounted.
QR label generation. Curation can produce a printable QR label for a Chemical Record (D-63), so The Plant can label its own containers. This is what makes the QR Identification route real rather than theoretical. It is curation-side scope, not app scope. The payload is csa:chemical-record/ followed by the record's label key — a database-generated UUID, never the row number, so a label printed from one store scans to Not In Corpus rather than to a different chemical in another (D-117, D-118). Anything without the prefix is an unrecognised label, and a label carries identity in words beside the code and no safety content in either.
Chemical Record identity
A Chemical Record is identified by product name, supplier, and revision date where the document states one (D-18, D-51). CAS Number is an optional attribute carried by a Pure Substance, never the key: a Formulated Product has none, and on a production floor those are the common case.
Revision date is optional, and its absence is displayed rather than filled in (D-51). A document that states no revision date — one of three in the spike carried none anywhere in eight pages — produces a record showing revision date not stated, and its staleness indicator reads unknown. Substituting the Curation date or a file timestamp is forbidden: it fabricates Provenance and displays it with the confidence of a real value. This is the Not Stated principle (D-03) applied to metadata. The cost is that two revisions of an undated product collide on the same identity; the curator must notice, because the product cannot.
Two revisions of a dated product are two records, which also supplies the staleness signal sync needs.
Two records for the same chemical are never merged
The Corpus may hold several SDS for what a user would call the same chemical, and they may disagree — one supplier's sheet forbids storing Acetone with nitrile rubber while another's recommends nitrile gloves. Neither is merged into the other and no canonical record is designated (D-45). The governing SDS is the one for the product the user is actually holding, which is why Identification reads a product label and why an ambiguous name search presents a chooser (D-46).
Merging is rejected for a specific reason worth restating: a union of two suppliers' advice is a document no supplier wrote and nobody is accountable for. In this exact case the conservative union of "wear nitrile" and "nitrile is incompatible" permits no glove at all.
The Corpus is therefore never deduplicated by chemical name.
Correcting what Curation already recorded
The Corpus is append-only, and until now that meant a selection made wrongly stayed wrong: there was no replace, no edit and no delete anywhere in Curation. Correcting one is now an act of its own, and it is a second act rather than an overwrite — the judgement it replaces stays readable, with the Curator and the date that produced it.
A selection is corrected by superseding the row that holds it (D-169). The unit is the attachment row — a curated field entry, a PPE item, a First Aid measure, a Spill Response branch or one of its steps — and never a Source Span, because a span carries no position and two of those rows carry two spans apiece. The replacement and the marker on the row it replaces are written in one statement, and the marker is two-sided: each names the other. A replacement states a changed passage as text, which becomes a new Source Span judged like any other, and names an unchanged one by id — and the only ids it may name are spans the replaced row was already carrying, so a correction costs the judgement of what actually changed and nothing more. A replacement that changes nothing is refused.
A selection is removed by retracting it, and the position it held stays vacant (D-170). Retraction is a separate act from supersession because removing content must never be mistaken for replacing it, and it is the only act in this product that takes content out of a Chemical Record. Ordinals are not renumbered: a branch whose second step is retracted runs 1, 3, 4, and that is a legal record.
Nothing cascades, and the gate that already exists does the work (D-171). A Source Span is never rewritten and never withdrawn, so a Restriction, a Curated Translation and every Reconciliation stay exactly as they were. Only a new span is unjudged, so only a new span opens a Reconciliation Gap — which the gaps read shows and the Corpus gate refuses to publish on, until a named Curator resolves it. A correction that trims a prohibition out of a displayed passage therefore cannot be quiet: the Restriction stays live and someone must say, on the record, whether it still governs what remains.
The position is released, and no constraint is narrowed (D-172). A superseded or retracted row's ordinal is set to null, which frees its position under the uniques already in the schema. Nothing is migrated to a partial index, because a uniqueness rule expressible only in hand-written SQL is one the schema gate cannot see.
Any Curator may correct any selection, and the act records who and why (D-173). A curator name and a reason, both required, both plain strings — the shape a Reconciliation already uses. There are no accounts, so this records who says they did it rather than who did, and no second person is required: a second name in an account-free service is a second string.
A corrected row never reaches a device (D-174). The snapshot serialiser excludes superseded and retracted rows before ordering, and a spec asserts the filter over the serialiser's own source, because twelve reads is exactly the count at which one omission stops being obvious. The payload's shape does not change.
Emptying a curated field is a claim about the document, and the Curator states it (D-175). A field holding no Source Spans renders as Not Stated, which says the supplier's document is silent. So a retraction that would leave its field empty requires an explicit assertion that it is, and is refused without one; supplying that assertion on a retraction that does not empty its field is refused too. Emptying a field wrongly filled from a document that says nothing is correct; emptying one the document does speak to is the product asserting a falsehood, and only the Curator can tell them apart.
Corrections chain, and the chain stays linear (D-176). Supersession is a field on the existing selection command, so a replacement passes the same verification and Reconciliation gates as any other selection; retraction writes no span, needs neither, and is its own command. There is no un-supersede: correcting a correction supersedes the row that is currently live. Refusing to supersede an already-superseded row is what stops two replacements naming one predecessor and leaving a position with two live rows.
Superseding a branch carries its steps, and the mutation list is closed (D-177). A Spill Response branch is the parent its steps hang from, so replacing one re-parents its live steps in the same statement — each keeps its row, its span, its ordinal and its judgements, and only where it sits changes. Retracting a branch retracts its live steps with it. The Corpus permits exactly two mutations of a stored row, releasing an ordinal and re-pointing a step's branch, and both record where a row sits rather than what it says. A third requires its own ruling.
A span named as kept and also stated as text is refused, under its own code (D-178). The keep list and the text keys are two ways to say the same thing, so a request can say both, and every way of resolving that quietly is wrong: taking the text discards an explicit instruction and mints a span nobody asked for, and taking the keep list discards a correction while reporting that it happened. It is not a blank field but two keys that disagree, so it names itself like every other rule.
A Spill Response branch is a container, and its stated condition is corrected on its own (D-179, D-180). A branch is not a selection: it may hold no Source Span at all — half the curation spike's sample states no condition, and both shapes are legitimate GHS (D-97) — and it owns the steps that hang from it. So the rule putting supersession on the selection command never reached it, and correcting a branch goes through POST /v1/curation/branch-supersessions, which names the branch by id, replaces the row, releases its position and re-parents its live steps. The body carries only the condition, because a branch's ordinal is where it sits and its steps are rows of their own; the condition may be stated, kept, or recorded as absent, exactly as a selection's spans may be. The command is named for the row rather than for the field so that nothing reads as an in-place edit. Its refusal table declares every code it can throw, borrowed ones included (D-181), which is what makes a code the API can emit one the contract and the curator surface both carry.
A branch's condition has two states, not three (D-182, D-183). It is stated, or it is recorded as absent. There is no carrying it forward: a branch's ordinal is where it sits and its steps move rather than change, so the condition is the only content such a correction can alter — and a replacement that keeps it has corrected nothing, which the command refuses. The three-state model belongs to the selection command, where a row carries values a correction may be about besides its spans; a branch carries none. The table is twelve codes, nine borrowed and three its own.
Two things this does not change. SPILL_BRANCH_CONDITION_CONFLICT stays: a curator may not alter a stored threshold by restating a different one on a step, and that refusal is what keeps the sanctioned path sanctioned. And dropping a branch's condition does not reach D-175 — a branch stating none still displays its steps, so the Spill Response field is not emptied and no assertion about the document's silence is involved.
A Curator reads what a record currently holds, and corrected rows are a count (D-184, D-185). GET /v1/curation/documents/:sdsId/record answers every live selection of one Chemical Record with the id a correcting act names, the field and position it occupies, and the Source Span it displays with that span's Section. The id is the point: every act above names a row, and before this read the only place an attachment row's id appeared was the response that created it — so the acts were reachable by the API and unreachable by a person. Superseded and retracted rows are reported as supersededCount and retractedCount and nothing else. A Curator is told corrections happened and how many; what a corrected row said is not on this surface, because a retracted passage rendered beside a live one is two answers on a screen shaped to be believed. Whether a surface should show a correction chain in full is not ruled — the audit trail is stored on the rows, and this read is not that surface.
Correcting a Curated Explanation, a Curated Translation or an Extraction Verification is not covered: those are keyed by an identity rather than a position, so releasing a position does not reach them, and each needs its own ruling. Whether a published version carrying content later corrected must be signalled to devices is likewise unruled — a correction rides the next publish and never rewrites a version already shipped.
The Curator's surfaces state what the server states
A page already verified is a coded refusal, and the client words it (D-161). POST /v1/curation/verifications answers a second verification of the same document page with a 409 carrying errorCode: PAGE_ALREADY_VERIFIED, joining the three curation routes that already carry codes. The Curator's Thai sentence is written from the code; message stays English developer text. The prototype showed why this is not tidiness: the surface printed the status number and then appended its standing sentence — that the page still counts as unverified — which is true of every other failure here and is the opposite of the truth for this one.
A document opens with the judgements it already carries (D-162). Opening a document reads GET /documents/:sdsId/verified-pages beside the Stored Copy and seeds each verified page read-only: the text the Corpus holds, whether it was corrected, and the Curator who verified it. The outstanding count is then true across a reload rather than true within one sitting. A corrected page's raw extraction stays unserved — it is the text the correction replaced — so the panel says a correction happened without showing what was wrong.
Closing a document is derived, and writes nothing (D-163). The control is enabled only where verified-pages answers selectable: true, and pressing it clears the document id field for the next document. No completion row is written: the gate already answers whether a document is fully verified, and a stored flag beside it is a second answer that can disagree. Where the gate refuses, its own refusal text is shown verbatim.
Stages link forward and none is locked (D-164). Verification links to span selection, span selection to reconciliation, each carrying ?sdsId=, so a Curator types a document id once. A forward control is enabled from that document's server-stated gate and shows the refusal verbatim when it is not. Every curation page stays independently addressable: the API enforces stage order — span selection already refuses unverified text — and a second enforcement in the client could disagree with it and would block returning to fix one span on a finished document.
Success may fade; a refusal may not (D-165). A recorded verification raises a toast. Every refusal keeps the persistent panel notice this surface already had, and may raise a toast as well — a refusal scrolled past must not be recoverable only from memory, and a faded "unreachable" is indistinguishable from a submission never made. handleLoading contributes the blocking overlay from the Loading store for an in-flight write, and nothing else here: its error path reads an axios-shaped e.response.data, and these resources deliberately use plain fetch and return a discriminated outcome rather than throwing. Do not make them throw to reach that path — returning the outcome is what keeps every failure reported to the caller and none swallowed. The refusal wording stays with the surface, so a coded refusal is never rendered as a generic failure.
Every published version is listed, and read from its own payload (D-167). GET /v1/curation/snapshots answers every snapshot in publish order — version, published at, document count, which one devices pull — and GET /v1/curation/snapshots/:version answers the documents that version shipped, out of that snapshot's stored payload rather than the live rows. The payload is what the gate checked and what devices received; the live rows are what the Corpus says today, and for any version but the current one those differ the moment Curation continues. Both routes are on the curation prefix and so are loopback-only (D-110).
Sourcing while access is pending
While access to The Plant is being secured, the pipeline is developed against a proxy set of public SDS for Acetone, IPA and Toluene (D-28). This is scaffolding. It must never ship as the Corpus (D-29) — see Declared Gaps for the proof test.
6. Surfaces and how they are reached
Home is the landing route. There is no login surface at all (D-60): accounts, cross-device synchronisation and saved favourites are out of prototype scope. Anonymous Use is not merely the default mode, it is the only mode, which makes the anonymous-first ruling trivially true rather than merely honoured (D-61). No safety capability requires an account because no account exists.
Home offers six entries, taken from the authoritative source (D-20): Identification by camera, search, PPE, First Aid, Spill Response, and the Conversational surface. First Aid is a surface of its own rather than a tab inside a Chemical Record, so that it sits at the same depth as Spill Response, which it matches in urgency (D-10).
One surface stands in front of the router, and it is not a route (D-138). On a device's First Run the app states the Standing Statement on a blocking First Run Notice, shown once per device and passed by a single tap. It is not routed, because Home remains the landing route (D-61) and a routed first-run screen would make that false on exactly the device where the claim matters most. It asks nothing, it offers nothing to sign in to, and it carries no account affordance of any kind (US-66, US-67) — not because it is inert, but because Anonymous Use is the only mode there is (D-60, D-61). It is deliberately tappable, which is what separates it from the startup state: US-71's "nothing to tap" is the Boot Splash's property in §4, not this surface's. Its counterpart, the same statement on every safety surface, is unchanged (US-43).
Home carries exactly six action cards, and an unbuilt route is absent rather than disabled (D-154, D-155). The six are Scan QR, manual search, PPE, First Aid, Spill Response and the Conversational surface, each a card with a bundled icon. Icon.plugin.ts already registers the offline entry point and the generated allow-list, which is empty because no surface renders an icon yet; these six are the first, so each name is written statically and bun run generate:icons is re-run — OfflineIcons.spec.ts fails a build on a stale allow-list or on a dynamic :icon binding whose name cannot be read at build time (D-06, D-39). Recently viewed and Settings leave Home for the Navigation Bar. Camera text recognition has no surface while Risks item 1 is open, and is shown nowhere rather than shown disabled: D-62's peer rule binds the routes that exist, and the camera card returns as a seventh when its surface is built. The six-entry list above (D-20) and this ruling agree on the count and disagree on the membership — this one is the operative one, and ADR-0076 states why.
Every Handler surface renders inside three Chrome parts (D-156, D-157). Topmost and unchanged, the full-width Escalation strip carrying ฉุกเฉิน · โทร 1669, which is never folded into the Appbar and never scrolls away (D-100). Beneath it the Appbar: back control where there is somewhere to go back to, surface title, Corpus freshness indicator. Beneath the page the Navigation Bar, carrying Home, Search, Recently viewed and Settings — destinations that need no Chemical Record, because PPE, First Aid and Spill Response each require a :chemicalRecordId and a persistent control that can only bounce to search is a control that lies about where it goes. No part of the Navigation Bar uses the alarm colour; bg-alarm stays spent on Escalation and on the First Run Notice's bar (D-139).
The eight numbered surfaces the source describes resolve to: Home, Identification by camera, Chemical Record, PPE, Spill Response, First Aid, history, and Settings. The source's login screen has no counterpart here; Settings takes its place in the count, holding the Corpus last-updated indicator and the standing statement rather than an account.
7. Presenting Safety-Critical Content
Every element of Safety-Critical Content is rendered from a stored Source Span, verbatim, displaying which SDS Section it came from (D-02). The full Section is meant to be reachable from any span (US-13), and nothing implements that yet: an SDS Section is a label a span points at, created for all sixteen GHS headings at admission, and holds no text (D-121). Nothing in this category is generated, rephrased, shortened or smoothed.
Extractive Summarisation (D-16) is how a forty-line Section becomes a readable card: the spans that matter are chosen and laid out as labelled fields, with their words untouched.
Selection is performed during Curation, not at the point of read (D-50). This is now a decision rather than an inference: what ships in the Corpus snapshot is the selection itself, already made and already reviewable. It is also forced by D-06 — the safety surfaces work with no network, so nothing that produces them may depend on a model call at that moment. The mechanics are in §5.
The restriction rule binds selection absolutely, and its scope is the whole document (D-44), not one SDS Section. Every span expressing a restriction, exclusion, incompatibility or negation anywhere in the document must be reconciled against a span before that span enters a Chemical Record. Reconciliation happens at Curation, by a human (§5).
The earlier within-Section form of this rule is superseded (ADR-0022), and why it failed is worth keeping visible: a real document carries zero restrictions in its PPE Section and forbids the recommended glove material three pages earlier, so a PPE card built from that Section alone satisfied the within-Section rule completely and still told a Handler to wear nitrile. Verbatim and sourced is not the same as safe — a selection can be entirely faithful in its words and lethal in its omissions.
Where an SDS names no PPE material, the generic span is shown verbatim and the absence is stated (D-52). A Section reading only "wear appropriate protective gloves and clothing" is displayed as it stands, with the app saying plainly that no material is specified. It is not approximated, and it is not supplemented from an external standard — that would admit safety content from outside the SDS, which is out of scope (D-53). The per-item PPE ideal the source mockup showed is an aspiration the data sometimes cannot meet, not a promise this product makes.
Where the Corpus does not answer, the surface says Not Stated (D-03). This is a correct outcome, must not be reported as an error, and must not be filled by the Conversational surface.
The Chemical Record tabs its reference fields, and emergency content stays flat (D-168). Above the tab bar and never behind a control: the Standing Statement, the record's identity, and the links to First Aid, Spill Response and PPE. Behind it: hazards, health effects, storage and handling, and the Source Span provenance — the fields read to understand a chemical rather than to act during an incident. The default tab is hazards and the open tab is in the URL as ?tab=. ADR-0008 rejected "a fourth tab on the chemical summary screen" because it buries emergency content one level deeper than the spill procedure it matches in urgency; that rejection is untouched here, because nothing emergency moved.
A Handler can open the original document where there is a connection (D-166). GET /v1/corpus/documents/:sdsId/original streams the Stored Copy's bytes on the prefix a device can reach, and the Chemical Record offers "open the original SDS" only while the device has a connection. With none, the control is absent and one line says the original needs a connection and that the safety content below is unaffected — never an error, never a blank, never a spinner. This is a network improving a surface, which D-94 permits; it is not a Safety-Critical read, so D-06 is untouched. The route issues no URL a third party can hold, which is D-110's reasoning applied outside Curation.
8. Emergency flows and standing
Escalation text is a Curated Escalation (D-95): written and reviewed by a named person at Curation and displayed verbatim with its origin visible — site procedure or Thai emergency services, not an SDS Section. It ships with the app shell, precached and versioned with it, rather than in the Corpus (D-98): the Corpus is evictable, and escalation must survive exactly the case where it is gone. Updating it is a deploy, not a sync.
Escalation is reachable from every surface at all times (D-100), and the Escalation Trigger is an accelerator rather than a path — a phrase it does not match costs a tap, not the call. The trigger's list is Thai and English; any list is incomplete, and a Handler reading neither must still reach the number. It is tuned for precision (D-115): it fires on something happening to a person now, never on the SDS's own vocabulary, and a fixture of routine questions must stay quiet — a false alarm teaches Handlers to dismiss the panel, which a missed phrase does not. On a match the user is handed to the Curated Escalation directly, needing no Chemical Record (D-99); identification is offered next and never required first, and routing continues so any span found appears beneath the escalation rather than displacing it. It stays Safety-Critical Content and is never styled as ignorable. The acts it may carry are a closed set: call 1669, evacuate, seek medical attention. It never carries a hazard, PPE, storage or first-aid-step claim, which still require a Source Span or are Not Stated.
Every piece of Safety-Critical Content now has a person accountable for it. This was the only text reaching a Handler mid-emergency that nobody had signed.
Until a site authority signs it, the Curated Escalation is hotline 1669 alone (D-113). The hotline is a fact about Thailand and needs no signature; evacuating and seeking medical attention are The Plant's own procedure and ship only under a named site authority. The build enforces it on content: any act other than the hotline without a named reviewer and a date fails (D-114), and "unsigned" is recorded as null rather than as a placeholder name. US-20's "when to evacuate" and US-50's evacuate-and-call point wait on that signature.
The hotline is persistent on both flows; the gate before step 1 is Spill Response only (D-96). Spill Response opens with the evacuate-and-call condition above step 1 — the hotline alone until that condition is signed (D-113). First Aid does not: its escalation is concurrent with the procedure, because Section 4's immediate action is the time-critical one and the call can be made alongside it. ADR-0006 argued the spill case and only the spill case; that reasoning was not re-run for exposure first aid until now. First Aid remains structured by exposure route — eye, skin, inhalation, ingestion — following SDS Section 4 (D-10).
Spill Response is a set of branches, not one procedure (D-54). Real SDS branch their spill guidance on a condition they state themselves — commonly volume, such as under or over 200 litres — and the branches differ in substance, with one carrying restrictions the other does not. Each branch is an ordered procedure whose order is semantic and is preserved.
The branch condition is not the escalation gate (D-97). They were bundled and are now separate, because they have different provenance. The branch condition is SDS content with a Source Span, it varies per document, and it is shown only where the document states one — the curation spike found one of two documents branching on volume and the other not branching at all, both legitimate GHS. The escalation gate is product behaviour: constant, authored, a Curated Escalation.
Where an SDS states no branch condition there is one procedure, no condition-derived gate, and no invented condition of any kind. The store records that absence as a null condition on a real branch, never as a blank and never by moving the steps out of the branch (D-116). Bundling the two made the app fabricate a threshold in order to keep a gate alive, and D-53 already forbids a curator supplying safety content the document does not carry — most of all this one, since D-54 says the branch condition decides which restriction applies.
A branch is never selected on the user's behalf. Auto-selecting the small-spill path drops the restrictions only the large branch carries, which is the omission failure of §7 in a different costume.
The app states that it retrieves SDS information and does not replace the SDS, site procedure or trained judgment (D-07). This appears at first run and remains present on safety surfaces, because the person reading is often not the person who installed it.
The First Run Notice carries an escalation control of its own (D-139). It stands in front of the router, so the layout's escalation bar is not on screen, and without a control the notice would be the one surface in the product where the always-reachable ruling (D-100) does not hold (US-68). The control is app-authored chrome — the same wording the layout writes — and it is not the Curated Escalation: rendering that artifact's text on a one-line bar would show it with no origin and no reviewer, which D-95 forbids. Instead the control routes to the Escalation surface, where the artifact is displayed with its provenance, and acknowledges First Run on the way so the notice is not left covering the surface the person just asked for.
Chrome and Conversational Content overlap rather than one containing the other (D-146): where chrome is something the app says it is also Conversational Content, and where it is a colour or a term matched against but never shown it is not. Chrome may never be model-generated whatever the rest of that category may be, and that property — not any containment — is what keeps this control honest.
9. The Conversational surface
Conversational Content — navigation help, explaining what a term means, rephrasing a question — may be generated (D-17). It is styled so that it cannot be mistaken for Safety-Critical Content.
The surface is a router, not an answerer (D-65). A question resolves to a Source Span shown verbatim with its SDS Section, to the chooser, or to Not Stated. The model decides where to point; it never writes hazard, PPE, storage, spill or first-aid text. ADR-0002 stands untouched and ADR-0031 records why retrieval is not a reopening of it.
Routing is two-tier (D-66). The Routing Floor is lexical retrieval over the IndexedDB Corpus and runs with no connection. The Query Intent tier turns the question into Query Intent — colloquial Thai to product name, an everyday word to the field that holds it — and that intent is fed into the same Floor index. With no signal the Floor answers alone, so the surface degrades rather than dies. This is a change from the earlier specification, in which the whole surface was unavailable offline; it became necessary once the surface was a route into First Aid rather than a glossary.
Query Intent is advisory (D-101). Its terms are added to the user's own and never substituted, so retrieval runs over the union and a term the model invented has to compete with what the person typed. A product name arriving from intent takes the same path D-68 gives any chemical name — manual search and the chooser — and never resolves straight to a record. Without that, a model returning a confident wrong product would designate a Chemical Record through the back door, which D-68 forbids on the surface. Malformed intent is discarded and the Floor answers on the raw question.
Only Query Intent crosses the network (D-69). No Corpus content leaves the device or the server. The prototype therefore uses no provider embedding API, and retrieval is lexical on both tiers — a deliberate quality ceiling, recorded in Declared Gaps.
Below the Routing Threshold the curated field is shown, not Not Stated (D-106, D-107). If the field holds Source Spans, the app is holding content about the thing that was asked — answering Not Stated would claim the supplier is silent about something on the device. The field is shown whole with nothing singled out; above the threshold the matching span is pointed at within it. The threshold decides whether to point, never whether to answer.
Not Stated is reached only by a curated field with no Source Spans, which is D-85's definition and the only case where silence is what happened.
The original rule read differently (D-67). Ranked retrieval always has a top hit and that is not a reason to show one; a wrong span arrives verbatim, cited and Section-numbered, wearing authority it has not earned. Under the threshold the surface says Not Stated and may name the SDS Section that would hold such an answer, without asserting its content. It may not fill a Not Stated. Not Stated must be reachable in no more taps than an answer. The threshold is set at Curation, not tuned at runtime.
Routing never designates a Chemical Record (D-68). A chemical name in a question is handed to manual search and D-46's chooser renders, by product name, supplier and revision date. The surface is a query entry point into an existing route, not a fourth Identification route, so the three peer routes of D-62 are unchanged. Where the user arrived from a Chemical Record, Routing is scoped to that record and says which.
Emergency handling runs ahead of the network (D-70). A curated Escalation Trigger phrase list is evaluated in the browser before the Query Intent tier is contacted, so hotline 1669 never waits on a signal that the problem statement says is frequently absent. Routing hands the user to the branch condition gate and never past it, pre-filling nothing — not spill volume, not exposure route — even where the question stated one (D-55).
Routing resolves to Source Spans — one singled out inside its curated field, or that field shown whole — to the chooser, or to Not Stated, Not In Corpus or Outside The Record (D-89, D-106). Corpus Unavailable is not among them — the Corpus being gone is not a fact about the user's question, so it pre-empts this surface as it pre-empts every other one.
Term explanation remains part of this surface and is no longer generated. An explanation is a Curated Explanation, written and reviewed by a person at Curation and held on the device (D-90), so it is displayed extractively and works with no network. The previous ruling made the surface a confused Handler reaches for the one that died exactly when the network did.
10. History and Settings
History is device-local, timestamped, and works under Anonymous Use (D-09). It is never sent to the server, and there is no account it could be attached to (D-60). Settings holds the Corpus last-updated indicator and the standing statement from §8 — no login, no synchronisation, no saved favourites.
Testing Decisions
What this spec claims is installed (D-105). Every package named here is verified against the app manifests by the STACKCLAIMS gate. Anything in D-38's chosen stack that is not listed — Zod and vue-i18n today — is a plan rather than a present fact, and the prose says so rather than saying "already in the stack".
stack-claims
frontend: vitest
frontend: @vue/test-utils
frontend: @playwright/test
frontend: happy-dom
frontend: jsqr
frontend: pdfjs-dist
frontend: qrcode-generator
frontend: vite-plugin-pwa
frontend: pinia
frontend: vue-router
api: prisma
api: elysiaThis block exists because the spec asserted four packages were already dependencies and three were not — including the tools Seam 2 and Seam 4 name as their mechanism.
A good test here asserts an external, observable property and does not reach inside the thing it tests. The properties worth guarding are the ones that are safety-critical and cheap to check mechanically — and one of them is both.
Seam 1 — the Corpus artifact (highest, and the one that matters)
A curated Corpus is data. It can be asserted over without a browser, a camera, a model, or any part of the interface. This is the highest seam in the product and almost everything worth guarding sits here.
Reconciliation coverage is the primary assertion (D-44). For every stored selection, compute the restriction-bearing spans present anywhere in the whole document — not merely in the Section the selection was drawn from — and assert that each one has been reconciled: either carried into the record, or explicitly marked resolved-as-not-applicable by a named curator. An unreconciled Restriction is a build failure. It fails loudly, naming the document, the Section the Restriction sits in, and the span it bears on.
Whole-document scope is the point of this assertion. The within-Section form it replaces would have passed the real document that motivated the change, because that document's PPE Section contains no Restriction at all and the contradicting one sits three pages away.
This is the single most valuable test in the project. The failure it catches is invisible by inspection: output that is entirely verbatim, correctly sourced, and wrong because of what is absent. No amount of interface testing finds it.
Note what this test can and cannot do, because the distinction is the whole reason Reconciliation is human (D-49): it asserts that a Restriction was considered, never that it was resolved correctly. Judgement is not mechanically checkable; its presence is.
Further assertions at the same seam, all pure data properties: every Safety-Critical element carries a Source Span naming its SDS Section; every document has complete Provenance; every Curated Translation has a reachable original; no Chemical Record shares its product name, supplier and revision date with another; no record carries a revision date its document does not state (D-51); no two records have been merged — every record traces to exactly one SDS (D-45); and every Spill Response branch preserves the step order of its own branch (D-54).
Zod is part of the chosen stack (D-38) and is not yet installed; it is the natural expression of the Corpus snapshot's shape, so the schema the client validates on sync is the same one the checker asserts against.
Prior art is in this repository. scripts/coverage_check.py and scripts/adr_status_check.py are exactly this shape — data-level assertions with a self-test, wired into init.sh as named gates that run unconditionally and report together. A Corpus checker belongs beside them as a further gate.
Seam 2 — Chemical Record presentation
Given a Chemical Record, the presentation layer produces the fields a surface displays. Asserting here — below the interface, above the store — covers the rules that decide what a person sees: that every Safety-Critical field arrives with its Source Span, that Not Stated appears where the record is silent, that Escalation precedes procedure in both emergency flows, and that First Aid is grouped by exposure route. Vitest with @vue/test-utils is the home for this, and it runs: the first component tests in this repository were written on 2026-09-10, having been specified long before anything could mount a component (D-105). The assertions that earn their place are the negative ones — no digit reaches an undated identity, no glove-material word reaches a generic PPE span, and each no-answer state renders its own component while the other three do not.
Three further properties belong here because each is a rule about what reaches a person:
- A record whose document states no revision date renders revision date not stated and an unknown staleness indicator, never a substituted date (D-51).
- A PPE Section naming no material renders the generic span with the absence stated as a fact about this document (D-52, D-108), never an approximation and never a conditional note about what the app does when a document is silent. A span that does name a material must produce no absence statement — the assertion the earlier test could not make, because the note it checked was true either way.
- Where an SDS states a branch condition, Spill Response presents it before step 1 of any branch and no branch is reachable without answering it (D-55). Auto-selection is a defect, and this is where it is caught.
- Where an SDS states none, one procedure is presented and no branch condition appears at all (D-97). This is the assertion that catches an invented threshold, which is the failure that went unnoticed while the branch condition and the escalation gate were one object.
- First Aid presents no gate before step 1 (D-96); its escalation is concurrent.
- Escalation renders on every surface with the Corpus removed (D-98, D-100), because it ships with the shell rather than the Corpus.
- With the Escalation Trigger disabled entirely, escalation is still reachable from every surface in one action (D-100). This is the assertion that keeps the trigger from quietly becoming the path, and it is the one that would have caught a phrase list carrying a person's route to a phone number.
Seam 3 — Identification
Given a label image or a search term, Identification returns candidate Chemical Records. Fixture inputs covering a printed CAS Number, a QR label, a Formulated Product with only a product name, a damaged label, and a label matching nothing exercise all three peer routes in §3 including the no-match outcome, without a camera.
One further assertion belongs here: a search term matching several products returns all of them for the chooser and never collapses them to one (D-46). Silent collapse is the failure this guards, and it would be invisible in the interface — a single confident result looks exactly like a correct one.
Seam 4 — no surface touches the network
The client/server split in §2 makes a claim that is mechanically checkable: with the Corpus present, exercising every surface must produce zero network requests (D-94). The single exception is the Query Intent tier, which is optional by D-66 and asserted dead by Seam 5 — so the assertion is "zero requests with that tier disabled", which is narrower to state than the carve-out it replaces. Seam 4 runs. A Playwright harness builds the app and serves dist/ — the built shell, because the service worker only exists there — seeds a Corpus into IndexedDB including the snapshot-version sentinel (without it every read is Corpus Unavailable and the gate is vacuous), and fails the run on any observed request. It exercises Home, search, the Chemical Record surface, QR identification, First Aid and Spill Response — both built, both made to render curated content from the seeded Corpus, with the branch condition answered so the steps behind the D-55 gate render too — then History, Settings and the Conversational surface, and finally PPE, which is the one placeholder route left. A separate case removes the Corpus entirely and asserts that Escalation still renders and still asks nothing of the network (D-74).
This paragraph said "the four placeholder routes … the day a surface is built behind them" until 2026-09-20, long after three of the four had surfaces behind them. A testing section that describes a repository the repository no longer is will be read as current by the next person who plans work from it.
Seam 4 also covers the boot path, and no seam is added for it. The First Run Notice and the startup state are app-shell behaviour, which is already this seam's subject, and it is the highest seam available — it drives the built shell with a real service worker, which a unit spec cannot see.
Most of the harness states First Run as a precondition, marking the device as told, because the zero-request claim is about a device in use rather than one being unboxed. Two cases deliberately do not, and are the boot path: a device on its First Run is shown the statement and reaches Home in one tap, and the notice's escalation control lands on the Escalation surface with the artifact's origin beside it — both with the request count still zero (US-66, US-67, US-68). Playwright gives each case a fresh context, so the absence of the acknowledgement is what makes the device virgin; adding it would silently turn these into duplicates of the cases above.
The one-palette consequence of D-142 is asserted over the source, not over a rendered page or the built output: a class name either appears in a file or it does not, which is the device CuratorSurfaceIsolation already uses for properties with no runtime behaviour. It sweeps src/ for Tailwind's own red-* and amber-* colour utilities outside a reasoned allow-list — the component library's wrappers, the curator's build, and the closest-match highlight, which is not the caveat meaning. It exists because this rule had been written down and was false: main.css claimed alarm covered the Escalation panel while that panel still wrote raw red-*, and three review rounds read the claim without checking it.
The closed content set of the startup state (D-141) has a mechanical assertion: BootSplashClosedSet.spec.ts sweeps SplashScreen.vue's source in the same shape as the palette sweep above, in both directions — the four permitted members are asserted present, and each of the four forbidden categories (tappable, loading affordance, account affordance, version or third-party branding) is asserted absent, with a guard case per category proving the pattern still matches a snippet shaped like the thing it exists to catch. What it cannot see — a class assembled at runtime, an element a script inserts outside the template — is stated in its own docblock rather than implied away.
No exception is encoded for the Query Intent tier and no allowlist exists. There is nothing to disable yet — the Conversational surface is a placeholder — and a flag nothing reads would look like a control without being one. The expected count is an unconditional zero; when the tier is built it is disabled in the harness, never named in an allowlist, because an allowlist is how a second exception arrives without anyone deciding to add one. This is the test that stops D-06 eroding one convenient fetch at a time — the most likely way offline operation dies on a web stack.
Seam 5 — Routing resolves to the right place, or to Not Stated
Seams 1 to 4 do not catch a router that returns the wrong span. The Routing Fixture is a curated set of Thai and English questions, each with one exact expected outcome — a named Source Span, the chooser, or Not Stated (D-71). Every case must pass. No case states a percentage or a threshold, so D-30 holds.
The fixture runs against the Routing Floor, deterministically and offline, in CI. The Query Intent tier is exercised with recorded Query Intent rather than a live provider, so the assertion is that a given intent routes correctly and not that the provider is having a good day. Every case must also pass with the Query Intent tier dead, which is the two-tier promise of D-66 made mechanical.
Each case's expected outcome is one of five (D-89), and a case can now fail for saying the wrong true thing rather than only for saying something false.
Negative cases are the valuable half and are mandatory: a chemical outside the Corpus resolves to Not Stated; a question the SDS never answers resolves to Not Stated; and a chemical name held by two supplier records resolves to the chooser and never to a span, which is the D-45 regression test.
Not Stated may not stand in for any of the other three. A fixture case whose chemical is not in the Corpus must resolve to Not In Corpus and must not resolve to Not Stated; a case whose question falls outside a record's curated fields must resolve to Outside The Record. These are the regression tests for D-85, and they exist because the glossary and ADR-0032 defined Not Stated incompatibly for a day and Seam 5 mandated the wrong reading.
A sub-threshold match inside a populated field yields that field, never Not Stated (D-106). This is the regression test for the third route by which a false claim of silence reached a user this week — after storage (ADR-0032) and vocabulary (ADR-0036).
Three cases exercise the tier being wrong (D-102), which the fixture previously never did — it tested the tier recorded and the tier dead only. Intent naming a different product than the user typed must reach the chooser or Not In Corpus and never a span; malformed intent must produce the identical outcome to the tier being dead; intent naming a product outside the Corpus must yield what the raw question alone yields. Intent is allowed to change outcomes, which is what the upgrade is for — the boundary it may never cross is selecting a Chemical Record the user's own terms did not name.
One case asserts the absence of an answer rather than its presence. With the Corpus removed, every safety surface must report Corpus Unavailable and none may report Not Stated (D-72). It is written as a prohibition, not as an expected value, because the defect it guards against is the two states being collapsed into one component — at which point every other fixture case still passes. It is the companion to Seam 4: Seam 4 asserts no request is made when the Corpus is present, this asserts no answer is fabricated when it is absent.
Offline as a whole-application property
Beyond Seam 4, offline behaviour is verified by loading the app with the network disabled after a first visit and confirming that every surface renders and answers — the Conversational one included (D-94). This is checked, but it is not a unit.
The earlier version of this check asserted the opposite: that the Conversational surface "reports its own unavailability". It is recorded here because it is the clearest illustration of what stale acceptance criteria cost — a correct implementation would have failed it, and the fix people reach for when a suite fails correct work is to change the code.
No performance assertions
No test states a number, and no acceptance criterion in this spec does either (D-30). There is no measured baseline to hold a threshold against, and a threshold invented now would be something the final report compares itself to dishonestly. Performance criteria are added after the needs study measures current practice (D-31).
Out of Scope
- Native mobile applications of any kind, and iOS-specific native work. The prototype is a web application; there is no native client.
- Live integration with a plant chemical-management system (D-15). Recorded as production scope. It presumes a plant IT system, an access owner and a stable interface, none of which this effort controls.
- Per-role interfaces (D-22). One interface serves all four roles. The roles decide what content matters and who is interviewed; they do not fork the surfaces.
- Performance targets (D-30). Not until a baseline exists.
- Runtime translation of Safety-Critical Content (D-12), in any form, for any reason, including for a chemical not yet curated.
- Generated Safety-Critical Content (D-02), including a generated summary displayed beside a verbatim span.
- Accounts, cross-device synchronisation and saved favourites (D-60). There is no login surface. History and preferences are device-local. This is a deliberate cut, not an omission: nothing is gated, the Corpus is identical for every user, and an account would buy a prototype user nothing while costing auth, sessions, a sync protocol and conflict resolution.
- Safety content sourced from outside the SDS (D-53). A curator may not supplement a generic PPE span from an external standard, however much more useful the result would be. If that is ever wanted it needs its own provenance channel and its own decision, never a quiet habit.
- Barcode resolution (D-05).
- Baseline and needs-study fieldwork (D-31) — a study activity that informs this spec's later revisions, not a build deliverable.
Declared Gaps
D-01 requires every stub to be named rather than silently omitted.
- The Corpus is not yet The Plant's. Development runs on a proxy set of public SDS (D-28). The proof test is น้ำยาล้างชิ้นงาน, the parts-cleaning solution: no public source holds the SDS for the specific blend The Plant buys, so a Corpus containing it is demonstrably The Plant's and a Corpus without it demonstrably is not (D-29). Any demonstration running on the proxy must say so aloud — every surface keeps working, so the substitution is invisible.
- No automatic Corpus updates. A revised SDS is not noticed until someone re-curates.
- Coverage is bounded by the Study Area, not guaranteed. Inside it, coverage is complete and a miss is a finding (D-79). Outside it a chemical has no Chemical Record and the app says so — as Not In Corpus (D-86), which is a statement about what was curated and never about what an SDS says.
- Text recognition is a new dependency, and an unproven one on scuffed labels — see Risks item 1. The QR route (D-62) exists partly so that this gap cannot sink Identification alone.
- Service worker, precaching and installability are new work on this stack — see Risks item 2.
- Curation is expensive, and the measured figure is a floor. Half a day to a full day of human effort per document, review and reconciliation dominating (D-58). The measurement came from a proxy sample of tidy, born-digital manufacturer sheets; The Plant's binder will contain scanned pages, older formats and formulated products, so the real figure is higher (D-59). Whatever the Study Area turns out to hold is weeks of human effort at that rate. The lever is not a document count — ADR-0034 withdrew that framing (D-79) on the grounds that documents chosen to fit a budget produce a study that measures emptiness. If the cost is unaffordable the question is which area, not how many sheets, and it is an owner's decision either way rather than something to absorb by rushing review.
- Zero retention was required, is not available, and was withdrawn (D-76). ADR-0031 asked the provider to retain nothing and train on nothing. The no-training half stands and the paid tier meets it; the free tier does not, which is why it is excluded (D-75). The zero-retention half is unmeetable — it exists only under enterprise contractual terms, a company-only resource under D-01. What remains exposed: the text a user typed, carrying no identity (D-60), no history (D-09) and no Corpus content (D-69), retained briefly by the provider for safety and compliance and not trained on. "สารเคมีเข้าตา" is a sentence about a person's eye, which is why this is written down rather than assumed away. If consent becomes a question during the needs study, the answer is here rather than constructed under pressure.
- The spend cap number is the owner's and is not set here (D-77). Measured expectation is about $4.50 across the prototype at
gemini-3.8-flashpaid-tier rates read on 2026-09-08 — $0.75 and $3.75 per million input and output tokens, rising to $1.50 and $7.50 on 2027-01-01. Those are dated facts; re-read them before quoting them. Reaching the cap disables the tier and the app serves the Routing Floor, which is the same state as no signal. - Routing quality is bounded by lexical retrieval. D-69 rules out a provider embedding API, so both tiers retrieve lexically. Not Stated will be returned more often than a generative chatbot would return it. That is intended behaviour under D-03 and not a defect to tune away: the rate is a finding for R-24's surviving study objective, the problems and needs of workers accessing chemical safety information, because it measures the gap between what a worker asks and what the Corpus holds (D-93). It is not evidence that extraction works — the objective that would have supported that reading was deleted by R-24 and must not be cited.
- Five Curation artifacts sit beside the per-document cost, and three of them are not what their label says (sizing note, 2026-09-10). The Routing Threshold, the Escalation Trigger, the Routing Fixture, the Curated Explanation glossary and the Curated Escalation were each introduced as costing something "once per Corpus". Only the Escalation Trigger and the Curated Escalation behave that way. The Routing Fixture scales directly with corpus size — its mandatory negative cases are about Corpus contents — the Curated Explanation glossary scales partly, and the Routing Threshold needs revalidation whenever the Corpus changes. So ADR-0028's "corpus size is the schedule" is more true than this bucket suggested, not less, and needs no restating. No hours are stated because no spike has measured any of them and D-30 forbids inventing one; the note says what a measurement would take. Two of the five also need a site authority rather than a curator, which puts them with the access blockers rather than in a schedule this project controls. →
.scratch/chemical-safety-assistant/once-per-corpus-sizing.md - Three new Curation artifacts, none of them per-document. The Routing Threshold, the Escalation Trigger phrase list and the Routing Fixture are each a human judgement made once under review. They sit on top of the half-day-to-a-day per document of D-58 and were not part of that measurement.
- The Study Area is not chosen, and The Plant has not supplied its chemical list (D-79, D-80). The criterion that chooses it is ruled: the area that uses น้ำยาล้างชิ้นงาน, the proof chemical, with ties going to where the needs study can recruit (D-125). The first question for The Plant is which area that is. Corpus size is now scoped by area rather than by a document count, which makes the list the sizing artifact — and the list is a smaller ask than the binder, answerable by a supervisor with no document handover. Until it exists, the Corpus has no size. D-58's half-day-to-a-day per document and D-59's floor are unchanged by this: it decides which documents, not what each costs.
- No account, no synchronisation (D-60). Recorded as a deliberate scope cut rather than an unresolved question. The honest framing is the stronger one: the prototype demonstrates that no safety capability needs an account.
Further Notes
A consequence of the platform change worth having
ADR-0019 recorded that anyone at the case-study site carrying an iPhone could not use the prototype, and that this had to be established before recruiting for the needs study. The web application removes that constraint. Participants can be selected for what they do rather than what handset they carry, which makes the needs study (D-31) both easier to arrange and more representative of the four roles.
Sizing: Curation is the dominant cost, and it is now measured
A two-document spike answered the question that gated this spec (D-32), and the answer changes how any ticket breakdown should be read.
Roughly half a day to a full day of human effort per document (D-58). Not an hour. The dominant stage is review and reconciliation, on the order of 90 to 180 minutes per document — not parsing, and not translation drafting. Parsing turned out to be nearly free: all three spike documents were born-digital with no scanned pages and all sixteen GHS Sections present and correctly numbered.
That figure is a floor, not an estimate (D-59). The proxy sample was chosen for availability and is unrepresentative in the direction that matters: major manufacturers' Acetone sheets are tidier than a working plant's binder, which will hold scanned pages, older formats and formulated products. Two costs this spec adds were also not separately budgeted in the spike — page-image verification (D-56) and whole-document Reconciliation (D-44) — and both land inside the stage that already dominates.
Preferring Thai documents does not avoid this cost, it changes its shape (D-57). The expectation was that a Thai-published SDS would remove translation review. It removes translation drafting and replaces it with transcription verification, because the text layers are corrupt. The saving that preferring Thai documents was expected to deliver does not exist, and the ruling stands anyway — on the grounds that a tooling defect must not decide which document is authoritative.
The practical consequence for stage 3: corpus size is the schedule. No engineering decision in this spec moves that. Tickets that build the application can be sized normally; tickets that fill the Corpus cannot be sized as engineering work at all.
What sizes the Corpus is now the Study Area, not a document count (D-79). The twenty-to-forty figure is withdrawn as a planning target — not for being too large, but because a count was never the right unit. A Corpus of twelve documents chosen for availability, put in front of a participant who handles thirty chemicals, measures Not Stated and Corpus Unavailable in most sessions and tells nobody whether the product works. The same twelve chosen because they are every chemical on that participant's line produce a study where a failure to answer is a finding rather than an artefact. The number falls out of the area; whatever it is, D-58 and D-59 apply to it unchanged, and if the area holds thirty-five chemicals this has bought no effort at all — it will simply have said so before the work started. The area's chemical list is requested ahead of the binder (D-80), because it is separable, cheap for The Plant to answer, and a better test of access than continuing to wait for the larger request.
Risks carried by the platform decision
ADR-0020 records these as D-41 and D-42 rather than leaving them implicit. They are repeated here with the concrete action each one needs, because a risk recorded and then not acted on is indistinguishable from a risk nobody noticed.
Camera Identification is materially weaker on the web (D-41). ADR-0019 chose native Android specifically because first-party on-device text recognition was available, and it explicitly recorded that in-browser recognition is markedly worse. That reasoning did not stop being true when the platform decision was overridden — it became a risk instead of a reason, and ADR-0020 calls it the largest single cost of the stack decision.
Two facts from the stack as it stands: the QR route is unaffected and cheap, because
jsqris a dependency as of 2026-09-09, having been installed rather than already present — this mitigation originally rested on an unchecked premise (D-105); text recognition is not present at all, and would be a new WASM dependency working on labels that are scuffed, curved, wet and poorly lit. §3 makes manual search an equal route, which is the mitigation available at spec level.Action needed — STILL OPEN. Prove CAS-number reading with a spike against photographs of real drum labels before any ticket assumes it works. If it does not clear a usable bar, the honest options are to lead with QR where The Plant will label containers, or to lead with manual search — not to ship a scan that fails quietly.
Offline operation is required on a platform that does not guarantee it (D-42). D-06 stands and §4 specifies the mechanism. But ADR-0005 assumed a native application with durable installed storage. On the web, storage can be evicted under pressure, installation is a per-person action that may never happen, and a service worker can be discarded. Neither a service worker nor precaching tooling is currently in the stack; both are new work.
Action needed, as originally written: treat Seam 4 as a standing gate rather than a one-off test, and decide what the app does when it starts and finds its Corpus missing or partial. That path is unspecified here, and it is the realistic failure — not the total absence of the app.
— CLOSED by ADR-0032 (D-72, D-73, D-74). Seam 4 is a standing gate, and the missing-Corpus path is no longer unspecified: §4 now requires startup verification, treats anything that does not verify as absent in full rather than partial, and answers Corpus Unavailable — a statement about the device, never Not Stated, which is a statement about an SDS. Escalation and hotline 1669 survive an empty Corpus, and Seam 5 asserts that no safety surface reports Not Stated when the Corpus is gone. The original wording is left above as the record of what the spec surfaced; do not read it as open.
Contradictions for the owner
Recorded rather than resolved, per the repository's rule that a change contradicting an accepted ADR is raised and not implemented over.
Status after the grill that followed this spec: items 1 and 2 are CLOSED — the curation spike ran (ADR-0028, D-58/D-59) and selection timing is now decided as a Curation-time act (ADR-0022, D-50). Items 3 and 4 are CLOSED by ADR-0029 (accounts cut from prototype scope) and ADR-0030 (three peer Identification routes, glossary extended). The numbering gap below is tombstoned as ADR-0021. All four are left in place as the record of what this spec surfaced; do not read them as open.
The curation spike (D-32) is required before
/to-specand has not been run. This spec was written first. D-32 exists so that stage 2 sizing knows whether a document costs an hour or a day, and that number has still not been measured — so any ticket breakdown derived from this spec is unsized in its largest dimension. Either run the two-document spike and revise, or supersede D-32.Selection timing is undecided (D-16), and offline-first (D-06) forces the answer. If a model chose spans at the point of read, safety surfaces would require a network, which D-06 forbids. §7 therefore reads selection as a Curation-time activity. That reading is sound but it is nowhere stated, and a reasonable implementer could build runtime selection and violate D-06 without noticing. It deserves an ADR of its own — more so now that D-40 puts a server within reach of every read.
Cross-device sync is promised (D-09) and nothing decides whether it is built. D-09 says an account buys exactly two things: cross-device sync and saved favourites. D-40 now puts a server in scope that could host it, and the work-permit stack already carries an authentication library — so this is a live decision rather than a hypothetical one. It remains substantial scope for two conveniences, in an effort whose corpus is a hard external dependency. D-01 says company-only concerns are stubbed and named; D-15 puts the plant integration out of scope but is silent on an account backend. Since decided: ADR-0029's D-60 cuts accounts, cross-device synchronisation and saved favourites from prototype scope outright, so this is no longer a Declared Gap awaiting a ruling. The paragraph is left as written for the record; the status note above governs.
The glossary definition of Identification omits the QR route. CONTEXT.md defines Identification as CAS Number, then product name, then manual search. D-05 keeps QR as a secondary route and R-29 confirms the source caption reads "label or QR code". §3 includes QR. The glossary entry should be extended, or QR should be dropped — at present the vocabulary and the decisions disagree.
A numbering gap in the decision sequence
There are two gaps, not one. ADR-0021 tombstones D-23, D-24 and D-25; ADR-0038 tombstones D-47 and D-48 (D-92). Five ids are void. Both gaps arose the same way — numbers spoken during grilling before any document existed — and ADR-0038 records that the second one cannot be explained as precisely as the first, because nothing on disk says what those two ids were spoken for.
Any arithmetic on the decision count must reckon with five void ids. The documentation site's decision index counts declarations from the filesystem rather than id ranges, for this reason.
Historical record, written when this spec was first drafted. The counts below were accurate then and are not current — the decision sequence has since grown well past D-36. The gap itself is permanent and is tombstoned by ADR-0021; only the totals are stale. Read it as an account of how the gap was found, not as a census.
D-23, D-24 and D-25 do not exist. The decisions are D-01 to D-22 and D-26 to D-36 — 33 of them, not 36. Nothing declares the three missing ids anywhere the coverage gate scans.
They appear to be numbers assigned in conversation and superseded when the ADRs were written, where the same rulings became D-28/D-29 (proxy corpus), D-32 (curation spike) and D-30/D-31 (evaluation). No ruling was lost; only the numbering.
The repository's own rule is that a deleted item becomes a tombstone row rather than a missing number. These are missing numbers with no tombstone. The Coverage table below therefore covers the 33 decisions that exist — listing the phantom ids would make the coverage gate fail, since it rejects a Coverage row naming an id declared nowhere.
Traceability (this spec's ids)
The 2026-09-08 grill found four Coverage rows landing on findings superseded on 2026-09-02 — one of them in this spec's oldest ADR, and one in the very ADR whose prose an earlier correction had already fixed while leaving its row alone. ADR-0035 closes that class.
A finding that has been replaced now says so on its own page (D-81), distinguishing wholesale replacement from partial (D-84) so that a finding whose surviving half is still cited does not read as dead. A Coverage row may still cite a replaced finding — this spec does exactly that for D-26 and D-36, deliberately and with the reason stated — but it must name the replacement, and the IDSTATUS gate fails it otherwise (D-82). Note cells inside Accepted ADRs were annotated to satisfy this, which is a narrow exception to the never-edit rule and is bounded in D-83.
What this does not establish is that a cited upstream says what a row claims it says. Currency is gated; truthfulness is not, and no gate can reach it — the check would be whether a finding supports the claim made about it, which is a reading task rather than a parsing one.
The rows the grill found wrong on content are now corrected (D-104). Six instances of one act had accumulated: reaching for a plausible finding to make a Coverage table look complete. Four rows were removed, one sharpened where the link turned out to be real, and the rule is now stated — an ADR whose decision was not driven by a research finding ships an empty Coverage table, and that is correct rather than incomplete. The sixth instance was written while resolving the ticket that fixed the fifth, which is why this is a ruling and a note in the ADR template rather than a resolution to be more careful.
Vocabulary
No term used in this spec sits outside CONTEXT.md, and keeping that true has meant revising the glossary rather than only adding to it. Eleven terms were coined across ADR-0031, ADR-0032, ADR-0034, ADR-0036 and ADR-0037: Routing, Query Intent, Routing Floor, Routing Threshold, Escalation Trigger, Routing Fixture, Corpus Unavailable, Study Area, Not In Corpus, Outside The Record and Curated Explanation.
Four more were coined on 2026-09-20 by the prototype-feedback rulings: Appbar and Navigation Bar (ADR-0077), Design Token (ADR-0078, extending what D-142 already bound) and Published Version (ADR-0082). The first two are Chrome and are glossed as such; none of the four names Safety-Critical Content.
One existing entry was rewritten, and that was the fix rather than a tidy-up. Not Stated previously read "when the Corpus does not contain a safety answer", which described the cases now called Not In Corpus and Outside The Record and not the case ADR-0032 reserved it for. ADR-0032 had added Corpus Unavailable beside it without re-reading it — adding a term is not the same as re-reading the one it borders. Three further notes:
- "Surface" is used for a screen or view. It is interface vocabulary rather than domain vocabulary, so it is deliberately not proposed as a glossary term.
- "Escalation" is used exactly as defined, including in First Aid, where it covers seeking medical attention as well as calling the hotline.
- "Chooser" is the interface element D-46 requires when a name matches several Chemical Records. Like "surface" it is interface vocabulary, deliberately lowercase and deliberately not proposed as a glossary term.
Coverage
| Upstream | Landed in | Evidence | Note |
|---|---|---|---|
| D-01 | Solution, Declared Gaps | every stub named in Declared Gaps rather than omitted | |
| D-02 | Implementation Decisions §7 | §7 requires a Source Span for every Safety-Critical element | |
| D-03 | US-14, US-07, Implementation Decisions §7 | Not Stated named as a correct outcome, not an error | |
| D-04 | — | Dropped: ADR-0003 is superseded by ADR-0011; D-14 governs corpus sourcing | |
| D-05 | Implementation Decisions §3 | camera route reads CAS Number first; QR and manual search are peers | ADR-0004 superseded by ADR-0030; the read order survives as D-64 |
| D-06 | Implementation Decisions §2 and §4, Testing Seam 4, US-29, US-31 | offline mechanism specified; zero-network assertion for safety surfaces | |
| D-07 | Implementation Decisions §8, US-42, US-43 | standing statement required at first run and on safety surfaces | |
| D-08 | Implementation Decisions §8, US-20, US-25 | Escalation precedes step 1 in both emergency flows | |
| D-09 | Implementation Decisions §6 and §10, US-01, US-40 | Home is the landing route; history device-local under Anonymous Use | the account half of D-09 is cut by D-60 |
| D-10 | Implementation Decisions §6 and §8, US-24, US-26 | First Aid is its own surface, grouped by exposure route | |
| D-11 | Implementation Decisions §1 and §5, US-33, US-34 | Curated Translation is Corpus data, original reachable, not an i18n catalogue | |
| D-12 | Out of Scope | runtime translation listed out of scope in any form | |
| D-13 | — | Dropped: source-completeness bookkeeping; shapes no product behaviour | |
| D-14 | Implementation Decisions §5, US-12 | Corpus sourced from The Plant's holdings, with Provenance | |
| D-15 | Out of Scope | live plant-system integration listed as production scope | |
| D-16 | Implementation Decisions §7 | Extractive Summarisation defined as selection performed at Curation | |
| D-17 | Implementation Decisions §9, US-37, US-38 | term explanation is Conversational Content, visibly distinct | |
| D-18 | Implementation Decisions §5, US-15 | identity is product name, supplier and revision date | |
| D-19 | Implementation Decisions §3, US-03, US-04, Risks item 1 | CAS Number read first, product name otherwise; web OCR risk raised | |
| D-20 | Implementation Decisions §6 | Home entries taken from the authoritative source | |
| D-21 | User Stories | every story is written against one of the four named roles | |
| D-22 | Out of Scope | per-role interfaces listed out of scope | |
| D-26 | Implementation Decisions §7, Testing Seam 1, US-18, US-28 | §7 records why the within-Section form failed and what replaced it | superseded by D-44; retained because §7 keeps the failure visible |
| D-27 | — | Dropped: repository bookkeeping on how confirmed inferences are recorded | |
| D-28 | Implementation Decisions §5, Declared Gaps | proxy corpus named as scaffolding, not the Corpus | |
| D-29 | Declared Gaps | the น้ำยาล้างชิ้นงาน proof test is stated | |
| D-30 | Testing Decisions, all acceptance criteria | no criterion or test in this spec states a number | |
| D-31 | Out of Scope, Further Notes | baseline listed as study activity; web platform widens participant recruitment | |
| D-32 | Further Notes, Sizing | the spike has since run; its result sizes stage 3 | Contradictions item 1 is annotated CLOSED |
| D-33 | — | Dropped: repository bookkeeping on stale rationales in five ADRs | |
| D-34 | — | Dropped: repository bookkeeping restating ADR-0005's rationale | |
| D-35 | Implementation Decisions §3 | the fallback is product name, explicitly not chemical name | |
| D-36 | Implementation Decisions §1, Out of Scope | native Android replaced by the work-permit web stack | Overridden by the owner, superseded by ADR-0020 / D-37. Row retained, not dropped. |
| D-37 | Solution (Platform), Implementation Decisions §1, Out of Scope | web application; native clients listed out of scope | |
| D-38 | Implementation Decisions §1 | the stack is enumerated, with the two capabilities it lacks named | |
| D-39 | Implementation Decisions §4, Testing Seam 4 | service worker, precached shell, Corpus in IndexedDB; zero-network assertion | |
| D-40 | Implementation Decisions §2 | client/server split states what is served, cached and synced | |
| D-41 | Implementation Decisions §3, Risks item 1 | manual search specified as an equal route; OCR spike requested | |
| D-42 | Implementation Decisions §4, Risks item 2 | offline specified as verified continuously rather than assumed | |
| D-43 | — | Dropped: tombstone for ids never issued; no ruling to specify | |
| D-44 | Implementation Decisions §5 (Reconciliation), §7, Testing Seam 1, US-54 | §5 specifies whole-document Reconciliation at Curation; Seam 1 asserts every Restriction was considered | |
| D-45 | Implementation Decisions §5 (never merged), Testing Seam 1, US-45, US-46 | §5 forbids merging and deduplication; Seam 1 asserts every record traces to one SDS | |
| D-46 | Implementation Decisions §3 (chooser), Testing Seam 3, US-44 | §3 requires a chooser by product and supplier; Seam 3 asserts no silent collapse | |
| D-49 | Implementation Decisions §5 (Reconciliation) | the cross-section detector is named a curator's tool that never runs at display time | |
| D-50 | Implementation Decisions §5 (Selection), §7 | selection is a Curation act; the runtime displays a curated record | resolves the spec's open question on selection timing |
| D-51 | Implementation Decisions §5 (identity), Testing Seams 1 and 2, US-47 | "revision date not stated" and unknown staleness; substitution forbidden and asserted | |
| D-52 | Implementation Decisions §7, Testing Seam 2, US-48 | §7 shows the generic span verbatim and states the absence of a material | |
| D-53 | Out of Scope, Implementation Decisions §7 | external supplementation excluded in both places | |
| D-54 | Implementation Decisions §8, Testing Seam 1, US-51 | §8 models Spill Response as branches; Seam 1 asserts per-branch step order | |
| D-55 | Implementation Decisions §8, Testing Seam 2, US-49, US-50 | the branch condition precedes step 1 and is the Escalation gate; auto-selection asserted against | |
| D-56 | Implementation Decisions §5 (text verification), US-55 | extraction verified against the rendered page before becoming a Source Span | |
| D-57 | Further Notes, Sizing | preferring Thai changes the cost shape rather than removing it | |
| D-58 | Further Notes, Sizing; Declared Gaps | half a day to a full day of human effort per document, review dominating | |
| D-59 | Further Notes, Sizing; Declared Gaps | the measured figure is stated as a floor, with the reasons it understates | |
| D-60 | Out of Scope, Implementation Decisions §6 and §10, Declared Gaps | no login surface anywhere; Settings holds no account | |
| D-61 | Implementation Decisions §6 | Anonymous Use is the only mode, so the ruling is trivially true | |
| D-62 | Implementation Decisions §3, US-05, US-06, US-53 | three peer routes, none a fallback; the earlier ranking is removed | |
| D-63 | Implementation Decisions §5 (QR label generation), US-52 | Curation produces a printable QR label per Chemical Record | curation-side scope, not app scope |
| D-64 | Implementation Decisions §3 | camera text reads CAS Number first, then product name | "product name", not "chemical name" |
| D-65 | Implementation Decisions §9, US-36, US-57 | the surface routes to a Source Span and writes no safety text | ADR-0002 stands; ADR-0031 supersedes nothing |
| D-66 | Implementation Decisions §9, Testing Seam 5, US-56 | two-tier Routing specified; every fixture case must pass with the Query Intent tier dead | |
| D-67 | Implementation Decisions §9, Testing Seam 5, US-57 | Not Stated below the threshold; the Section may be named, its content may not | threshold fixed at Curation |
| D-68 | Implementation Decisions §9, Testing Seam 5, US-44 | ambiguity delegates to D-46's chooser; no record is designated | adds no fourth Identification route, D-62 unchanged |
| D-69 | Implementation Decisions §2 and §9, Declared Gaps, US-59 | Query Intent is the only thing that crosses; no Corpus content egresses | forces lexical retrieval on both tiers |
| D-70 | Implementation Decisions §9, US-58 | Escalation Trigger evaluated before any network call; no branch pre-filled | D-08 and D-55 preserved under Routing |
| D-71 | Testing Seam 5, Declared Gaps | exact per-case outcomes, negative cases mandatory, no numbers stated | Routing Fixture is a Curation artifact |
| D-72 | Implementation Decisions §4, Testing Seam 5, US-60 | Corpus Unavailable specified as a statement about the device, never about an SDS | closes Risks item 2's unspecified path |
| D-73 | Implementation Decisions §4 | startup verification; anything not verifiably complete is absent in full | covers eviction, which sync atomicity does not reach |
| D-74 | Implementation Decisions §4 and §8, US-61 | Escalation, 1669 and the standing statement render with no Corpus | none of them is a Corpus read |
| D-75 | Implementation Decisions §2, Declared Gaps | gemini-3.8-flash on the paid tier; the free tier excluded on the no-training ground | prices dated 2026-09-08, one changes 2027-01-01 |
| D-76 | Implementation Decisions §2, Declared Gaps, US-59 | zero retention sought, found unavailable, withdrawn; residual exposure stated | amends ADR-0031's D-69, which otherwise stands |
| D-77 | Implementation Decisions §2, Declared Gaps | server-side cap; reaching it disables the tier and serves the Routing Floor | the cap number is the owner's, per D-30's discipline |
| D-78 | Implementation Decisions §2 | per-address rate limit; provider key server-side only | the endpoint is unauthenticated because D-60 removed accounts |
| D-79 | Further Notes (Sizing), Declared Gaps, US-62 | the Corpus is sized to the Study Area; the document count is withdrawn as a target | D-58 and D-59 unchanged |
| D-80 | Further Notes (Sizing), Declared Gaps | the area's chemical list is requested ahead of the binder | converts a large blocked dependency into a small one |
| D-81 | Traceability (this spec's ids), Testing Decisions | findings record their own supersession, so a reader who opens a dead one is warned | mirrors the two-sided rule ADRs already follow |
| D-82 | Traceability (this spec's ids) | a Coverage row on a superseded upstream must name the replacement; IDSTATUS fails it otherwise | citing a dead id stays legal; citing it silently does not |
| D-83 | Traceability (this spec's ids) | Coverage Note cells in an Accepted ADR are editable for bookkeeping; nothing else is | narrow exception, drawn explicitly so it is not read as licence |
| D-84 | Traceability (this spec's ids) | an amended finding stays live and citable, so partial replacement does not fail good rows | R-19 is the worked case |
| D-85 | Implementation Decisions §4 and §9, Testing Seam 5, US-14 | Not Stated narrowed to a consulted SDS inside a curated field | the glossary entry was rewritten; it had defined the wrong set |
| D-86 | Implementation Decisions §4, Testing Seam 5, US-63 | Not In Corpus specified and asserted against standing in for Not Stated | |
| D-87 | Implementation Decisions §4, Testing Seam 5, US-64 | Outside The Record specified, with the original SDS reachable | fact 4's residue is named in ADR-0036, not fixed by a state |
| D-88 | Implementation Decisions §4 | the snapshot-version sentinel read on every safety read | closes the startup-only window ADR-0032 left open |
| D-89 | Implementation Decisions §9, Testing Seam 5 | Routing resolves to five outcomes; Corpus Unavailable pre-empts rather than answers | |
| D-90 | Implementation Decisions §2 and §9, US-65 | term explanation is a Curated Explanation, held on device and displayed extractively | amends ADR-0012's D-17 permission to generate |
| D-91 | Implementation Decisions §2 | no text the app displays is generated by a model | makes §2's "Query Intent only" literal |
| D-92 | A numbering gap in the decision sequence | the second tombstone, recorded beside the first | ADR-0038; bookkeeping, no product behaviour |
| D-93 | Declared Gaps (Routing quality) | the Not Stated rate re-landed on R-24's surviving objective, with the reading it may not support named | ADR-0039 amends ADR-0031's Consequences clause; the ruling is untouched |
| D-94 | Solution, Implementation Decisions §9, Testing Seam 4, Offline as a whole-application property, US-31 | no surface requires a network; Seam 4 widened from safety surfaces to all surfaces | ADR-0040 amends ADR-0005's D-06 scope, which is unchanged in itself |
| D-95 | Implementation Decisions §8, US-20, US-25 | escalation text is a curated artifact with a named author and a closed set of acts | widens Extractive's origin for escalation alone; D-02 untouched |
| D-96 | Implementation Decisions §8, Testing Seam 2, US-25 | hotline persistent on both flows; the gate before step 1 is spill-only | ADR-0006's D-08 argued the spill case only |
| D-97 | Implementation Decisions §8, Testing Seam 2, US-49, US-50 | branch condition separated from the escalation gate; no invented condition where the SDS states none | half the spike sample states no condition |
| D-98 | Implementation Decisions §4 and §8 | the Curated Escalation ships with the precached shell, outside the evictable Corpus | repairs D-74, which D-95's storage clause had broken |
| D-99 | Implementation Decisions §8, US-26, US-58 | the trigger lands on the Curated Escalation, no record required, routing continuing beneath | supplies the destination D-70 left unwritten |
| D-100 | Implementation Decisions §8, Testing Seam 2, US-58 | escalation reachable from every surface; the trigger is an accelerator, not a path | no phrase list may be load-bearing |
| D-101 | Implementation Decisions §9 | intent terms are added, never substituted; a product name from intent still reaches the chooser | closes a back-door bypass of D-68 |
| D-102 | Testing Seam 5 | three adversarial-intent cases; the boundary is record selection, not outcome stability | the fixture tested recorded and dead, never wrong |
| D-103 | Implementation Decisions §2, Declared Gaps | the cap bounds exposure; the per-address limit is a fairness and accident guard sized for a shared egress | corrects D-78's characterisation, not its ruling |
| D-104 | Traceability (this spec's ids) | a Coverage row records what drove a decision; an empty table is correct where nothing did | ADR-0044; five stretched rows removed or sharpened |
| D-105 | Testing Decisions (stack-claims block), §3, Seam 2, Seam 4, Risks item 1 | claimed packages are listed and gated; chosen-stack claims are worded as plans | ADR-0045; three of four claims were false |
| D-106 | Implementation Decisions §9, Testing Seam 5 | a sub-threshold match in a populated field shows the field; Not Stated needs an empty one | ADR-0046 repairs D-67's consequence; D-85 is protected, not amended |
| D-107 | Implementation Decisions §9 | the threshold gates singling out a span, not whether an answer is given | a mis-set threshold is now a usability problem, not a safety one |
| D-108 | Implementation Decisions §7, Testing Seam 2 | a missing PPE material is a fact in the data and is stated; the conditional note is gone | ADR-0047; the server had it, the client dropped it in transit |
| D-109 | Implementation Decisions §7 | a curated field describing a span never displaces it; the span is what a person reads | general rule, stated because material is the first such field |
| D-110 | Implementation Decisions §5 (Where Curation runs) | Curation and upload routes exist only on a loopback bind; the curator's pages only in the curation build | ADR-0048; the bind is the access control, so no credential is invented under D-60 |
| D-111 | Implementation Decisions §5 (Where Curation runs), Testing Decisions (stack-claims block) | page images rendered in the curator's browser and not kept; pdfjs-dist declared and gated | ADR-0048 records ticket 26's ruling |
| D-112 | Implementation Decisions §5 (Where Curation runs) | a refused admission reuses its upload for the same picked file; no object delete mounted | ADR-0048; the one remaining orphan is an abandoned upload, named as a cost |
| D-113 | Implementation Decisions §4, §8 | the Curated Escalation ships as the hotline alone until a site authority signs; US-20 and US-50 named as waiting | ADR-0049 amends D-08, D-95 and D-98 |
| D-114 | Implementation Decisions §8 | the build fails on an unsigned act other than the hotline; unsigned is null, not a string | ADR-0049; the old check passed on "no reviewer assigned" |
| D-115 | Implementation Decisions §8 | the trigger fires on a person, not a topic, held by a must-stay-quiet fixture | ADR-0049; eight of nine routine questions fired before |
| D-116 | Implementation Decisions §8 | a branch stating no condition is stored with a null condition; a blank is refused | ADR-0050 records ticket 27's ruling; the ADR-0026 clause it was paired with was already amended by ADR-0041 |
| D-117 | Implementation Decisions §5 (QR label generation) | a record ships by its database-generated label key; the row number never leaves the store | ADR-0051; a row-number label could scan to a different chemical after a rebuild |
| D-118 | Implementation Decisions §5 (QR label generation), Testing Decisions (stack-claims block) | the payload is the prefix plus the label key; unprefixed payloads are unrecognised; qrcode-generator declared and gated | ADR-0051 records ticket 30's agreement |
| D-119 | Implementation Decisions §5 (text verification) | no span for a document with an unverified page; a span carries verified text only | ADR-0052 records ticket 27's ruling; trailing pages remain uncatchable |
| D-120 | Implementation Decisions §5 (selection) | identity is read from the Sds; the selection route accepts none | ADR-0052 records ticket 27's ruling |
| D-121 | Implementation Decisions §5, §7 | all sixteen GHS Sections created at admission as labels, no text; the full-Section claim corrected to an open item | ADR-0052; grill Q2 |
| D-122 | Implementation Decisions §2 | a snapshot names the documents it covers and carries a published marker | ADR-0053 records ticket 33's ruling on blocker 10 |
| D-123 | Implementation Decisions §2 | explanations are those that existed when the snapshot was published | ADR-0053; grill Q5a |
| D-124 | Implementation Decisions §2 | the server emits the device's shape, decides every per-field value, loses no caveat; a cross-repository check holds it | ADR-0053; grill Q8b, blocker 13 |
| D-125 | Further Notes (Risks: Study Area) | the Study Area is the area that uses น้ำยาล้างชิ้นงาน | ADR-0054 amends ADR-0034; grill Q3 |
| D-126 | Implementation Decisions §2 | the Query Intent endpoint waits for the Study Area's Corpus | ADR-0055 amends ADR-0033; grill Q6 |
| D-127 | Implementation Decisions §2 | the stored payload the gate passed is what every pull serves; nothing is read live | ADR-0056 amends ADR-0053; grill Q9, blocker 14 |
| D-128 | Implementation Decisions §2, §4 | §2 specifies the versioned sync endpoint this refuses on; §4 states "Corpus Unavailable is not Not Stated" (D-72) and is one of four reasons the app cannot answer — the name this server-side refusal may not take | ADR-0057. Absent from the spec: the 409 itself and the no-placeholder rule. Neither section states either, so this row maps the decision to where it belongs and not to text that carries it |
| D-129 | Implementation Decisions §4 | §4 states "A snapshot-version sentinel is read on every safety read" (D-88) — the value D-129 rules must come from the payload | ADR-0057. §2 was dropped from this row: it specifies the versioned sync endpoint but nowhere describes the version read and the snapshot pull as two calls, which is what the row previously claimed (finding 12). The two-read tear was recorded in ADR-0053, not here |
| D-130 | Implementation Decisions §5 (Where Curation runs) | §5 specifies stored copies as curation-only behind a loopback bind (D-110); no deletion path is specified there, and this ruling is what makes that absence deliberate | ADR-0058; the accumulation cost is stated in the ADR rather than discovered later |
| D-131 | Implementation Decisions §5 (text verification) | §5 specifies that a document is verified whole before anything is selected from it (D-119); the recorded page count is what makes "whole" checkable | ADR-0059 is superseded by ADR-0074 — this row records where D-131 landed while it was live; the ruling that holds is D-151, under which the count is nullable |
| D-132 | Implementation Decisions §5 (text verification) | §5 states that every extraction is checked against the rendered page image before anything is selected from it; refusing withdrawal is what keeps that check final | ADR-0059 is superseded by ADR-0074 — the ruling that holds is D-152. Evidence corrected: §5 does not describe a verification as a named person's record, which is what this row previously claimed (finding 12) |
| D-133 | Implementation Decisions §7 | §7 states an SDS Section is a label a span points at, "created for all sixteen GHS headings at admission", holding no text (D-121) — the sixteen labels whose names this rules on | ADR-0060, read with ADR-0072 (D-149), which supplies the payload shape the reader half needs. Absent from the spec: which language each surface shows. §7 does not specify what a Handler reads of a heading, which is what this row previously claimed (finding 12) |
| D-134 | Implementation Decisions §2 | §2 states "A check crosses the two repositories to hold that contract" (D-124) — the instrument this applies to the heading list | ADR-0060; ticket 39 recorded the duplicate. Absent from the spec: the heading fixture itself. §5 and §7 were dropped from this row — neither mentions the list being held once |
| D-135 | Implementation Decisions §2, §9 | §9 describes the Curated Explanation as written and reviewed by a person at Curation; §2 specifies that a published version carries the explanations that existed at publish (D-123) | ADR-0061 is superseded by ADR-0073 — this row records where D-135 landed while it was live; the ruling that holds is D-150. The §5 citation was corrected to §9: §5 specifies no explanation authoring |
| D-136 | Implementation Decisions §2, §5 (selection) | §5 states the Reconciliation rule the codes name (D-44) and the selection rules around it; §2 states the check crossing the two repositories that the code fixture reuses | ADR-0062, amended by ADR-0075 (D-153). Absent from the spec: the codes themselves. The ADR's twelve are a starting list the API owns and has since corrected to seventeen, which ADR-0062 delegates in terms |
| D-137 | Implementation Decisions §2 | §2 specifies a check crossing the two repositories, which is the check whose paths this ruling fixes | ADR-0063; both application gates are red on the assumption this ADR rules against; the layout itself is workspace structure, not spec text |
| D-138 | Implementation Decisions §6, US-42 | §6 specifies the surface: stated on First Run, blocking, once per device, in front of the router rather than routed; US-42 is the first-run half of D-07 | ADR-0064 attaches US-42 to a blocking surface in front of the router; §6's "Home is the landing route" is unchanged |
| D-139 | Implementation Decisions §8 (standing), Testing Seam 4 | §8 specifies the control as app-authored chrome that routes to the Escalation surface rather than rendering the Curated Escalation without its origin, and that it acknowledges First Run on the way; Seam 4 asserts both halves from a virgin device | ADR-0064 |
| D-140 | Implementation Decisions §4, US-69 | §4 specifies First Run as a fact about the device's own storage, and every failure to read or write it as erring toward stating the statement again | ADR-0064; the storage-failure half is what US-69 asks for, and it is the half a reviewer is most likely to read as defensive coding rather than as the ruling |
| D-141 | Implementation Decisions §4, Testing Decisions | §4 carries the closed set in full — both the MAY list and the MAY-NOT-EVER list — around the startup verification (D-73) the state stands in front of | ADR-0065; Testing Decisions names BootSplashClosedSet.spec.ts, a source sweep over SplashScreen.vue in the same shape as the palette sweep, as the mechanical assertion of both halves of the set |
| D-142 | Implementation Decisions §1, Testing Seam 4 | §1 specifies the ranking, the four practice losses and the one-palette consequence; the palette half is asserted by a source sweep named in Seam 4 | ADR-0066; the four losses are each checkable against code or a spec file, which is what makes §1's ranking evidence rather than assertion |
| D-143 | Implementation Decisions §5 (selection) | §5 specifies the selection surface as the place a curator's judgement about one passage is recorded; this rules on what does not survive a submission | ADR-0067; ratifies built behaviour that was settled in a docblock, which is the blocker 9 and blocker 11 shape |
| D-144 | Implementation Decisions §5 (selection), §2 | §5 states a Restriction bearing on a span must be resolved before it is shown, and that the reconciled content is what the Corpus stores (D-44) — the judgement an empty list must not claim falsely; §2 states the check crossing the two repositories that gates the widened payload | ADR-0068, amended by ADR-0071 and ADR-0075. Evidence corrected: this row cited D-49 for "names the curator"; D-49 is the cross-section detector ruling and says no such thing (finding 10). Absent from the spec: the read's widened shape |
| D-145 | Implementation Decisions §5 (selection), §2 | §5 states the same Reconciliation rule (D-44), which is what the every-pair-judged wording asserts has happened; §2 states the cross-repository check that gates the third fact | ADR-0069 amends ADR-0068. Evidence corrected: the D-49 miscitation, as above (finding 10). Withdrawn by ADR-0075: ADR-0069's added "the count must not display" — it was never part of D-145. Absent from the spec: the pair count |
| D-146 | Implementation Decisions §8 (standing) | §8 states the overlap where the control that depends on it is specified; the ruling is about a glossary relation, so CONTEXT.md carries the definition | ADR-0070 amends ADR-0064; §8 states the overlap. D-139's ruling is untouched — only its supporting sentence is withdrawn |
| D-147 | Implementation Decisions §1 | §1 names the presentation layer the extract binds under D-142, which is what these claims are evidence for | ADR-0070 amends ADR-0066; §1 requires a claim about the extract to cite the file it was read from |
| D-148 | Implementation Decisions §2, §5 (selection) | §2 specifies the cross-repository check the golden fixture enforces, which is what the four members and their order are gated by; §5 specifies the reconciliation states the members describe | ADR-0071 amends ADR-0068; the union and its precedence are not yet in the spec text — owed at the next spec pass |
| D-149 | Implementation Decisions §2, §7 | §2 specifies the snapshot the device pulls and the cross-repository check that holds its shape; §7 specifies what a Handler reads, which is the Thai heading keyed off the number | ADR-0072 completes ADR-0060's reader half; cites ADR-0056 and amends nothing — the payload shape is not yet in the spec text, owed at the next spec pass |
| D-150 | Implementation Decisions §2, §9 | §9 describes the Curated Explanation as written and reviewed by a person at Curation — the reviewer this refusal protects; §2 specifies the published copy that is unaffected either way | ADR-0073 supersedes ADR-0061; the refusal matches author-explanation as built, and correcting a wrong explanation is recorded in the ADR as an open hole |
| D-151 | Implementation Decisions §5 (text verification) | §5 specifies that a document is verified whole before anything is selected from it (D-119); the page count is what "whole" is measured against where one can be read, and contiguity stands in where it cannot | ADR-0074 supersedes ADR-0059; the nullable count matches both repositories as shipped. §5 does not yet describe the image-admission case or the two gate forms |
| D-152 | Implementation Decisions §5 (text verification) | §5 specifies that every extraction is checked against the rendered page image before anything is selected from it; refusing withdrawal is what keeps that check final | ADR-0074 supersedes ADR-0059, which stated this four times and denied it once; D-152 states it once. Whether a withdrawal capability should exist is named in the ADR as unruled |
| D-153 | Implementation Decisions §5 (selection) | §5 specifies the reconciliation outcomes that make a caveat required by the value beside it rather than by the schema, which is the boundary this row's ruling draws | ADR-0075 amends ADR-0062; settles CAVEAT_REQUIRED_ON_CARRIED against the blank-field rule, in the API's favour |
| D-154 | Implementation Decisions §6 | §6 specifies the six cards by name, the two entries that move to the Navigation Bar, and the bundled-icon requirement the offline shell imposes | ADR-0076 amends ADR-0030, which stands; §6's older six-entry list (D-20) and this one differ on membership, and §6 says which is operative |
| D-155 | Implementation Decisions §6 | §6 states that a route with no surface is absent rather than disabled, and names Risks item 1 as what keeps camera text recognition unbuilt | ADR-0076; D-62's peer rule is unchanged and is stated in §6 as binding the routes that exist |
| D-156 | Implementation Decisions §6 | §6 specifies the three Chrome parts in order and states that the Escalation strip is never folded into the Appbar | ADR-0077; D-100's one-action reachability is the constraint §6 cites, and CONTEXT.md carries Appbar |
| D-157 | Implementation Decisions §6 | §6 names the four Navigation Bar destinations and the record-id reason the three record surfaces are excluded | ADR-0077; the alarm-colour reservation is restated in §6 and CONTEXT.md carries Navigation Bar |
| D-158 | Implementation Decisions §1 | §1 names the five type tokens, the self-hosted font assertion that is unchanged, and the source sweep that fails a build naming a raw size utility | ADR-0078 amends ADR-0066, which stands; CONTEXT.md carries Design Token |
| D-159 | Implementation Decisions §1 | §1 names the two durations, the one easing curve and the three uses, and the source sweep over them | ADR-0078 amends ADR-0066, which stands |
| D-160 | Implementation Decisions §1 | §1 names the ported files, the wrapper and its variants, the three hardcoded values retinted on arrival, and the sweep that makes a raw <button> a build failure on both builds | ADR-0079; the work-permit repository has no BaseButton — its button is the volt component — which is why the wrapper is specified rather than copied |
| D-161 | Implementation Decisions §5 (the Curator's surfaces) | §5 names the errorCode, the status it rides on, and the standing sentence the prototype appended to it that was false for this refusal | ADR-0080; verify-extraction is the only refusing curation route absent from curation-refusals.golden.json, which is why its client had a status number to print |
| D-162 | Implementation Decisions §5 (the Curator's surfaces) | §5 specifies what a reopened page shows and states that a corrected page's raw extraction stays unserved | ADR-0080; the route already serves verified text, corrected flag and Curator name, so the surface half is the whole change |
| D-163 | Implementation Decisions §5 (the Curator's surfaces) | §5 states that the control is enabled from selectable, writes nothing, and shows the gate's refusal verbatim | ADR-0080; the gate is the one find-verified-pages already runs (D-119), so no second answer is stored |
| D-164 | Implementation Decisions §5 (the Curator's surfaces) | §5 specifies the forward links, the carried ?sdsId=, and that every page stays independently addressable because the API enforces order | ADR-0080; span selection's refusal of unverified text is the enforcement §5 cites |
| D-165 | Implementation Decisions §5 (the Curator's surfaces) | §5 splits toast from persistent notice by outcome and names the overridden default error toast on handleLoading | ADR-0080; the surface's existing rule that a verification is never silently dropped is what the split preserves |
| D-166 | Implementation Decisions §7 | §7 names the route, the prefix it is on, the present-or-absent affordance, and the sentence shown when there is no connection | ADR-0081; D-94 permits a network to improve a surface, D-06 is untouched because no Safety-Critical read passes through it, and D-110's no-URL reasoning is applied outside Curation |
| D-167 | Implementation Decisions §5 (the Curator's surfaces) | §5 names both routes, what each answers, and that the detail is read from the stored payload rather than the live rows | ADR-0082; ADR-0056 is the ruling that makes the payload the record of what shipped, and CONTEXT.md carries Published Version |
| D-168 | Implementation Decisions §7 | §7 names what sits above the tab bar and what sits behind it, and quotes ADR-0008's rejected option to show that nothing emergency moved | ADR-0083; First Aid and Spill Response are already routes rather than fields of this surface, which is why the rejection is untouched |
| D-169 | Implementation Decisions §5 (correcting what Curation already recorded) | §5 names the attachment row as the unit, the two-sided marker, the one statement, and the two ways a replacement states a span | ADR-0084; the reference key is bounded to the replaced row's own spans, which is what keeps it outside select-span's stated objection to a span id |
| D-170 | Implementation Decisions §5 (correcting what Curation already recorded) | §5 names retraction as a separate act, the only one that removes content, and states that ordinals are not renumbered | ADR-0084 |
| D-171 | Implementation Decisions §5 (correcting what Curation already recorded), Testing Seam 1 | §5 states that only a new span is unjudged and that the Reconciliation Gap is what holds publication; Seam 1 is the gate it relies on | ADR-0084; D-44 is the pairwise rule this leans on rather than changes |
| D-172 | Implementation Decisions §5 (correcting what Curation already recorded) | §5 states the ordinal is released and that no constraint is narrowed, with the schema gate as the reason | ADR-0084; the rejected partial index is recorded in the ADR |
| D-173 | Implementation Decisions §5 (correcting what Curation already recorded) | §5 requires a curator name and a reason, and states plainly that neither is an authorisation | ADR-0084; D-60 is why both are plain strings |
| D-174 | Implementation Decisions §5 (correcting what Curation already recorded), Testing Seam 1 | §5 requires the serialiser filter and the spec that asserts it over the source, and states the payload shape is unchanged | ADR-0084; ADR-0056 is why a published version is unaffected |
| D-175 | Implementation Decisions §5 (correcting what Curation already recorded), US-14 | §5 ties an emptied field to Not Stated and requires the Curator to assert the document is silent | ADR-0084; D-85 is the narrowed Not Stated this protects |
| D-176 | Implementation Decisions §5 (correcting what Curation already recorded) | §5 puts supersession on the selection command, retraction on its own, and states that the chain is linear | ADR-0084; D-136 is why every code derives from its model's table |
| D-177 | Implementation Decisions §5 (correcting what Curation already recorded) | §5 names the re-parenting, the retraction of a branch's steps, and the closed list of two mutations | ADR-0084; D-54 is the semantic order re-parenting preserves |
| D-178 | Implementation Decisions §5 (correcting what Curation already recorded) | §5 names the contradiction between the keep list and the text keys, and why neither reading may be taken quietly | ADR-0085, which amends ADR-0084's count of the supersede refusals; D-153 is why it carries a code rather than being a blank-field 400 |
| D-179 | Implementation Decisions §5 (correcting what Curation already recorded) | §5 states that a branch may hold no Source Span and owns its steps, which is why the selection command's rule never reached it | ADR-0086; D-97 is the nullable condition it turns on, and D-176 is left unamended for the four rows it governs |
| D-180 | Implementation Decisions §5 (correcting what Curation already recorded) | §5 names the command, that it addresses the branch by id, that the body carries only the condition, and why it is named for the row | ADR-0086; D-177 is the re-parenting it makes reachable |
| D-181 | Implementation Decisions §5 (correcting what Curation already recorded) | §5 states that the table declares every code the command can throw, borrowed ones included | ADR-0086; D-136 is the derivation rule this keeps intact |
| D-182 | Implementation Decisions §5 (correcting what Curation already recorded) | §5 states the two states and why a third cannot exist — the ordinal is a position and the steps move, so the condition is the only alterable content | ADR-0087, which amends ADR-0086's D-180; D-169 and D-177 are the two closures that make it structural |
| D-183 | Implementation Decisions §5 (correcting what Curation already recorded) | §5 states the table is twelve codes, nine borrowed and three its own | ADR-0087, amending ADR-0086's D-181 count; the declaration rule itself is untouched |
| D-184 | Implementation Decisions §5 (correcting what Curation already recorded) | §5 names the route, what it answers, and that the id it carries is what every correcting act names | ADR-0088; without it the acts D-169, D-170 and D-180 rule are unreachable by a person |
| D-185 | Implementation Decisions §5 (correcting what Curation already recorded) | §5 states that corrected rows are two counts and never their passages, and why | ADR-0088; a correction-history surface is named there as unruled rather than closed |
| R-46 | — | Dropped: informs the frontend repo's AGENTS.md, which cites it — house style for an app repository, not a spec decision (D-104) | |
| R-47 | — | Dropped: describes the sibling frontend repo's convention and drove no spec decision (D-104); no app AGENTS.md cites it, so any match in house style there is untraced | |
| R-48 | — | Dropped: describes the sibling frontend repo's convention and drove no spec decision (D-104); no app AGENTS.md cites it, so any match in house style there is untraced | |
| R-49 | — | Dropped: describes the sibling frontend repo's convention and drove no spec decision (D-104); no app AGENTS.md cites it, so any match in house style there is untraced | |
| R-50 | — | Dropped: describes the sibling frontend repo's convention and drove no spec decision (D-104); no app AGENTS.md cites it, so any match in house style there is untraced | |
| R-51 | — | Dropped: describes the sibling frontend repo's convention and drove no spec decision (D-104); no app AGENTS.md cites it, so any match in house style there is untraced | |
| R-52 | — | Dropped: describes the sibling frontend repo's convention and drove no spec decision (D-104); no app AGENTS.md cites it, so any match in house style there is untraced | |
| R-53 | — | Dropped: describes the sibling frontend repo's convention and drove no spec decision (D-104); no app AGENTS.md cites it, so any match in house style there is untraced | |
| R-54 | — | Dropped: describes the sibling frontend repo's convention and drove no spec decision (D-104); no app AGENTS.md cites it, so any match in house style there is untraced | |
| R-55 | — | Dropped: describes the sibling frontend repo's convention and drove no spec decision (D-104); no app AGENTS.md cites it, so any match in house style there is untraced | |
| R-56 | — | Dropped: describes the sibling frontend repo's convention and drove no spec decision (D-104); no app AGENTS.md cites it, so any match in house style there is untraced | |
| R-57 | — | Dropped: informs the frontend repo's AGENTS.md, which cites it — house style for an app repository, not a spec decision (D-104) | |
| R-58 | — | Dropped: describes the sibling frontend repo's convention and drove no spec decision (D-104); no app AGENTS.md cites it, so any match in house style there is untraced | |
| R-59 | — | Dropped: informs the frontend repo's AGENTS.md, which cites it — house style for an app repository, not a spec decision (D-104) | |
| R-60 | — | Dropped: describes the sibling frontend repo's convention and drove no spec decision (D-104); no app AGENTS.md cites it, so any match in house style there is untraced | |
| R-61 | — | Dropped: describes the sibling frontend repo's convention and drove no spec decision (D-104); no app AGENTS.md cites it, so any match in house style there is untraced | |
| R-62 | — | Dropped: describes the sibling frontend repo's convention and drove no spec decision (D-104); no app AGENTS.md cites it, so any match in house style there is untraced | |
| R-63 | — | Dropped: describes the sibling frontend repo's convention and drove no spec decision (D-104); no app AGENTS.md cites it, so any match in house style there is untraced | |
| R-64 | — | Dropped: describes the sibling frontend repo's convention and drove no spec decision (D-104); no app AGENTS.md cites it, so any match in house style there is untraced | |
| R-65 | — | Dropped: informs the frontend repo's AGENTS.md, which cites it — house style for an app repository, not a spec decision (D-104) | |
| R-66 | — | Dropped: describes the sibling frontend repo's convention and drove no spec decision (D-104); no app AGENTS.md cites it, so any match in house style there is untraced | |
| R-67 | — | Dropped: informs the frontend repo's AGENTS.md, which cites it — house style for an app repository, not a spec decision (D-104) | |
| R-68 | — | Dropped: describes the sibling frontend repo's convention and drove no spec decision (D-104); no app AGENTS.md cites it, so any match in house style there is untraced | |
| R-69 | — | Dropped: informs the frontend repo's AGENTS.md, which cites it — house style for an app repository, not a spec decision (D-104) | |
| R-70 | — | Dropped: informs the frontend repo's AGENTS.md, which cites it — house style for an app repository, not a spec decision (D-104) | |
| R-71 | — | Dropped: describes the sibling frontend repo's convention and drove no spec decision (D-104); no app AGENTS.md cites it, so any match in house style there is untraced | |
| R-72 | — | Dropped: describes the sibling frontend repo's convention and drove no spec decision (D-104); no app AGENTS.md cites it, so any match in house style there is untraced | |
| R-73 | — | Dropped: describes the sibling frontend repo's convention and drove no spec decision (D-104); no app AGENTS.md cites it, so any match in house style there is untraced | |
| R-74 | — | Dropped: describes the sibling frontend repo's convention and drove no spec decision (D-104); no app AGENTS.md cites it, so any match in house style there is untraced | |
| R-75 | — | Dropped: describes the sibling frontend repo's convention and drove no spec decision (D-104); no app AGENTS.md cites it, so any match in house style there is untraced | |
| R-76 | — | Dropped: informs the api repo's AGENTS.md, which cites it — house style for an app repository, not a spec decision (D-104) | |
| R-77 | — | Dropped: describes the sibling api repo's convention and drove no spec decision (D-104); no app AGENTS.md cites it, so any match in house style there is untraced | |
| R-78 | — | Dropped: describes the sibling api repo's convention and drove no spec decision (D-104); no app AGENTS.md cites it, so any match in house style there is untraced | |
| R-79 | — | Dropped: describes the sibling api repo's convention and drove no spec decision (D-104); no app AGENTS.md cites it, so any match in house style there is untraced | |
| R-80 | — | Dropped: describes the sibling api repo's convention and drove no spec decision (D-104); no app AGENTS.md cites it, so any match in house style there is untraced | |
| R-81 | — | Dropped: informs the api repo's AGENTS.md, which cites it — house style for an app repository, not a spec decision (D-104) | |
| R-82 | — | Dropped: describes the sibling api repo's convention and drove no spec decision (D-104); no app AGENTS.md cites it, so any match in house style there is untraced | |
| R-83 | — | Dropped: describes the sibling api repo's convention and drove no spec decision (D-104); no app AGENTS.md cites it, so any match in house style there is untraced | |
| R-84 | — | Dropped: informs the api repo's AGENTS.md, which cites it — house style for an app repository, not a spec decision (D-104) | |
| R-85 | — | Dropped: informs the api repo's AGENTS.md, which cites it — house style for an app repository, not a spec decision (D-104) | |
| R-86 | — | Dropped: informs the api repo's AGENTS.md, which cites it — house style for an app repository, not a spec decision (D-104) | |
| R-87 | — | Dropped: describes the sibling api repo's convention and drove no spec decision (D-104); no app AGENTS.md cites it, so any match in house style there is untraced | |
| R-88 | — | Dropped: describes the sibling api repo's convention and drove no spec decision (D-104); no app AGENTS.md cites it, so any match in house style there is untraced | |
| R-89 | — | Dropped: describes the sibling api repo's convention and drove no spec decision (D-104); no app AGENTS.md cites it, so any match in house style there is untraced | |
| R-90 | — | Dropped: describes the sibling api repo's convention and drove no spec decision (D-104); no app AGENTS.md cites it, so any match in house style there is untraced | |
| R-91 | — | Dropped: informs the api repo's AGENTS.md, which cites it — house style for an app repository, not a spec decision (D-104) | |
| R-92 | — | Dropped: describes the sibling api repo's convention and drove no spec decision (D-104); no app AGENTS.md cites it, so any match in house style there is untraced | |
| R-93 | — | Dropped: informs the api repo's AGENTS.md, which cites it — house style for an app repository, not a spec decision (D-104) | |
| R-94 | — | Dropped: describes the sibling api repo's convention and drove no spec decision (D-104); no app AGENTS.md cites it, so any match in house style there is untraced | |
| R-95 | — | Dropped: describes the sibling api repo's convention and drove no spec decision (D-104); no app AGENTS.md cites it, so any match in house style there is untraced | |
| R-96 | — | Dropped: informs the api repo's AGENTS.md, which cites it — house style for an app repository, not a spec decision (D-104) | |
| R-97 | — | Dropped: informs the api repo's AGENTS.md, which cites it — house style for an app repository, not a spec decision (D-104) | |
| R-98 | — | Dropped: informs the api repo's AGENTS.md, which cites it — house style for an app repository, not a spec decision (D-104) | |
| R-99 | — | Dropped: informs the api repo's AGENTS.md, which cites it — house style for an app repository, not a spec decision (D-104) | |
| R-100 | — | Dropped: describes the sibling api repo's convention and drove no spec decision (D-104); no app AGENTS.md cites it, so any match in house style there is untraced | |
| R-101 | — | Dropped: informs the api repo's AGENTS.md, which cites it — house style for an app repository, not a spec decision (D-104) | |
| R-102 | — | Dropped: describes the sibling api repo's convention and drove no spec decision (D-104); no app AGENTS.md cites it, so any match in house style there is untraced | |
| R-103 | — | Dropped: informs the api repo's AGENTS.md, which cites it — house style for an app repository, not a spec decision (D-104) | |
| R-104 | — | Dropped: describes the sibling api repo's convention and drove no spec decision (D-104); no app AGENTS.md cites it, so any match in house style there is untraced | |
| R-105 | — | Dropped: informs the api repo's AGENTS.md, which cites it — house style for an app repository, not a spec decision (D-104) | |
| R-106 | — | Dropped: informs the api repo's AGENTS.md, which cites it — house style for an app repository, not a spec decision (D-104) | |
| R-108 | — | Dropped: informs the api repo's AGENTS.md, which cites it — house style for an app repository, not a spec decision (D-104) | |
| R-109 | — | Dropped: informs the api repo's AGENTS.md, which cites it — house style for an app repository, not a spec decision (D-104) | |
| R-110 | — | Dropped: informs the api repo's AGENTS.md, which cites it — house style for an app repository, not a spec decision (D-104) | |
| R-111 | — | Dropped: describes the sibling api repo's convention and drove no spec decision (D-104); no app AGENTS.md cites it, so any match in house style there is untraced | |
| R-112 | — | Dropped: informs the api repo's AGENTS.md, which cites it — house style for an app repository, not a spec decision (D-104) |