Release strip → active release flowchart → its table

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.

1 · Landing — 0.2.0 focused because it is in progress

zone 1 · release strip
Start
0.1.0
60
shipped
2026-08-19
0.1.1
0
shipped
2026-08-19
0.1.2
1
shipped
2026-08-20
0.1.3
1
shipped
2026-08-27
0.2.0
20
in flight
no target declared
End
zone 2 · flowchart — CR-CRU-096 TARGET. One CR per ROW: merged collapse into the SUMMARY row; the rows below are the top of the SCHEDULED queue.
Start

Wave 5 · active28

21 merged ✓ · awaiting the tag
CR-CRU-095 next · deps 091, 092
CR-CRU-096 deps 078
CR-CRU-079 deps 078
CR-CRU-085 deps 078
CR-CRU-093 deps 006
+2 more — see the table below
0.2.0
planned
no target declared
End

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.

zone 2 · the same wave with CR-CRU-095 ACTIVATED — an active CR is ALWAYS shown, and is the only thing that moves
Start

Wave 5 · active28

21 merged ✓ · awaiting the tag
CR-CRU-095 ▸ active
CR-CRU-096 deps 078
CR-CRU-079 deps 078
CR-CRU-085 deps 078
CR-CRU-093 deps 006
+2 more — see the table below
0.2.0
planned
no target declared
End

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.

zone 3 · table — always the focused release, never the whole project
CRtitledepends onstatuscycle
CR-CRU-078graph and table are complementary077, 084▸ next
CR-CRU-079roadmap deep-link parity and active-CR078pending
CR-CRU-077the roadmap graph is the execution DAG014, 076, 080, 083, 084✓ merged6/6
CR-CRU-084a release records the package(s) it delivered080, 081✓ merged
CR-CRU-088a failure detail printed after its own leaf087✓ merged
CR-CRU-087CI floats to the newest bun✓ merged
CR-CRU-083derived status conflates "never started" with014, 081✓ merged
CR-CRU-086the provenance repair must never erase081✓ merged
CR-CRU-081release provenance must use commit ancestry080✓ merged
CR-CRU-080the release ceremony cannot report a release074✓ merged
CR-CRU-076Roadmap is first in the workspace tab band014, 021✓ merged
CR-CRU-074Crucible has never been told a release013✓ merged
CR-CRU-073Finished releases keep showing their gate013, 071, 074✓ merged
CR-CRU-072The installer cannot upgrade066, 069, 071✓ merged
CR-CRU-071In-place upgrade001, 043, 068✓ merged
CR-CRU-070systemd --user unit066, 069✓ merged
CR-CRU-069Install has no inverse009, 066✓ merged
CR-CRU-068The server never says which store it opened043, 066✓ merged
CR-CRU-017Run lifecycle: start/end events + the Aborted008, 011✓ merged
CR-CRU-014Execution roadmap011, 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.

2 · Selecting a shipped release — states what it delivered; Workflow owns its history

zone 1 · strip — focus moved to 0.1.0
Start
0.1.0
60
shipped
2026-08-19
0.1.1
0
2026-08-19
0.1.2
1
2026-08-20
0.1.3
1
2026-08-27
0.2.0
20
no target declared
End
zone 2 · flowchart — delivered summary, not a wave reconstruction
Start
60 CRs
waves 1–4 · shipped 2026-08-19
pypi crucible-axi · npm @anthill-tec/crucible-server
0.1.0
shipped
2026-08-19
End
zone 3 · table — 0.1.0's 60 CRs, in authored order
CRtitledepends onstatuswave
CR-CRU-001Domain core + SQLite storage✓ merged1
CR-CRU-002Codec translation layer001✓ merged1
CR-CRU-003v1 compatibility shim + contract tests001, 002✓ merged1
CR-CRU-004Clean v2 API + SSE003✓ merged2
CR-CRU-005AXI layer: TOON subset + help hints004✓ merged2
CR-CRU-006Dashboard shell (Mission Control + workspace004✓ merged3
…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.

3 · A release that shipped nothing — 0.1.1, drawn honestly

Start
no CRs in this release
it shipped both packages — that is the whole story
0.1.1
shipped
2026-08-19
End

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.

4 · Multi-track — swimlanes only when tracks > 1

Wave 5 · active · 2 tracks20

track-1
CR-CRU-080
CR-CRU-078 ▸ 2/6
track-2
CR-CRU-084
CR-CRU-081 ▸ 1/4
0.2.0
planned
no target declared

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.

5 · Shape and colour grammar — shape says what it is, colour says where it stands

Two independent channels, so neither has to carry the other's meaning: shape is the kind of thing, colour is its state.

Start
Stadium — terminal. One Start, one End.
Diamond — release gate, in the flow.
Dashed diamond — release proposed by the orchestrator, not cut.
Box — wave: the abstract container of CRs.
CR
Rectangle — one CR; id + terse status. Never a title — that is the table's column.
Dashed divider — track swimlane, multi-track only.
Green — COMPLETED, merged.
Green, dimmed — COMPLETED_UNTRACKED: shipped before plan tracking.
Ember — IN_PROGRESS. The only thing that ever moves.
·
Plain — PENDING, nothing to report yet.
Amber — a release, shipped or planned.
Dimmed — not the focused release; click to focus.

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.

6 · Selection and navigation

ActionFlowchartTable
Land on Roadmapfocuses the release in progressthat release's CRs
Click another diamondredraws for that releasefollows the focus, always
Click the active waveopens / closes in placenarrows to that wave
Click a CR or its rowdrills to Workflow — that CR's cycles, ← roadmap back (existing behaviour)
Historical wavesnot drawn here — the Workflow history view owns them

7 · Conditional chrome — the single rule

ElementDrawn whenCrucible today
Release stripalways — it is the spine5 gates
Active-release focusa release is in progress; else newest shipped0.2.0
Wave containerthe focused release is the active oneW5 only
Delivered summarythe focused release has shipped0.1.0–0.1.3
Track lanes + table column> 1 track reportedno — single track
Wave column in the tablethe release spans > 1 wave0.1.0 only
Dashed gatethe orchestrator registered a proposed release; one per proposal0.2.0 — once registered
Motionthe CR is IN_PROGRESSnone active

8 · What determines every position

VisualDetermined byData
Strip ordershipped by ship date; proposed always after them, in version orderreleases[].releasedAt · semver
Which CRs are inside — shippedrelease membership — settled fact, frozen at shipreleases[].crs
Which CRs are inside — proposeddeclared 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 itcr-plan --release · POST …/queue
Dashed gate existsan orchestrator-registered proposal — never inferredproposed-release record
Order in the wave / tableauthored queue orderqueue row index
Lanereported track; absent ⇒ no lanesplan.track
Node labelCR id + terse status, never the titlederived status
The deps annotation on a rowdeclared 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 cellthe CR's own brief titleCR spec H1
Motion + cycleactive cycle of an open planplan.cycles
Packages on the gatewhat the release publishedreleases[].packages
Date under a shipped gatethe tag's own commit date, written at release timereleases[].releasedAt
Date under a proposed gatethe orchestrator's declared target — empty state when nonerelease-propose --target

No row reads "whatever the layout chose". Nothing here needs a layout engine, a crossing heuristic, or a compound-graph library.

9 · The containment model — release ⊃ wave ⊃ CR, all three declared

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.

release
0.2.0
super container

wave 5container

CR-CRU-080 seq 1
CR-CRU-078 seq 19
CR-CRU-079 seq 20

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.

▲ TWO CODE FACTS, BOTH VERIFIED — one kills my own proposal

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 ingestedreleasedAt 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.

RuleConsequence
Who declaresMainline orchestrator only — the existing ORCHESTRATOR role. A track executes; it never re-plans the roadmap.
A proposal is a planRevisable and withdrawable at any time; a CR may be moved between waves or releases until it ships.
A shipped release is a factImmutable, membership frozen into crs. The shipped record supersedes and consumes its proposal — one gate, never a pair.
Zero-CR containers are legalA proposed release with no CRs is a declared intent; 0.1.1 proves a shipped one happens too.
Gate retirementA proposal retires nothing — CR-CRU-073's rule stays scoped to real releases.
A shipped release carries its ship dateRecorded 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 dateDeclared 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 stripA 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.

10 · The AXI interface — built during design, one CR at a time

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.

the ordered call chain — outermost container first
#CallWhen, and why it sits here
0register --role ORCHESTRATOR Identity before any write. Already enforced — an unregistered id gets HTTP 409; it refused me twice today.exists
1release-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
2cr-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
2bcr-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
3wave-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
4cr-supersede · cr-void Gap-analysis phase — see §11. Declarations, never deletions.shipped · 091
5plan-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
5bnext [--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
5cgate-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
6milestone --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".

✓ APPROVED 2026-09-04 — STEP 2b, A DEPS-DECLARING VERB. SHAPE A OF THREE. Filed as CR-CRU-106.

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.

ShapeCallTrade
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.

the client ASKS — principle 9 + principle 7, not a blank failure

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.

division of labour — your requirement 5
OwnsDeliberately does not
Server APIPlain 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.

11 · Editing the roadmap — add, reorder, supersede; nothing is ever deleted

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.

RuleWhy
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 deletioncr-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.
VerbMeaningReal precedent on your board
cr-supersede --cr X --by YThe 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.

acceptance shape
DoExpect
Generate the whole roadmap twice, end to endSecond run reports converged everywhere and mutates nothing.
Insert a CR mid-wave and re-send the listIts neighbours' seq shift by one; no other wave changes.
Move one CR to another wave via cr-planIt leaves the old wave's sequence, the gap closes, and only that CR moves.
Supersede a CR that others depend onDependants resolve through the successor; the board still shows the superseded CR and the pointer.
Void a CR that others depend onThe broken dependants are named in the response, not discovered later on a render.
Attempt either on a shipped CRNamed refusal — settled fact is immutable.
▲ TWO TRAPS THE DATE REQUIREMENT WALKS INTO — both checked

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.

12 · Dependencies validate, they never order — the orchestrator owns the sequence

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 findsResponseWhy that severity
A cycle — A depends on B depends on Ahard 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 existflagged, 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 Awarned, 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 wavewarned, 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.

13 · The next interface — sequence validation during execution

Your 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.

the three decisions — borrowed verbatim from the vocabulary already in use
DecisionMeansCarries
NEXTThis CR is actionable now. The CR, its position in the wave (seq), and the help[] template for the call that starts it.
HOLDThe 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.
DRAINEDNothing left for this track. A definitive empty state (P5): wave complete, or awaiting assignment. Never an empty array with no explanation.
multi-track — the track id is required, never guessed
$ 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.

PropertyRule
Read-onlynext 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 correctIf 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 confusedThe 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.

14 · Layout at scale — whole containers only, and the strip does not own the window

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 viewportValue
app-center — the roadmap surface1130px
app-pane — the Project + Vitals rail434px · 27.5%
Release gates that fit today (144px pitch + terminals)6.8
Release gates that fit with the rail collapsed9.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.

zone 1 — every container drawn WHOLE; the remainder is a clickable tag. Click either tag.

0
earlier
0
later

RuleWhy
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.

the collapsible rail — shell work, not roadmap work
PointDetail
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.
§14.1 — how it collapses · CR-CRU-093 gap analysis 2026-09-06 · your three corrections applied

Not a drawing — this is your board, captured at 1600×900

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.

The live Crucible workspace with the project rail expanded: the release strip shows one gate and a '4 earlier' tag.
Today. .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.
The same workspace with the rail collapsed to a 34px sliver: the release strip now shows all five gates.
Collapsed, toggle ON the pane. The pane becomes a 34px sliver carrying only the re-open button and a vertical PROJECT · VITALS label; the view goes 1025 → 1385px (1.351×). All five gates now draw — the hidden tag is gone, and nothing asked it to page.
The Runs tab with the same rail collapsed, showing the run timeline at full width.
Global, by construction. Same sliver, Runs tab, same 1385px. There is exactly ONE .app-pane in the shell — ProjectPane, mounted once beside the body — so every tab inherits the state with nothing per-tab to build.
Your correctionWhat the code saysConsequence 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.jsProjectPane), 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-06Rail openRail slivered
.app-workspace-body1431px1431px
.app-center — the view1025px1385px (1.351×)
.app-pane — the rail394px · 27.5%34px · 2.4%
Release gates drawn whole1 · ◀ 4 earlier5 · 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)".

15 · What survives from CR-CRU-077

Keep — measured, layout-agnosticDiscard
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.