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.
| # | Phase | Gate | What it shows | What advances it |
|---|---|---|---|---|
| 1 | The field | ✅ human | One sheet: rows are pieces, columns are models, at judging size | A human picks a model and writes down why. art bakeoff |
| 2 | The look | ✅ human | Directions to choose between, then the confirmed contract — plate, palette, rarity, QA | A human confirms the reference sheet |
| 3 | Review | ✅ human | Each set awaiting a verdict, at the size it is judged at | A verdict on every piece |
| 4 | The board | — | Every requested piece and the one candidate currently representing it | Producing and approving what is still empty |
| 5 | Approved | — | The version that counts, per piece | Nothing — a record, and the source of exemplars |
| 6 | History | — | Superseded and rejected attempts, grouped by the reason | Nothing — kept because the reasons outlive the images |
| 7 | Delivered | — | Assets written to the client's real key paths | art 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:
- A fork is declared, not inferred. Alternates carry
option: '<label>'. Inferring a fork from "more than one undecided candidate" was tried and was wrong within a minute: docade's Stage 4.5 calibration probes answer real manifest keys on purpose, sodeepsea/octopushad sixteen "options". A sandbox probe is not an offer. - Judged pieces are left alone. An approved candidate outranks an undecided one, so an alternate beside it would arrive already superseded. Forcing a fork there means clearing a human's verdict to re-litigate a settled judgement — a separate and much louder decision.
- It needed no new asset state. The model already required a superseded candidate to carry a reason, so "undecided and unexplained" was already the signature of a choice nobody had made.
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.
| State | Ships | Means |
|---|---|---|
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:
- A second machine, or a second person, needing to write.
- 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.
- 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:
- Delivery target. docade delivers to manifest key paths; salon-structure delivers to a branch and never to
main. - Extra gates. A client with legal or brand review adds a gate between Approved and Delivered.
- What "the look" settles. A vector lane settles a glyph system; a plush lane settles a reference plate. Same phase, different contract.
Adding a phase
- Add it to
LANE_PHASESinlib/workflow.mjswith itsslugandgate. - Add
console/app/clients/[id]/lanes/[lane]/<slug>/page.jsx. npm run checkasserts 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.