# SPEC — Adopt AI_INFRA 2026-07-13 updates (presence-not-age selector guardrail + rec-3 STOP-HARD)

> **Run:** `2026-07-13-presence-not-age-adopt` · **Engine:** `/fable-clone reviewer` (H21 ensemble, `wf_b621aac4-f0b`) → lead verify+synthesize → this spec (H21 propose→verify→write→execute).
> **Lead:** Opus 4.8 (1M) Max (Fable outage). **Provenance:** broadcast `ab6c387e` (presence-not-age, type=update) + directed `6c32df89` (approve + rec-3). Sub-traces: `sub-reviewer-{1..5}.md`.
> **Fable-real spec-review:** `spec-review-fable-real.md` — PASS-WITH-FIXES (0C/2M/5m); **M1 + M2 + minors APPLIED in this revision** (see change-log at bottom). **Owner scope decision (S115): execute ALL 3 (D1+D2+D3).**
> ⚠️ Line numbers below are ANCHORS-first (match by surrounding code, not hard line#); files drift.

---

## ① Tính-năng / đề-bài

Adopt two AI_INFRA 2026-07-13 items into SOLUTION_ERP:

1. **Broadcast `ab6c387e` — "presence-not-age selector guardrail"** (type=**update**, NOT a new rule). Re-verify ONLY the selector of the memory refine-step against a 3-point FUNCTION-floor: **(i)** reinject by ABSENCE/coverage-gap (not "old"); **(ii)** archive via VALUE-GATE (not FIFO-by-date; recurring-bug/anti-pattern kept regardless of age); **(iii)** self-check for age-sorting red flags.
2. **Directed `6c32df89` rec-3** — raise SE `hmw.js` fail-soft-WARN (unknown role → default subagent) to **STOP-HARD + ask owner** (fail-soft = the silent-break that caused a real hub-side error; owner fixed hub-side today, 5-case sim test). Also approves SE H22 + H21/MTv3 at VERIFIED (SE = first-in-fleet full H22).

**Ensemble verdict (5 reviewer lanes, convergent):** SE selector is **already presence-based on 2 of 3 floors** (reinject + MFE age-band); the one real selector red-flag is the **archive-gate's oldest-by-position base ordering** (SHOULD-fix); the one real code MUST is **Part B (hmw.js STOP-HARD)**. Matches the broadcast's own framing: small delta, already-mostly-compliant, function-not-form.

### 3 honest-notes (MANDATORY — keep verbatim-in-spirit in SE docs, broadcast §5)
1. Basis = **ONE** occurrence already fixed elsewhere → **proactive-prevention**, NOT a spreading SE incident.
2. **SPECIFIC-APPLICATION**, not a new rule — SE reinject + MFE age-band were already presence/flag-based (quick re-verify, no change); only the archive-gate needs hardening.
3. Floor = **FUNCTION not FORM** — implement in SE's own script shape; no obligation to copy hub structure/filenames.

---

## ② Cách implement — 3 deliverables (tiered by necessity)

### D1 — Presence re-verify STAMP (MUST · docs-only · owner: em-main)

Append an add-only re-verify block to `.claude/governance/reinject-ledger.md` (below the ledger table, above the honest-note footer):
- **Floor (i) reinject = MET.** Trigger = floor-rot `"ĐÃ-từng-ở-L1 ∩ CÒN-giá-trị ∩ RỚT-khỏi-L1"` (presence-gap, `reinject-ledger.md:12`). CG-1 N=3 uses the `reinjected` session column as a **reinject-event rate-limiter / loop-breaker (max 1 reinject per N=3 sessions), NOT an age-rank** → OK per broadcast (iii) carve-out ("date used only for last-seen/loop-breaking = OK"; fable-real m5: label is rate-limiter not literal last-seen — verdict unchanged). Drop-date test: changes only the rate-limit/termination count, never the *what-to-reinject* decision.
- **MFE age-band = FLAG-only** (`mfe-eval.ps1` `$oldN` computed then `Write-Host` "KEPT status-driven age-blind"; no downstream consumer; denominator gated by STATUS not date). Drop-date test: changes one diagnostic count, not denominator/FIT/Goodhart/any selection.
- **Archive-gate (iii) = RED-FLAG surfaced → hardened by D3** (cross-ref).
- **Known BUILD-GAP disclosed:** mechanized per-item-L1-presence detection is still PARTIAL (age-band counts present marks, not per-item drops) — reinject detection stays convention + em-main judgement. Do NOT overclaim full mechanization.
- Embed the 3 honest-notes.

### D2 — hmw.js fail-soft → up-front STOP-HARD (MUST · code · owner: em-main solo)

**File:** `.claude/workflows/hmw.js`. **Decisive design constraint:** the throw MUST be an **up-front validation pass BEFORE `parallel(...)`** — chosen for **halt-independence** from unverifiable runtime semantics. A `throw` in the INNER thunk `(t,i)=>()=>{…}` *may* be caught by `parallel()` and swallowed to `null` by the tail `return results.filter(Boolean)` (`~:152-154`, comment "lọc null nếu agent lỗi/null") = STOP-SILENT. ⚠️ **fable-real m1:** `parallel()` is a Workflow-runtime builtin, NOT defined in-repo → swallow-to-null is an INFERENCE from the `:152` comment, not source-proven; and only the INNER thunk (not the outer synchronous map callback) is subject to it. Up-front validation is robust under BOTH truth-values (swallows → avoids drop; propagates → harmlessly earlier), so it does not depend on that inference. Mirror the existing up-front `checkpointApproved` tripwire (`~:83-85`).

**D2a (the MUST) — insert up-front block** after the `taskList.length > 16` notice (`~:89`), before `const memoryPack` (`~:91`):
```js
// ─── 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 H6.2): role null/omitted/'' = HỢP-LỆ role-less (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 parallel()/.filter(Boolean).
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).`)
}
```
**D2b (cleanup) — inner map** (`~:105-107`): keep `const role = VALID_ROLES.includes(raw) ? raw : undefined` but change the comment to "role-less (null/'') → undefined → inherit lead"; **delete** the now-dead `if (raw && !role) log('⚠️ … default subagent …')` WARN line (invalid non-empty role already threw up-front).
**D2c (comment-only, no behavior change)** — `resolveModel` (`~:46-48`) comment "Invalid-role → fail-UP inherit" → "role-less (null) → inherit lead; invalid-role đã throw up-front"; tail comment (`~:152`) "invalid-role vẫn chạy default subagent" → "invalid-role đã STOP-HARD up-front; .filter(Boolean) chỉ lọc null-lane do agent lỗi runtime".

**Doc-sync (LIVE-behavior docs — MUST update to STOP-HARD; FROZEN adap-reports/sent-broadcasts/session-logs/diaries excluded):**
- `.claude/commands/ultra-on.md` (`~:21` "role lạ → default subagent + cảnh báo"; `~:26` "degrade về default subagent + WARN (fail-soft, KHÔNG crash)") → "role lạ ∉ VALID_ROLES → hmw.js THROW (STOP-HARD) + báo owner; role-less null → inherit lead".
- `.claude/agents/README.md` (`~:208` "Role lạ → default subagent + WARN (fail-soft, S4c)") → STOP-HARD + báo owner; role-less null → inherit lead.
- `docs/governance/harness-11-engine.md` §K.E (`~:345`) — update current-behavior clause to STOP-HARD (keep the 🧊 saga note as history).
- `docs/governance/fable-real-runbook.md` (`~:520`, `~:74`, `~:191`) — fail-soft mentions → STOP-HARD (`:74` caveat: typo-guard is NOW code-enforced at hmw.js layer; command-layer "hỏi anh" stays convention = defense-in-depth).
- **NO change to `session-start.md`** (fable-real independently re-confirmed: no fail-soft rule there; BƯỚC 0.5b already "thiếu/sai vai → hỏi anh").

**Alias-sync:** N/A for SE — `/fable-real` (single) vs `/fable-clone` (ensemble) = deliberate H21 **depth-toggle**, not a conversational-vs-ensemble alias pair. (Optional N/A-severity: drop the vestigial `args.wave→args.run` data-alias — NOT required by rec-3; defer.)

### D3 — archive-gate value_protect advisory → PRE-selection hard-skip (SHOULD · code · owner: em-main solo)

**File:** `scripts/memory-archive-gate.ps1`, PASS-1 drain block (`~:141-191`). **Problem:** base drain ordering is OLDEST-by-position (age-proxy; `~:137-138,153-162`, resolve-string `~:182` "move N oldest"); `value_protect` (`~:167-191`) is an **advisory post-hoc flag** that annotates the already-chosen move-set but never re-selects it. Worse for SE: structured `MEMORY.md` → top-of-file = evergreen spine (Role-baseline/Recurring-bug/Anti-patterns), so age-proxy is **anti-correlated with value**; spine + paraphrased anti-patterns carry none of the 9 value-tokens → drain **unflagged**.

**Change (keep DRY-RUN + keep_floor + strike-gating + em-main-final intact) — CORRECTED per fable-real M2:** the existing drain is a CONTIGUOUS top-prefix cut (`$prefixBytes = Σ lines[0..cutLine-1]`; `$afterEst = $bytes - $prefixBytes`). Skipping a *middle* protected entry makes the move-set NON-CONTIGUOUS, which that model CANNOT express — a naive `if ($protected) continue` leaves the protected entry ABOVE the cut = **still archived while the code believes it's excluded (silent inversion)**. D3 therefore MUST (a) exclude protected entries from the CUT (not just flag), (b) replace prefix-sum with **per-entry byte accumulation over the chosen (non-contiguous) low-value set**, (c) define protection over **LOGICAL entries (heading markers only)** for `---`-robustness:
```powershell
# (1) LOGICAL entries = HEADING markers ONLY (^#{2,3}\s) — NOT the '---' separators that
#     Get-EntryMarkerLineNumbers also counts (:80). A '---' inside one logical entry must NOT
#     split it, else a value token after the '---' would flag the wrong (pseudo) span.
#     span(k) = [ headingLine[k] .. headingLine[k+1]-1 ]  (last -> EOF); bytes(k) = Σ (len+2).
# (2) protected(k) = ANY line in span(k) matches any $valPatterns token (age-blind, whole span).
# (3) movablePool = logical entries NOT protected AND NOT in keep_floor (newest-N).
# (4) NON-CONTIGUOUS size-drain — accumulate INDIVIDUAL chosen movable bytes:
#       $afterEst = $bytes
#       foreach $e in movablePool (position-order = WITHIN-low-value tiebreak only):
#           choose $e; $afterEst -= bytes($e); if ($afterEst -lt $lowMark) { break }
#     A protected / keep_floor entry is NEVER chosen, regardless of file position.
# (5) if movablePool exhausted AND $afterEst -ge $cap -> $warnValueFloor = $true
#       "over-cap but every drainable entry is value-protected/keep-floor -> condense by hand"
#       (mirror keep_floor WARN ~:180; propose ZERO protected entries).
```
The move-set is now a SET of entry-indices (not a top-prefix); the printed proposal lists the specific low-value entries and `$afterEst` sums only their bytes → coherent under interleaving.
- Resolve-string (`~:182`): "move N oldest" → "move N lowest-value (position tiebreak) → curate by hand". Add `WARN value-floor hit` beside the keep-floor WARN.
- **`---` double-count (fable-real M2c — NOW REQUIRED, not deferred):** step (1) uses heading-only markers, so the `---`-as-marker double-count in the legacy `$markers` cannot corrupt `$protected`/byte-spans. If an implementer reuses legacy `$markers` instead, they MUST prove `---`-robustness — heading-only is the simpler correct path.
- `budget.json` note (`value_protect._note ~:39` + `mfe age_band ~:67`): selector is now value-PRIMARY (pre-selection hard-skip), position only a within-low-value tiebreak; the 9-token grep is a **lower-bound** signal → em-main must value-scan the WHOLE proposed set. **Do NOT claim "now fully value-gated"** — grep unchanged so the spine/paraphrase leak (fable-real m2) REMAINS; procedural em-main-scan stays the true guarantee. D3 = defense-in-depth codify, NOT leak-closure.

---

## ③ Checklist (vai + deliverable + measurable acceptance)

| # | Deliverable | Necessity | Owner-vai | Measurable acceptance (fault-injection, not happy-path) |
|---|---|---|---|---|
| D1 | reinject-ledger re-verify stamp + 3 honest-notes | **MUST** | em-main | git-diff = add-only append to `reinject-ledger.md`; block names floor(i)=MET + MFE=FLAG-only + (iii)→D3 + BUILD-GAP disclosed + 3 honest-notes present; `governance-detectors.ps1` 0 new HIGH. |
| D2 | hmw.js up-front STOP-HARD + doc-sync | **MUST** | em-main solo | (1) `node --check hmw.js` exit-class identical pre/post. (2) Stub test: `role:'reviewr'`→**throws** (msg `/STOP-HARD/` + `"reviewr"` + VALID_ROLES); `role:null`→**no throw**; `role:''`→**no throw**; `role:'reviewer'`→**no throw**. (3) Fail-fast: typo role → `agent()` stub counter stays **0** (throw before parallel). (4) **bare-token** `rg -i "default subagent"` across {ultra-on.md, agents/README.md, harness-11-engine.md, fable-real-runbook.md} → human-classify EVERY hit as (a) synced STOP-HARD line OR (b) the known frozen 🧊 saga note (`harness-11-engine.md ~:345`); PASS = zero un-synced current-behavior "default subagent + WARN/fail-soft" lines (fable-real M1: a narrow ordered regex false-PASSes `ultra-on.md:21` "cảnh báo" + `README.md:208`). (5) Regression: `role:'reviewer'` taskList still fans out. |
| D3 | archive-gate value_protect → pre-selection hard-skip + budget.json note | **SHOULD** | em-main solo | Use `-RepoRoot <temp-tree>` fixtures (`feedback_faultinjection_proves_teeth`): (1) high-value entry (**MUST carry a `value_protect` token** — fable-real m3) at TOP (oldest) is **NOT** in proposed move-set; a low-value entry is. (2) Permutation-invariance: same entries, positions shuffled → same value-CLASS proposed. (3) **Interleaved-protected (fable-real M2b):** protected entry BETWEEN two low-value entries → protected NOT in move-set AND `$afterEst` reflects ONLY the moved low-value bytes (proves non-contiguous accounting, not top-prefix). (4) value-floor WARN fires when every non-keep_floor entry protected (moveCount = 0). (5) DRY-RUN preserved (no file mutated); keep_floor never proposed; over-cap exits 0; A7 PASS-2 (exit-2-on-pointer-fail) untouched. (6) `git diff` touches only PASS-1 block + resolve-string + budget.json note. |
| D4 | adap-report + send-email AI_INFRA | MUST (report) | em-main | `adap-reports/2026-07-13-Governance-presence-not-age-reinject.md` per REPORT-FORMAT LOCK (evidence + tailored/skip + honest-caveat); send-email selftest hash MATCH; report states re-verify-verdict (i/MFE compliant, archive hardened, Part B stop-hard done) + keeps the 3 honest-notes. |

### Sequencing (step-by-step)
1. **D2 first** (MUST engine change) — verify stub test + node --check BEFORE anything else.
2. **D3** (archive-gate) — fault-injection acceptance in temp-tree; DRY-RUN so zero live-memory risk.
3. **D1** (docs stamp) — after D3 lands (cross-ref the hardening).
4. **D4** (adap-report + email) — closeout.

### Scope guard (lane 5 — SKIP as over-engineering)
- Do **NOT** rewrite the reinject predicate (compliant) or remove `keep_floor` / `value_protect` / MFE age-band (all legitimate).
- D3 is a **bounded reorder** (no value-SCORING system, no new file, no hub-org mirroring). Position survives only as a within-low-value tiebreak. The 9-token grep is unchanged (leak stays procedural).
- No new User-Mark (applies existing mark `RC-…10-29-11` age=false-proxy to the selector layer; codify-only, like H16/H17/H18).

---

## Change-log (fable-real fixes applied to this revision)
- **M1** (D2 accept 4): narrow ordered regex → bare-token `default subagent` grep + human-classify (caught false-PASS of ultra-on:21 + README:208).
- **M2** (D3 impl): contiguous-prefix "skip" = silent-inversion bug → non-contiguous per-entry byte accumulation + heading-only spans (`---`-robust) + interleaved-protected acceptance case (D3 accept 3).
- **m1** (D2 premise): relabeled the swallow-to-null as an inference (parallel() not in-repo); up-front chosen for halt-independence.
- **m3** (D3 accept 1): fixture high-value entry must carry a value_protect token.
- **m5** (D1): CG-1 relabeled reinject-event rate-limiter/loop-breaker (not "last-seen debounce").
- **m2/m4**: already honestly caveated (D3 = defense-in-depth not leak-closure; stub proves predicate not integration) — kept prominent.
