📘 reference — overview/guide, not tied to a code symbol
Validation by Beadloom
doc_sync— same source assync-check.
Parallel Waves
What a wave of concurrent agents guarantees, what it only reports, and what nothing here can check.
This guide is for whoever decides that two pieces of work may run at the same time — a coordinator launching subagents, or a person opening a second terminal. It covers the sentence beadloom waves is built to keep, the split inside that sentence, the two mechanisms whose failure direction was chosen deliberately, and the limits that are stated rather than hidden.
The sentence
For any two beads placed in the same wave, no medium they share can carry one bead's in-progress state into the other's result — and where a medium cannot give that guarantee, the wave says so and names the one bead that measures the combined outcome.
Everything below is either a way of keeping that sentence or a statement of where it stops. The sentence is also in src/beadloom/application/waves/__init__.py and in the Wave Plan SPEC, because half of it is not decidable from the graph and a reader meeting only the code would not know which half.
Which half is which
Code independence is decided from the graph. A tracker knows which beads block which. Only the architecture graph knows which code they occupy, so beadloom waves resolves each bead's declared refs: to nodes and files and serialises a pair for one named reason: blocked_by_bead, unresolved_scope, shared_node, shared_file, dependency_edge or override_serial. This half is a decision, not advice. An advisory shape is prose that a model may act on or ignore, which is the failure the enforced-flow work exists to remove.
The seven shared media are measured as a precondition, before the wave runs. One graph, one working tree, one pre-commit hook, one landing order, one focus document, one doc-freshness baseline and one tracker id space are shared no matter which shape is chosen. The graph is the plan's own input, so its verdict answers one question and states another: it fails when the node population the graph files declare is not the one the index resolved these scopes from, and it says in its pass that a bead which ADDS a node writes that file and is invisible here, because the node it adds is in no graph the plan could read (BDL-UX #261). Each carries a verdict that can come back failed, and a medium nobody observed comes back unmeasured, which is a finding rather than a silent pass. What the run establishes is that the wave may start, not that it went well.
The wave's conduct afterwards is checked by nothing here, and cannot be. No command holding a plan can know whether the gate owner ran the combined tree, whether an agent committed outside its own scope, or whether the doc pass that followed read what it re-attested. That is why every wave names a gate_owner: the step that no check can perform belongs to a named bead instead of to a coordinator's habit. Every wave also gets one clean-room path per bead, room-<bead-id> — two agents in one wave once each built a room called cleanroom under a shared scratchpad, and one of them measured over its neighbour's untracked files (BDL-UX #235).
beadloom waves BEAD [BEAD ...] [--parent WORK-ITEM] [--json] [--project DIR]Exit 0 = a shape was decided and rests on nothing unstated. Exit 1 = a shape was decided and carries findings, which are visible and never blocking. Exit 2 = no shape could be decided. Read the exit code or --json, never the number of lines printed (BDL-UX #148).
The bead list was the last thing here a human typed, and it is now derivable. Everything above decides the hard half from the graph; the SET of beads it decides over came from the command line. This project's own coordinator lost three beads of a slice that way — they sat in bd ready --limit 0 through fifteen launches, every plan was internally correct about the smaller world it was asked about, and none of them could say the world was smaller (BDL-UX #274). --parent <work-item-id> derives the list instead: every bead the tracker lists as ready under that work item. And every plan, with or without --parent, prints how many ready beads under the same work item it was not asked about:
Ready under this plan's work item and not in it:
1 ready bead(s) this plan was not asked about: beadloom-iur5 (4 of 31 bead(s) under
beadloom-0mdo.14 are ready)
a subset is legitimate; this line says the narrowing happened, not that it was wrongThat line is a notice and never a finding: measured over this epic's own S6, 15 of 15 launches were subsets, and a line that goes red on every real run is a line its reader discounts. What can fail is the answer the count was taken from — a bd ready the tracker capped makes the count a claim about part of the tracker, and that is reported as a finding.
A bead already in progress is compared too, and its conflicts are printed apart from the plan's own. A bead in progress is not ready, so before BDL-UX #283 a running bead was compared against nothing and the plan printed 0 serialisation(s) beside it — which is how this project's own coordinator launched two agents into a pair that had to be serialised. The first line carries that comparison as its fourth field, N against N running bead(s), and a conflict with running work is separate from a serialisation because it does not order the plan's waves: it holds a planned bead back until the running one lands. A running bead the tracker could not show is a finding, running_not_compared; a serialisation against running work is not.
A run over three beads of this project's own BDL-068 S6, all three of which turned out to be dependent. Re-recorded on 2026-09-12 against the same three bead ids, now closed, so the shape below is the one the command prints today. Three things were done to it and nothing else: the axes ruling and the seven shared-media descriptions are elided, the precondition reasons and the findings are trimmed at …, and four lines too long for this page are wrapped.
$ beadloom waves beadloom-mr2l.21 beadloom-mr2l.78 beadloom-mr2l.79
3 wave(s) for 3 bead(s), 3 serialisation(s), 0 against 0 running bead(s), 3 finding(s).
Wave 1: beadloom-mr2l.21
combined-tree gate: beadloom-mr2l.21
clean room: beadloom-mr2l.21 -> room-beadloom-mr2l.21
Wave 2: beadloom-mr2l.78
combined-tree gate: beadloom-mr2l.78
clean room: beadloom-mr2l.78 -> room-beadloom-mr2l.78
Wave 3: beadloom-mr2l.79
combined-tree gate: beadloom-mr2l.79
clean room: beadloom-mr2l.79 -> room-beadloom-mr2l.79
Serialised because:
beadloom-mr2l.21 | beadloom-mr2l.78 — shared_node: sync-check
beadloom-mr2l.21 | beadloom-mr2l.79 — shared_node: cli-commands
beadloom-mr2l.78 | beadloom-mr2l.79 — dependency_edge: cli-commands -> axes-section
0 declared override(s).
Ready under this plan's work item and not in it:
every ready bead under beadloom-mr2l.22 is in this plan (0 of 3 bead(s) under
beadloom-mr2l.22 are ready)
In progress under this plan's work item, and compared against it:
no bead under beadloom-mr2l.22 is in progress outside this plan
Plan-time precondition of each shared medium:
graph-files: passed — 106 node(s) declared across 106 graph file(s), the set the index
resolved these scopes from
working-tree: failed — 3 path(s) differ from HEAD and are owned by no bead in this plan
commit-gate: passed — the installed pre-commit hook judges the paths a commit stages
landing-order: passed — all 18 instruction(s) of `bd merge-slot` name the holder
focus-document: failed — 3 of 3 bead(s) of this plan write into a focus document no row
of it names
doc-baseline: passed — no doc pair is stale before the wave starts
tracker-ids: passed — every bead's title agrees with the number the tracker allocated
FINDING: medium_failed: working-tree — …
FINDING: medium_failed: focus-document — …
FINDING: declared_outside_the_axes: beadloom-mr2l.79 declares `review-brief` and BDL-069
rules `review-brief` OUT of scope — …The third serialisation is the one no tracker could have produced. beadloom-mr2l.79 edited the CLI, which depends on the doc-sync domain that beadloom-mr2l.78 rewrote, and no bead blocked the other.
The three findings are the re-recording's own, and they are what findings look like. This run was taken on a tree carrying another work item's changes, against beads whose focus document is that other work item's: two shared media failed their precondition, and the third finding is an axes declaration ruled out of scope by the work item checked out at the time. The command exited 1 and decided the shape anyway, which is the exit code's whole meaning.
The direction a mechanism fails in
Two mechanisms in this slice were wrong in the same way: each had a failure mode, and the failure mode pointed at the outcome that costs more. Both were repaired by choosing the direction rather than by patching the individual cases.
The declaration parser fails toward serialisation
A bead says what it occupies in the tracker, in its own words:
bd update <bead-id> --append-notes "refs: wave-plan, sync-check"The declaration opens a line, and its list runs to the end of that line, separated by commas or semicolons. Four things count as a declaration that cannot be read, each printed with its own remedy, and every one of them serialises the bead against every other bead:
| Reason | What it means |
|---|---|
no_declared_refs | the bead declared nothing |
ref_not_in_graph | a name the graph does not have |
declaration_not_at_a_line_start | a refs: written inside a sentence, which is prose |
declaration_dropped_a_node | a second ref written without a separator that the graph confirms is a node |
An unknown scope is not an empty scope. An empty scope compares independent of everything, so a parser that read silence as independence would rest the command's whole claim on it.
The two later reasons were bought with measured defects, both of which widened a wave. The sentence "It is serialised until it declares refs: <ref_id>, billing being the example." resolved to refs=('billing',) with nothing unresolved, so a bead acquired a genuine scope it never declared and every pairwise verdict then rested on it. And refs: wave-plan; sync-check beside refs: sync-check produced one wave, zero findings and exit 0, because the list stopped at the semicolon: two beads that both declared sync-check were placed side by side and nothing said so.
A wave shape is acted on. A parser whose errors widen a wave is therefore worse than no parser at all, which is why the repair was the direction and not the three parses.
The release gate fails toward withholding
beadloom review-brief --release prints the author's account only once a verdict is recorded on the bead. Two defeats were measured, and both are now closed by requiring more of the verdict:
- The marker must carry its colon.
REVIEW ISSUES are still open, will fixreleased the account, and the docstring of the function that matched it named that exact string as the case it prevented. - The marker must open the comment's first non-blank line. A checkpoint reading
COMPLETED: shipped itwith a verdict line beneath it opened the gate from the middle of the author's own progress note.
The author of the verdict comment is now compared with the bead's assignee. The answer is reported, not enforced: a self-recorded verdict still releases the account, the run prints why its independence cannot be established before the account rather than after it, and it exits 1.
Refusing was rejected, and the reason is a measurement about this repository. Every comment on every bead here carries the one tracker identity v.zoologov, which is also every bead's assignee — the dev agent, the review agent and the human all write under it. A gate that refused a self-recorded verdict would refuse every release in this repository, including the one that corrected a Major on this slice. A gate nobody can pass is bypassed rather than obeyed, and the bypass is one shell command away, because bd comments was never something this tool could prevent. A rule that gets worked around is worse than one that reports. On a tracker where the roles hold separate accounts the same code reports an independent verdict with nothing to say.
What a wave shares no matter what shape it takes
Printed by every plan, whatever the width of its waves, each with the evidence it came from and each with a plan-time precondition that is actually checked:
| Medium | Precondition checked | Observed from | Evidence |
|---|---|---|---|
working-tree | no path differs from HEAD that no bead in the plan owns | git status | BDL-UX #181 |
commit-gate | the installed pre-commit hook judges the paths a commit stages | .git/hooks/pre-commit | BDL-UX #118 |
landing-order | every instruction of the landing lock names its holder and asks for no queue | the composed flow artifacts | BDL-UX #194, #237 |
focus-document | the document every route writes carries a row for each bead of the plan | the composed /task-init routing table and the work item's folder | BDL-UX #257 |
doc-baseline | no doc pair is stale before the wave starts | the doc index | BDL-UX #182, #133 |
tracker-ids | every bead's title numbers it the way the tracker did | the bead records | BDL-UX #171 |
failed and unmeasured are findings and reach exit 1; passed is not.
Every medium is measured for every plan, a fully serial one included (BDL-068 S4). Until then, the three machine-observed media of the day — landing-order was added in S5 and the count refers to the four media that existed — read not_applicable when no wave held more than one bead, on the reasoning that a wave of one shares nothing concurrently. That reasoning does not hold: a plan is one slice of one epic, so a wave's width is not a claim that its bead is alone in the tree, and the working-tree check exists precisely to report paths that no bead in the plan owns — a question a wave of one can and does fail. not_applicable is gone as a verdict a plan's shape can produce.
The document every bead writes is shared, and the code graph cannot see it
focus-document (BDL-068 S6) answers BDL-UX #257. A wave plan resolves a bead to the nodes and the SOURCE FILES its code occupies, so two beads can hold disjoint code scopes and one shared document, and the plan reports 0 serialisations truthfully about the wrong population. Measured twice on this project. In S6 wave 2, two beads with disjoint declared scopes both edited docs/domains/application/README.md; one committed the file whole and the other's hunk landed inside that commit — correct in the tree, wrong in the history. In S6 wave 4, waves derived 0 serialisations for four beads that all write into the same ACTIVE.md.
The second is structural rather than unlucky, which is what makes it a medium rather than a collision to be planned away. /task-init routes every work-item type through a document both of its flows write, so every concurrent wave this project has run shared one.
The population is derived, not written down. Routing.shared_kinds is the intersection of the document kinds every route of the composed /task-init writes — ACTIVE on this project — and the folder is the work item's own, taken from the same branch read the commit gate makes. A project that adds a work-item type, or moves a document between the two flows, changes what this medium looks at by the same act. Widening a bead's refs: to reach the document is the defect BDL-UX #232 was filed against and is not how this is answered.
Four confirmations in one slice, and the fourth is the one to read. An agent acquired the merge slot with --holder, waited, and 43 lines of its ACTIVE.md entry still landed inside a neighbour's commit. The discipline was followed exactly: the lock orders the COMMITS, and the edit had already happened. A third instance measured that the shared population is wider than the documents — three beads with disjoint code scopes shared four artifacts, two of which are not documents at all, and one of which is .beadloom/_graph/services.yml, the graph this plan derives its scopes FROM. A derivation of ownership out of the graph cannot reach the graph. On this repository that one is now gone rather than reported: BDL-UX #265 split the file into one per node, so two beads that add nodes write two files. A single-file graph stays valid, which is why the medium stays and reports the number instead of taking a verdict. focus-document names one member of that population and does not claim to name the rest.
What the check asks is not whether the beads share it — they do — but whether the document gives each of them a place of its own. A bead the table carries no row for has only the prose around it, and that is where one bead's hunk lands inside another bead's commit. The damage is attribution rather than lost content, which is what makes it easy to leave: a defect whose only symptom is a wrong author is one nobody notices until they read the log to find out why something changed.
The landing lock orders commits, and orders nothing else
landing-order (BDL-068 S5) is the medium whose evidence had to be re-measured before it could be stated. BDL-UX #194 and #237 were filed nine days apart by two agents that had never met, and both concluded that bd merge-slot was not an exclusion primitive. Re-measured in an isolated rig on bd 1.0.4, with every exit code read in the foreground without a pipe, the primitive is sound: acquire on a held slot exits 1, and across four rounds of eight simultaneous acquires exactly one caller won each round. Both entries are withdrawn. What was wrong was this project's call form, and the correction is what the medium now checks.
- The slot orders commits. It does not keep two agents out of one file. That is what the disjoint scopes this plan derives are for, and every concurrent wave this project ran before 2026-09-04 was serialised by those scopes and by the file sets happening to be disjoint, while believing it held a lock that granted nothing.
acquire --holder <bead-id>, read by exit code. The default holder is the tracker actor —$BEADS_ACTOR, thengit user.name, then$USER— one identity for every role on one machine, so the slot cannot tell a neighbour's hold from your own. A non-zero exit means you do not hold it.release --holder <bead-id>is the only release bd checks. A release naming no holder frees whoever holds the slot, including a live neighbour, and reports success.--waitdoes not wait. It appends the caller to a queue nothing drains and returns at once, and nothing removes a waiter, so the queue accumulates identities from sessions that ended weeks ago.
The check reads the composed flow artifacts an agent is HANDED, never the templates they were composed from: a template fixed and never recomposed leaves the instruction wrong on disk, and red is the correct verdict for that. It judges the FLAGS of each invocation and never the prose around it. A subcommand the derivation has not measured is reported as unknown-form rather than passed. The wider population — every place this project reaches bd at all, and what each call form assumes about the answer — is beadloom bd-calls.
tracker-ids is checked even for a fully serial plan. The mis-numbering it looks for happens at bead creation, upstream of any wave, so a plan that serialises the beads it mis-wired is exactly the plan whose ids most need checking. Dogfooding found a live one on this repository: beadloom-mr2l.72 carried the title BDL-061.17b.
Two obligations the shape hands to a named bead rather than to habit.
- The wave's
gate_ownerruns the combined-tree Gate once the wave has landed. Every agent verifying in its own clean room is correct and blind by construction to any interaction between beads: four agents once each reported green on a tree that was red, and none of them was wrong. - An agent reports its result in the words that say which measurement it made. "Green in a clean room over 16 files" is a different claim from "green on the tree", and "green on
tests-locale" is a third; reporting them with one word is what makes a discrepancy read as a contradiction later.beadloom roomsprints the room a run is in and the declared rooms it did not enter, andbeadloom ciprints the same census beside its own verdict — so the address is derived from the project's CI declaration rather than typed by the agent. Naming the room does not make the verdict stronger. It makes it answerable.
The room is built by the command that derives it
beadloom waves prints the room each bead owes; beadloom clean-room creates it.
beadloom clean-room BEAD [--at DIR] [--carry PATH]... [--extras LIST] [--no-environment]
[--rebuild] [--project DIR] [--json]Naming the room was not enough, twice. Two agents of one wave reached one directory because the convention named the room after the concept (BDL-UX #235), and a room entered a second time manufactured a failure of its own: files copied into an already-indexed room postdate its doc-freshness baseline, measured as sync-check exit 2 with stale: 2 against a change that is clean at HEAD (BDL-UX #243). A convention that is only correct when performed exactly once, and does not say so, will be performed twice.
So the command derives the path from the bead and CREATES the directory rather than entering one. An existing directory is refused and left byte-for-byte as it was; --rebuild replaces a room rather than refreshing it, and deletes only a directory whose .beadloom-room.json names that same bead. --carry copies the files you name and nothing else — there is no "everything that differs from HEAD" mode, because on a shared tree that set holds your neighbour's work.
You name that list once. --rebuild reads the request out of the record it is about to delete — the carried files, and the extras you pinned with --extras — because retyping it was measured at 16 flags twice on one bead, and what an agent reaches for under that friction is copying files into the live room, which is #243 again. What is reused is the LIST: the files are copied from the working tree at build time, so a rebuild is still a room nothing inside postdates, and an option given beside --rebuild replaces its remembered counterpart rather than adding to it. The room also builds its own interpreter, so a verdict here is not decided by what the machine happened to hold; --no-environment declines that and is the one part of the request a rebuild does NOT remember, because a remembered decline would quietly hand back the weaker room.
Exit 0 = built, and the tracker says the bead is in_progress. Exit 1 = built, and its ownership is unconfirmed. Exit 2 = no room was built, under a named refusal (already_exists, not_a_room, inside_the_project, no_commit, file_missing, not_a_file, file_outside_the_project, unknown_bead).
The command hands back the invocation to run in the room, and the invocation is not a formality: with an editable install, running the suite from inside the room under the project's environment imports the tree's source, and the first run that did it was caught from a warning path rather than from a failure. PYTHONPATH pointing at the room's own src is the fix, and the import beadloom line printing a path under the room is the check.
Overriding the shape
A human outranks the computation by declaring it, with a reason and an exit condition, the way every other stand-down in this tool is recorded:
waves:
overrides:
- beads: [proj-1, proj-2]
decision: parallel # or: serial
reason: "the two touch one vocabulary module and nothing else"
until: "2026-09-01"Every key is required, and required by its content — a key present but blank is a configuration error, because an override with no reason and no deadline outranks the graph permanently by accident. Each override is reported with the number of decisions it changed, and one that changed none is a finding: an override nobody can see doing anything is how a check gets switched off without anybody saying so.
The tracker still outranks the override. A parallel entry cannot place a bead ahead of a bead that blocks it.
Handing a reviewer the change: beadloom review-brief
A review that reads what the author said it did is not an independent check. The measurement behind that: in hidden-profile tasks, where the facts needed for the right answer are split across a group, groups scored 17–36% accuracy against ~100% for a single agent holding all the facts, because hearing one member's conclusion first silences the dissenting evidence (BDL-UX #155 C).
beadloom review-brief BEAD [--since REF] [--release] [--json] [--project DIR]The brief hands the reviewer the assignment (the bead's title and description), the declared scope, the specification (the graph's documents for those nodes and every scenario whose @bead: tag names the bead) and the change (three git questions, each path carrying the node that owns it). It does not hand over the bead's comments. Those are counted, never printed: N author comment(s) withheld, with the reason, the release condition and a notice about the defeats the command cannot see.
Its boundary is as sharp as its purpose, and both belong in the same paragraph.
Enforced, in code, with a test that bites: the command will not print the comments before a verdict comment exists. It exits 3 — distinct from 2, so a caller cannot confuse refused with failed — and says the account stays withheld.
Documented, not enforced, for three defeats it cannot observe:
- A reviewer with a shell can run
bd commentsdirectly. Nothing here can prevent that, and a mechanism that claimed to would be the overstatement this feature exists to remove. - A coordinator can paste the author's summary into the launch prompt.
coordinator.mdnow forbids it: the review launch prompt carries the bead id and nothing else about the change. That is a rule, not a lock, and the duty to report a defeat is placed where the only observation is — the brief tells the reviewer to say in its verdict if the prompt carried anything it did not derive itself, including the coordinator's own observation of the change rather than only a pasted summary. - The commit bodies on the reviewed range carry the account as well.
review-briefwithholds bead comments and does not touchgit log, while step 3 of the review protocol sends the reviewer togit diff <base>...HEAD. Measured on BDL-067: the.21and.22commit messages were longer and more specific than any bead comment, one of them opening with an explicit FINDING for.23, while the brief reported 0 comments withheld. It is not reachable by prompt discipline, because the protocol itself sends the reviewer to the diff and this project deliberately writes long commit bodies — the better the message, the more completely the mechanism is defeated. Filed as BDL-UX #219 and undecided: either the brief withholds or summarises the commit bodies on the range and says how many, or it stops claiming to withhold and instead reports what is reachable so the reviewer can declare it. Both reviewers who met it declared it unprompted, which is the only reason it is known.
What changed is the default. The cheap path is now the independent one, and the withholding is counted and visible.
The account is released, not destroyed. After the verdict is recorded, --release prints the comments, so the deferrals, sabotage tables and measured numbers stay available to a reviewer who would otherwise re-derive them or file a finding against something deferred on purpose. A bead that already carries a verdict releases at once, because a second pass reviews the author's response to a judgement already independently recorded. The hidden-profile risk is in the first pass.
The first use, recorded as evidence rather than as anecdote
This epic's S6 review (beadloom-mr2l.23) was the first review run under the brief. It covered five beads, reported withheld counts of 5/4/4/4/3, and every finding was formed before --release was run.
Its sharpest finding was about a sentence rather than about code. beadloom-mr2l.79's own honesty note listed a protection under ENFORCED — "the marker is matched at a LINE START, so a checkpoint that MENTIONS a review does not open the gate" — in the paragraph written specifically to avoid overstating what the code did. The code did not provide it, measured: REVIEW ISSUES are still open, will fix released the account. The reviewer said it would have accepted that sentence as the specification had it read it first, and probed the function only because the note was withheld.
A sincere, careful, wrong sentence is the most expensive kind of documentation defect, because it is the kind a reader has no reason to check. That is what the withholding caught on its first outing, and it is the argument for the mechanism that no feature list makes.
The release also corrected a finding in the other direction. A Major filed against a missing CLI reference section was withdrawn once the account showed the omission was a deliberate, stated hand-off to the documentation wave. The ordering cost nothing, because the first judgement was already recorded and could not be un-said.
The doc baseline: a pair nobody can revise
A wave shares one doc-freshness index, and until this slice it shared a defect that made integrating a wave expensive. The freshness fact was stored per pair and computed per node, so one changed file marked every pair its node owned stale/symbols_changed, and the only way to clear the followers was the blanket re-attestation that BDL-UX #163 was filed to prevent.
sync_state now also carries file_symbols_hash, the symbol surface of a pair's own code file. Only that fact can make a pair stale. A pair whose own file did not move while a sibling file of the same node did is reported:
[not verified] <doc> ↔ <code> (not checked: this file's symbols did not move;
architecture_view.py did — revise the document against that file, not against this pair)Three decisions are worth reading off that line.
- The verdict is
unverified, and the reason token issibling_symbols_changed. No fifth status was invented. The epic had already answered this question four times, andunverifiedalready means "reported by name, never counted as fresh, never blocking", which is exactly the treatment a pair nobody can revise needs. The verdict sumok + stale + missing + unverified + exempt + incomplete = totalis untouched. - The moved file is named. The remedy is to revise the document against that file, and a line that reported the condition without naming the file would leave the reader to find it.
sync-updateis not offered. There is nothing for this pair's author to revise, and offering re-attestation would be offering the bulk re-baseline the change exists to remove.
Measured in two clean rooms differing only in this change, each run against its own code: appending one function to application/architecture_view.py produced 69 stale pairs, 67 of them naming a file nobody touched, and afterwards 2 stale plus 67 unverified/sibling_symbols_changed, every one carrying details: architecture_view.py. Both rooms exit 2. The gate still bites, on the two pairs somebody can act on.
This closed BDL-UX #182, #133 and #105 — three filings of one root over eleven weeks (2026-06-01, 2026-06-15 and 2026-08-23), each from a fresh measurement, none of them finding the earlier one. That is the clearest evidence this epic produced that a written issue log earns its keep, and also the clearest statement of how it fails: the entries are searched by symptom, and the three symptoms — symbols_changed, worktree, re-baseline — share no word.
When a sibling wave has moved a file, read the reason before revising or attesting. A follower pair is unverified, not stale, and re-attesting it would record a claim about a comparison nobody made.
Stated limits
Each of these is a limit somebody measured and chose to state, rather than a gap nobody noticed.
The git add half of BDL-UX #118 is not fixable at the hook layer. The pre-commit hook now judges the commit rather than the tree — ruff runs over the staged files, mypy over the staged files inside the surface pyproject declares typed, sync-check runs with --staged, and the hook prints how many modified files outside the commit it did not judge. What it cannot catch is a neighbour's hunk swept in through git add: that hunk is inside the commit, which is the region the gate judges, and the index does not record who wrote a line.
Part of that gap now has an instrument, and it reports rather than prevents. BDL-068 S1.6 shipped beadloom scope-check, which compares the paths a commit stages against the ## Axes section the work item declared. It is not the mechanism beadloom-mr2l.81 filed and the difference is deliberate: the unit is the WORK ITEM's axes, not the committing bead's scope. A bead may narrow freely inside an approved scope, so judging against the bead would fire on every legitimate cross-bead commit, while a path that leaves the work item's axes means the approval no longer covers the change. Nothing about it stops a determined agent — a shell can commit anything the file system allows. What it raises is detectability, at the moment the commit is made rather than at review.
Where it runs, and what it does there:
| Where | Scope | On a finding |
|---|---|---|
| the pre-commit hook | the staged paths | warns, in both hook modes, and never blocks |
beadloom ci (scope-check step) | <trunk>...HEAD, what the pull request contains | reports; the step passes |
Both are warn, and the reason was measured twice. One work item in 64 on this repository carries an ## Axes section today, so a check that blocked would meet a repository that cannot satisfy it and be answered with --no-verify. And over the eleven commits of features/BDL-068 — 52 paths, of which 11 have an owner in the graph and 41 have none — the check produced 0 findings, so its false-positive rate is zero; that is still not a reason to block, because only two of those commits touched a path a node owns at all.
Both surfaces print the verdict whatever it says, and until BDL-068 S4 (beadloom-0mdo.32) only the beadloom ci step did. The hook read the command as 2>/dev/null while the reason for having compared nothing went to standard error, so a clean run and a run that could attribute no work item were the same empty string there and the hook printed neither. A sentence in this guide claimed otherwise for both surfaces; it was true of one.
The verdict also carries the population, because the finding count alone is the smaller half: 41 of those 52 paths are owned by no node — the tracker export, the planning documents, the tests, the docs, the graph YAML — and so were never compared. That is the exempt set, and it is derived from graph ownership rather than authored as a path list, so it cannot drift out of step with the graph. Neither surface catches a neighbour's hunk that lands inside the same approved axes, which is the half of BDL-UX #118 that stays open.
beadloom ci's step is branch-scoped rather than tree-scoped for the reason the whole guide turns on: the tree is shared, so judging it would fail one agent's push on a neighbour's edit. See beadloom scope-check for the flags and Scope Check SPEC for the rule.
Adopters must re-run beadloom install-hooks to pick the commit-scoped hook up, and again to pick up the scope-check warning. An installed hook keeps its old behaviour until they do, and beadloom waves now says so by name (commit-gate: failed) instead of leaving it to be noticed.
A "field read and never compared" lint is not feasible, and the reason is that the three occurrences are alike only in prose. The shape appeared three times in this epic: a validator computed an answer the linter discarded, waves held both the allocated id and the title id without comparing them, and the release gate held the verdict comment's author without comparing it. Mechanically they are three different things — a discarded return value, two live values in one scope that are never compared, and a dataclass field populated at one seam and read at none. The middle one is not a static property at all, because "never compared" is only a defect relative to an intent no analyser holds. The third is decidable per field inside a closed package and would fire on every legitimately carried-through field. What generalises is not a lint: a field that a mechanism's own purpose depends on should be named in that mechanism's tests, and the durable form of that is a findings ledger with a meta-test asserting every closed finding is still owned by a passing test.
waves judges only direct depends_on edges between the declared scopes. Transitive reach through an unchanged intermediate node is not judged. On a real graph everything reaches infrastructure eventually, and the honest answer would degrade to "serialise everything".
The working-tree check will report failed on this repository for tracker and session files — .beads/*.jsonl and ACTIVE.md are owned by no node's code. It errs toward safety, which is right, and a check that is always red is a check people learn to scroll past. Naming those paths as a stated exclusion is unfiled work.
Attestation is still per ref while the freshness fact is now per file. sync-update <ref> re-baselines every pair of that node, so a document pass that revised one pair still attests its siblings. That is the residue of BDL-UX #133 rather than its return, it is correctly diagnosed, and a per-pair attestation has no CLI today.
See also
- Wave Plan SPEC — the decision, its invariants and its API.
- Review Brief SPEC — what the brief carries, how a verdict is recognised, and the honest limits in full.
- Agentic Dev Flow — the packaged roles, the guards and the Gate the wave runs inside.
- CLI Reference —
beadloom waves,beadloom clean-roomandbeadloom review-brief.