# sub-impl-M2 — `scripts/session-counter-tick.ps1` (cơ-khí-hoá `_contract`)

> Vai: implementer (hmw). Task: M2 + FIX-1(CRITICAL) + checklist K2, spec `spec-hoi-tu-bookend-24-07-2026.md`.
> Nguồn contract = `.claude/governance/.session-counter.json` khối `_contract` (đọc nguyên văn, THI HÀNH đúng).
> Khuôn hub tham khảo = `AI_INFRA/scripts/h17_cadence.py` (`_save_atomic` :174-184 atomic tmp+replace).
> VERDICT: **DELIVERED — 8/8 fault-inject PASS + 2 teeth-check PASS + 0 tokenize-error.**

## 0. Trạng thái
- [x] Đọc spec M2/FIX-1/K2 + contract `_contract` verbatim
- [x] Đo môi trường (encoding/PS-version) — quyết định chiến-lược ghi
- [x] Viết script `scripts/session-counter-tick.ps1` (250 dòng, pure-ASCII, LF)
- [x] Fault-inject K2 (a)-(e) + bonus (f) trên cây tạm — 8/8 PASS
- [x] Teeth-check (head-match NO-OP · diff-minimality · emoji preserve) — PASS
- [x] Incident: lỡ tick file THẬT khi smoke-test → REVERT sạch, verify byte-exact
- [x] Verdict + return

---

## 1. Contract `_contract` — THI HÀNH đúng (không sáng tác)
Khối `_contract` có 5 mệnh (đọc nguyên văn ở `.session-counter.json:5-11`). Script map 1-1:
1. **tick(session, head):** OR-guard NO-OP nếu `head==last_ticked_head` HOẶC `session==last_ticked_session`; else `counter+1` + set 4 field.
2. **fail_loud_on_regress (v2, 2 trigger 2 verdict):** (1) counter LÙI → FAIL-LOUD; (2) last_ticked_head không-reachable → **phân-loại TRƯỚC báo**: object CÒN (`cat-file -t`=commit) + counter không-lùi ⇒ **squash-benign** (ghi 1 vết + đi tiếp); object MẤT hoặc counter lùi ⇒ FAIL-LOUD.
3. overdue / 4. jump / 5. writer — KHÔNG thuộc phạm-vi M2 (không đụng).

FIX-1 (CRITICAL) yêu cầu KHÔNG đụng `class_repeat`, KHÔNG reset — script chỉ chạm 4 field + append `history[]`. ✅ (chứng: K2-b `class_repeat` + `last_audit` UNTOUCHED.)

## 2. Quyết định thiết-kế (evidence-based, đo trên cây tạm trước khi code)
| Quyết định | Đo được | Vì sao |
|---|---|---|
| **WRITE = surgical string-replace, KHÔNG ConvertTo-Json** | PS5.1 `ConvertTo-Json` round-trip 25900→**28495** byte (CRLF + char-escape); surgical 25900→25995 (chỉ +history) | Chỉ có **PowerShell 5.1** (không pwsh7 ⇒ không `System.Text.Json`). ConvertTo-Json 5.1 chèn CRLF + reformat = diff bẩn trên file governance committed |
| **READ+parse = ConvertFrom-Json** (guard-logic) | parse OK, đọc counter=23/S149/head/8-key class_repeat | Parse robust cho guard, tách khỏi write |
| **encoding = UTF-8 no-BOM, LF** | file THẬT: no-BOM, LF-only, 4-space indent, **8 emoji 🔴 = 32 non-ascii byte** | Bẫy E-010: thiếu `-Encoding` đọc ANSI. `[IO.File]::ReadAllText(UTF8)` + `WriteAllText(UTF8NoBom)` giữ byte-exact, KHÔNG dịch EOL |
| **git call bọc `EAP='Continue'`** (`Invoke-GitLines` :112) | `cat-file -t <missing>` ghi stderr → dưới `EAP=Stop` PS5.1 gói NativeCommandError = **terminating** (crash exit 1) kể cả `2>$null` | Hạ EAP quanh git-call ⇒ non-zero-exit là DATA để phân-loại, không phải crash. (Bắt được vì fault-inject (f) crash exit1 thay exit3) |
| **`last_ticked_at` = git HEAD commit-date** (`log -1 --format=%cd --date=short`) | khớp format `2026-07-24` sẵn có; fallback wall-clock nếu git fail | Theo nguyên-tắc M1 "máy chạm đĩa không wall-clock"; deterministic |
| **regression guard = SỐ-PHIÊN parse từ label** (`Parse-SessionNum` :82) | K2-c: stored S200 > input S150 ⇒ FAIL-LOUD | `counter`(23) ≠ `session-num`(149) ⇒ so-sánh phải là session↔session (chuẩn hub `sid < sid_last_tick`). "counter" trong contract = tiến-trình-phiên loose-wording |

## 3. Script — cấu trúc + evidence file:line (`scripts/session-counter-tick.ps1`, 250 dòng)
- **param** `:63-68` — `-Session` (bắt buộc, validate `^S\d+`), `-RepoRoot` (default = parent của `scripts/`), `-FaultStopAfterTemp` (switch fault-inject K2(e) ONLY, `:67`).
- **helpers:** `Fail-Loud` :76 (stderr 1 dòng + exit≠0) · `Parse-SessionNum` :82 · `Replace-LineOnce` :91 (**count-assert = 1**, shape-drift⇒fail-loud #7) · `Json-Escape` :101 · `Invoke-GitLines` :112 (EAP-Continue git wrapper).
- **(1) HEAD** `:148` — `Invoke-GitLines rev-parse HEAD`; không resolve ⇒ exit 4.
- **(2) OR-guard** `:157-164` — `$Session -eq $storedSession -OR $headSha -eq $storedHead` ⇒ NO-OP exit 0, in 1 dòng.
- **(3) classify TRƯỚC tick** `:165-193`:
  - (3a) regression `:166-171` — `inNum < storedNum` ⇒ FAIL-LOUD **exit 2** (trigger-1), KHÔNG ghi.
  - (3b) missing-object `:177-181` — `cat-file` fail ⇒ FAIL-LOUD **exit 3** (trigger-2 missing).
  - (3c) not-reachable + object-còn + không-lùi `:184-192` — `merge-base --is-ancestor` exit≠0 ⇒ `$eventClass='squash-benign'`, đi tiếp.
- **(4) tick** `:195-236` — `counter+1`, event-string (clean :209 / squash-benign :206), 4 surgical `Replace-LineOnce` (counter/session/head/at), append `history[]` qua EOF-anchored regex (count-assert=1), **re-parse ConvertFrom-Json TRƯỚC khi chạm đĩa** (`:233`, fail⇒exit 9 no-write).
- **(5) atomic write** `:238-249` — `WriteAllText($tmp, UTF8NoBom)` → nếu `-FaultStopAfterTemp` STOP exit 42 (file gốc nguyên) `:243` → else `Move-Item -Force -LiteralPath $tmp $counterFile` `:248` (khuôn hub `_save_atomic`).

Exit codes: 0 ok/noop · 2 regress · 3 missing-obj · 4 no-HEAD · 5 no-file · 6 bad-json · 7 shape-drift · 8 usage · 9 post-edit-unparse · 42 fault-stop.

## 4. Fault-inject K2 — cây tạm git-repo (C1→C2 HEAD, C3 orphan) + copy `.session-counter.json` THẬT
Harness `scratchpad/k2-faultinject.ps1` (Start-Process, exit-code sạch). **8/8 PASS:**

| Ca | Kịch bản | Kỳ vọng | Kết quả đo |
|---|---|---|---|
| **a** | cùng-label S149 | NO-OP exit0, không ghi | exit0, counter 23 giữ, **bytes unchanged**, out="NO-OP: session-label 'S149' already ticked" |
| **b** | HEAD-mới(C2)+label-mới S150 | +1 đúng 1 | exit0, counter **23→24**, sess S150, head=C2, hist 14→15 CLEAN |
| **b'** | class_repeat/last_audit sau tick | UNTOUCHED | `gap-owner-specifics`=4, `light_at_counter`=22 (nguyên) |
| **c** | stored S200 > input S150 | FAIL-LOUD exit≠0 KHÔNG ghi | **exit2**, stderr có REGRESSION, file byte-identical, **0 .tmp** sót |
| **d** | head=C3 orphan (exists, unreachable) | squash-benign history+1 đi tiếp | exit0, counter→24, hist+1 **event=squash-benign**, sess S150 |
| **e** | `-FaultStopAfterTemp` (kill trước move) | file gốc parse OK nguyên | **exit42**, gốc unchanged, parse OK, counter=23, **.tmp tồn tại** |
| **e'** | recovery: chạy lại không-fault | hoàn tất move | exit0, counter→24, **.tmp consumed** |
| **f** (bonus) | head=40-zeros MISSING | FAIL-LOUD trigger-2-missing | **exit3**, stderr có "MISSING object", file unchanged |

**Teeth-check** (`scratchpad/k2-verify2.ps1`, anti-Goodhart) — PASS:
- **head-match NO-OP:** label MỚI S151 nhưng `storedHead==HEAD` ⇒ NO-OP exit0 (chứng OR-guard nhánh head, không chỉ nhánh label). out="NO-OP: HEAD ... already ticked".
- **diff-minimality:** clean tick đổi ĐÚNG L14-17 (4 field in-place) + L109 `}`→`},` + chèn 1 entry. MỌI dòng khác BẤT BIẾN.
- **emoji/EOL preserve:** non-ascii 32→**32**, `f09f94b4` present, **0 CRLF**, stderr rỗng trên success.

## 5. 🔴 INCIDENT (khai thẳng) — lỡ tick file THẬT + revert sạch
Smoke-test default-`-RepoRoot` với giả-định "HEAD==2f39a7e (từ session-start) ⇒ NO-OP an-toàn". **SAI:** trong fan-out song-song, Stop-hook `wal-flush.ps1` auto-commit mỗi turn-boundary ⇒ HEAD đã dời `2f39a7e→611b41f`. Script chạy ĐÚNG (head≠stored ⇒ CLEAN tick 23→24, ghi file THẬT). Đây là **process-error của em, KHÔNG phải bug script** (thực ra nó CHỨNG script chạy đầu-cuối đúng trên file thật: giữ 25900+ byte/emoji, append entry chuẩn, atomic).
- **REVERT** (`scratchpad/revert-real.ps1`): reverse 3 field (24→23, S150→S149, 611b41f→2f39a7e; `last_ticked_at` không đổi vì 611b41f commit-date=2026-07-24) + xoá entry S150 (EOF-anchored, S150-pinned). Sanity-guard confirm state=S150-của-em trước khi chạm.
- **VERIFY restore:** bytes **25900**, non-ascii **32**, counter **23**, sess **S149**, head **2f39a7e**, history **14**, last=**S149-W3-kiem**. `git diff --stat` = **18 ins / 6 del** (khớp pre-smoke). Residue: **S150=0, session-counter-tick=0, "counter": 24=0**. Pre-existing S149 change (counter 22→23) còn nguyên.
- **BÀI HỌC:** CẤM chạy script tick trên repo THẬT để "smoke" — HEAD dời bất-kỳ lúc nào trong fan-out. Mọi test = temp-repo `-RepoRoot`. (Đã có trong prompt "cây tạm copy" — em vi-phạm ở bước smoke, đã sửa sạch.)

## 6. Isolation + housekeeping
- Code-file DUY-NHẤT tạo: `scripts/session-counter-tick.ps1` (pure-ASCII 0 non-ascii, LF-only 250 dòng, khớp `.gitattributes * text=auto eol=lf`). Không `.cs/.csproj` ⇒ KHÔNG cần update `SolutionErp.slnx`.
- `.session-counter.json` THẬT: đã REVERT về pre-smoke — không phải deliverable của em (thay đổi trong đó là S149 pre-existing).
- Không đụng `class_repeat` (M3), không đụng agent-memory/MD-canonical/sub-MD khác. Mọi test-artifact nằm trong `scratchpad/` (ngoài repo).
- PSParser tokenize: **0 error**. Invalid `-Session 'garbage'` ⇒ exit 8 fail-loud.

## 7. Verdict
**M2 DELIVERED + K2 FULL PASS.** `scripts/session-counter-tick.ps1` cơ-khí-hoá `_contract` verbatim: OR-guard idempotent (2 nhánh), classify-3-nhánh (regress/missing/squash-benign) fail-loud-đúng-trigger, tick 4-field + history-append, atomic tmp+Move-Item-Force, KHÔNG đụng class_repeat, KHÔNG reset. 8/8 fault-inject + 2 teeth + tokenize-clean. 1 incident lỡ-tick-real đã revert byte-exact (25900/counter23, diff 18+/6-, 0 residue).
