WootBuild.

Studio/Doctrine/Standards

docs/17-STANDARDS.md

17 — Standards: naming, numbering, labelling, state

Justin, 2026-08-19: "standards about process, tagging, naming, numbering, labeling, states, et cetera, having that be consistently integrated on each and every build."

Every identifier this studio produces is listed here, with one owner in code and, wherever it is possible, one check that fails if it drifts. A standard that lives only in a document is a preference; the column that matters below is "enforced by."

The rule behind all of them: an identifier that changes when the thing it names has not changed is not an identifier. Everything mutable — verdict, state, rarity, style version, file path — is a field, never part of a name.


The identifiers

ThingFormExampleOwnerEnforced by
Clientlowercase slug, no spacesdocadedirectory name under clients/art client sync
Lanelowercase slugplushkey in client.yaml lanes:unknown lane fails loudly and names the real ones
Style<id>@<version>docade-plush@1lib/style.mjsevery committed style.yaml parses
Asset idCLI-LAN-NNNNDOC-PLU-0042lib/assetid.mjswell-formed + globally unique
Manifest key<set>/<piece>frostline/narwhalthe client's own CSVit is a foreign key — see below
Run idr-YYYYMMDD-xxxxr-20260819-b3b6lib/pipeline/run.mjsdate-sortable by construction
CandidateNNN-<subject>-<variant>005-narwhal-alib/pipeline/generate.mjsindex-ordered, stable within a run
LessonL-NNN per styleL-010styles/<s>@<v>/lessons.mdappend-only; numbers never reused
Craft entrytopic slugset-coherencedocs/corpus/craft/loaded into every compile
Model corpusrouter key, not vendor idgemini-flash-image.mddocs/corpus/models/routed model must have an entry whose model_id matches the router
IssueATL-NATL-24Linear team ATLLinear is a view; files win any disagreement
Canon docNN-TITLE.md15-LANE-WORKFLOW.mddocs/numbers are permanent; a superseded doc is rewritten, not renumbered
Contact sheetcontact-<lane>-at-<px>.pngcontact-plush-at-80.pngart contactregenerable — never hand-made
Setlowercase slug, first segment of the manifest keysunbakedthe client's own CSVa unit of judgement and a section of the board — never its own file
Boardone per lanedocade/plushlib/pipeline/board.mjsart review <target>; sets are sections on it
Target<client>/<lane>[/<set>], or a run iddocade/plushlib/pipeline/sets.mjswhat art review and art verdict both take
Piece nameleaf, or full manifest keyjackalope · sunbaked/jackaloperesolvePiece in lib/pipeline/sets.mjsambiguity is an error naming both, never a guess

Asset id

DOC-PLU-0042
└┬┘ └┬┘ └─┬┘
 │   │    └── sequence, per client + lane, never reused
 │   └─────── lane, three letters
 └─────────── client, three letters

Assigned before the image exists, so a slot that fails to generate still has a name — a retryable slot with no identity is indistinguishable from one nobody asked for. Derived by scanning sidecars rather than from a counter file, because a counter is a second source of truth that can disagree with the files.

Never encoded: style version, rarity, set, verdict, file path. All of those change over an asset's life.

Manifest key — treat it as a foreign key

<set>/<piece> is not a label, it is collectibles.image_key in the client's own database. A subject typed by hand can drift from the key by one character and deliver art the app cannot see. Always brief from the manifest (art brief --from-manifest), which pulls the key, the path, the rarity and the client's own brief text together.


Target — what a board is a board of, and what a verdict addresses

A run id addresses a billing event. A target addresses what a person sits down to review. They are different things and not interchangeable: one set routinely spans several runs, because a re-run of one piece is its own run.

art review  docade/plush                    art verdict docade/plush --approve gecko,pika
art review  docade/plush/sunbaked           art verdict docade/plush/sunbaked 111411
art review  r-20260819-b3b6                 art verdict r-20260819-b3b6 1123

One board per lane. A set is a unit of judgement (16-PRODUCTION-DOCTRINE.md level two) and a section of that board — it is not its own artifact. The rule, because it was got wrong in both directions inside one session:

The judging unit decides the layout. The reviewer's sitting decides the file.

Verdicts address a piece by name, not by position. A name is the piece's leaf when that is unique in the lane, or its full manifest key always. A positional string survives for one set or one run — a small grid you can count, and a run's candidates have no manifest name to be called by.

Positions were the original grammar and they do not scale past a screen: sixty characters of which fifty-eight mean "leave alone" is not a command anyone can check, and every surface that indexes into a position has to agree on an ordering forever. Names are order-independent, readable in a commit a year later, and the same words a person says out loud.

Vocabulary that is one letter apart

approve is a verdict. approved is an asset state. They are different vocabularies and the difference has already shipped a bug: the console's board page compared piece.state === 'approve' and rendered 0/6 for every set while the header above it correctly read 20 of 60.

Never compare a state to a literal. Ask stateMeta(state).ships. npm run check greps the console for .state === '<verdict>' because nothing else would have caught it.

Labelling: every image carries a state, always

lib/states.mjs is the only place an image's state is decided, and every surface calls it. There is no unlabelled asset: a candidate with no verdict is in-review, which is a state, not an absence.

StateShipsMeans
delivered✅Written into the client's repo at its real key path
promoted✅Human-approved and promoted to canon — the compiler reads it later
approved✅A human said yes
machine-approved❌The grader passed it. Not seen by a human, does not ship on this alone
in-review❌Generated, waiting on a verdict
revise❌A human asked for a change. Re-running costs money, so never automatic
qa-failed❌Failed mechanical QA
rejected❌A human said no. Kept with the reason
superseded❌Replaced by a later candidate for the same piece
failed❌The provider returned nothing. A retryable slot, not an asset

The four rules, each closing a bug that actually happened

  1. State is recorded, never inferred. Not from a directory, not from which step ran last. A normalized file once appeared under a heading reading ready to ship purely because normalize had run on it.
  2. A step that changes what an asset IS must update its state. normalize once wrote the cut-out file without touching the sidecar, so a generation-time "no alpha channel" verdict outlived the step that supplies the alpha and 28 finished assets reported qa-failed.
  3. One candidate is current; everything else is history — on the board, in Approved, and in every queue. Filtering a queue on "the key is settled" is not the same rule, and it let three attempts at one piece all ask to be judged.
  4. A grader verdict is not a human verdict. Every verdict records verdict_by, and machine-approved is its own non-shipping state. Autonomy 2 and 3 let the grader write through the same code path — that is the design — but a machine pass must never be indistinguishable from a person's.

Tagging: attribution and reasons

FieldOnMeans
verdictcandidate sidecarapprove · reject · revise · promote
verdict_bycandidate sidecarhuman or the grader. Never omitted
critiquecandidate sidecarwhy — and History groups by this, because the reason is the reusable part
critique_bycandidate sidecarwho wrote the reason
qacandidate sidecarthe check that reflects the file as it is now
qa_at_generationcandidate sidecarthe original check, kept because "the model cannot emit alpha" stays true
asset_idcandidate sidecarassigned before the image exists
hue / hue_namebrief subjectcomputed by lib/hueplan.mjs, validated before the estimate

A superseded candidate without a critique is a lost lesson. Ten pieces redone for one reason is one lesson, not ten events — which is why History groups by reason rather than by date.


Piece kind — what a lane assumes is uniform, and what is not

Owner: lib/piecekind.mjs. Declared per piece in the style sheet as kind:.

A lane means one production method. It does not mean one file shape, and it took two failures on the same lane in one day to establish that:

Assumed uniformActually per pieceFixed by
Sizedocade/crane is one visual family at 1024, 512, 256 and 128ATL-39 — spec: from-manifest, one run per size
Groundthe claw keys out; the cabinet carries its own lit interiorkind: — one run, per-piece mechanics
KindThe runtimeAlphaCutoutGround asserted in the prompt
moving (default)moves it independentlyyesyesdrawn alone on the lane's flat keyable colour
scenecomposites over itnonodrawn as a lit scene carrying its own background and depth

Three rules make it a standard rather than a field:

  1. A kind decides mechanics only — alpha, cutout, ground clause. Never register, palette, construction or light: those are what make fifteen files belong to one machine, and a kind that started deciding them would be a second style sheet wearing a mechanical name.
  2. The default is moving, and it is the safe default rather than the common one. An unnecessary cutout is visible in review; a missing one delivers a welded background into a client repo.
  3. A mixed-kind lane states no lane-wide ground rule. qa.must, normalize and spec_defaults hold the base; the kind swaps its own in. Enforced — npm run check fails on a ground assertion in a mixed lane's lane-wide must.

Why the check exists. docade-crane@4 split the ground rule per piece in prose and left @3's "flat #262c4a ground, uniform across the whole frame" in the lane-wide qa.must. The first probe came back correct against the new register and failed mechanical QA against the old declaration — "spec requires transparency and the file has no alpha channel". Prose moved; three declarations did not. That is docs/16's line, and this is it enforced.

Unlike size, transparency does not split the run: no routed model emits alpha, normalize supplies it, so it is a downstream stage rather than a routing constraint. One run, one model, one estimate, two contracts.

The architectural half of this — whether cabinet-frame should be a component at all or a composed plate — is clients/docade/advisory-13.md, and only the client can answer it.


Process standards

Standard
Verdict grammarone keystroke per candidate in grid order — 1 approve · 2 reject · 3 revise · 4 approve+exemplar · . undecided
Batch sizesix, matching a set. Big enough to judge coherence, small enough that a bad batch costs one set
Spendevery generating command estimates first. art brief prices for free
Commitssay what changed and what was wrong — the corrections are the valuable part
Linearmaintained continuously, not at session end. Close when work lands; file when work is discovered
Lessonsnever speculative. Every entry names the run that produced it
Canondocs/ outranks anything inferred from code. If they disagree, the code is wrong or the doc is stale — say which
Piece kinda lane may hold more than one mechanical contract. Declared per piece, never inferred from a name — see above

Adding a standard

  1. Give it exactly one owner in code.
  2. Add a row above.
  3. Add a check to scripts/check.mjs that fails when it drifts.

If step 3 is impossible, say so in the row. An unenforceable standard is worth having and worth being honest about; an unenforceable standard presented as enforced is how the studio ends up believing things that stopped being true.