ADR-0028: Conditional Slice Edges (roadmap schema_version 3, demand-driven)
- Status: Accepted. Implemented in
lib/slice-conditions.cjs; evaluated by/np:execute-phasebefore every wave. - Date: 2026-07-30
- Supersedes: None
- Amends: ADR-0006 — extends the roadmap dependency vocabulary with conditional, status-dependent edges at slice level. The
depends_onfield and its milestone-level semantics are unchanged. - Related: ADR-0003 (slice remains a wave, no new unit type), ADR-0010 (the loop inside a wave), ADR-0027 (draws the gate)
Context and Problem Statement
After plan-phase, a milestone's slice list is a fixed serial sequence: every slice runs, in order, unconditionally. That is the right default and stays the default.
What it cannot express is a case real plans keep producing — a slice whose work only makes sense depending on how an earlier slice turned out:
- a migration-verification wave that only matters if the migration wave actually ran;
- a fallback wave that only matters if the preferred approach failed;
- a cleanup wave that is pointless if the thing it cleans up was never created.
Today the only options are to plan the slice and let it run into a no-op, or to leave it out and re-plan the milestone by hand once the outcome is known. Both push a decision the plan could have recorded into the operator's head.
The shape borrowed here is the Flows-vs-Crews split used by multi-agent frameworks: autonomous delegation inside a unit of work, deterministic branching between units. nubos-pilot already has the first half — a wave dispatches parallel executor agents with real latitude. It has no vocabulary for the second.
Decision Drivers
- The plan must stay reviewable text. A branching decision made by an agent at runtime is not reviewable; a branching decision recorded as data is.
- An unresolvable gate must never open. This is the safety property the whole design turns on.
- "Not yet decided" is not "decided no". A slice whose predecessor is still running has not been ruled out.
- Existing projects must not be disturbed. Most roadmaps will never use this.
- An older install must not mis-execute a newer file.
Considered Options
- A: Let the executor decide at runtime. Rejected: it moves a plan decision into an agent's judgement, and the plan stops describing what will happen.
- B: Re-plan the milestone when the outcome is known. Status quo. Correct but manual, and the reason for the branch is never recorded.
- C: A new unit type for a conditional group. Rejected: ADR-0003 caps the unit types at six, and a gate is a property of an edge, not a new kind of thing.
- D: An optional
whenon a slice, evaluated deterministically before each wave. Chosen.
Decision Outcome
Chosen: Option D. A slice may carry a when; the condition is data in roadmap.yaml, evaluated by lib/slice-conditions.cjs, never a judgement the executor makes.
schema_version: 3
milestones:
- id: M001
slices:
- id: S001
name: migrate
- id: S002
name: verify the migration
when:
slice: S001
status: doneOperators are status and status_not. A bare list of terms means all must hold; all_of / any_of make it explicit. References must name a slice in the same milestone — a slice condition is evaluated while executing one milestone and cannot see beyond it, and cross-milestone ordering already has depends_on.
Decisions are three-valued
| Decision | Meaning |
|---|---|
run | The condition holds — dispatch the wave. |
skip | The condition can no longer hold — the slice is permanently out. |
wait | Not decided yet — the referenced slice has not reached a terminal state. |
wait is the decision that makes this correct. Collapsing it into skip permanently drops a slice whose predecessor merely has not finished, which is the silent-data-loss bug in this feature. Because a wait resolves only as the milestone executes, execute-phase re-evaluates before every wave rather than once up front.
The combinator asymmetry follows from the same reasoning: in all_of a definite skip beats an undecided term, because no later resolution rescues an AND that already has a false conjunct; in any_of an undecided term beats a definite skip, because it could still become true.
An unresolvable condition never means "run"
A condition referencing a slice that does not exist, or a malformed term, is an error. slice-plan exits non-zero and execute-phase refuses the milestone rather than proceeding with the gate ignored. Such a slice is neither run nor skipped: the milestone is not executable until the plan is fixed.
The reasoning is one line: a gate that opens when it breaks is not a gate. Defaulting to run would silently execute work the plan explicitly gated.
schema_version is demand-driven, not bumped
CURRENT_SCHEMA_VERSION stays 2. _mutate stamps that value on every write, so raising it would migrate every existing project to a version older installs cannot read — for a feature those projects do not use.
3 is required only once a slice carries a when, and that requirement is real rather than bookkeeping: a reader that ignores the field runs a slice the plan gated, which is exactly the situation a MAJOR bump exists to prevent. A conditional roadmap declaring 2 is therefore refused on read, not repaired on write — that is the file an older install would parse successfully and then execute wrongly.
The derivation is deliberately bidirectional and not a max(): removing the last when drops the file back to 2, which makes it readable by older installs again, and honestly so, because nothing is left for them to mishandle.
Static lint
slice-plan lint rejects cross-milestone references, self-references (which can never resolve), and cycles — reporting each cycle once, normalised to its smallest member, rather than once per entry point.
Consequences
Good, because:
- A branch that previously lived in the operator's head is now recorded, reviewable and diffable.
- The failure mode is refusal, not silent execution.
- Existing roadmaps are untouched: no
when, no version change, no behavioural difference.
Bad, because:
- A third valid
schema_versionis a third case in every reader, and the version is now computed rather than constant — a subtler invariant than a literal. - Conditions are per-milestone by design, so a genuinely cross-milestone gate has no expression here and must use
depends_onat milestone granularity. waitadds a state the orchestrator has to loop on. A plan with a condition that never resolves stalls rather than failing loudly, and only the operator can tell those apart.- Expressiveness is capped at two operators over slice status. Gating on a verify outcome or a task status is not expressible yet; widening the vocabulary is a future ADR, deliberately not pre-built.
Two bugs this exposed in the existing write path
_mutatestampedCURRENT_SCHEMA_VERSIONunconditionally, so any unrelated mutation of a conditional roadmap silently downgraded it 3 → 2, stripping the marker that protects the conditions.- The fix had to be a derivation rather than a
max(), or the file could never become readable by older installs again after a condition was removed.
More Information
- Implementation:
lib/slice-conditions.cjs, version coupling inlib/roadmap-schema.cjs(requiredSchemaVersion,validateConditionalVersion), CLIbin/np-tools/slice-plan.cjs(verbsplan <N> | lint). - Wiring:
workflows/execute-phase.mdStep 0b, re-evaluated before every wave. - Rendering: conditional edges appear as dotted labelled edges via ADR-0027.
