18 — The client bridge
How a project and WootBuild talk to each other. This is the playbook for putting a new client on the bridge, and the record of the day it was built and tested end to end — 2026-08-19, docade, both terminals open.
Declared in lib/bridge.mjs. Run with art intake <client>. Described here.
1. What problem this solves
WootBuild reads a client's repo to know what to make. Before this existed, that reading was implicit: docade's work-list path was a comment in client.yaml and a default parameter in lib/manifest.mjs. Both correct for docade, neither a declaration, so client #2 had nowhere to say it differed.
And the reading was destructive: syncManifest read and wrote in one call, so there was no moment at which the old and new work-lists both existed. That is how docade's catalogue went 246 → 336 with nothing noticing until a person happened to re-read it (clients/docade/advisory-04.md).
The client side was worse: a docade session had no way to say it needed something. Their manifest is a catalogue — planned, deliberate, hand-edited — and the wrong shape for a need discovered mid-task. A session that found one could make the image itself or forget. The first is worse.
2. The shape
Reads seamless, writes gated. That asymmetry is the whole design and it does not soften as the tooling improves.
| Reads | Declared, hashed, diffed. As frictionless as we can make them |
| Writes | Behind a human verdict — hard rule 3 — no matter how good the transport gets |
| Async messages | Advisories. Files, in git, surviving both sides being offline. The only channel that also works for a client that is neither local nor a repo, so it is primary rather than a fallback |
No package. Clients install nothing. A package would put our release cadence inside every client's build, and the only thing it could usefully carry — how to emit a work-list — is the thing each client already does its own way. Their manifest is theirs; consuming it is not a licence to dictate its shape.
What they get instead is clients/<id>/bridge.md, generated from their client.yaml and rendered at /c/<id>/doc/bridge. A page, not a dependency, and a check fails if the page and the declaration disagree.
2a. THE DOORS OUT OF THE STUDIO — which one, and why there is more than one
Read this before writing anything into a client repo. There is exactly one way for an ASSET to leave and exactly one way for SOURCE to leave, plus two commands that make a piece eligible for the first. Everything else is refused, including a direct Write — .claude/hooks/guard-write.sh denies it and names this page.
| What moves | The gate | When | |
|---|---|---|---|
art deliver | an APPROVED candidate → the path the client's own manifest declares | a human verdict, and lib/autonomy.mjs asking whether the lane may deliver at all | the normal path. Everything the studio produces to order |
art mount | a declared SOURCE module → a path the mounts: block names | the declaration, which is committed, diffed and reviewable | a playable, a module — anything with no candidate to judge |
art adopt | an AUTHORED file → becomes a candidate | none, deliberately: it lands with NO verdict | drawn geometry, an authored SVG, a .riv — anything a model did not make |
art rekey | binds an existing APPROVED candidate to a key that did not exist when it was made | the verdict must already exist and be a human's | work the studio HELD before the client wrote a row |
adopt and rekey are not exits. They put a piece into a state where deliver will consider it, and then deliver applies every gate it always does. Neither can move a file into a client repo and neither shortens the review path by a single step.
The one sentence that decides it
An asset is judged. Source is declared.
An asset has a candidate, an id, a size the manifest states and a verdict a person gave — so deliver can check all four. A source module has none of those, and inventing a fake candidate to smuggle it through deliver would put an unjudged file behind a gate that says "judged". So it gets its own door, with its own gate, and the gate is that a human wrote a declaration into a file that gets reviewed.
What no door does
- None of them stages, commits or pushes. Files land in the client's working tree and stay theirs. A delivery that lands inside somebody else's commit is how art ends up in a docs PR — which nearly happened on 2026-09-09.
- None of them invents a destination.
deliverreads the client's manifest;mountreads a declaration. A path nobody wrote down cannot be written to. - None of them escapes. Source must be inside the studio, destination inside the client's declared root, both checked after
resolve()— a prefix test on the raw string accepts../sibling-repo. - Every one of them reads its own write back. A write that does not happen throws nothing.
A destination is the client's
Every door reads a destination from a declaration, and none of them owns one. deliver reads the client's manifest, which is theirs by construction. mount reads a mounts: block in clients/<id>/client.yaml, which is ours — and that is the one place the studio can hold an opinion about the shape of somebody else's repo without noticing that it is holding one.
Gizmo's game.js was declared into src/lib/games/gizmo/. docade moved it to vendor/gizmo/ the day it landed, and their reason was better than ours:
their spelling check excludes
vendor/because rewriting a delivered artifact is "invisible to both sides and survives right up until the next sync" — and the same comment sets the limit: the exclusion "must NOT cover anything ported OUT of here intosrc/: that code is ours the moment it lands."
The module is not ported. It is vendored verbatim against a sha in a receipt, so the whole value of the file is being byte-identical — and in src/ its British spellings become twelve things to fix, each one a silent divergence from the studio's copy.
The declaration is not a no-op once the file has landed. It said src/ for a day and would have written there again on the next mount — a second copy, at the path the client had already rejected, with nothing failing. A client moving a delivered file is not tidying; it is an amendment to the declaration, and the declaration is the thing to change.
A stale destination is invisible from this side. Nothing here can see that a file moved; a mount whose target is already correct reports identical and rewrites no receipt, so the receipt keeps naming the old path. Ask, or look.
A delivery lands in a working tree, and that tree is somebody's work in progress. bridge.delivers.branch is optional and the CLIENT'S to set: declare it and art deliver refuses when their HEAD is elsewhere; leave it absent and there is no opinion. The studio never checks out a branch in a client's repo — moving somebody else's HEAD is worse than landing in the wrong place, and it is the same authority the studio declined when docade offered it the other way. What the studio owes instead is the list, before it writes: the plan pass prints every file it would place, so it can be sent and the client can park.
And a stale receipt is worse than a missing one, because it answers the question confidently. A missing record sends the reader to look; a wrong one ends the search. That is docade's line, and it holds for every record this studio writes into somebody else's repo — a delivery receipt, a mount receipt, an advisory. A record regenerated only when something changes goes stale exactly when the change was to the record's own subject.
Why this list is short on purpose
Each door was added the day something legitimate could not move, and each one was written as narrowly as that day required:
| Forced by | |
|---|---|
art adopt | docade's capsule — the one piece of art in a machine that draws everything else, on disk since the first claw lab, with no way in (ATL-73) |
art rekey | twelve avatars the studio produced before docade asked for them; when the rows arrived the art was structurally undeliverable (ATL-95) |
art mount | Gizmo's game.js — 198KB with no candidate, no verdict and no row. Our guard permits one path in; docade's rules forbid them reading out. Neither side could move it and neither side was wrong (ATL-96) |
A fifth door should be equally hard to justify. The question is never "how do I get this file over there" — it is what kind of thing is this, and what would make it safe to move.
3. Onboarding a client onto the bridge
- Declare
bridge.readsinclients/<id>/client.yaml. Every path WootBuild takes as input, each with a role. Roles are the point: "the brief changed" and "the work-list changed" are different events and must not collapse.
| Role | What it is | Unique |
|---|---|---|
worklist | the machine-readable piece list — the work itself | yes |
brief | prose about intent; a person reads it, nothing derives | no |
style | brand guide or identity package | no |
requests | append-only drop-box for needs found while working | yes |
Add optional: true for a path that does not exist yet — a drop-box nobody has dropped into is a valid state. Declare it anyway: the path must be agreed before the first request, or the first request goes somewhere nobody looks.
Add held: where we keep a copy of one of their directories, and held_authored: [...] for files in our copy we wrote ourselves and that are supposed to differ.
- Declare
bridge.delivers.receipt— where an index of delivered work goes in their repo. Agreed before the first delivery rather than invented at delivery time. (ATL-40 — declared, not yet written.)
art intake <client>— reports, writes nothing.art intake <client> --accept— records the snapshot, the source hashes and the regenerated contract page. Accepting is a human act; reporting is not.
- Hand them
clients/<id>/bridge.md. It ends with the block they paste into theirCLAUDE.md.
4. What goes in the client's CLAUDE.md — and why not memory
Posture belongs in their CLAUDE.md, not in anyone's memory. Memory is per-person and per-machine. This has to hold for every session and everyone who works there, so it goes in the file that is checked in and inherited.
The block is advisory, with a real carve-out, and the carve-out is what keeps it from being ignored: a diagram, a chart, a placeholder, anything internal or temporary gets made in their repo and nobody routes a two-minute job through a studio. Authored SVG beats a diffusion model for anything with real text in it.
What stays firm is narrow: do not ship product art made by handing a screenshot to an image model. The style contract and the review exist so piece 40 still looks like piece 1, and the mismatch surfaces at 80px in a card grid rather than on the screen of whoever made it.
The operative instruction is keep going: filenames are foreign keys, so the key can exist before the art does. Write the code, the migration and the document against the path now; the file arrives underneath it later.
5. The live test — what it looked like, and what it found
Three rounds, both terminals open, WootBuild facilitating. Worth repeating for the next client, because every round found something the design had not anticipated.
Round 1 — adopt the protocol, file one real request. docade was told to pick the request themselves, which is the only version of this that tests anything: a session that knows its product and nothing about WootBuild filed empty-states/today-done with a caveat explaining why it deliberately did not add the manifest key. Parse was faithful, including both multi-paragraph fields.
Round 2 — three edits at once, designed to be distinguishable: a respec, an addition inside a lane'd category, an addition with no lane. All three came across distinctly and NEEDS A LANE fired for the first time against real data.
Round 3 — the revert. Their check was better than mine: they regenerated the CSV from the reverted manifest and compared byte-for-byte, which proves the revert is complete at the source rather than only at the output. A hash on the output cannot tell you that.
What the test found that the design had not
- Role separation is load-bearing, and an accident proved it. docade did not regenerate
ART-BRIEF.md. One run reportedworklist CHANGED/brief same, which no designed step would have shown. - The spec sweep is a tripwire, not an audit. Respeccing one piece to 1024 put
plush— 20 approved pieces, clean twenty minutes earlier — in contradiction with its own spec immediately. art auditandart intakeanswered different questions and looked like they disagreed. Audit reads the accepted snapshot, correctly; intake reads the repo live. Audit now says which snapshot it read, and only when the two have diverged. SeeworklistStale().- A mis-specified pass condition. The revert was expected to report
-2 removed; it reported no change, because Round 2 was never accepted. Correct behaviour — we never agreed to the test edits, so there was nothing to un-agree. The removal path was then tested separately against our own snapshot. Intake diffs against what was ACCEPTED, never against what was last seen.
6. What the bridge does not do
- It never writes into a client repo. Read-only, always, including during intake.
- It never creates a lane. A new lane is forged by a person and its first phase is
field— the bake-off — which is a gate. - It never costs or specs a request. That is
art brief. - It does not see their exit codes. docade mentioned
npm run assets:sheethad been failing for some time; WootBuild had no visibility into it at all. The bridge reads files, not the health of the other side's checks. Worth knowing before assuming silence means agreement. - It does not see their PLACEMENTS, so a clean spec sweep can be wrong. A flat per-category spec is safe exactly as long as every placement in the lane is a fixed number. The moment one becomes viewport-derived — docade's entry heroes are
min(32dvh, 100%), computed at render and declared nowhere a manifest can see — the lane needsfrom-manifest, and no check on either side will say so.specMismatchescompares declared size against declared size; both agreed, andsignin-herowas served as an enlarged 480 against a 320 row on the first screen a parent ever sees. It surfaced as no fault, no 404, and no softness anyone would have attributed to a pipeline.
Found 2026-09-06 by docade measuring bytes after the fix: the piece got smaller and no sharper, which is what 36KB of interpolation looks like from the outside. Sweeping all twelve of docade's remaining flat lanes found zero off-spec pieces, so the risk is not "flat lanes are bad" — it is that the one question that would catch this (is any placement in this lane computed?) can only be answered by the client, and nothing asks them.
7. Work the studio HELD, and how it becomes deliverable — art rekey
The bridge's hardest rule is the one that makes it trustworthy:
WootBuild consumes the work-list and never invents a row. A piece that is not in it does not exist.
That rule is right, and it produces a problem nobody hits until the studio does something generous. The studio can produce work a client has not asked for — docade's twelve extra avatars, forged 2026-08-25 on an owner directive, held rather than delivered because no row existed.
A piece made before its row has no key to carry. It is briefed with --subjects, and its sidecar records manifest_key: null. When the client later writes the row, art deliver refuses it — correctly, and permanently:
resolved to this piece by name, not by key — the candidate carries no manifest
key. A foreign-key path needs a key-bound candidate
So accepted work becomes structurally undeliverable, and the only paths were to regenerate approved art — throwing away a human verdict and paying twice — or to hand-edit provenance.
art rekey <client>/<lane> is the third path. It binds an already-approved candidate to a key that now exists, and it is a door rather than a hole for the same reasons art adopt is:
| It does not | Because |
|---|---|
| approve anything | It touches only candidates a human already approved. A key is a binding, never a blessing |
| bind a grader verdict | verdict_by must be human, or this becomes a laundry for machine approvals (hard rule 8) |
| overwrite an existing key | A candidate that carries one is either already correct or is a different piece |
| resolve by its own logic | It calls laneRows — the resolver deliver, audit and contact all use. A second matcher is the second answer that drifts |
| rewrite history | The previous value, the day, and the reason land on the sidecar as a retrofit record |
It is dry-run by default and writes only on --confirm.
The prevention is better than the cure, and it is one sentence: when a client session is reachable, ask for the manifest row before generating. docade's took about twenty minutes. docs/AUTOMATION_LEDGER.md carries the cost of not asking.
⚠ And a lane binds to pieces by CATEGORY ALONE, so held work must land in a category the holding lane declares. docade's twelve went into a new avatars-plus category with its own directory — while they sat in avatars they bound to the avatar lane, whose style they had never been judged against. ATL-95 is the real fix: a lane needs an optional key filter so one category can serve two contracts.
8. A SOURCE module — art mount, and why it is not a delivery
art deliver moves approved candidates to paths a client's manifest declares, and refuses everything else. That refusal is correct and it is the reason the bridge is trustworthy — but it means the bridge has no vocabulary for source, and a playable is source.
Gizmo forced it. A playable is a MOUNT, not an asset drop — start(canvas, run, { onEvent, onEnd }) — and the file docade needed was 198KB of JavaScript. It has no candidate, no verdict, no manifest row and no asset id, so every gate deliver applies is inapplicable to it and deliver_to rightly throws. Meanwhile our write guard permits exactly that one path into a client repo, and docade's own rules forbid them reading outside theirs.
Neither side could move it, and neither side was wrong.
art mount <client> [<id>] is the door. Declared in clients/<id>/client.yaml:
mounts:
gizmo:
from: games/gizmo/game.js # relative to the STUDIO
to: src/lib/games/gizmo/game.js # relative to THEIR root
note: >
why this exists, and who decided
THE DECLARATION IS THE DECISION. There is nothing to approve — a source file is not judged on a board — so the gate is that a person wrote this block, where it is committed, diffed and reviewable. art mount will not write a path it has not been told about here. If that ever feels too thin, the remedy is a stricter declaration, never a quieter command.
Every run, regardless:
| the source must be inside the studio | a mount copies our work, never something else on this machine |
| the destination must resolve inside the client root | checked after resolve(), because a prefix test on the raw string accepts ../sibling-repo |
dry run unless --confirm | and it prints the byte count and sha of what it would place |
| it reads its own write back | a write that does not happen throws nothing |
a receipt — WOOTBUILD-MOUNTED.md | source path, sha and day, because "where did this come from and is it current" is the question asked six weeks later |
| it never stages, commits or pushes | their repo is theirs, same manners as art deliver |
10. What NOT to mechanize — and why this section exists
Justin, 2026-09-10: this is one family, one project, just segmented. So the question was never what governance do two repos need. It is which frictions actually cost something, and which would only add ceremony.
Two Claude sessions worked opposite ends of one delivery on 2026-09-10 and surfaced roughly eighteen defects with zero audits. Afterwards both sides wrote up what should become a rule. This is the half that should not, recorded here because the natural drift of a repo like this one is to declare everything, and three of these would be actively destroyed by declaring them.
| Why not | |
|---|---|
| Mandated review between the two sides | Every defect that day surfaced from somebody explaining their own reasoning until its shape was visible to a reader who did not share the assumptions. A reviewer starts from what you meant. A mandate would have found fewer |
| Sign-off gates between sessions | Everything needing a decision went to Justin, correctly, and the two sessions adjudicated nothing. An approval step between two parties that already escalate properly is administration |
| A formal handoff document per delivery | Placement notes written in a message were faster and better than a template. What was missing was not a form — it was the list, and that is now art deliver's plan pass |
| A shared style or contract repo | Their §1 forbids them reading our files. That constraint read as friction and turned out to be load-bearing: it forced every claim into prose that survives arriving alone. Relaxing it would let both sides start gesturing at files the other cannot open |
And the one that is a habit rather than a rule, and is the most valuable thing either side did: retracting your own evidence, unprompted. Both sides did it repeatedly — a measurement inverted, a canary misread as work, a deadlock that was not one, a determinism claim too strong. No mechanism can require this, and any that tried would produce defensive hedging instead. The same is true of refusing an authority you were offered: it fired twice that day, both times correctly, and neither instance needed a rule.
The test for this section: a process that works because someone is thinking cannot be preserved by writing it down as a step. Writing it down converts it into something that can be satisfied without thinking, which is the failure docs/16 names from the other direction.
9. Still open
| ATL-40 | the delivery receipt — declared, not written |
| ATL-39 | per-piece specs. Six of docade's lanes contradict their manifest — clients/docade/advisory-05.md |
| — | live session-to-session calls. docs/14-CAPABILITY-RADAR.md: build the read contract first and most of the transport question evaporates |