decideSpecRoute function

SpecRouteVerdict decideSpecRoute({
  1. required List<SpecLane> lanes,
  2. required String gating,
  3. required String sessionRoot,
  4. required int priorRound,
  5. int maxRounds = kMaxRespecRounds,
})

The SPEC-route matrix (pure — zero I/O; the whole decision, unit-testable).

In order:

  1. the GATING lane at F (a structurally broken spec; a MISSING gating grade fail-closes here too) ⇒ SpecEscalate — a deterministic structural block is not an "actionable critic grade with a rationale"; a human rules. Its reason NAMES the gating lane (ADR-0000 A13(5)'s invariant, preserved in the fork).
  2. any NON-gating lane at F ⇒ SpecEscalate. An F is the scope/decompose-class judgement (ADR-0000 A13(4): the station has no separate decompose verdict — its semantics ride this gate), or a verdict-transport miss whose fail-closed F carries no usable rationale. Neither is respec-fixable.
  3. the FIXABLE set = every non-gating lane at D or E. Empty ⇒ SpecAdvance — INCLUDING when priorRound has already reached maxRounds. A CONVERGED join advances no matter how many rounds were consumed: the cap bounds the LOOP, it never condemns the spec (bead pow-p8w).
  4. a fixable lane with an EMPTY rationale ⇒ SpecEscalate (no-rationale) — there is nothing to feed the re-specify agent, and a respec that re-runs specify with no guidance would re-write the same spec and re-park. LOUD.
  5. a fixable join containing ANY author-owed lane ⇒ SpecEscalate (author-owed) IMMEDIATELY, with the correction bound unspent and every author-owed rationale quoted verbatim for the governor. A blank or unrecognised owner reads as architect-owed — today's behaviour; the LOUD guard against a missing owner is the DECODER (requireOwner), which refuses a live D/E naming no owner at all.
  6. TWO OR MORE fixable lanes ⇒ SpecEscalate (multi-action-grade) — the round did not converge on a single carriable finding, so a human rules (bead pow-bhm, policy Nico-ratified 2026-07-18: "hard-gate ONLY on F or on two-plus D lanes"). The auto-respec bound is NOT spent here.
  7. a SINGLE architect-owed D WITH a rationale ⇒ SpecAdvance carrying it as SpecAdvance.fixInFlight — the finding rides the build brief VERBATIM as a BINDING fix-in-flight item and the code committee re-checks it at review, so the catch keeps its value without spending a respec cycle (bead pow-bhm). A single E falls through to arm 8: E is the band above D, and the ratified rule names D alone.
  8. a fixable join UNDER the bound (priorRound < maxRounds) ⇒ SpecRespec carrying the current circuit round.
  9. else — a join that is fixable RIGHT NOW, at the bound ⇒ SpecEscalate (respec-cap). This arm is the FALL-THROUGH of arm 8, so the cap is the conjunction "the rounds are spent AND the CURRENT join still fails" by STRUCTURE rather than by a second read, and its reason quotes the same fresh vector the matrix just decided on — never a ledger-recorded last failure. (decideDiscovery's regather arm carries the identical shape.)

The spread rule is GONE from the spec route (and Step 4's pow-bhm cut removed it from CodeRouteCapability as well, for the identical reason). A spread ≥ 3 across A..F necessarily puts some lane at D or worse, so arms 2/3 already cover every case it caught — and what it did with them ("human ultimatum") is exactly what this bead exists to stop doing by default. The spread still rides SpecAdvance as provenance. Recorded as power_station#a14-bead-pow-7nm-the-spec-route-auto-respecs-a-fixable-spec clause (4), extended to the code route by the pow-bhm decision entry committee-gate-single-finding-advance-and-the-refinement-flag.

Arm numbering is a CITED contract. Bead pow-s2t (rework can DECOMPOSE) will hang its breakdown disposition off the BOUND arms — today arms 8 (SpecRespec) and 9 (respec-cap), renumbered from 6 and 7 by bead pow-bhm. The threshold itself (kMaxRespecRounds) is unmoved and the cap arm's rule string is still respec-cap; only the ordinals shifted.

Implementation

SpecRouteVerdict decideSpecRoute({
  required List<SpecLane> lanes,
  required String gating,
  required String sessionRoot,
  required int priorRound,
  int maxRounds = kMaxRespecRounds,
}) {
  String gradeOf(SpecLane lane) =>
      (lane.grade == null || lane.grade!.trim().isEmpty)
      ? 'F'
      : lane.grade!.trim().toUpperCase();

  String ownerOf(SpecLane lane) => lane.owner.trim().toLowerCase();

  // The FRESH grade vector — the ONE verdict source this matrix decides on, in
  // `critics` order. Every arm that REPORTS a grade quotes this binding, so a
  // flare can never cite a grade the decision did not read (bead `pow-p8w`: the
  // cap flare and the respec decision must share one channel).
  final gradesCsv = lanes.map((l) => '${l.id}=${gradeOf(l)}').join(',');

  // 1. the deterministic structural lane — a hard block (fail-closed on missing).
  final gate = lanes.where((l) => l.id == gating);
  final gatingGrade = gate.isEmpty ? 'F' : gradeOf(gate.first);
  if (gatingGrade == 'F') {
    final why = gate.isEmpty || gate.first.rationale.trim().isEmpty
        ? ''
        : ' — ${gate.first.rationale.trim()}';
    return SpecEscalate(
      rule: 'gating-hard-block',
      reason: '$gating failed: hard block$why',
    );
  }

  final judged = [
    for (final l in lanes)
      if (l.id != gating) l,
  ];

  // 2. any judgement lane at F — a scope/decompose-class ruling, or a transport
  //    miss. A human decides; never an auto-respec.
  final hard = [
    for (final l in judged)
      if (gradeOf(l) == 'F') l,
  ];
  if (hard.isNotEmpty) {
    return SpecEscalate(
      rule: 'critic-F',
      reason:
          'a spec critic returned F (${hard.map((l) => l.id).join(', ')}) — an F '
          'is not a fixable nit (a wrong scope, a bead that needs decomposing, '
          'or a missing verdict that fail-closed). A human rules; auto-respec is '
          'withheld.',
    );
  }

  // 3. the FIXABLE set — an actionable critic grade.
  final fixable = [
    for (final l in judged)
      if (gradeOf(l) == 'D' || gradeOf(l) == 'E') l,
  ];
  // The spread is PROVENANCE only (A14(4)) — no arm decides on it. Hoisted out
  // of the converged arm so the single-finding advance (arm 7) reports it too.
  final indices = [
    for (final l in lanes)
      if (l.grade != null && l.grade!.trim().isNotEmpty)
        _gradeIndex(gradeOf(l)),
  ];
  final spread = indices.isEmpty
      ? 0
      : indices.reduce((a, b) => a > b ? a : b) -
            indices.reduce((a, b) => a < b ? a : b);
  if (fixable.isEmpty) {
    return SpecAdvance(gradesCsv: gradesCsv, spread: spread);
  }

  // 4. a fixable grade with NO rationale — nothing to correct against. LOUD.
  final mute = [
    for (final l in fixable)
      if (l.rationale.trim().isEmpty) l,
  ];
  if (mute.isNotEmpty) {
    return SpecEscalate(
      rule: 'no-rationale',
      reason:
          'a spec critic graded '
          '${mute.map((l) => '${l.id}=${gradeOf(l)}').join(', ')} but returned '
          'NO rationale — there is no correction guidance to respec with, so an '
          'auto-respec would rewrite the same spec blind. A human rules.',
    );
  }

  // 5. AUTHOR-OWED — the fix is a decision only the bead's AUTHOR can make, so
  //    an auto-respec round is UNWINNABLE by construction: `specify` re-runs
  //    with the same bead text and re-draws the same grade (the receipt:
  //    pow-n6n.1 burned both rounds on a coherence D that asked the architect
  //    to choose between its own names and pow-n6n.2's). Park IMMEDIATELY,
  //    spending NO round of the bound, and hand the governor the fork verbatim
  //    so the bead BODY is cured once. ANY author-owed lane decides the join:
  //    the human is already in the loop, and the architect-owed lanes ride the
  //    same rework wave for free. Bead `pow-hxme`'s ADR-0000 A37 records
  //    this refinement of ratified A14(3).
  final authorOwed = [
    for (final l in fixable)
      if (ownerOf(l) == kOwnerAuthor) l,
  ];
  if (authorOwed.isNotEmpty) {
    final b = StringBuffer()
      ..write(
        'author-owed: '
        '${authorOwed.map((l) => '${l.id}=${gradeOf(l)}').join(', ')} — the '
        'critic marked the fix AUTHOR-owed: it needs a decision the BEAD TEXT '
        'does not make (which names win, whether the scope splits, which of '
        'two designs), not a spec correction the architect can derive from the '
        'tree. Auto-respec is WITHHELD with the bound UNSPENT ($priorRound of '
        '$maxRounds round(s) used): re-running `specify` cannot decide it. '
        'Cure the bead BODY once, then resolve this gate. The current join is '
        '$gradesCsv.',
      );
    for (final l in authorOwed) {
      b.write('\n\n### ${l.id} — grade ${gradeOf(l)}\n${l.rationale.trim()}');
    }
    return SpecEscalate(rule: 'author-owed', reason: b.toString());
  }

  // 6. TWO OR MORE action grades — the round did not converge on a single
  //    finding, so it GATES (bead `pow-bhm`, policy Nico-ratified 2026-07-18:
  //    "hard-gate ONLY on F or on two-plus D lanes"). The auto-respec loop is
  //    NOT spent here: a multi-lane join is the shape a human reads once, and
  //    the governor's rework is one command. A29(2): the flare quotes the FRESH
  //    vector this matrix decided on.
  if (fixable.length >= 2) {
    final b = StringBuffer()
      ..write(
        'multi-action-grade: '
        '${fixable.map((l) => '${l.id}=${gradeOf(l)}').join(', ')} — two or '
        'more lanes returned an action grade, so the round did not converge on '
        'a single carriable finding. The current join is $gradesCsv.',
      );
    for (final l in fixable) {
      b.write('\n\n### ${l.id} — grade ${gradeOf(l)}\n${l.rationale.trim()}');
    }
    return SpecEscalate(rule: 'multi-action-grade', reason: b.toString());
  }

  // 7. a SINGLE architect-owed D WITH a rationale — ADVANCE, carrying the
  //    finding VERBATIM to the build brief as a BINDING fix-in-flight item.
  //    The receipt (bead `pow-bhm`): `tg-h4u` burned two substantive spec
  //    rounds on a single sub-C lane, one of them a decision-alignment D whose
  //    load-bearing defect was a citation misattribution. Every catch keeps its
  //    value — the finding rides the build and the code committee re-checks it
  //    — and the respec cycle is not spent. A single E still respecs: E is the
  //    band above D, and the ratified rule names D alone.
  final only = fixable.single;
  if (gradeOf(only) == 'D') {
    return SpecAdvance(
      gradesCsv: gradesCsv,
      spread: spread,
      fixInFlight: RespecLane(
        rubric: only.id,
        grade: gradeOf(only),
        rationale: only.rationale.trim(),
      ),
    );
  }

  // 8. RESPEC — a fixable join UNDER the bound auto-loops with the failing
  //    lanes' rationales as the correction guidance.
  if (priorRound < maxRounds) {
    return SpecRespec(
      RespecLedger(
        sessionRoot: sessionRoot,
        round: priorRound,
        lanes: [
          for (final l in fixable)
            RespecLane(
              rubric: l.id,
              grade: gradeOf(l),
              rationale: l.rationale.trim(),
            ),
        ],
      ),
    );
  }

  // 9. the BOUND — reachable ONLY as arm 8's fall-through, i.e. over a join that
  //    is fixable RIGHT NOW (arm 3 already advanced a converged one). So the cap
  //    IS "rounds spent AND the current join still fails", and the reason quotes
  //    `fixable` plus the whole fresh `gradesCsv` the matrix decided on — a human
  //    reading the parked gate can check the cited grade against the critique on
  //    disk without trusting a second channel.
  return SpecEscalate(
    rule: 'respec-cap',
    reason:
        'respec-cap: $priorRound auto-respec round(s) already ran (cap '
        '$maxRounds) and the spec STILL fails '
        '(${fixable.map((l) => '${l.id}=${gradeOf(l)}').join(', ')}) — the '
        'current join is $gradesCsv. Flaring to a human: the committee and the '
        'specify agent are not converging.',
  );
}