diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f83d95..aceb71a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,26 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). green even if they validated nothing. Both were confirmed to fail on the old hint before this was committed. +- **The response now says when a call CREATED the ledger instead of appending to one.** + measure-mirror 0.41.0 taught its CLI to refuse creating a ledger without `--new-ledger`. + That gate lives in `main()`; every tool here goes through the Python API and misses it + entirely — so the two lanes reported on 2026-08-26 sealing into unaudited ledgers were + both on *this* path, and the fix never reached them. + + Blocking the API is not the answer: it would stop every lane mid-seal for a mistake most + callers are not making. So this does not block. `mm_preregister`, `mm_retract`, + `am_record`, `am_witness` and `pm_verify` add a `⚠️ new_ledger_created` field naming the + **absolute** path, and `mm_preregister` also raises a `㉙ ledger-birth` WARN inside `lint` + (WARN survives output compaction; everything else is collapsed to a count). + + This is the gap nothing else in the stack can close: the chain of a brand-new ledger is + perfectly intact, so every integrity check is green and no probe has any reason to + mention that the file is new. The only moment the information exists is the call that + creates it. + + A negative control ships with it — the notice must NOT fire when appending, or it is + noise, and noise is what a reader learns to skip. + --- ## [0.2.12] — 2026-08-28 diff --git a/mirror_stack_mcp/server.py b/mirror_stack_mcp/server.py index 87f4806..7578d46 100644 --- a/mirror_stack_mcp/server.py +++ b/mirror_stack_mcp/server.py @@ -181,6 +181,34 @@ def _remind(tool, result): return f"{result}\n\n{msg}" +# ── a ledger that did not exist is being born, not appended to ─────────────── +# measure-mirror 0.41.0 taught the CLI to refuse creating a ledger without +# `--new-ledger`. The Python API did not change, and every tool here goes through +# the API — so the whole gate misses this server. Two lanes were reported on +# 2026-08-26 sealing into ledgers nobody audits, both via this path. +# +# Blocking the API would stop every lane mid-seal for a mistake most callers are +# not making, so this does not block. It makes the birth VISIBLE in the response +# the caller already reads — which is the part that was missing: the chain is +# intact either way, so every integrity check stays green and nothing else in the +# stack will ever mention that this file is new. +_BIRTH_KEY = "⚠️ new_ledger_created" + + +def _birth_msg(path: str) -> str: + return (f"{os.path.abspath(path)} did not exist — this call CREATED it rather than " + "appending to an existing ledger. If you meant an existing ledger, what you " + "just sealed is in a file no audit covers, and its chain will verify green " + "forever. Check the path (and which directory you are in), then re-seal in " + "the right ledger — append-only means this entry stays where it is.") + + +def _birth(path: str, existed: bool) -> dict: + """The response field that distinguishes 'appended' from 'created'. Empty when + the ledger already existed, so a normal call carries no extra noise.""" + return {} if existed else {_BIRTH_KEY: _birth_msg(path)} + + # ───────────────────────── 🪞 measure-mirror (claims) ───────────────────────── @mcp.tool() def mm_preregister(ledger_path: str, claim_id: str, metric: str, min_n: int = 200, @@ -203,13 +231,20 @@ def mm_preregister(ledger_path: str, claim_id: str, metric: str, min_n: int = 20 The response carries an automatic seal-quality lint (`lint` key): a FAIL there means the compute gate will BLOCK this claim — fix and re-seal under a NEW claim_id.""" + existed = os.path.exists(ledger_path) entry = mm.preregister( ledger_path, claim_id, metric=metric, min_n=min_n, baseline=baseline, pass_threshold=pass_threshold, kill_condition=kill_condition, kill_threshold=kill_threshold, depends_on=depends_on, metric_range=metric_range, chance=chance, pre_seal_checks=pre_seal_checks) - lint = _compact(_findings(mm._preseal_lint(entry))) - return _remind("mm_preregister", {**entry, "lint": lint}) + findings = list(mm._preseal_lint(entry)) + if not existed: + # WARN level on purpose: compaction keeps WARN verbatim, and this is the one + # line that a green-everywhere response would otherwise never carry. + findings.append(mm.Finding("㉙ ledger-birth", "WARN", _birth_msg(ledger_path))) + lint = _compact(_findings(findings)) + return _remind("mm_preregister", {**entry, "lint": lint, + **_birth(ledger_path, existed)}) @mcp.tool() @@ -291,7 +326,9 @@ def mm_multiseed_check(seed_results: list[float], baseline: float = 0.5) -> str: @mcp.tool() def mm_retract(ledger_path: str, claim_id: str, reason: str) -> dict: """Append a chain-linked retraction (cannot be silently deleted; dependents go STALE).""" - return _remind("mm_retract", mm.retract(ledger_path, claim_id, reason)) + existed = os.path.exists(ledger_path) + return _remind("mm_retract", {**mm.retract(ledger_path, claim_id, reason), + **_birth(ledger_path, existed)}) @mcp.tool() @@ -352,14 +389,19 @@ def mm_preflight(ledger_path: str, claim_id: str, gate: str = "compute", def am_record(ledger_path: str, agent: str, action: str, target: str | None = None, payload: dict | None = None) -> dict: """Seal one agent action. Set target= to tie the action to a claim (J1).""" + existed = os.path.exists(ledger_path) return _remind("am_record", - am.record(ledger_path, agent=agent, action=action, target=target, payload=payload)) + {**am.record(ledger_path, agent=agent, action=action, + target=target, payload=payload), + **_birth(ledger_path, existed)}) @mcp.tool() def am_witness(my_ledger: str, peer_ledger: str, peer_name: str) -> dict: """Pin a peer ledger's head into mine (J3). Catches whole-ledger replacement that chains miss.""" - return am.witness_peer(my_ledger, peer_ledger, peer_name=peer_name) + existed = os.path.exists(my_ledger) + return {**am.witness_peer(my_ledger, peer_ledger, peer_name=peer_name), + **_birth(my_ledger, existed)} @mcp.tool() @@ -373,7 +415,9 @@ def am_verify(ledger_path: str) -> list[str]: def pm_verify(file_path: str, ledger_path: str = "pm_ledger.jsonl", origin: str | None = None) -> dict: """Verify a content file's provenance/integrity across 5 signals (a verifier, not a detector).""" - return pm.verify(file_path, ledger_path=ledger_path, origin=origin) + existed = os.path.exists(ledger_path) + return {**pm.verify(file_path, ledger_path=ledger_path, origin=origin), + **_birth(ledger_path, existed)} # ───────────────────────── 🪞🔎🪪 stack-level ───────────────────────────────── diff --git a/tests/test_server.py b/tests/test_server.py index 27e5b26..fcd698f 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -307,3 +307,51 @@ def narrow(ledger_path: str, claim_id: str, metric: str, func_metadata(narrow).arg_model.model_validate( {"ledger_path": "x", "claim_id": "c", "metric": "acc", "pre_seal_checks": _STRUCTURED}) + + +# ── created vs appended ────────────────────────────────────────────────────── +# The CLI gate added in measure-mirror 0.41.0 lives in main(); every tool here +# goes through the Python API and misses it entirely. Blocking the API would stop +# every lane mid-seal, so the response says so instead. What matters is that it +# says so ONLY when the ledger is actually new — a notice that always fires is +# noise, and noise is what a reader learns to skip. + +def test_preregister_says_so_when_it_creates_the_ledger(tmp_path): + led = tmp_path / "typo.jsonl" + assert not led.exists() + r = s.mm_preregister(str(led), "c1", metric="acc", min_n=240, + kill_threshold={"metric": "acc", "threshold": 0.55, + "direction": "below"}, + pre_seal_checks=[{"name": "neutral-control", + "result": "not_fired"}]) + assert s._BIRTH_KEY in r + assert str(led.resolve()) in r[s._BIRTH_KEY] + # and it survives compaction, which keeps WARN verbatim and collapses the rest + assert any("ledger-birth" in line and "WARN" in line for line in r["lint"]) + + +def test_preregister_is_silent_when_it_appends(tmp_path): + # negative control: the notice must not fire on the normal path, or it means nothing. + led = tmp_path / "real.jsonl" + s.mm_preregister(str(led), "c1", metric="acc", min_n=240, + kill_threshold={"metric": "acc", "threshold": 0.55, + "direction": "below"}) + r = s.mm_preregister(str(led), "c2", metric="acc", min_n=240, + kill_threshold={"metric": "acc", "threshold": 0.55, + "direction": "below"}) + assert s._BIRTH_KEY not in r + assert not any("ledger-birth" in line for line in r["lint"]) + + +def test_am_record_says_so_when_it_creates_the_ledger(tmp_path): + led = tmp_path / "actions.jsonl" + r = s.am_record(str(led), agent="t", action="a") + assert s._BIRTH_KEY in r + r2 = s.am_record(str(led), agent="t", action="b") + assert s._BIRTH_KEY not in r2 # negative control on the same file + + +def test_retract_says_so_when_it_creates_the_ledger(tmp_path): + led = tmp_path / "r.jsonl" + r = s.mm_retract(str(led), "never-registered", reason="wrong ledger") + assert s._BIRTH_KEY in r