From c961d713d0da8a050da21110d5717351dfc880c1 Mon Sep 17 00:00:00 2001 From: Mother Seara Date: Wed, 19 Aug 2026 06:40:15 +0900 Subject: [PATCH] =?UTF-8?q?fix(stack=5Fverify=5Fall):=20=EC=A6=9D=EC=9D=B8?= =?UTF-8?q?=20=EA=B3=84=EC=B8=B5=EC=9D=84=20=EC=A1=B0=EC=9A=A9=ED=9E=88=20?= =?UTF-8?q?=EA=B1=B4=EB=84=88=EB=9B=B0=EA=B3=A0=20ALL=20OK=20=EB=A5=BC=20?= =?UTF-8?q?=EB=83=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `if am_ledger and am_peer_name:` 에 else 가 없었다. 증인 원장을 넘겨도 `am_peer_name` 이 없으면 **L2 교차증인이 아예 안 돌고**, 그런데도 판정은 `ALL OK` 였다. ⇒ am 체인이 **초록불 뒤에서 영영 안 측정될 수 있었다.** stack_verify_all(mm_ledger=…, am_ledger=…) # peer 이름 없이 → (before) verdict: "ALL OK" → (after) verdict: "ALL OK (partial: 1 requested layer(s) did not run)" scope.layers_skipped[0].why = "am_ledger given but am_peer_name missing — the witness layer did not run; this verdict says nothing about the am chain" ★ **요청했는데 안 돈 것**만 partial 로 친다. `anchor_dir` 를 안 준 건 그게 아니다 — L3 를 **요청하지 않은** 것이라 `scope.layers_not_requested` 로 적고 판정은 깨끗이 둔다. (이 구분을 안 하면 L1만 부르는 정상 호출까지 partial 이 된다.) 같이 고친 둘: · **docstring 이 "the whole stack" 이라 약속했다.** 전체를 잰 적이 없다 — 인자로 받은 원장만 재고 `stack.json` 을 **안 읽는다**. 그대로 적고, 디렉토리 전체 커버리지는 `verify_all.py` 오케스트레이터로 가라고 가리켰다. · `seals valid` 가 **잰 개수**를 같이 낸다. 그 문장은 빈 원장에서도 참이다. 시험 51 통과. 남은 실패 1건(`test_prereg_lint_clean_seal_has_no_warn_or_fail`)은 **이 변경 전 origin/main 에서도 재현된다** — 이 커밋과 무관하다. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 24 +++++++++++++++++ mirror_stack_mcp/__init__.py | 2 +- mirror_stack_mcp/server.py | 51 ++++++++++++++++++++++++++++++------ pyproject.toml | 2 +- 4 files changed, 69 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4eac2f4..0ce8829 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,30 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). --- +## [0.2.11] — 2026-08-19 + +### Fixed +- **`stack_verify_all` skipped the witness layer in silence.** Passing `am_ledger` + without `am_peer_name` fell through `if am_ledger and am_peer_name:` with no + branch, so the L2 cross-witness check never ran — and the call still returned + `ALL OK`. The am chain could therefore go permanently unmeasured behind a green + verdict, which is the one thing a verifier must never do. + + A requested-but-unrun layer is now reported in `scope.layers_skipped` and marks + the verdict `ALL OK (partial: N requested layer(s) did not run)`. Not passing + `anchor_dir` is *not* that — you did not ask for L3, so it is listed under + `scope.layers_not_requested` and leaves the verdict clean. + +- **The docstring promised "the whole stack".** It never verified the whole stack: + it verifies the ledgers passed in these arguments and does not read `stack.json`, + so it cannot know about ledgers the caller did not name. Said so, and pointed at + the `verify_all.py` orchestrator for directory-wide coverage. + +- **`seals valid` now reports how many entries it checked.** That string is also + true of a ledger with nothing in it. + +--- + ## [0.2.10] — 2026-08-05 ### Changed diff --git a/mirror_stack_mcp/__init__.py b/mirror_stack_mcp/__init__.py index 1845120..8866c9a 100644 --- a/mirror_stack_mcp/__init__.py +++ b/mirror_stack_mcp/__init__.py @@ -1,2 +1,2 @@ """🪞🔎🪪 Mirror Stack unified MCP server.""" -__version__ = "0.2.10" +__version__ = "0.2.11" diff --git a/mirror_stack_mcp/server.py b/mirror_stack_mcp/server.py index 292215f..115d4c7 100644 --- a/mirror_stack_mcp/server.py +++ b/mirror_stack_mcp/server.py @@ -358,7 +358,19 @@ def pm_verify(file_path: str, ledger_path: str = "pm_ledger.jsonl", @mcp.tool() def stack_verify_all(mm_ledger: str, anchor_dir: str | None = None, am_ledger: str | None = None, am_peer_name: str | None = None) -> dict: - """Verify the whole stack in one call: mm chain (L1) + anchors (L3) + cross-witness (L2).""" + """Verify the layers you point it at: mm chain (L1) + anchors (L3) + cross-witness (L2). + + SCOPE — read this before quoting the verdict: + · It verifies the ledgers named in THESE ARGUMENTS. It does not read `stack.json`, + so it cannot tell you about ledgers you did not pass. For directory-wide, + default-include coverage use the `verify_all.py` orchestrator instead. + · L3 runs only with `anchor_dir`; L2 only with BOTH `am_ledger` and `am_peer_name`. + A layer that did not run is reported as `skipped`, never counted as passed. + + This docstring used to say "the whole stack". It never was: passing `am_ledger` + without `am_peer_name` skipped the witness layer in silence and still returned + ALL OK, so the am chain could go permanently unmeasured behind a green verdict. + """ out, ok = [], True def add(level, layer, name, msg): @@ -366,9 +378,13 @@ def add(level, layer, name, msg): ok = ok and level out.append({"ok": level, "layer": layer, "name": name, "msg": msg}) - bad = [str(f) for f in mm.verify_chain(mm_ledger) - if getattr(f, "level", "OK") not in ("OK", "INFO")] - add(not bad, "L1 chain", Path(mm_ledger).name, "seals valid" if not bad else str(bad)) + findings = mm.verify_chain(mm_ledger) + bad = [str(f) for f in findings if getattr(f, "level", "OK") not in ("OK", "INFO")] + # Say how many seals were checked. "seals valid" is also true of an empty ledger. + n_entries = sum(1 for l in Path(mm_ledger).read_text(encoding="utf-8", + errors="replace").splitlines() if l.strip()) + add(not bad, "L1 chain", Path(mm_ledger).name, + f"seals valid ({n_entries} entries checked)" if not bad else str(bad)) if anchor_dir: for af in sorted(Path(anchor_dir).glob("anchor_*.json")): @@ -385,14 +401,33 @@ def add(level, layer, name, msg): extended = len(entries) >= n and str(entries[n - 1].get("seal", "")) == a["head_seal"] add(extended, "L3 anchor", af.name, "extended" if extended else "REPLACED?") + skipped = [] if am_ledger and am_peer_name: f = am.verify_peer(am_ledger, mm_ledger, peer_name=am_peer_name) good = getattr(f, "level", "OK") in ("OK", "INFO") add(good, "L2 witness", am_peer_name, str(f)) - - return _remind("stack_verify_all", - {"verdict": "ALL OK" if ok else "FAILURES", "ok": ok, - "checks": out, "passed": sum(c["ok"] for c in out), "total": len(out)}) + elif am_ledger: + # Was a silent `pass`: the caller handed over a witness ledger, the layer never + # ran, and the verdict still came back ALL OK. A skipped layer must be visible. + skipped.append({"layer": "L2 witness", + "why": "am_ledger given but am_peer_name missing — the witness layer " + "did not run; this verdict says nothing about the am chain"}) + elif am_peer_name: + skipped.append({"layer": "L2 witness", + "why": "am_peer_name given but am_ledger missing — the witness layer did not run"}) + result = {"verdict": "ALL OK" if ok else "FAILURES", "ok": ok, "checks": out, + "passed": sum(c["ok"] for c in out), "total": len(out), + "scope": {"mm_ledger": Path(mm_ledger).name, + "layers_run": sorted({c["layer"] for c in out}), + "layers_not_requested": [] if anchor_dir else ["L3 anchor"], + "layers_skipped": skipped}} + if skipped: + # `skipped` means REQUESTED-but-did-not-run, which is the defect this fixes. + # Not passing `anchor_dir` at all is not that — you did not ask for L3, so it is + # reported under `layers_not_requested` and does NOT make the verdict partial. + # Callers that read only `verdict` exist, so this has to show up there. + result["verdict"] += f" (partial: {len(skipped)} requested layer(s) did not run)" + return _remind("stack_verify_all", result) def main(): diff --git a/pyproject.toml b/pyproject.toml index 150bd01..2dc486e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "mirror-stack-mcp" -version = "0.2.10" +version = "0.2.11" description = "Unified MCP server for the Mirror Stack — claims, actions, provenance + verify-all in one server" readme = "README.md" requires-python = ">=3.10"