Skip to content

ADR-0051: A QR label carries csa:chemical-record/ and a key that survives the database that issued it

Status: Accepted Date: 2026-09-11

Decisions

D-117 A Chemical Record carries a database-generated UUID, its label key, and that key — never the row number — is the id a device and a label know it by

ChemicalRecord.labelKey is a unique UUID generated by the database. The snapshot ships it as the record's id. The autoincrement row number stays internal to the store that issued it and is not selected for the snapshot.

D-118 The QR payload is csa:chemical-record/<labelKey>, carrying identity and nothing else

The scanner accepts a payload only when it starts with the prefix and the id after it matches a bounded pattern that a UUID satisfies. Anything else — a supplier's own code, an asset tag, a URL, or an id that merely looks plausible without the prefix — is an unrecognised label, stated as one, and never treated as an id. The generator refuses to print a label the scanner would reject. A label carries the product, supplier and revision date in words beside the code, and no Safety-Critical Content in either half.

Context

D-63 puts QR label generation in Curation scope and says nothing about what goes in the code. The scanner's own TODO(spec) said the format was provisional and had to be agreed with the Curation tool before any label is printed, because a mismatch is discovered on a drum, in a plant, by someone who needs an answer. Ticket 30 built the generator to the decoder's contract and asserted the agreement end to end — generate, rasterise, decode the pixels through the same library the camera path uses, identify — and left the TODO standing rather than settle a printed format unilaterally. The grill of 2026-09-11 ruled it be recorded.

Writing it down exposed the id. The payload carried ChemicalRecord.id, a Postgres autoincrement integer. That is stable inside one database and reassigned by any other. Under D-110 every curator curates into their own, and a Corpus re-curated into a fresh store renumbers from one. A label printed for record 42 — acetone — would then scan, confidently, to whatever the new store calls 42. The printed words beside the code are the only guard, and a Handler scanning a drum is scanning so as not to read it. The owner ruled on this the same day (grill Q8a).

A database-generated UUID fails the other way. A label from a rebuilt store names a key no record carries, and the scan says Not In Corpus — visibly wrong, never quietly wrong. It is generated by the database rather than by Prisma so that every insert path gets one, and so the migration adding the column gives any existing row its own.

The same change removes one of blocker 13's two mismatches: the client already models a record id as a string, and the snapshot now ships one.

Decision

Labels are printed with csa:chemical-record/ and the record's label key. The key is a database-generated UUID, and the row number never leaves the store.

Rejected options

  • Keep the row number — rejected. It fails open, to a different chemical, the first time a Corpus is curated into a second database.
  • Encode the identity triple — rejected. A long mixed-script payload, and two undated sheets for the same product and supplier collide, which D-51 admits and ADR-0024 records as a consequence the curator must notice.
  • A Prisma-generated UUID — rejected. A value set by the client library is absent from any row written another way, including the rows present when the column is added.
  • Put safety content in the code — rejected, as ticket 30 did. A label carrying hazard text is a second, unversioned source of truth that no snapshot can update, and the container outlives the sticker.
  • Treat an unprefixed payload as an id when it matches the pattern — rejected. It would identify a container by a supplier's own asset tag.

This ruling may not be re-decided

If a change contradicts this ADR: stop and raise it. Do not implement over it.

Specifically: do not change the prefix, do not print the row number, do not generate the key outside the database, and do not let the scanner fall through to reading an unprefixed payload as an id. Every one of these strands labels already on containers, or makes them lie.

Consequences

What becomes true. A printed label resolves to its record or to Not In Corpus, and never to another record. The key costs nothing to carry: 36 characters, well inside the pattern's bound and a QR code's capacity at label size.

What this costs. Labels are reprinted after the Corpus is rebuilt in a fresh store — which is also the only case where the old ones would have been dangerous. A fifth migration joins four unapplied ones, produced by prisma migrate diff against no live database.

What it does not settle. Whether a device can load the snapshot carrying these keys at all: blocker 13 records that the two sides' snapshot shapes do not meet, and the owner has ruled that the server emits the client's shape. Until that lands, the round trip is asserted in the client's own fixtures only.

Coverage

No upstream finding lands here. The format came from ticket 30's agreement between the two programs, and the key from reading the schema while writing this (D-104).