# sub-reviewer-4 — LENS 4 (PART B): engine-whitelist fail-soft → STOP-HARD + alias-sync

**Floor point:** `PartB engine-whitelist`
**Verdict:** NEEDS-FIX (necessity SHOULD — directed-recommended proactive guardrail, low-risk + strictly-better change; not a live SE incident)
**Source directive:** DIRECTED `6c32df89` §2.3 (REC-3) + broadcast `ab6c387e` (presence-not-age, separate lens). REC-3: "chỗ fail-soft-chỉ-WARN của bạn nên nâng lên cùng chuẩn (dừng hẳn + hỏi chủ-sở-hữu)". Hub fixed hub-side today with a 5-case sim test; fail-soft = "the silent-break that caused a real error hub-side".

---

## 1. One-line finding

`hmw.js` still **fail-softs** an unknown non-empty role to a generic default subagent (`:106`, `:107`, `:145`, `:152`) — this contradicts DIRECTED REC-3. Fix = an **up-front STOP-HARD throw** that halts before any fan-out, while **preserving the legitimate role-less (`null`/`''`) inherit-lead path** (H6.2).

---

## 2. Evidence (file:line + quote)

The fail-soft lives at four points in `.claude/workflows/hmw.js`:

- **`:106`** — `const role = VALID_ROLES.includes(raw) ? raw : undefined` `// S4c whitelist; invalid → default subagent (fail-soft)`
- **`:107`** — `if (raw && !role) log(\`⚠️ hmw: agentType "${raw}" ∉ VALID_ROLES → default subagent cho task #${i}\`)` — **WARN only, no halt**.
- **`:145`** — `agentType: role || undefined,` — invalid role collapses to `undefined` → generic subagent.
- **`:152`** — comment `// ... invalid-role vẫn chạy default subagent, KHÔNG skip`.
- **`:46-48`** (`resolveModel`) — `// Invalid-role (typo ∉ VALID_ROLES ...) → fail-UP inherit`; `if (!role && rawRole) return undefined`.

Docs that **describe this CURRENT behavior** (must sync):
- `.claude/commands/ultra-on.md:21` — "role lạ → default subagent + cảnh báo".
- `.claude/commands/ultra-on.md:26` — "Role lạ ∉ list → `hmw.js` degrade về default subagent + WARN (fail-soft, KHÔNG crash)".
- `.claude/agents/README.md:208` — "Role lạ → default subagent + WARN (fail-soft, S4c)."
- `docs/governance/harness-11-engine.md` §K.E `:345` — 🧊 note "limitation cũ ... fail-soft default-subagent" (historical framing, but the current-behavior implication is still fail-soft).
- `docs/governance/fable-real-runbook.md:520` (P5-mới) — "2 monitor × lệnh-B → limitation K.E (fail-soft default-subagent)"; also `:74`, `:191`.

**Reachability today:** `VALID_ROLES` = **12** (full roster, S110 — `:22-30`). So all legitimate roster roles are valid; the fail-soft branch is now reachable **only by a genuine typo** (e.g. `reviewr`) or a rename-drift (a role renamed in the roster but a caller still passing the old name). Current effect of that typo: **silent** loss of the role's persona + memory-pack + skill identity → runs a generic subagent. This is exactly the silent-degrade class REC-3 targets.

---

## 3. KEY TECHNICAL FINDING — the naive "just change `:106` to throw" is a TRAP

The fan-out is `await parallel(A.taskList.map((t,i) => () => { ... agent(...) }))` (`:104-150`), and the tail is:

- **`:152`** — comment `// trả mảng kết quả (lọc null nếu agent lỗi/null ...)`
- **`:154`** — `return results.filter(Boolean)`

`.filter(Boolean)` + the "lọc null nếu agent lỗi/null" comment mean **individual lane failures may be swallowed into `null` and silently dropped** by the runtime. Therefore a per-lane `throw` inside the map callback (the "minimal" fix) risks being caught → `null` → filtered at `:154` = **STOP-SILENT, which is WORSE than fail-soft** (silent skip vs. at-least-a-default-subagent). This is the very anti-pattern REC-3 warns against.

**Conclusion:** the throw MUST be an **up-front validation pass BEFORE `parallel(...)`**, so halt semantics do not depend on `parallel()`'s per-lane error handling. This also fails fast before spending tokens on any lane, and mirrors the existing up-front `checkpointApproved` tripwire (`:83-85`).

---

## 4. EXACT change (unified-diff style, against current `hmw.js`)

### 4a. Add up-front STOP-HARD block — insert after `:89` (the `taskList > 16` notice), before `const memoryPack` (`:91`)

```diff
 if (A.taskList.length > 16) {
   log(`hmw: taskList=${A.taskList.length} (>16) → harness queue theo slot (thoải mái, không cap cứng).`)
 }
+
+// ─── STOP-HARD role-whitelist (DIRECTED 6c32df89 REC-3, 2026-07-13) ──────────
+// Vai ∉ VALID_ROLES (typo / rename-drift) = DỪNG HẲN + báo owner — KHÔNG fail-soft
+// về default subagent (fail-soft = "đứt âm-thầm" đã gây lỗi thật hub-side; sim 5-ca).
+// PHÂN-BIỆT (backward-compat): role null/omitted/'' = HỢP-LỆ role-less (H6.2
+// governed-ultracode → inherit lead) → KHÔNG throw. CHỈ non-empty-string ∉ whitelist
+// mới throw. Validate UP-FRONT (trước parallel) — halt KHÔNG phụ-thuộc semantics của
+// parallel()/.filter(Boolean) (per-lane throw có thể bị nuốt thành null = STOP-SILENT, tệ hơn).
+const badRoles = A.taskList
+  .map((t, i) => ({ i, raw: t && t.role }))
+  .filter(x => x.raw != null && x.raw !== '' && !VALID_ROLES.includes(x.raw))
+if (badRoles.length > 0) {
+  const detail = badRoles.map(x => `#${x.i}="${x.raw}"`).join(', ')
+  throw new Error(
+    `hmw: vai ∉ VALID_ROLES (${detail}) — DỪNG HẲN (STOP-HARD). ` +
+    `Sửa typo HOẶC báo owner thêm vai vào VALID_ROLES [${VALID_ROLES.join(', ')}]. ` +
+    `KHÔNG fail-soft về default subagent (DIRECTED 6c32df89 REC-3 2026-07-13).`)
+}

 const memoryPack = A.memoryPack || {}
```

### 4b. Simplify the now-guaranteed inner map — `:105-107`

```diff
   const raw = t && t.role
-  const role = VALID_ROLES.includes(raw) ? raw : undefined           // S4c whitelist; invalid → default subagent (fail-soft)
-  if (raw && !role) log(`⚠️ hmw: agentType "${raw}" ∉ VALID_ROLES → default subagent cho task #${i}`)
+  // STOP-HARD validate up-front (DIRECTED REC-3): raw ở đây CHỈ có thể ∈ VALID_ROLES HOẶC null/'' (role-less H6.2).
+  const role = VALID_ROLES.includes(raw) ? raw : undefined           // role-less (null/'') → undefined → inherit lead
```

### 4c. Comment-only stale-fix (no behavior change, avoids doc-drift) — `:46-48` and `:152`

- `:46-48` `resolveModel`: the `if (!role && rawRole) return undefined` branch is now **dead** (invalid non-empty role already threw). Keep as defensive no-op but update the comment from "Invalid-role → fail-UP inherit" to "role-less (null) → inherit lead; invalid-role đã throw up-front".
- `:152`: change "invalid-role vẫn chạy default subagent, KHÔNG skip" → "invalid-role đã STOP-HARD up-front; `.filter(Boolean)` chỉ lọc null-lane do agent lỗi runtime".

> The essential MUST is **4a** (the up-front throw). 4b/4c are consistency cleanups so the code doesn't keep describing the retired fail-soft.

---

## 5. Backward-compat / does throwing break a legitimate flow? — NO

| taskList role | Before | After | Legit? |
|---|---|---|---|
| `'reviewer'` (∈12) | fan-out | fan-out (no throw) | ✓ unchanged |
| `null` / omitted (role-less H6.2) | inherit lead, default subagent | **inherit lead, no throw** (filter excludes `raw==null`) | ✓ unchanged — **critical boundary preserved** |
| `''` empty string | role-less (raw falsy) | role-less, no throw (filter excludes `raw===''`) | ✓ unchanged (matches old `if (raw && !role)`) |
| `'reviewr'` typo (non-empty ∉12) | default subagent + WARN | **THROW (STOP-HARD)** | ✓ intended change — only a *buggy* flow "breaks" |

- The args schema (`:18`) explicitly allows `role:<VALID_ROLES|null>` — `null` is a documented legitimate value; my filter's `x.raw != null` clause preserves it. This is the single most important compat guard.
- No documented SE flow intentionally passes an invalid non-empty role expecting default-subagent. The historical "fail-soft" uses (database-agent 3× S56; 2 monitors S110) are all **now in VALID_ROLES=12** — moot.
- **Scope check:** `hmw.js` is shared by regular HMW-mode AND the engine-đắt `/fable-clone` ensemble. Applying STOP-HARD at this single chokepoint is *correct*, not over-broad: a typo'd role should not silently degrade in either path, and mechanizing at the code chokepoint is more robust than the command-layer convention alone (which stays as first-line: `/fable-*` "thiếu/sai vai → hỏi anh"). Defense-in-depth.
- **Regression witness:** the current run under review (`/fable-clone reviewer` — 5 lanes, `role='reviewer'`) still fans out with zero throw, because `reviewer ∈ VALID_ROLES`.

---

## 6. Doc-sync list (Harness-20 §L.B 4-group classification)

**LIVE behavior docs — MUST update to STOP-HARD:**
1. `.claude/workflows/hmw.js` — the code (§4).
2. `.claude/commands/ultra-on.md:21` — "role lạ → default subagent + cảnh báo" → "role lạ ∉ VALID_ROLES → hmw.js THROW (STOP-HARD) + báo owner; role-less null → inherit lead".
3. `.claude/commands/ultra-on.md:26` — "degrade về default subagent + WARN (fail-soft, KHÔNG crash)" → "THROW (STOP-HARD) + báo owner (REC-3 2026-07-13); role-less null → inherit lead".
4. `.claude/agents/README.md:208` — "Role lạ → default subagent + WARN (fail-soft, S4c)." → "Role lạ ∉ VALID_ROLES → THROW (STOP-HARD) + báo owner; role-less null → inherit lead".
5. `docs/governance/harness-11-engine.md` §K.E `:345` — update current-behavior clause to STOP-HARD (keep the 🧊 saga note as history); optional: add role-whitelist throw to `§D.4:67` as a 2nd mechanized tripwire alongside `hmw.js:76` checkpoint.
6. `docs/governance/fable-real-runbook.md:520` (P5-mới "fail-soft default-subagent") + `:74` (caveat "KHÔNG code-enforced" — now the typo-guard IS code-enforced at hmw.js layer; command-layer ask-owner stays convention) + `:191`.

**FROZEN — DO NOT edit (snapshot-by-date / adap saga; H20 §L.B "KHÔNG rewrite LỊCH-SỬ"):**
- `docs/governance/adap-reports/2026-06-03-*.md`, `2026-06-15-Agent-harness-6-*.md`, `2026-07-03-Agent-harness-19-*.md`.
- `broadcasts/outbox/ai_infra/2026-06-10-*.md`, `2026-06-15-*.md` (sent mail).
- `docs/changelog/sessions/*.md`, agent-memory diaries.

**Correction to the task premise:** `.claude/commands/session-start.md` does **NOT** document a fail-soft rule (grep-verified: no "default subagent"/"fail-soft"/"Role lạ" in it). BƯỚC 0.5b `:32` already says "thiếu/sai vai → em main hỏi anh, KHÔNG tự chọn" — already STOP-HARD-spirit for the engine-đắt path. **session-start needs no change** (contra the lens's "session-start §BƯỚC 0.5/HMW" assumption).

---

## 7. Alias-shortcut to remove? — LARGELY N/A for SE

DIRECTED §2.3: hub "gộp lệnh hội-thoại vào lệnh ensemble (một cổng duy nhất) + bỏ các alias gán-tắt — nếu hệ của bạn còn alias tương tự, cân nhắc đồng bộ."

- SE's two engine-đắt commands `/fable-real <vai>` (lệnh-A single top-model) and `/fable-clone <vai>` (lệnh-B ensemble) are **two distinct engines differentiated by DEPTH** (single-deep vs N-lane ensemble) — a deliberate H21 depth-toggle, **not** a conversational-vs-ensemble alias pair. Neither is "conversational". There is **no** SE command that aliases a chat-style invocation onto the ensemble command. → the hub's specific merge has **no SE counterpart**.
- `/ultra-on` `/ultra-off` sit on a different axis (bulk-fan-out consent gate), not aliases of `/fable-*`.
- The H19 marker `.claude/fable-real-mode.on` alias/toggle was already **retired S110** (`.gitignore:92` keeps the pattern only as a precaution).
- **Only vestigial alias:** the `args.wave` → `args.run` **data-arg** back-compat alias in `hmw.js:19` + `:98` (`(A.wave && A.wave.dir) ? A.wave : null`). This is a data alias (RUN-TRACE arg), **not a command alias**, and is a harmless defensive fallback. Optional cleanup only (remove `[legacy alias: args.wave]` + the `A.wave` branch once no caller uses `wave:`); **N/A-severity**, not required by REC-3.

**Verdict on alias:** N/A (no conversational-vs-ensemble alias exists); optional minor = drop the `args.wave` data-alias.

---

## 8. Measurable acceptance criteria

1. **Syntax invariance:** `node --check .claude/workflows/hmw.js` exit code is **identical before and after** the change. The added block is pure synchronous JS (`.map`/`.filter`/`throw`/template-literal) — introduces no new top-level `await`/`return`/`import`, so cannot change the syntax-validity class (whatever the baseline result is under the Workflow-runtime wrapper).
2. **Behavior assertion (deterministic stub harness — the teeth):** stub the validation logic with `VALID_ROLES` and assert:
   - `[{role:'reviewr',prompt:'x'}]` → **throws**, and the message matches `/STOP-HARD/` and contains `"reviewr"` and `VALID_ROLES`.
   - `[{role:null,prompt:'x'}]` → **does NOT throw** (role-less path preserved).
   - `[{role:'',prompt:'x'}]` → **does NOT throw**.
   - `[{role:'reviewer',prompt:'x'}]` → **does NOT throw**.
   ```js
   const VALID_ROLES=['investigator-codebase','investigator-api','implementer-backend','implementer-frontend','test-specialist','reviewer','cicd-monitor','frontend-designer','database-agent','office-document','tooling-auditor','harvest-curator']
   const val=tl=>{const b=tl.map((t,i)=>({i,raw:t&&t.role})).filter(x=>x.raw!=null&&x.raw!==''&&!VALID_ROLES.includes(x.raw));if(b.length)throw new Error('STOP-HARD '+b.map(x=>`"${x.raw}"`).join(','))}
   let e=0;try{val([{role:'reviewr'}])}catch(x){e=/STOP-HARD/.test(x.message)&&/reviewr/.test(x.message)}
   let a=1;try{val([{role:null}]);val([{role:''}]);val([{role:'reviewer'}])}catch(x){a=0}
   console.log(e&&a?'PASS':'FAIL')
   ```
3. **Fail-fast proof:** with a typo role, an `agent()` stub call-counter stays **0** (throw happens before `parallel(...)`) — proves no token spend AND not a per-lane silent-drop.
4. **Doc-sync grep:** `rg -i "fail-soft.*default subagent|degrade về default subagent|default subagent.*(fail-soft|WARN)"` over `.claude/commands/*.md` + `.claude/agents/README.md` + `docs/governance/harness-11-engine.md` + `docs/governance/fable-real-runbook.md` returns **0** current-behavior hits (frozen adap-reports/sessions/broadcasts excluded).
5. **Regression:** the `/fable-clone reviewer` run under review still fans out (no throw).

---

## 9. Self-refutation (strongest case AGAINST NEEDS-FIX)

- **"It's a no-op — VALID_ROLES=12 = full roster, fail-soft is unreachable, so this is paperwork."** Rebuttal: reachable by **typo / rename-drift**; the current effect is a *silent* loss of persona+memory+skill (generic subagent), the exact silent-degrade REC-3 names. STOP-HARD converts a silent quality-break into a loud, owner-actionable halt, and future-proofs role renames. Non-trivial.
- **"The directive scoped STOP-HARD to engine-đắt commands; hmw.js also serves regular HMW-mode — over-broad."** Rebuttal: a typo'd role should not silently degrade in *either* path; role-less (`null`) — the only legitimate "no specific role" case — is explicitly preserved. Chokepoint mechanization > command-layer convention alone.
- **"Just throw at `:106` — minimal diff."** Rebuttal: `:154 .filter(Boolean)` + "lọc null nếu agent lỗi/null" mean a per-lane throw may be swallowed to `null` = STOP-SILENT (worse). Up-front validation is required for real halt semantics — this is the decisive technical reason, not a style choice.
- **"`/fable-real` vs `/fable-clone` IS the conversational-vs-ensemble alias to collapse."** Rebuttal: both are engine-đắt deep commands differentiated by depth (single vs ensemble), a deliberate H21 toggle; neither is conversational. No SE conversational alias exists → alias-sync genuinely N/A.
- **Net:** the refutations soften *necessity* (SHOULD, not MUST — proactive, no live SE incident) but do **not** overturn the verdict: SE code still fail-softs where the fleet directive says STOP-HARD → **NEEDS-FIX**, apply this adoption cycle.
