diff --git a/CHANGELOG.md b/CHANGELOG.md index a1dc3f1..5f83d95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,46 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). --- +## [0.2.13] — 2026-08-28 + +### Fixed +- **`mm_preregister` told clients that pre-seal checks must be strings, while its own lint + told them to send objects.** The published schema carried + `pre_seal_checks: {items: {"type": "string"}}`, derived faithfully from a `list[str]` + type hint that measure-mirror's library had long outgrown. So a client that followed the + ⑫h advice — *"Seal each as an object instead: {'name': …, 'result': …}"* — was rejected + by pydantic before the call ever reached the function: + `Input should be a valid string [type=string_type]`. + + The obvious workaround clears nothing. Writing the result into the string + (`"neutral-control: not_fired — 30 runs"`) still draws the same WARN — the lint keys on + the entry being a bare string, not on its content — and additionally makes the check name + unrecognised, so no later audit can aggregate that check by name. Every route was a dead + end, and a WARN nobody can act on is a WARN readers learn to skip. + + Reported by another lane on 2026-08-26 (which could not test the object form: sealing is + append-only and they would not risk a failed attempt in their ledger). Reproduced and + measured here 2026-08-28. 🔴 The rejection happens at the validation layer, so **nothing + is written** — the ledger file was not even created. That fear was unfounded, and worth + saying out loud, because it is what kept the interface unmeasured for two days. + + The hint is now `list[str | dict] | None`, and the tool description states the object form. + +### Added +- Three tests covering **the wire, not just the function**. Every existing test called these + tools as plain Python functions, which skips MCP validation entirely — that is precisely + how this shipped: `test_prereg_lint_clean_seal_has_no_warn_or_fail` had been passing + objects to `mm_preregister` and going green while every real client was rejected. The new + tests validate through the server's own arg model and assert the published schema shows + the object form. + + 🔬 One of them is a **positive control on the instrument**: it asserts the same payload is + REJECTED under the narrow hint this release replaces. Without it the other two would stay + green even if they validated nothing. Both were confirmed to fail on the old hint before + this was committed. + +--- + ## [0.2.12] — 2026-08-28 ### Fixed diff --git a/mirror_stack_mcp/__init__.py b/mirror_stack_mcp/__init__.py index 7bb327b..178edbb 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.12" +__version__ = "0.2.13" diff --git a/mirror_stack_mcp/server.py b/mirror_stack_mcp/server.py index 142598a..87f4806 100644 --- a/mirror_stack_mcp/server.py +++ b/mirror_stack_mcp/server.py @@ -188,13 +188,19 @@ def mm_preregister(ledger_path: str, claim_id: str, metric: str, min_n: int = 20 kill_condition: str | None = None, kill_threshold: dict | None = None, depends_on: list[str] | None = None, metric_range: list | str | None = None, chance: float | None = None, - pre_seal_checks: list[str] | None = None) -> dict: + pre_seal_checks: list[str | dict] | None = None) -> dict: """Seal a claim BEFORE measuring (preregistration). kill_condition/threshold = what falsifies it. For a non-[0,1] metric, declare metric_range (e.g. [0,100] for a %, or "unbounded" for a delta/span) + chance (the real chance level, e.g. 1/24≈0.042) so audit doesn't false-FAIL or assume baseline 0.5. Omit for a plain [0,1] accuracy. + pre_seal_checks entries may be a bare check name, or an object recording what the + check returned — {"name": "neutral-control", "result": "not_fired", "n": 30}. A bare + name declares work without recording it, so the lint WARNs (⑫h) and no later audit can + aggregate its outcome. Writing the result into the string instead clears neither: the + WARN stands, and the check name stops being recognised. + 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.""" entry = mm.preregister( diff --git a/pyproject.toml b/pyproject.toml index 18332da..a185a5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "mirror-stack-mcp" -version = "0.2.12" +version = "0.2.13" description = "Unified MCP server for the Mirror Stack — claims, actions, provenance + verify-all in one server" readme = "README.md" requires-python = ">=3.10" diff --git a/tests/test_server.py b/tests/test_server.py index 2a3a804..27e5b26 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -243,3 +243,67 @@ def test_stack_verify_all_on_a_freshly_sealed_ledger(tmp_path): r = s.stack_verify_all(mm_ledger=str(led)) assert r["ok"] is True and r["verdict"] == "ALL OK" assert r["passed"] >= 1 + + +# ── the wire schema, not just the function ─────────────────────────────────── +# Every test above calls a tool as a plain Python function, which skips the MCP +# validation layer entirely. That is how `pre_seal_checks` could accept objects +# in-process for months while every real client got "Input should be a valid +# string": the type hint said list[str], the library had long outgrown it, and +# nothing here measured the schema a client actually receives. +# Measured 08-28 from [자생]'s report (inbox 0826-145737). + +_STRUCTURED = [{"name": "neutral-control", "result": "not_fired", "n": 30}, + "positive-control"] # object + bare name, mixed + + +def _arg_model(name): + tool = [t for t in s.mcp._tool_manager.list_tools() if t.name == name][0] + return tool.fn_metadata.arg_model + + +def _types_under(node): + """Every `type` value anywhere in a JSON-schema subtree.""" + out = [] + if isinstance(node, dict): + t = node.get("type") + out += [t] if isinstance(t, str) else [] + for v in node.values(): + out += _types_under(v) + elif isinstance(node, list): + for v in node: + out += _types_under(v) + return out + + +def test_preregister_wire_schema_admits_structured_pre_seal_checks(): + # The lint (⑫h) tells the author to seal each check as an object. The wire + # must be able to carry what the lint asks for, or the advice is unfollowable. + _arg_model("mm_preregister").model_validate( + {"ledger_path": "x", "claim_id": "c", "metric": "acc", + "pre_seal_checks": _STRUCTURED}) + + +def test_preregister_published_schema_shows_the_object_form(): + # What the client READS, not only what the validator accepts — a client that + # believes the schema never sends the object at all. + tool = [t for t in s.mcp._tool_manager.list_tools() + if t.name == "mm_preregister"][0] + types = _types_under(tool.parameters["properties"]["pre_seal_checks"]) + assert "object" in types, tool.parameters["properties"]["pre_seal_checks"] + + +def test_the_wire_schema_check_can_actually_fail(): + # Positive control on the instrument: the same payload must be REJECTED under + # the narrow hint this fix replaced. Without this, the two tests above would + # stay green even if they were validating nothing. + from mcp.server.fastmcp.utilities.func_metadata import func_metadata + from pydantic import ValidationError + + def narrow(ledger_path: str, claim_id: str, metric: str, + pre_seal_checks: list[str] | None = None) -> dict: ... + + with pytest.raises(ValidationError): + func_metadata(narrow).arg_model.model_validate( + {"ledger_path": "x", "claim_id": "c", "metric": "acc", + "pre_seal_checks": _STRUCTURED})