Mockup for review, no code written. Every figure read from your live board: 88 queue rows, 4 shipped releases, 1 in flight, single track.
Ten instructions, applied. It is a workflow flowchart, not a graph. Releases are already sequential, so the top level is just that sequence, and clicking a release drills into it. The system defaults to the release in progress. The table sits on the same surface and always shows the focused release's CRs. Waves are the abstract container of CRs. Swimlanes appear only when a project runs more than one track. And historical waves are not redrawn here — the Workflow history view is enough for those.
Zone 2 answers one question — what is being worked, and what is next. It is not an inventory: zone 3’s table below already holds the detailed list of all 28. So the 21 merged CRs collapse into a single 21 merged ✓ summary row, exactly as this design already did, and the rows beneath are the top of the scheduled queue. No paging machinery — no ◀ earlier tag, because nothing to the left is hidden (it is summarised), and no pager for the remainder, because the table is the detail surface. +2 more is a static POINTER, not a control. The header count 28 and the roll-up 21 merged always state the WHOLE wave, so a trimmed view can never misreport the true size.
Why the spine is horizontal: measured on the live board, an UNCAPPED grid of all 28 CRs makes the wave box 1082 × 150, so a horizontal spine would need 1378px against the 1130px the roadmap actually has — it cannot fit, which is why the shipped build stacks vertically and drops its connectors. One CR per row, merged collapsed to the summary and only the scheduled top shown puts the box at ~300px and the budget at ~600px: the spine fits comfortably. The layout and the trim are ONE decision, not two.
Scope: zone 2 draws every wave of the FOCUSED release — one box per wave, not one box ever. Crucible shows a single Wave 5 box only because 0.2.0 spans exactly one wave; 0.1.0 spanned waves 1–4. A multi-wave ACTIVE release is therefore the case that widens this zone, and the spine budget below must be read per wave box.
Activation is a GUARANTEE, not a coincidence: the active CR is always present even when it sits outside the scheduled top, because an orchestrator may activate out of authored order. Motion stays reserved for IN_PROGRESS (§5, CR-078 AC24) — next above carries NO glyph and NO colour of its own — ▸ and ember are reserved for IN_PROGRESS, and “next” is a scheduling fact, not a state, so it is written as plain bold TEXT on a PENDING row. The two never read alike, and neither relies on colour.
Amended 2026-09-03 (user ruling). This design originally
drew the active row as ▸ active · C1 RED — a cycle overlay
inside the zone 2 row. It was dropped during CR-CRU-096 and the drop is accepted: the design now
matches what ships, ▸ active alone. The cycle is not lost — it rides
zone 3's own cycle column (below, 6/6), which is the DN's
division this design already states: structure in the picture, detail in the rows. Zone 2 answers
what is being worked and what is next; which cycle it is in is detail.
| CR | title | depends on | status | cycle |
|---|---|---|---|---|
| CR-CRU-078 | graph and table are complementary | 077, 084 | ▸ next | — |
| CR-CRU-079 | roadmap deep-link parity and active-CR | 078 | pending | — |
| CR-CRU-077 | the roadmap graph is the execution DAG | 014, 076, 080, 083, 084 | ✓ merged | 6/6 |
| CR-CRU-084 | a release records the package(s) it delivered | 080, 081 | ✓ merged | — |
| CR-CRU-088 | a failure detail printed after its own leaf | 087 | ✓ merged | — |
| CR-CRU-087 | CI floats to the newest bun | — | ✓ merged | — |
| CR-CRU-083 | derived status conflates "never started" with | 014, 081 | ✓ merged | — |
| CR-CRU-086 | the provenance repair must never erase | 081 | ✓ merged | — |
| CR-CRU-081 | release provenance must use commit ancestry | 080 | ✓ merged | — |
| CR-CRU-080 | the release ceremony cannot report a release | 074 | ✓ merged | — |
| CR-CRU-076 | Roadmap is first in the workspace tab band | 014, 021 | ✓ merged | — |
| CR-CRU-074 | Crucible has never been told a release | 013 | ✓ merged | — |
| CR-CRU-073 | Finished releases keep showing their gate | 013, 071, 074 | ✓ merged | — |
| CR-CRU-072 | The installer cannot upgrade | 066, 069, 071 | ✓ merged | — |
| CR-CRU-071 | In-place upgrade | 001, 043, 068 | ✓ merged | — |
| CR-CRU-070 | systemd --user unit | 066, 069 | ✓ merged | — |
| CR-CRU-069 | Install has no inverse | 009, 066 | ✓ merged | — |
| CR-CRU-068 | The server never says which store it opened | 043, 066 | ✓ merged | — |
| CR-CRU-017 | Run lifecycle: start/end events + the Aborted | 008, 011 | ✓ merged | — |
| CR-CRU-014 | Execution roadmap | 011, 013 | ✓ merged | — |
20 rows, not 88. Row grammar is the DN's, with one amendment: CR id + brief title + bare depends-on + status + cycle overlay. The DN forbade titles on graph nodes, which still holds — the flowchart labels carry no title. The table is where the title belongs, which is the DN's own division: structure in the picture, detail in the rows. The highlighted row is the CR selected in the flowchart; clicking a row drills to that CR's cycles in Workflow.
crucible-axi · npm @anthill-tec/crucible-server| CR | title | depends on | status | wave |
|---|---|---|---|---|
| CR-CRU-001 | Domain core + SQLite storage | — | ✓ merged | 1 |
| CR-CRU-002 | Codec translation layer | 001 | ✓ merged | 1 |
| CR-CRU-003 | v1 compatibility shim + contract tests | 001, 002 | ✓ merged | 1 |
| CR-CRU-004 | Clean v2 API + SSE | 003 | ✓ merged | 2 |
| CR-CRU-005 | AXI layer: TOON subset + help hints | 004 | ✓ merged | 2 |
| CR-CRU-006 | Dashboard shell (Mission Control + workspace | 004 | ✓ merged | 3 |
| …54 more, in authored order | ||||
No wave boxes here, deliberately: the Workflow history view
is enough for historical waves, so the roadmap does not duplicate it. The table keeps a
wave column because this release spans four.
0.1.1 genuinely carries crs: [] with two
packages — a packaging fix. Its table is empty with the same message. Not hidden, not back-filled.
The table gains a track column in this case. Crucible is
single-track, so neither lanes nor that column appear — correct, not a gap. Two CRs are concurrent
because they sit in different lanes; no fan-out edges needed to say so.
Two independent channels, so neither has to carry the other's meaning: shape is the kind of thing, colour is its state.
Colour never encodes anything shape already says, and no element relies on colour alone — status is also written as text, so the view survives a colour-blind reader and a greyscale screenshot.
| Action | Flowchart | Table |
|---|---|---|
| Land on Roadmap | focuses the release in progress | that release's CRs |
| Click another diamond | redraws for that release | follows the focus, always |
| Click the active wave | opens / closes in place | narrows to that wave |
| Click a CR or its row | drills to Workflow — that CR's cycles, ← roadmap back (existing behaviour) | |
| Historical waves | not drawn here — the Workflow history view owns them | |
| Element | Drawn when | Crucible today |
|---|---|---|
| Release strip | always — it is the spine | 5 gates |
| Active-release focus | a release is in progress; else newest shipped | 0.2.0 |
| Wave container | the focused release is the active one | W5 only |
| Delivered summary | the focused release has shipped | 0.1.0–0.1.3 |
| Track lanes + table column | > 1 track reported | no — single track |
| Wave column in the table | the release spans > 1 wave | 0.1.0 only |
| Dashed gate | the orchestrator registered a proposed release; one per proposal | 0.2.0 — once registered |
| Motion | the CR is IN_PROGRESS | none active |
| Visual | Determined by | Data |
|---|---|---|
| Strip order | shipped by ship date; proposed always after them, in version order | releases[].releasedAt · semver |
| Which CRs are inside — shipped | release membership — settled fact, frozen at ship | releases[].crs |
| Which CRs are inside — proposed | declared by the orchestrator when the CR is planned. Two declaring paths since CR-CRU-099, both orchestrator-gated: the per-CR verb, and the bulk queue post, which reads release/track/lifecycle since that CR. Membership is still never inferred — CR-CRU-104 makes the two paths share one rule rather than mirror it | cr-plan --release · POST …/queue |
| Dashed gate exists | an orchestrator-registered proposal — never inferred | proposed-release record |
| Order in the wave / table | authored queue order | queue row index |
| Lane | reported track; absent ⇒ no lanes | plan.track |
| Node label | CR id + terse status, never the title | derived status |
The deps annotation on a row | declared by the orchestrator, whole set at a time — never inferred from wave order. Rendered as the design draws it: the bare number, derived by comparing the two ids rather than by knowing any prefix (CR-CRU-102) | cr-depends --on |
| Table title cell | the CR's own brief title | CR spec H1 |
| Motion + cycle | active cycle of an open plan | plan.cycles |
| Packages on the gate | what the release published | releases[].packages |
| Date under a shipped gate | the tag's own commit date, written at release time | releases[].releasedAt |
| Date under a proposed gate | the orchestrator's declared target — empty state when none | release-propose --target |
No row reads "whatever the layout chose". Nothing here needs a layout engine, a crossing heuristic, or a compound-graph library.
Your model: a release is a super container. When a CR is planned it is the orchestrator's job to state which release it targets and which wave it belongs to; once a wave's sequence is decided, the orchestrator sends that ordered sequence of CRs with its metadata. So all three levels are declared data — nothing about the roadmap is inferred by the renderer.
This overrides my recommendation — I argued for deriving membership from the queue's release qualifier, and you are right that declaring it is correct. Checking the code before writing this section proved my suggestion was not merely weaker but impossible, which is the second finding below.
1 · The release target never reaches the server from the queue file.
parse_queue_table reads the Wave cell with
re.match(r"\s*(\d+)", wave_cell) and keeps only the leading integer. The release
qualifier — the (0.2.0) / (post-0.2.0) part this board has carried for months —
is discarded at parse time. Each entry POSTs as {cr, title, wave, dependsOn}: the
bootstrap sends no release field. So "derive membership from the queue qualifier" could not have
worked — the data is thrown away before transmission. Declaring it is the only workable answer.
Amended after CR-CRU-099: this is still true of what queue-file SENDS, and no longer
true of the ROUTE — the bulk post accepts and stores a declared release. The client-side gap
is deliberate and still open, with its own register entry; what changed is that the door exists, not that
the queue file walks through it.
2 · A proposal cannot be a dateless release milestone.
listReleases (src/store.ts) orders releases by when they SHIPPED, not when they
were ingested — releasedAt when present, and an undated row stands in its ingest instant
instead, which is what keeps pre-provenance rows in the newest-ingest-first order they already had.
Either way an undated 0.2.0 would be ordered by an instant nobody recorded as a ship date, and would
hand a plan the gating of shipped history. A proposal must be its own kind, ordered by version, and
must stay outside listReleases' type === 'release' filter so settled history is
never polluted by a plan — which is what shipped: recordMilestoneEvent type-scopes
targetAt to a proposal and releasedAt/crs/packages to a
release, in both directions.
Amended 2026-09-03: the conclusion is unchanged and now CODE, but the evidence had rotted — this
cited public/app-logic.mjs:875-893 and quoted a comment about an undated tag being "legacy
history". That file no longer holds the ordering at all (it resolves gate DATES; the release sort moved
server-side into listReleases) and the quoted sentence exists nowhere in the tree. Symbol
anchors above, no line numbers, per the citation rule.
| Rule | Consequence |
|---|---|
| Who declares | Mainline orchestrator only — the existing ORCHESTRATOR role. A track executes; it never re-plans the roadmap. |
| A proposal is a plan | Revisable and withdrawable at any time; a CR may be moved between waves or releases until it ships. |
| A shipped release is a fact | Immutable, membership frozen into crs. The shipped record supersedes and consumes its proposal — one gate, never a pair. |
| Zero-CR containers are legal | A proposed release with no CRs is a declared intent; 0.1.1 proves a shipped one happens too. |
| Gate retirement | A proposal retires nothing — CR-CRU-073's rule stays scoped to real releases. |
| A shipped release carries its ship date | Recorded by the orchestrator's release tasks, which already do this — releasedAt is the tag's own commit date, written by emit_release_milestone at release time. Existing behaviour (CR-CRU-080 §S4), now displayed. |
| A proposed release carries a target date | Declared at proposal time and revisable, because a milestone is time-bound. It is a declaration, not an estimate — see the forecast boundary below. |
| Version still orders the strip | A target date is a plan and can slip; the version cannot. If a declared target contradicts version order, that is a planning conflict to surface, not a reason to re-sort the flow. |
You left this to me, then gave me five constraints. The one that reshapes it most: the roadmap is built during the design phase, as each CR is authored — not snapshotted at the end. So the API is incremental and declarative, and the client's job is to ask the orchestrator for what it is missing rather than accept a half-planned CR or fail blankly.
| # | Call | When, and why it sits here | |
|---|---|---|---|
| 0 | register --role ORCHESTRATOR | Identity before any write. Already enforced — an unregistered id gets HTTP 409; it refused me twice today. | exists |
| 1 | release-propose --label 0.2.0 --target 2026-09-12 | Design phase, once per planned release. The super container must exist before a CR can target it. --target is the declared date — REQUIRED as of CR-CRU-118, no longer optional, because it is the axis burn-down reads; still revisable, and a revision retires its predecessor rather than editing it, so a moved target stays auditable. The strip still orders by version. | shipped · 091 |
| 2 | cr-plan --cr … --release … --wave … --title … | As each CR is authored. Declares both memberships + the brief title. Re-running with different values is a legitimate re-plan, not an error. --release is required (server answers 400; the client lists the live proposals and exits 2 rather than guessing), and CR-CRU-118 adds the one label it could not accept before: a shipped release, where that release's own crs already names the CR — historical membership as a derivation. | shipped · 091/104 |
| 2b | cr-depends --cr … --on A,B,C… | When a CR's dependencies are known, and again whenever they change. Carries the whole set; re-sending REPLACES it, so a dropped dep is a declaration and not an accident. Deps are their own axis, so a re-plan at step 2 never restates or loses them. ORCHESTRATOR-gated and cycle-refusing. APPROVED 2026-09-04 — shape A of three. | shipped · 106 |
| 3 | wave-sequence --release … --wave … --crs A,B,C… [--track N] | When the wave's order is decided, and again whenever it changes. Carries the whole ordered list; array position becomes seq. A member the list omits is appended and named in an unsequenced-members warning. | shipped · 091 |
| 4 | cr-supersede · cr-void | Gap-analysis phase — see §11. Declarations, never deletions. | shipped · 091 |
| 5 | plan-file → cycle-add → cycle-activate → cycle-done → cr-close → cr-merged | The roadmap is fully declared before a cycle runs. The chain itself is NOT unchanged since this was
written: CR-CRU-111 replaced the single test verb with the six tier verbs
(unit · module · integration · e2e · regression · bdd), and CR-CRU-112 made ONE
pre-merge-gate invocation cover every suite the project declares — including this repo's
python client suite, dispatched to python-crucible.py. | shipped · 111/112 |
| 5b | next [--track N] | Throughout execution — the orchestrator asks what is actionable; validates the declared sequence against live state (§13). CR-CRU-114 added --release and --wave: the wave predicate reads wave ALONE, so every lane gets one verdict, and a crossed wave is announced as waveCompleted until the new wave lands its first CR. | shipped · 092/114 |
| 5c | gate-run --release … · gate-report --release … | The release's own tracked trace (DN D3). CR-CRU-115 added --release at ten gate-verb
sites across five clients: it posts the event's top-level version, which is what the server
RETIRES when the matching release records — so "a release is in flight" is readable with no new record
kind. Same CR killed the green-biased seal: a run with no resolved outcome is a HOLD, not a
passed. | shipped · 115 |
| 6 | milestone --type release --label … --crs … --packages … | The cut. Freezes membership as settled fact and consumes the proposal from step 1. | exists |
| — | queue-file --from-file docs/changes/README.md | NOT part of this chain, and being retired. The bulk table-replace door exists because this project
is Crucible's source and its first user; the five per-CR routes above replaced it. Two measured data losses
came through it (a CR arriving with no release and an invented seq; a VOID disposition
destroyed on a re-import). DEPRECATED by user ruling 2026-09-10 — the warning ships in CR-CRU-118, removal
plus a per-CR sync in 0.3.0. | deprecated |
Step 3 is one call, and that is the load-bearing decision.
The order is the payload. Sending CRs one at a time makes their sequence an accident of arrival — exactly
the CR-CRU-077 AC2 defect you already paid +41% crossings for, where authored order was not carried as
data so the layout substituted its own. It also lands the field the app already reads: data.seq,
shipped in CR-077 C2 as "numeric, distinct, strictly increasing in authored order".
Why this gap exists, stated plainly. The chain above has no step that
declares dependsOn, yet 93 of the board's 102 rows carry dependencies. They arrived through
the orchestrator's own bulk queue post — migration tooling built to move this project's
README-table roadmap onto the board, and no part of this approved API. So at the time of this approval a dependency could only be
declared by the migration door, which meant a project authored natively in Crucible could not declare one at
all. That was the hole your ruling named — and it is CLOSED: cr-depends shipped as
CR-CRU-106, and the migration door it worked around is now itself deprecated (see the last row above).
Three shapes were offered. You approved A.
| Shape | Call | Trade |
|---|---|---|
| A — its own verb ✓ APPROVED | cr-depends --cr CR-X --on A,B,C | The WHOLE set is the payload, exactly as step 3 argues for order: re-sending replaces, so a dropped dep is a declaration and not an accident of arrival. Deps are their own axis — a re-plan of release or wave must not silently restate them. One new verb, and the graph stays declarative. |
| B — a flag on step 2 (rejected) | cr-plan … --depends-on A,B,C | No new verb. But cr-plan is "membership + title", and re-planning a CR's release would then
have to re-send its deps or lose them — the accident-of-arrival failure, one axis over. |
| C — fold into step 3 (rejected) | wave-sequence … --crs A,B,C --deps … | Order and graph in one call, but a dependency can cross waves and releases, so the wave call is the wrong container for it. |
Two things follow from the approval, and both are in CR-CRU-106: the verb is
ORCHESTRATOR-gated like every other declaring verb, and it refuses a cycle the way
cr-plan already does. The migration door keeps accepting an unknown dep and flagging it
(CR-CRU-014 §S1), because a bulk import cannot require its own targets to exist yet — that asymmetry is
deliberate and is stated rather than inherited. One question the CR must ANSWER rather than assume: whether an
unknown dependency TARGET is refused here (as cr-plan refuses an unproposed release) or
accepted-and-flagged (as CR-CRU-014 does). The two precedents disagree, so the choice is forced into the open.
Your requirement 2. A cr-plan missing its targeting must not be
accepted, and must not fail with "missing argument". It answers with the live candidates, so the
orchestrator never guesses or remembers:
$ python3 clients/python-crucible.py cr-plan --cr CR-CRU-092 --title "…" cr-plan: ok=false needs=[release, wave] # exit 2 — usage, principle 6 releases[2]{label,status,waves}: # ambient context, principle 7 0.2.0,proposed,"4,5" 0.3.0,proposed,"—" help[2]: # next-step templates, principle 9 cr-plan --cr CR-CRU-092 --release 0.2.0 --wave 5 --title "…" release-propose --label 0.3.1 # if it targets a release that does not exist yet
It never guesses, even when only one wave is open — silent inference is the whole failure class this design removes. When no release is proposed at all, principle 5 applies: a definitive empty state that says propose one first, with the template to do it.
| Owns | Deliberately does not | |
|---|---|---|
| Server API | Plain functional REST. Idempotent writes, structured records, referential refusals. No opinion about ergonomics. | No guidance, no templates, no prompting. It answers what is true and what it rejected. |
| Client (AXI) | The whole agent-facing surface: TOON envelope (P1), --fields (P2), --full (P3), totalCount (P4), empty states (P5), errors on stdout + exit 0/1/2 + idempotency (P6), ambient context (P7), content-first (P8), help[] (P9), consistent --help (P10). | No business rules of its own. If the client had to decide anything, two clients would decide differently. |
All three new verbs land in the shared parser clients/_crucible_axi.py
so all five clients get them at once — the CR-CRU-054 DRY rule. Worth flagging: queue-file is in
1 of 5 clients today, python only, and roadmap registration is stack-agnostic orchestrator work — that
is already a correctness gap, and these verbs must not repeat it.
Your requirements 3 and 4. An initial roadmap is a draft: gap analysis kills some CRs, new ones arrive, and the queue gets reordered. Two rules make that safe.
| Rule | Why |
|---|---|
1 · Declarative replace, not positional edits — reorder and insert are the same call:
wave-sequence with the new list. |
No --after X / --move-to N API, which is stateful and misfires when two edits
race. Sending the intended order cannot express "the order I did not intend". One verb covers add anywhere,
reorder, and drop-from-wave, and it converges on re-run. |
2 · Removal is a declaration, never a deletion — cr-supersede or cr-void. |
The queue's history is evidence. This project has the scar: a provenance repair that silently dropped nine CRs from a shipped release's membership, and CR-CRU-086 exists because erasing a record destroys the account of what happened. A killed CR stays visible as killed. |
| Verb | Meaning | Real precedent on your board |
|---|---|---|
| cr-supersede --cr X --by Y | The work still happens, elsewhere. X points at its successor. | CR-CRU-089 — "VOID; fixed by CR-CRU-088". It had a successor, so it was a supersession, not a void. |
| cr-void --cr X --reason … | The work is not happening. No successor exists. | CR-CRU-082 — "a wave cannot declare the release it targets". Genuinely dead, nothing replaced it. |
Distinguishing them is not pedantry: a superseded CR's dependants are still satisfied — by its successor — while a voided CR's dependants are now broken and must be surfaced. Collapsing both into "removed" is what makes a dependency silently rot. Neither may touch a CR that has already shipped inside a cut release: that membership is settled fact.
| Do | Expect |
|---|---|
| Generate the whole roadmap twice, end to end | Second run reports converged everywhere and mutates nothing. |
| Insert a CR mid-wave and re-send the list | Its neighbours' seq shift by one; no other wave changes. |
Move one CR to another wave via cr-plan | It leaves the old wave's sequence, the gap closes, and only that CR moves. |
| Supersede a CR that others depend on | Dependants resolve through the successor; the board still shows the superseded CR and the pointer. |
| Void a CR that others depend on | The broken dependants are named in the response, not discovered later on a render. |
| Attempt either on a shipped CR | Named refusal — settled fact is immutable. |
1 · releasedAt is epoch SECONDS, not milliseconds.
Documented at public/app-logic.mjs:874 — "the tag's OWN commit date, epoch SECONDS" — and the
stored values confirm it (0.1.0 → 1787149125). Nothing formats it as a date today; it is used
only for sorting, so the unit has never mattered. The moment it becomes visible the first naive
new Date(releasedAt) renders 1970-01-21 — I did exactly that in my own verification an
hour ago and had to correct it. So: a target date must use the same unit (one convention, not two),
and both dates must go through one shared formatter.
2 · A declared target is not the deferred forecast.
docs/research/DN-crucible-roadmap-view.md:31 already rules a "forecast/possible release
date" out of this surface: the confidence-gated P50/P80 band belongs to CR-CRU-022, deferred past
0.2.0, and the standing rule was to omit the row rather than estimate it. Your requirement does not
reopen that — an orchestrator declaring a target is authored data, the opposite of a fabricated
estimate. They compose later: the declared target is the commitment, CR-022's band is the prediction, and a
gap between them is the interesting signal. Worth writing into the spec so nobody reads it as overturning
that decision.
Your rule: dependsOn is a cue and a validation input,
and the orchestrator is ultimately responsible for the sequence. That is not a new policy here — it is
already law in the server: src/v2.ts:1759 says an unresolvable dependency is
"flagged in unknownDependencies, never rejected", and queue-file already
returns that array today. So the design extends an existing rule instead of introducing a competing one.
| What validation finds | Response | Why that severity |
|---|---|---|
| A cycle — A depends on B depends on A | hard refusal | Provably unsequenceable. No ordering satisfies it, so there is no orchestrator judgement to defer to; accepting it would store a roadmap that cannot be executed. |
| Unknown dependency — a dep naming a CR that does not exist | flagged, never rejected | Existing law (v2.ts:1759). During design a CR routinely depends on one not yet authored; refusing would make incremental authoring impossible. |
| Out-of-order — B sits before its dependency A | warned, offending pairs named, the sequence stands | Your rule. The orchestrator may know something the graph does not — a dep already satisfied out-of-band, or a deliberate parallel start. Crucible reports; it does not re-sort. |
| Cross-wave backwards — a CR depends on one in a later wave | warned, both containers named | Usually a mis-assigned wave rather than a bad sequence, so the useful output names the two containers, not the two CRs. |
The consistent line: Crucible never substitutes an order of its own. That is the same commitment CR-CRU-077 AC2 makes about the render layer — authored order wins over the layout's preference — applied to the write path. A tool that silently re-sorts is a tool the orchestrator has to fight.
next interface — sequence validation during executionYour requirement. During execution the orchestrator asks Crucible what it
should pick up, and that answer validates the declared sequence against live state. There is no
next verb in the client fleet today — I checked all five — but the orchestrator already
thinks in this vocabulary, so the verb adopts it rather than inventing one.
| Decision | Means | Carries |
|---|---|---|
| NEXT | This CR is actionable now. | The CR, its position in the wave (seq), and the help[] template for the call that starts it. |
| HOLD | The next CR in sequence is blocked. | The trigger — precisely which dependency or in-flight CR is holding it. A hold without its cause is useless. |
| DRAINED | Nothing left for this track. | A definitive empty state (P5): wave complete, or awaiting assignment. Never an empty array with no explanation. |
$ python3 clients/python-crucible.py next --track 2 next: ok=true decision=NEXT track=2 cr=CR-CRU-079 release=0.2.0 wave=5 seq=20 help[1]: plan-file --cr CR-CRU-079 --title "…" --cycles "…" --wave 5 $ python3 clients/python-crucible.py next # multi-track project, no --track next: ok=false needs=[track] tracks=[1,2] # exit 2 — never picks a lane for you
Single-track projects omit it — Crucible itself is single-track, so
next takes no argument here and the flag would be noise. It becomes required the moment more
than one track is reported, which is the same conditional-chrome rule the swimlanes follow (§7): the interface
grows only when the data justifies it. Track assignment comes from the metadata that rode in with
wave-sequence (§10 step 3), so nothing new has to be declared.
| Property | Rule |
|---|---|
| Read-only | next changes nothing. It is an oracle, not a scheduler — asking twice gives the same answer, and asking does not claim the CR. |
| It validates, it does not correct | If the declared sequence says X is next but X's dependency is unmerged, the answer is HOLD with the trigger named — not a silent skip to the next actionable CR. Skipping would be Crucible re-ordering the roadmap behind the orchestrator's back (§12). |
Two nexts must not be confused | The harness's worktree-flow next reads its own lane plan; this one reads the declared roadmap. They answer different questions, and a disagreement between them is a real signal — the lane plan has drifted from the roadmap — so neither should be silently reconciled into the other. |
Your last issue, and I measured it in the running app rather than guess. The release strip is the one zone that grows without bound — a project that ships monthly adds a diamond a month forever — and it does not get the whole window: the project rail on the right takes 434 of 1576px, 27.5%, leaving the roadmap 1130px.
| Measured on the live board, 1600px viewport | Value |
|---|---|
app-center — the roadmap surface | 1130px |
app-pane — the Project + Vitals rail | 434px · 27.5% |
| Release gates that fit today (144px pitch + terminals) | 6.8 |
| Release gates that fit with the rail collapsed | 9.8 (+38% width) |
So Crucible's five gates fit now, and stop fitting at the seventh — well inside 0.3.x. Both fixes are needed: paging because growth is unbounded, and the collapse because 27.5% is a lot to spend on a rail while reading a timeline.
| Rule | Why |
|---|---|
| Every container is drawn whole, or not at all | Your correction, and it is the stronger rule. I had proposed free scrolling with labelled edges — but
a half-drawn diamond is a defect, not something a label excuses. The window holds
floor(width / pitch) gates and never a fraction of one. |
The remainder becomes a clickable tag — ◀ N earlier / N later ▶ |
The tag is the affordance and the count: it says how much is hidden and pages to it on click. When nothing is hidden on a side, that tag disappears rather than sitting there disabled. |
| Paging, not scrolling — a click advances a whole window | Snapping to whole gates is what makes the "never partial" guarantee hold by construction, instead of depending on where a drag happened to stop. |
| Landing window contains the focused release | Unchanged from before, and it survives your correction: a twenty-release project that opens on 0.1.0 hides the only thing you came to see. The window is positioned around the focus, not at offset 0. |
| Past pages left, plan pages right | Direction keeps its meaning: leftwards is history, rightwards is commitment — free from ship-order + version order (§8). |
| Window size is measured, never hardcoded | It depends on the rail below: 6 gates with the rail open, 9 with it collapsed. So collapsing the rail does not merely add space — it reduces the hidden counts, and the two features are one behaviour. |
What this deleted, which is the point: no scroll container, no edge fades, no scroll-position state, and no drag affordance to teach. It also removed a defect class outright — I had to fix the scroll container slicing the diamonds' top and bottom vertices (a scroll container clips on both axes, and a rotated square hangs ~18px past its layout box). With paging, that bug cannot exist. Fewer moving parts and a stronger guarantee.
| Point | Detail |
|---|---|
| It belongs to a different CR | Every workspace view gains the width — Workflow, Runs, Coverage, Compile, BDD — so the collapse is shell chrome. Smuggling it into the roadmap CR would hide a cross-cutting change inside a feature and make the AC untestable. It wants its own small CR. |
| The state must live outside the render tree | Not a guess — public/app.js:2490 already says it, verbatim: state kept "OUTSIDE the render
tree exactly like lensOpenKeys and state.collapsedCycles". The body re-renders
on the poll cadence, so a mount-local flag gives the click a sub-poll lifetime — the exact bug
CR-CRU-077 hit with expansion state. |
| And it must persist | CR-077 left this gap open in writing: "Still persisted nowhere: a reload comes up collapsed."
The pattern to close it already exists — localStorage key crucible.density.mode
(app.js:435). A rail the user collapses should still be collapsed after a reload. |
| Nothing to migrate | I checked the shell: there is no collapse affordance anywhere today — the only toggles are the tab chips and the roadmap's table/graph segmented control, which CR-CRU-078 removes. This is net-new. |
First draft was a hand-built mock with the toggle in the tabs row. You corrected it three ways: (1) it did not look like what is online, (2) the behaviour is global, not the Roadmap's, and (3) the toggle belongs on the app pane itself. All three are applied below. The collapsed frames are the live board with the sliver injected client-side, screenshotted, then reverted — so every number under them was measured, not designed.
.app-pane 394px of a 1431px body — 27.5%, reproducing §14's
measured share. The strip can draw one whole gate, so four releases hide behind
◀ 4 earlier..app-pane in the shell — ProjectPane, mounted once beside the body — so every tab
inherits the state with nothing per-tab to build.| Your correction | What the code says | Consequence for CR-CRU-093 |
|---|---|---|
| The toggle goes on the app pane itself | The pane's first child is already its section title (ProjectPane →
.app-pane-section-title "Project"), so the button has a header to sit in — and when collapsed the
sliver keeps it on screen, which is the requirement a 0px pane could not meet. |
AC1 changes: "the pane contributes 0px" becomes "≤ 36px", and the stated gain moves from ≥1.30× to ≥1.30× still — measured 1.351×, so the threshold survives the sliver. |
| It is global, not the Roadmap's | Already true and nothing to add: .app-pane renders in exactly one place
(public/app.js → ProjectPane), mounted by Workspace beside
WorkspaceBody. Home is Timeline() full-width — it has no right
pane, so there is no second surface to make consistent. |
The spec's non-goal "the workspace rail only" is kept but reworded: it excludes the tabs row and the top bar, and states that home carries no pane rather than implying one was skipped. AC4 (state is workspace-global, survives tab switches) already pins the behaviour you asked for. |
| It must look like what is online | Captured, not drawn — the three frames above are the running board at
127.0.0.1:3849. |
The both-widths sweep (AC7) keeps its value: these frames are two of the twelve cells, and the strip
already re-measures through stripWindowSize() + a ResizeObserver. |
| Measured on your board, 2026-09-06 | Rail open | Rail slivered |
|---|---|---|
.app-workspace-body | 1431px | 1431px |
.app-center — the view | 1025px | 1385px (1.351×) |
.app-pane — the rail | 394px · 27.5% | 34px · 2.4% |
| Release gates drawn whole | 1 · ◀ 4 earlier | 5 · no hidden tag |
What the sliver costs, stated plainly: a 0px pane would have given
1.398×; the 34px sliver gives 1.351×. You buy back ~3.4% of body width in exchange for
the re-open control living where the thing it controls lives — and for never having to answer "where did my rail
go". AC11 becomes falsifiable again because of this choice: at 1024×640 the sliver takes real width, so
"no pane child is squeezed below the 660px floor" is now a test that can fail, where against a 0px pane it could
not. The remaining amendment is AC12's carve-out: the strip's window legitimately moves 1→5 here (6→9
on the design's 1600px figures), and tests/roadmap-release-strip.test.ts:945 already asserts exactly
that under the name "the rail-collapse case (CR-093)".
| Keep — measured, layout-agnostic | Discard |
|---|---|
Release membership from crs; ship-order sorting by releasedAt;
authored order; id + status labels; the four derived statuses; per-release counts;
packages on the gate. |
cytoscape + dagre; the 160 drawn dep: edges; the fold: reroute
machinery; the post-layout re-seat and its +41% crossings; every assertion written against
cytoscape's element registry. |