WootBuild.

Studio/Doctrine/The lane

docs/15-LANE-WORKFLOW.md

15 — The lane workflow

Every lane, for every client, moves through the same six phases. This document is the standard; lib/workflow.mjs is its executable form, and the console renders from that rather than hardcoding a shape per page.

Written 2026-08-18, after the docade plush lane was built by hand and the console had to be redrawn three times to keep up with it. Justin: "I would rather give it the attention it needs now rather than us keeping on slowing down and having to redraw the same process."

Why it is standard

A lane's work is the same regardless of what it draws: settle the look, judge the output, track what is left, keep what was kept, remember what was wrong, ship. A glyph lane and a plush lane differ in content, not in sequence. The per-client variation that does exist — an extra gate, a different delivery target — is a modification of this, never a fresh invention.

The failure this replaces is worth naming, because it will recur otherwise: the lane page grew by appending. New work went to the bottom, superseded work stayed where it was, and position on the page had to carry meaning it could not carry. A rejected batch sat under a heading that said ready to ship, directly below the batch that replaced it. Nothing was factually wrong in the data; the page was rendering files where it should have been rendering state.

The seven phases

Declared once in lib/workflow.mjs. Adding one is an edit to that declaration plus a view — never a hunt through components that each assumed how many there were.

#PhaseGateWhat it showsWhat advances it
1The field✅ humanOne sheet: rows are pieces, columns are models, at judging sizeA human picks a model and writes down why. art bakeoff
2The look✅ humanDirections to choose between, then the confirmed contract — plate, palette, rarity, QAA human confirms the reference sheet
3Review✅ humanEach set awaiting a verdict, at the size it is judged atA verdict on every piece
4The board—Every requested piece and the one candidate currently representing itProducing and approving what is still empty
5Approved—The version that counts, per pieceNothing — a record, and the source of exemplars
6History—Superseded and rejected attempts, grouped by the reasonNothing — kept because the reasons outlive the images
7Delivered—Assets written to the client's real key pathsart deliver, never below autonomy 3 without a verdict

The field is first, and that ordering is the point. A style is forged against a model's behaviour — its register, its ground discipline, what a reference plate does and does not carry on that family. Settle the look first and pick the model after, and you have forged a contract against a model nobody compared; every later disagreement then reads as a prompt problem. The first bake-off cost $1.96 and overturned a routing decision that had been standing on price and availability.

It is per LANE, never per client. A client does not have "a model"; a lane does. docade's plush is stylized-volume raster, glyph is 58 vector marks at 24px, brand is typographic — the model that wins a soft-toy race has no claim on a typography lane. A client is allowed to be a hybrid, and a bake-off result must not be promoted to a house decision.

The A/B fork — two options for one piece

Review normally asks is this good enough. Sometimes the useful question is which of these, and the studio can carry both answers and defer the choice.

art ab <client>/<lane> --with <model>

Every piece in the lane still awaiting a verdict gets a second option from another model, and the review board splits that cell in two. Picking one settles the piece and supersedes the other in the same act — there is no way to leave a fork half-resolved.

Three things about it are deliberate:

What it costs is attention, not money. Forty open pieces becomes forty choices between two images, which is slower per piece than a yes or no. Fork when a model decision has genuinely changed and the existing work is worth keeping — not by default.

The two rules that make it work

Only a gate can block a person. A phase is awaiting if and only if it is a gate and a human decision is outstanding. That is what makes "what needs me?" a computed answer rather than a judgement — the studio home page and every lane header derive their queue from exactly this, so a new phase joins the queue by declaring gate: true and nothing else has to change.

One candidate is current; everything else is history. For any manifest key the current candidate is the best verdict, then the newest — promote, approve, awaiting, revise, reject. Only the current one appears on the board, in Approved, or in a review queue. Everything else moves to History and is rendered desaturated so it can never be mistaken for work that ships.

That second rule is the one that was missing, and its absence is what produced the "two sets of different animals, one with a completely different look" problem. Both sets were real; one had been superseded hours earlier.

Every image carries a state

Justin, 2026-08-18: "All images should then be given a state so we never produce or have an image that is labeled anything other than what it is."

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✅Approved by a human and promoted to canon — the compiler reads it when writing later batches
approved✅A human said yes
machine-approved❌The grader passed it. Not seen by a human, and 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 it is never automatic
qa-failed❌Failed mechanical QA — wrong size, missing alpha, a cutout that left a rectangle
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

Three rules follow, and each closes a bug that actually happened:

State is recorded, never inferred. It cannot be derived from which directory a file sits in or which step last touched it. A normalized file once appeared under a heading reading ready to ship purely because normalize had run on it — a claim about a verdict, inferred from a mechanical fact.

superseded is a fact about a relationship, not about a candidate. The same approved image is current until something better replaces it, which is why assetState() takes isCurrent from the caller.

A grader verdict is not a human verdict. Every verdict records verdict_by, and machine-approved is its own state that does not ship. Autonomy levels 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.

Every image has an id

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

Assigned in lib/assetid.mjs 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.

Deliberately not encoded: style version, rarity, set, verdict, file path. Every one of those changes over an asset's life — a style forks, a piece is re-rendered, a verdict is revised — and an identifier that changes when the thing it names has not changed is not an identifier. Those all live as fields on the sidecar, where they are free to change.

The sequence is derived by scanning sidecars rather than kept in a counter file, because a counter is a second source of truth that can disagree with the files. When scanning stops being cheap, that is a real signal about storage — not a reason to keep a shadow counter now.

npm run check asserts every id is well-formed and unique. Two images answering to one name is a correctness bug.

On putting a database behind this

Asked twice, and the answer is still not yet — but the reasoning matters more than the answer, because the trigger is written down.

What a database would fix: nothing we currently have. Every verdict, state and id is recorded in files, rebuildable with art reindex, and diffable in git. The two console bugs that shipped wrong information — an empty board, then zero approvals — were both .vercelignore excluding tracked files. A database would have moved the same wrong information somewhere more expensive to inspect.

What would genuinely trigger it, from 00-CHARTER.md and unchanged:

  1. A second machine, or a second person, needing to write.
  2. Write-back from the browser — clicking approve on the console rather than telling Claude. That is the charter's named trigger and it is a real one.
  3. Scanning stops being cheap. Ten clients at docade's scale is ~2,500 sidecars; that is still milliseconds. A hundred thousand assets is not.

The work done here is what makes that migration cheap when it comes. Stable ids, one authority for state, and a declared workflow are the things you need before a database, not after — without them, a migration just moves an ambiguous model into a schema and freezes it.

Per-client and per-lane modification

Expected. A lane in client.yaml may carry a phases: key to add or reorder. The core six stay, because dropping one means dropping either a gate or a record, and both have already been shown to cost more than they save.

What legitimately varies:

Adding a phase

  1. Add it to LANE_PHASES in lib/workflow.mjs with its slug and gate.
  2. Add console/app/clients/[id]/lanes/[lane]/<slug>/page.jsx.
  3. npm run check asserts every declared phase has a page — the two cannot drift, because a phase in the nav with no page is a dead link and a page with no declaration never appears in the nav.