WootBuild.

Studio/Doctrine/The client bridge

docs/18-CLIENT-BRIDGE.md

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.

ReadsDeclared, hashed, diffed. As frictionless as we can make them
WritesBehind a human verdict — hard rule 3 — no matter how good the transport gets
Async messagesAdvisories. 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 movesThe gateWhen
art deliveran APPROVED candidate → the path the client's own manifest declaresa human verdict, and lib/autonomy.mjs asking whether the lane may deliver at allthe normal path. Everything the studio produces to order
art mounta declared SOURCE module → a path the mounts: block namesthe declaration, which is committed, diffed and reviewablea playable, a module — anything with no candidate to judge
art adoptan AUTHORED file → becomes a candidatenone, deliberately: it lands with NO verdictdrawn geometry, an authored SVG, a .riv — anything a model did not make
art rekeybinds an existing APPROVED candidate to a key that did not exist when it was madethe verdict must already exist and be a human'swork 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

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 into src/: 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 adoptdocade'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 rekeytwelve avatars the studio produced before docade asked for them; when the rows arrived the art was structurally undeliverable (ATL-95)
art mountGizmo'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

  1. Declare bridge.reads in clients/<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.
RoleWhat it isUnique
worklistthe machine-readable piece list — the work itselfyes
briefprose about intent; a person reads it, nothing derivesno
stylebrand guide or identity packageno
requestsappend-only drop-box for needs found while workingyes

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.

  1. 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.)
  1. 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.
  1. Hand them clients/<id>/bridge.md. It ends with the block they paste into their CLAUDE.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

6. What the bridge does not do

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 notBecause
approve anythingIt touches only candidates a human already approved. A key is a binding, never a blessing
bind a grader verdictverdict_by must be human, or this becomes a laundry for machine approvals (hard rule 8)
overwrite an existing keyA candidate that carries one is either already correct or is a different piece
resolve by its own logicIt calls laneRows — the resolver deliver, audit and contact all use. A second matcher is the second answer that drifts
rewrite historyThe 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 studioa mount copies our work, never something else on this machine
the destination must resolve inside the client rootchecked after resolve(), because a prefix test on the raw string accepts ../sibling-repo
dry run unless --confirmand it prints the byte count and sha of what it would place
it reads its own write backa write that does not happen throws nothing
a receipt — WOOTBUILD-MOUNTED.mdsource 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 pushestheir 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 sidesEvery 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 sessionsEverything 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 deliveryPlacement 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 repoTheir §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-40the delivery receipt — declared, not written
ATL-39per-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