From 327bbf7c425fb359f9a54a8116c035d2761c6325 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 08:46:54 +0000 Subject: [PATCH 1/8] Refuse a replacement that is not new ground, before rejecting reject(replacement=...) minted the replacement through assume(), whose same-statement call is a documented no-op returning the existing record. So a replacement statement that already existed came back as that record -- even a rejected one -- and reject() rewrote its version and supersedes in place, erasing its own lineage. With the rejected statement itself as the replacement, the self-edge error fired only after REJECTED and the invalidation had been committed, leaving the assumption rejected, invalidated and superseding itself. The replacement id is now checked before the first write, and a statement that already names any assumption is refused with the graph untouched. The same no-op also silently discarded evidence_ids: re-asking an existing statement with new evidence returned the record without writing the justified_by edges the caller asked for. That is refused now too; repeating evidence the record already carries stays a no-op. Co-Authored-By: Claude Opus 5.5 --- contextmesh/assumptions.py | 29 ++++++++++++ tests/test_assumptions.py | 91 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+) diff --git a/contextmesh/assumptions.py b/contextmesh/assumptions.py index 283c694..25af730 100644 --- a/contextmesh/assumptions.py +++ b/contextmesh/assumptions.py @@ -132,6 +132,11 @@ def assume( digest is truncated, GRAPH.md rule 8) is a collision, not a reuse, and is refused the same way ``add_assumption`` refuses one. + A reuse that asks for justification the existing record does not + already carry is refused too. Returning the record unchanged would + tell the caller its evidence was recorded when no ``justified_by`` + edge was written -- the no-op would silently drop part of the call. + The write is atomic: if any justifying evidence id is invalid, the assumption this call would have created is rolled back rather than left standing without the justification the caller asked for. @@ -144,6 +149,13 @@ def assume( f"{existing.statement!r}; refusing to treat {statement!r} " "as the same assumption" ) + missing = [ev for ev in evidence_ids if ev not in existing.evidence_ids] + if missing: + raise AssumptionError( + f"assumption {existing.id!r} already exists " + f"({existing.status.value}) without evidence {missing!r}; " + "re-asking the same statement does not add justification" + ) return existing assumption = Assumption( @@ -288,6 +300,12 @@ def reject( GRAPH.md Rule 7: An assumption is only ever rejected by evidence that contradicts it. A valid, live NodeType.EVIDENCE node is required. + + ``replacement`` must be new ground. A statement that already names an + assumption -- the one being rejected, or any other, in any state -- + is refused before anything is written: reusing it would rewrite that + record's version and lineage in place, and re-grounding on the + rejected statement itself would make it supersede itself. """ if not evidence_id or not isinstance(evidence_id, str): raise AssumptionError("reject() requires a non-empty evidence_id string") @@ -317,6 +335,17 @@ def reject( f"evidence_id {evidence_id!r} is invalidated" ) + if replacement: + replacement_id = slug(replacement, "assumption") + clash = graph.assumptions.get(replacement_id) + if clash is not None or replacement_id in graph.nodes: + named = clash.statement if clash is not None else replacement + raise AssumptionError( + f"replacement {replacement!r} is not new ground: id " + f"{replacement_id!r} already names {named!r}" + + (f" ({clash.status.value})" if clash is not None else "") + ) + radius = self.blast_radius(assumption_id) node = graph.node(assumption_id) diff --git a/tests/test_assumptions.py b/tests/test_assumptions.py index df6d763..e5b22a8 100644 --- a/tests/test_assumptions.py +++ b/tests/test_assumptions.py @@ -340,5 +340,96 @@ def __setitem__(self, k, v): self.assertEqual(len(self.graph.edges), initial_edges) +class ReplacementMustBeNewGroundTest(unittest.TestCase): + """reject(replacement=...) used to route through assume(), whose + same-statement no-op handed back an existing record for reject() to + rewrite in place -- or, for the rejected statement itself, raised a + self-edge error after the rejection had already been committed.""" + + def setUp(self): + self.graph = ContextGraph() + self.graph.build = 1 + self.ledger = AssumptionLedger(self.graph) + self.source = self.graph.add_node( + NodeType.SOURCE, + "Postmortem", + attrs={"origin": "review", "retrieved_at": "2026-07-01"}, + ) + + def witness(self, text): + return self.graph.add_node( + NodeType.EVIDENCE, + text, + attrs={"kind": "postmortem"}, + provenance=Provenance(source_id=self.source.id, recorded_at_build=1), + ).id + + def snapshot(self, assumption_id): + record = self.graph.assumptions[assumption_id] + node = self.graph.node(assumption_id) + return ( + record.status, + record.version, + record.supersedes, + record.superseded_by, + node.invalidated, + len(self.graph.edges), + ) + + def test_replacing_with_the_rejected_statement_is_refused_before_any_write(self): + a = self.ledger.assume("A holds") + before = self.snapshot(a.id) + with self.assertRaisesRegex(AssumptionError, "not new ground"): + self.ledger.reject(a.id, evidence_id=self.witness("A broke"), replacement="A holds") + self.assertEqual(self.snapshot(a.id), before) + self.assertIs(self.graph.assumptions[a.id].status, AssumptionStatus.ACTIVE) + + def test_replacing_with_an_existing_rejected_statement_leaves_it_untouched(self): + a = self.ledger.assume("A holds") + b = self.ledger.assume("B holds") + self.ledger.reject(b.id, evidence_id=self.witness("B broke")) + b_before, a_before = self.snapshot(b.id), self.snapshot(a.id) + with self.assertRaisesRegex(AssumptionError, r"not new ground.*rejected"): + self.ledger.reject(a.id, evidence_id=self.witness("A broke"), replacement="B holds") + self.assertEqual(self.snapshot(b.id), b_before) + self.assertEqual(self.snapshot(a.id), a_before) + + def test_a_new_replacement_still_supersedes(self): + a = self.ledger.assume("A holds") + report = self.ledger.reject( + a.id, evidence_id=self.witness("A broke"), replacement="C holds" + ) + new = self.graph.assumptions[report.replacement_id] + self.assertEqual((new.statement, new.version, new.supersedes), ("C holds", 2, a.id)) + + +class ReassumeDoesNotDropEvidenceTest(unittest.TestCase): + def setUp(self): + self.graph = ContextGraph() + self.ledger = AssumptionLedger(self.graph) + source = self.graph.add_node( + NodeType.SOURCE, "Bench", attrs={"origin": "bench", "retrieved_at": "2026-07-01"} + ) + self.ev = self.graph.add_node( + NodeType.EVIDENCE, + "linear growth observed", + attrs={"kind": "bench"}, + provenance=Provenance(source_id=source.id), + ).id + + def test_new_evidence_on_an_existing_statement_is_refused_not_dropped(self): + a = self.ledger.assume("X holds") + edges = len(self.graph.edges) + with self.assertRaisesRegex(AssumptionError, "does not add justification"): + self.ledger.assume("X holds", evidence_ids=[self.ev]) + self.assertEqual(self.graph.assumptions[a.id].evidence_ids, []) + self.assertEqual(len(self.graph.edges), edges) + + def test_repeating_the_same_evidence_is_still_a_no_op(self): + a = self.ledger.assume("X holds", evidence_ids=[self.ev]) + self.assertIs(self.ledger.assume("X holds", evidence_ids=[self.ev]), a) + self.assertIs(self.ledger.assume("X holds"), a) + + if __name__ == "__main__": unittest.main() From 304321c9524297182ecdb6715f03cc0a8b75906d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 08:48:36 +0000 Subject: [PATCH 2/8] Recheck: fail closed on a broken evidence contract, not on an outage Two opposite mistakes in the same except clause. An auditor that breaks the evidence contract inside ctx.disproved() -- evidence_id="" -- raised EvidenceRecheckError into `except Exception`, which recorded it as an auditor outage and failed the task, and the MCP write committed that. The identical violation detected after the auditor returns was already fatal. It is now re-raised, so the staged MCP transaction is discarded either way. An auditor that genuinely could not answer -- a provider outage during mesh_recheck -- moved finished, already-audited work from DONE to FAILED. Nothing re-runs a FAILED task, so recovery took a mesh_repair and a full re-execution, and the recheck payload reported only audited: 0. The exception proves nothing about the work, so the task now stays DONE, the error is recorded as a new AUDIT_ERROR ledger event, mesh_recheck reports it under audit_errors, and the next recheck simply asks again. Runner.recheck gets the same change. An auditor that raises during the initial run still fails its task: that work was never verified. This deliberately revises the recheck half of #29/#33; their tests are updated to the new contract. Co-Authored-By: Claude Opus 5.5 --- contextmesh/execute.py | 14 ++++-- contextmesh/recheck.py | 16 ++++-- contextmesh_mcp/writes.py | 6 +++ tests/test_auditor_failures.py | 21 ++++++-- tests/test_mcp_recheck_auditor_failures.py | 58 ++++++++++++++++++---- 5 files changed, 94 insertions(+), 21 deletions(-) diff --git a/contextmesh/execute.py b/contextmesh/execute.py index 479c303..304b2cb 100644 --- a/contextmesh/execute.py +++ b/contextmesh/execute.py @@ -112,6 +112,10 @@ class Event(str, Enum): CACHED = "cached" BLOCKED = "blocked" FAILED = "failed" + #: A recheck auditor raised instead of returning a verdict. Recorded + #: without changing the task: the work was verified when it ran, and an + #: auditor that could not answer now proves nothing either way. + AUDIT_ERROR = "audit_error" # ── verdicts ───────────────────────────────────────────────────────────── @@ -1651,12 +1655,14 @@ def recheck(self) -> List[InvalidationReport]: verdict = self._audit(task, task.output, assumption) except _AuditorRaised as exc: # One broken auditor must not abort independent rechecks. - # An exception proves no assumption false, so fail only - # this task and keep its ground standing. - task.state = TaskState.FAILED + # An exception proves nothing -- not that the ground is + # false, and not that the work is: it already passed its + # audit when it ran. So the task stays DONE and the error + # is recorded; the next recheck asks again. Failing it here + # would be permanent, since nothing re-runs a FAILED task. self.ledger.record( self.round, - Event.FAILED, + Event.AUDIT_ERROR, name, f"auditor error: {exc}", node_id=task.node_id, diff --git a/contextmesh/recheck.py b/contextmesh/recheck.py index 6b784a0..24d5c0a 100644 --- a/contextmesh/recheck.py +++ b/contextmesh/recheck.py @@ -184,13 +184,21 @@ def recheck( graph=runner.graph, ) ) + except EvidenceRecheckError: + # Raised by ctx.disproved() itself: the auditor broke the evidence + # contract. That is the same violation as a disproof returned + # without evidence, which is fatal below, so it is fatal here too + # and the staged MCP transaction is discarded rather than + # committing it as an ordinary auditor outage. + raise except Exception as exc: - # Match Runner.recheck(): an unavailable auditor is a task failure, - # never a disproof, and must not prevent independent rechecks. - task.state = TaskState.FAILED + # Match Runner.recheck(): an unavailable auditor is never a + # disproof, must not prevent independent rechecks, and does not + # fail work that already passed its audit. Record it; the task + # stays DONE and the next recheck asks again. runner.ledger.record( runner.round, - Event.FAILED, + Event.AUDIT_ERROR, task.name, f"auditor error: {type(exc).__name__}: {exc}", node_id=task.node_id, diff --git a/contextmesh_mcp/writes.py b/contextmesh_mcp/writes.py index 23adb87..d8d89a4 100644 --- a/contextmesh_mcp/writes.py +++ b/contextmesh_mcp/writes.py @@ -279,10 +279,16 @@ def mutate(staged: Session) -> Tuple[Dict[str, Any], bool]: reports = evidence_recheck(runner, require_evidence=True) new_entries = runner.ledger.entries[before:] audited = sum(1 for entry in new_entries if entry.event is Event.AUDITED) + audit_errors = [ + {"task": entry.task, "error": entry.detail} + for entry in new_entries + if entry.event is Event.AUDIT_ERROR + ] return ( { "round": runner.round, "audited": audited, + "audit_errors": audit_errors, "invalidations": [report.to_dict() for report in reports], "ledger_head": runner.ledger.head, }, diff --git a/tests/test_auditor_failures.py b/tests/test_auditor_failures.py index b93532c..6e9aa1e 100644 --- a/tests/test_auditor_failures.py +++ b/tests/test_auditor_failures.py @@ -96,20 +96,33 @@ def second_audit(ctx): self.assertEqual(reports, []) self.assertEqual(audited, ["first", "second"]) - self.assertIs(runner.state("first"), TaskState.FAILED) + # Work that already passed its audit is not failed by an auditor + # that cannot answer now: nothing would ever re-run it. + self.assertIs(runner.state("first"), TaskState.DONE) self.assertIs(runner.state("second"), TaskState.DONE) assumption = runner.graph.assumptions[runner["first"].assumption_id] self.assertIs(assumption.status, AssumptionStatus.ACTIVE) - failures = [ + errors = [ entry for entry in runner.ledger.of("first") - if entry.event is Event.FAILED + if entry.event is Event.AUDIT_ERROR ] self.assertIn( "auditor error: ConnectionError: audit backend down", - failures[-1].detail, + errors[-1].detail, + ) + self.assertFalse( + [e for e in runner.ledger.of("first") if e.event is Event.FAILED] ) + # The outage clears; the next recheck simply asks again. + fail_now["value"] = False + audited.clear() + self.assertEqual(runner.recheck(), []) + self.assertEqual(audited, ["first", "second"]) + self.assertIs(runner.state("first"), TaskState.DONE) + self.assertIs(runner.ledger.of("first")[-1].event, Event.AUDITED) + def test_invalid_auditor_return_is_still_a_contract_error(self): runner = Runner("bad verdict") runner.task( diff --git a/tests/test_mcp_recheck_auditor_failures.py b/tests/test_mcp_recheck_auditor_failures.py index 647fdbd..355ff62 100644 --- a/tests/test_mcp_recheck_auditor_failures.py +++ b/tests/test_mcp_recheck_auditor_failures.py @@ -5,6 +5,7 @@ from contextmesh.execute import Event, ExecutionError, Runner, TaskRegistry, TaskState from contextmesh.model import AssumptionStatus +from contextmesh.recheck import EvidenceRecheckError from contextmesh_mcp.session import Checkpointer, Session from contextmesh_mcp.writes import mesh_recheck @@ -33,7 +34,7 @@ def _saved_session(self, first_audit, second_audit): base.runner = runner return base, registry - def test_one_auditor_outage_is_committed_as_failure_and_second_recheck_runs(self): + def test_one_auditor_outage_is_reported_not_failed_and_second_recheck_runs(self): fail = {"value": False} calls = [] @@ -60,24 +61,35 @@ def second(ctx): self.assertTrue(result.changed) self.assertEqual(cp.commits, 1) self.assertEqual(calls, ["first", "second"]) + # The client is told, instead of seeing audited: 1 and nothing else. + self.assertEqual(result.payload["audited"], 1) + self.assertEqual( + result.payload["audit_errors"], + [{"task": "first", "error": "auditor error: RuntimeError: auditor outage"}], + ) runner = result.session.runner assert runner is not None - self.assertIs(runner.state("first"), TaskState.FAILED) + self.assertIs(runner.state("first"), TaskState.DONE) self.assertIs(runner.state("second"), TaskState.DONE) assumption = runner.graph.assumptions[runner["first"].assumption_id] self.assertIs(assumption.status, AssumptionStatus.ACTIVE) - failures = [ - entry for entry in runner.ledger.of("first") if entry.event is Event.FAILED + errors = [ + entry for entry in runner.ledger.of("first") if entry.event is Event.AUDIT_ERROR ] - self.assertIn( - "auditor error: RuntimeError: auditor outage", - failures[-1].detail, - ) + self.assertIn("auditor error: RuntimeError: auditor outage", errors[-1].detail) restored = Session.load(tmp, registry=registry) assert restored.runner is not None - self.assertIs(restored.runner.state("first"), TaskState.FAILED) + self.assertIs(restored.runner.state("first"), TaskState.DONE) self.assertIs(restored.runner.state("second"), TaskState.DONE) + # Recovery needs no repair: the next recheck asks again. + fail["value"] = False + calls.clear() + again = mesh_recheck(restored, Checkpointer(restored)) + self.assertEqual(calls, ["first", "second"]) + self.assertEqual(again.payload["audited"], 2) + self.assertEqual(again.payload["audit_errors"], []) + def test_invalid_auditor_return_remains_a_contract_error_and_commits_nothing(self): bad = {"value": False} @@ -104,6 +116,34 @@ def second(ctx): assert live.runner is not None self.assertIs(live.runner.state("first"), TaskState.DONE) + def test_an_evidence_contract_violation_inside_disproved_commits_nothing(self): + """ctx.disproved(evidence_id="") breaks the evidence contract. It used + to be caught as an auditor outage and committed as a task failure; the + same violation detected after return was already fatal.""" + bad = {"value": False} + + def first(ctx): + if bad["value"]: + return ctx.disproved("gone", evidence_id="") + return ctx.ok("holds") + + base, registry = self._saved_session(first, lambda ctx: ctx.ok("holds")) + with tempfile.TemporaryDirectory() as tmp: + base.save(tmp) + live = Session.load(tmp, registry=registry) + cp = Checkpointer(live) + bad["value"] = True + + with self.assertRaises(EvidenceRecheckError): + mesh_recheck(live, cp) + + self.assertEqual(cp.commits, 0) + assert live.runner is not None + self.assertIs(live.runner.state("first"), TaskState.DONE) + self.assertFalse( + [e for e in live.runner.ledger.of("first") if e.event is Event.FAILED] + ) + if __name__ == "__main__": unittest.main() From 3dad9696b5a25f9cc3d181b5e4a345e5cd6f4ebe Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 08:48:59 +0000 Subject: [PATCH 3/8] Report the released version at import time contextmesh-graph 0.1.1 shipped with contextmesh.__version__ and contextmesh_mcp.__version__ both still "0.1.0": the release bumped pyproject.toml, and the publish workflow's identity check compares the tag against pyproject alone. Both now say 0.1.1, and a test compares each against pyproject's [project] version, so the next bump that misses one fails CI on the version-bump pull request rather than after publication. The test reads pyproject with a regex because the suite still runs on 3.9, where tomllib does not exist. Co-Authored-By: Claude Opus 5.5 --- contextmesh/__init__.py | 2 +- contextmesh_mcp/__init__.py | 2 +- tests/test_version.py | 38 +++++++++++++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 tests/test_version.py diff --git a/contextmesh/__init__.py b/contextmesh/__init__.py index 1ca9661..179acaf 100644 --- a/contextmesh/__init__.py +++ b/contextmesh/__init__.py @@ -86,7 +86,7 @@ from .telemetry import TelemetryProjection, project_telemetry from .traverse import DeadEnd, Walk, Walker -__version__ = "0.1.0" +__version__ = "0.1.1" __all__ = [ "LIVE_PROVIDERS", diff --git a/contextmesh_mcp/__init__.py b/contextmesh_mcp/__init__.py index 8a81a9e..7201c38 100644 --- a/contextmesh_mcp/__init__.py +++ b/contextmesh_mcp/__init__.py @@ -39,7 +39,7 @@ mesh_lineage, ) -__version__ = "0.1.0" +__version__ = "0.1.1" __all__ = [ "TOOLS", diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 0000000..9093348 --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,38 @@ +"""The version a user sees at import time is the version that was released. + +0.1.1 shipped to PyPI while both packages still reported 0.1.0: the release +workflow checks the tag against pyproject.toml and never read __version__. +This fails on the version-bump pull request instead of after publication. +""" + +import re +import unittest +from pathlib import Path + +import contextmesh +import contextmesh_mcp + +PYPROJECT = Path(__file__).resolve().parent.parent / "pyproject.toml" + + +def _project_version() -> str: + # tomllib is 3.11+ and the suite runs on 3.9, so read the one key directly: + # the first top-level `version = "..."` inside the [project] table. + text = PYPROJECT.read_text(encoding="utf-8") + table = re.search(r"^\[project\]\s*$(.*?)(?=^\[|\Z)", text, re.M | re.S) + assert table is not None, "pyproject.toml has no [project] table" + match = re.search(r'^version\s*=\s*"([^"]+)"\s*$', table.group(1), re.M) + assert match is not None, "[project] has no static version" + return match.group(1) + + +class VersionAgreementTest(unittest.TestCase): + def test_contextmesh_reports_the_packaged_version(self): + self.assertEqual(contextmesh.__version__, _project_version()) + + def test_contextmesh_mcp_reports_the_packaged_version(self): + self.assertEqual(contextmesh_mcp.__version__, _project_version()) + + +if __name__ == "__main__": + unittest.main() From c089038c7f6bc27f3df36c3207d5bdd37c73bd5c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 08:50:57 +0000 Subject: [PATCH 4/8] Stop retrying an oversized provider response as a transport failure _bounded_read raised LLMTransportError for a body over max_response_bytes, and _send treats LLMTransportError as transient. So a deterministic oversize was re-sent max_attempts times -- re-billing the completion each time -- and the final "transport failed after N attempt(s)" was raised `from None`, hiding the limit. Error bodies go through the same read, so an oversized 400 was retried too, and its status lost. An oversized successful body now raises LLMResponseTooLargeError, a non-retryable LLMProviderError carrying the status and the limit, on the first attempt. An oversized error body is still read no further than limit + 1 bytes, then dropped: nothing reads an error body, so the status is left to decide as it does for any other error -- a 4xx fails closed at once, a 429/5xx keeps its retries. Co-Authored-By: Claude Opus 5.5 --- contextmesh/llm.py | 38 +++++++++++++++++++++++++------ tests/test_llm.py | 57 ++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 81 insertions(+), 14 deletions(-) diff --git a/contextmesh/llm.py b/contextmesh/llm.py index a707883..a61b000 100644 --- a/contextmesh/llm.py +++ b/contextmesh/llm.py @@ -63,6 +63,16 @@ def __init__(self, message: str, *, status: Optional[int] = None) -> None: self.status = status +class LLMResponseTooLargeError(LLMProviderError): + """A successful provider response exceeded ``max_response_bytes``. + + Deliberately not an :class:`LLMTransportError`: an HTTP response *was* + obtained, and the same request will draw the same oversized reply, so + retrying it only re-bills the completion. It fails closed on the first + attempt, with the status and the limit in the message. + """ + + class LLMResponseError(LLMError): """A provider response cannot be interpreted as requested output.""" @@ -284,7 +294,7 @@ def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D102 return None -def _bounded_read(readable: Any, max_bytes: int) -> bytes: +def _bounded_read(readable: Any, max_bytes: int) -> Optional[bytes]: """Read at most ``max_bytes`` and refuse whatever comes after. ``.read()`` with no argument buffers the whole body regardless of how @@ -296,9 +306,9 @@ def _bounded_read(readable: Any, max_bytes: int) -> bytes: """ data = readable.read(max_bytes + 1) if len(data) > max_bytes: - raise LLMTransportError( - f"provider response exceeded the {max_bytes}-byte limit; refusing to buffer it" - ) + # What an oversized body means depends on the status, so the caller + # decides; this only guarantees nothing past limit + 1 is buffered. + return None return data @@ -317,13 +327,26 @@ def __call__(self, request: HTTPRequest) -> HTTPResponse: ) try: with self._opener.open(raw, timeout=request.timeout) as response: + status = int(response.status) + body = _bounded_read(response, request.max_response_bytes) + if body is None: + raise LLMResponseTooLargeError( + f"provider response (HTTP {status}) exceeded the " + f"{request.max_response_bytes}-byte limit; refusing to buffer it", + status=status, + ) return HTTPResponse( - status=int(response.status), + status=status, headers={str(k): str(v) for k, v in response.headers.items()}, - body=_bounded_read(response, request.max_response_bytes), + body=body, ) except urllib.error.HTTPError as exc: - body = _bounded_read(exc, request.max_response_bytes) + # Nothing reads an error body -- the client acts on the status + # alone -- so an oversized one is dropped after the bounded read + # and the status still decides: a 4xx fails closed at once, a + # 429/5xx is retried. Raising here turned every oversized error + # page into a retried "transport" failure that hid the status. + body = _bounded_read(exc, request.max_response_bytes) or b"" headers = {str(k): str(v) for k, v in exc.headers.items()} if exc.headers else {} return HTTPResponse(status=int(exc.code), headers=headers, body=body) except (urllib.error.URLError, TimeoutError, OSError) as exc: @@ -1173,6 +1196,7 @@ def worker(context: RunContext) -> Dict[str, Any]: "LLMProviderError", "LLMProvenance", "LLMRequestTooLargeError", + "LLMResponseTooLargeError", "LLMResponseError", "LLMResult", "LLMSchemaError", diff --git a/tests/test_llm.py b/tests/test_llm.py index c25c5bd..b90451b 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -21,6 +21,7 @@ LLMConfigurationError, LLMProviderError, LLMRequestTooLargeError, + LLMResponseTooLargeError, LLMResponseError, LLMSchemaError, LLMTransportError, @@ -940,11 +941,13 @@ def test_a_plain_response_still_arrives(self): self.assertEqual(response.status, 200) self.assertEqual(json.loads(response.body), {"ok": True}) - def oversized_server(self, *, status=200): + def oversized_server(self, *, status=200, hits=None): payload = b"x" * 4096 class Oversized(BaseHTTPRequestHandler): def do_POST(self): + if hits is not None: + hits.append(status) self.send_response(status) self.send_header("Content-Type", "application/json") self.end_headers() @@ -958,15 +961,55 @@ def log_message(self, *args): def test_an_oversized_success_body_is_refused_without_buffering_it(self): port = self.oversized_server(status=200) request = self.request_to(port, max_response_bytes=1024) - with self.assertRaisesRegex(LLMTransportError, "1024-byte limit"): + pattern = r"HTTP 200.*1024-byte limit" + with self.assertRaisesRegex(LLMResponseTooLargeError, pattern) as caught: UrllibTransport()(request) - - def test_an_oversized_error_body_is_refused_the_same_way(self): - """A provider returning a huge *error* page is the same memory risk.""" + self.assertEqual(caught.exception.status, 200) + # Not a transport failure: a response was obtained, so it must not be + # retried as if the network had dropped. + self.assertNotIsInstance(caught.exception, LLMTransportError) + + def test_an_oversized_error_body_is_dropped_and_the_status_kept(self): + """A huge *error* page is the same memory risk, but nothing reads an + error body, so it is discarded after the bounded read and the status + is what the client acts on.""" port = self.oversized_server(status=500) request = self.request_to(port, max_response_bytes=1024) - with self.assertRaisesRegex(LLMTransportError, "1024-byte limit"): - UrllibTransport()(request) + response = UrllibTransport()(request) + self.assertEqual(response.status, 500) + self.assertEqual(response.body, b"") + + def client(self): + return LLMClient( + LLMConfig(provider="openai", model="gpt-test", api_key="sk-not-a-real-key"), + transport=UrllibTransport(), + sleep=lambda _seconds: None, + ) + + def test_an_oversized_success_is_not_retried(self): + hits = [] + port = self.oversized_server(status=200, hits=hits) + with self.assertRaisesRegex(LLMResponseTooLargeError, "1024-byte limit"): + self.client()._send(self.request_to(port, max_response_bytes=1024)) + self.assertEqual(hits, [200]) + + def test_an_oversized_client_error_fails_closed_on_the_first_attempt(self): + hits = [] + port = self.oversized_server(status=400, hits=hits) + with self.assertRaises(LLMProviderError) as caught: + self.client()._send(self.request_to(port, max_response_bytes=1024)) + self.assertEqual(caught.exception.status, 400) + self.assertIn("failed closed", str(caught.exception)) + self.assertEqual(hits, [400]) + + def test_an_oversized_server_error_keeps_its_retry_semantics(self): + hits = [] + port = self.oversized_server(status=503, hits=hits) + client = self.client() + with self.assertRaises(LLMProviderError) as caught: + client._send(self.request_to(port, max_response_bytes=1024)) + self.assertEqual(caught.exception.status, 503) + self.assertEqual(len(hits), client.config.max_attempts) def test_a_body_at_exactly_the_limit_is_accepted(self): port = self.oversized_server(status=200) From 97605bc73027202022dc5119249f33745ee13f56 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 08:50:57 +0000 Subject: [PATCH 5/8] Bound evidence text before stripping it #34 moved external_id's byte check ahead of strip(), because stripping first allocates a temporary proportional to whatever the caller sent before the limit can refuse it. Evidence text -- the larger field, at 64 KiB -- was still stripped first. It is now bounded first too; blank text within the limit is refused exactly as before. Co-Authored-By: Claude Opus 5.5 --- contextmesh/evidence.py | 7 ++++++- tests/test_evidence.py | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/contextmesh/evidence.py b/contextmesh/evidence.py index 5fe653c..1dfba68 100644 --- a/contextmesh/evidence.py +++ b/contextmesh/evidence.py @@ -316,9 +316,14 @@ def submit( external_id: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, ) -> EvidenceReceipt: - if not isinstance(text, str) or not text.strip(): + if not isinstance(text, str): raise EvidenceIntakeError("text must be a non-empty string") + # Bounded before strip() for the same reason external_id is below: + # stripping first allocates a temporary proportional to whatever the + # caller sent before the limit gets a chance to refuse it. _check_string_bytes(text, MAX_TEXT_BYTES, "text") + if not text.strip(): + raise EvidenceIntakeError("text must be a non-empty string") if not isinstance(source_id, str) or not source_id.strip(): raise EvidenceIntakeError("source_id must be a non-empty string") source = self.graph.get(source_id) diff --git a/tests/test_evidence.py b/tests/test_evidence.py index eae3b90..8b61fe0 100644 --- a/tests/test_evidence.py +++ b/tests/test_evidence.py @@ -122,6 +122,25 @@ def test_oversized_text_is_refused_before_mutation(self): # The boundary itself is accepted: this is a limit, not an off-by-one trap. self.submit(text="x" * MAX_TEXT_BYTES, external_id="at-the-text-limit") + def test_oversized_text_is_refused_before_it_is_stripped(self): + """strip() on an oversized string allocates a copy of it before the + limit can refuse it -- the reason external_id is bounded first. Text + used to be stripped first.""" + stripped = [] + + class Recording(str): + def strip(self, *args): + stripped.append(len(self)) + return super().strip(*args) + + with self.assertRaisesRegex(EvidenceIntakeError, "byte limit"): + self.submit(text=Recording(" " * (MAX_TEXT_BYTES + 1))) + self.assertEqual(stripped, []) + # Blank text within the limit is still refused as blank. + with self.assertRaisesRegex(EvidenceIntakeError, "non-empty"): + self.submit(text=Recording(" ")) + self.assertEqual(stripped, [3]) + def test_oversized_metadata_is_refused_before_mutation(self): before = self.graph.to_dict() with self.assertRaisesRegex(EvidenceIntakeError, "byte limit"): From 8f6dda1292e97c25a9853fdbdee6fea128e8e31b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 08:54:16 +0000 Subject: [PATCH 6/8] Keep explicit-id decisions loadable after decide() and in projections An explicit-id decision's digest covers the edges decide() writes and is checked against the node's actual edges, on load and on retry. Three ways to make a correct graph fail that check: - An edge of a fingerprinted kind added after decide() -- the documented AssumptionLedger.depends(decision, assumption), say. It was accepted, and then the saved graph refused to load as "tampered with or corrupted", and the original decide() could no longer be retried. add_edge now refuses it where it happens, naming decide() as the way to record it. A depends_on into another decision (the Runner's task ordering) is left out of the fingerprint and still added freely, and repeating an edge the decision already has remains a no-op. - decide(assumptions=[]) hashed the target into depends_on, while the structure check counts depends_on only into assumptions, so the stored digest could never match. Non-assumption targets are refused before anything is written. - as_of_graph keeps a decision by its own date and drops edges whose other end came later, then loads the projection through from_dict -- which rejected the stale digest, so the time-travel query crashed. The digest is now wound back to the projected edges, reusing _decision_structure, so the projection is still one the ordinary loader accepts rather than a special case of it. No shipped path reached these -- the Runner passes its assumption to decide(), its decisions are undated, and the demo uses auto-minted ids -- but a library caller who did lost the ability to restore their graph. Co-Authored-By: Claude Opus 5.5 --- GRAPH.md | 9 +++ contextmesh/assumptions.py | 8 ++- contextmesh/decisions.py | 13 +++- contextmesh/graph.py | 44 +++++++++++++ contextmesh/temporal.py | 31 ++++++++- tests/test_decision_identity.py | 112 ++++++++++++++++++++++++++++++++ tests/test_edge_binding.py | 21 ++++-- 7 files changed, 228 insertions(+), 10 deletions(-) diff --git a/GRAPH.md b/GRAPH.md index bd9b9b2..1fc58e4 100644 --- a/GRAPH.md +++ b/GRAPH.md @@ -163,6 +163,15 @@ stronger identity scheme: it only protects a caller who reuses one id on purpose, the same way `add_node`'s type/label check only protects against `slug`'s truncation, and the two checks answer different questions (one caller-declared, one derived) that can both apply to the same write. +Because the check reads the node's actual edges, an explicit-id decision's +fingerprinted edges are fixed once `decide` returns: `add_edge` refuses a +new `cites`, `derived_from`, `produces`, `supersedes`, or `depends_on` into +an assumption out of it, since accepting one would make the next load +refuse a correct snapshot and the next identical retry refuse the original +call. (A `depends_on` into another decision is a task ordering, not part of +the decision, and is still added freely.) A time-travel projection that +drops such an edge because its other end came later re-derives the digest +for what it kept, the same way it rewinds assumption state. A decision's immutability does not stop at `DecisionLog`, and neither does the trust boundary around its identity metadata. `add_node` refuses to mint diff --git a/contextmesh/assumptions.py b/contextmesh/assumptions.py index 25af730..6260d35 100644 --- a/contextmesh/assumptions.py +++ b/contextmesh/assumptions.py @@ -235,7 +235,13 @@ def justifies(self, assumption_id: str, edge_id: str) -> None: edge.assumption_id = assumption_id def depends(self, node_id: str, assumption_id: str) -> None: - """Wire a decision or claim to the assumption it rests on.""" + """Wire a decision or claim to the assumption it rests on. + + An explicit-id decision takes its assumptions through + ``DecisionLog.decide(assumptions=...)`` instead; adding one here + afterwards is refused, because it would change the content that + decision's digest vouches for. + """ self.graph.add_edge(node_id, EdgeType.DEPENDS_ON, assumption_id) # ── the interesting part ───────────────────────────────────────────── diff --git a/contextmesh/decisions.py b/contextmesh/decisions.py index 2f22b93..b4210e0 100644 --- a/contextmesh/decisions.py +++ b/contextmesh/decisions.py @@ -129,6 +129,17 @@ def decide( cites = tuple(cites) assumptions = tuple(assumptions) produces = tuple(produces) + for target in assumptions: + # depends_on also legally points decision->decision, but that is a + # task ordering wired after decide(), which the fingerprint leaves + # out. Accepting one here would store a digest the decision's own + # edges can never reproduce: unloadable, and never retriable. + found = self.graph.get(target) + if found is not None and found.type is not NodeType.ASSUMPTION: + raise OntologyError( + f"assumptions= takes assumption ids; {target!r} is a " + f"{found.type.value}" + ) fingerprint = decision_fingerprint( title=title, rationale=rationale, @@ -200,7 +211,7 @@ def decide( def _tracked_edge(src: str, etype: EdgeType, dst: str) -> None: key = (src, etype.value, dst) already = key in self.graph._edge_key - edge = self.graph.add_edge(src, etype, dst) + edge = self.graph.add_edge(src, etype, dst, _decision_edge_authorized=True) if not already: created_edge_ids.append(edge.id) diff --git a/contextmesh/graph.py b/contextmesh/graph.py index 78d70d3..c429146 100644 --- a/contextmesh/graph.py +++ b/contextmesh/graph.py @@ -78,6 +78,27 @@ def _no_constants(value: str) -> float: ) +#: Edge kinds out of a decision that ``decide()`` itself writes and that its +#: fingerprint covers. ``depends_on`` counts only into an assumption: a +#: decision->decision dependency is a task ordering the Runner wires after +#: ``decide()`` returns, not part of the decision (see ``_decision_structure``). +_DIGESTED_EDGES = ( + EdgeType.CITES, + EdgeType.DERIVED_FROM, + EdgeType.PRODUCES, + EdgeType.SUPERSEDES, +) + + +def _digest_covers(src: Node, type: EdgeType, dst: Node) -> bool: + """Would this edge change an explicit-id decision's fingerprinted content?""" + if src.type is not NodeType.DECISION or src.attrs.get("decision_identity") != "explicit": + return False + if type is EdgeType.DEPENDS_ON: + return dst.type is NodeType.ASSUMPTION + return type in _DIGESTED_EDGES + + class ContextGraph: """A typed context graph. @@ -264,9 +285,21 @@ def add_edge( *, evidence_ids: Optional[Iterable[str]] = None, weight: float = 1.0, + _decision_edge_authorized: bool = False, ) -> Edge: """Add a typed edge. Raises OntologyError if the pair is not legal. + An explicit-id decision's own edges -- the ones its + ``decision_payload_digest`` covers -- are written by + ``DecisionLog.decide()`` and by snapshot restoration, and by nothing + else. A later edge of one of those kinds would change the content the + digest vouches for, so the next load would refuse a correct snapshot + as tampered and an identical retry of the original ``decide()`` would + be refused as different content. It is refused here instead, where + the caller can see it: pass it to ``decide()``. Adding an edge the + decision already has is still an idempotent repeat. + ``_decision_edge_authorized`` is not part of the public contract. + This is not a binding path: it never takes an ``assumption_id``. A live edge is bound through :meth:`AssumptionLedger.justifies`, which enforces the write boundary (assumption exists and is active, edge @@ -303,6 +336,15 @@ def add_edge( e for e in evidence_ids if e not in edge.evidence_ids ) return edge + if not _decision_edge_authorized and _digest_covers( + self.nodes[src], type, self.nodes[dst] + ): + raise OntologyError( + f"decision {src!r} was recorded with an explicit id, so its " + f"{type.value} edges are part of the content its digest vouches " + f"for; refusing to add {src!r}-[{type.value}]->{dst!r} after the " + "fact -- pass it to DecisionLog.decide() instead" + ) edge_id = slug(f"{src}|{type.value}|{dst}", "edge") if edge_id in self.edges: collision = self.edges[edge_id] @@ -606,6 +648,8 @@ def from_dict( stored.dst, evidence_ids=stored.evidence_ids, weight=stored.weight, + # Restoring is not adding: the digest check below judges these. + _decision_edge_authorized=True, ) # Edge identity is derived from (src, type, dst), so a snapshot id # that disagrees with the recomputed one means the file was edited diff --git a/contextmesh/temporal.py b/contextmesh/temporal.py index 49b8d40..e072e09 100644 --- a/contextmesh/temporal.py +++ b/contextmesh/temporal.py @@ -55,7 +55,7 @@ from typing import Any, Dict, Optional from .graph import ContextGraph -from .model import AssumptionStatus, EdgeType, Node, NodeType +from .model import AssumptionStatus, EdgeType, Node, NodeType, decision_fingerprint #: The source attribute GRAPH.md already requires a ``source`` to carry. SOURCE_TIME_ATTR = "retrieved_at" @@ -339,12 +339,41 @@ def as_of_graph(graph: ContextGraph, as_of: Any) -> ContextGraph: for a in payload["assumptions"] if a["id"] in kept ] + for record in payload["nodes"]: + _rewind_decision_digest(record, kept, graph) _mirror_status(payload) projection = ContextGraph.from_dict(payload) _replay_invalidation(projection) return projection +def _rewind_decision_digest( + record: Dict[str, Any], kept: Dict[str, Node], graph: ContextGraph +) -> None: + """Fingerprint an explicit-id decision as it stands in the projection. + + A decision survives by its own source date, while an edge survives only + if its other end does -- so a decision whose ``supported_by`` claim came + later keeps its node and loses that edge. Its stored + ``decision_payload_digest`` still covers the edge, and the ordinary + loader refuses the mismatch as tampering: the projection crashed instead + of answering. The digest is wound back to the projected edges the same + way assumption state is, so the projection stays a graph the ordinary + loader accepts rather than a special case of it. + """ + if ( + record.get("type") != NodeType.DECISION.value + or record.get("attrs", {}).get("decision_identity") != "explicit" + ): + return + structure = graph._decision_structure(record["id"]) + for name in ("cites", "derived_from", "depends_on", "produces"): + structure[name] = {target for target in structure[name] if target in kept} + if structure["supersedes"] not in kept: + structure["supersedes"] = None + record["attrs"]["decision_payload_digest"] = decision_fingerprint(**structure) + + def _replay_invalidation(projection: ContextGraph) -> None: """Invalidation as it stood, derived rather than carried forward. diff --git a/tests/test_decision_identity.py b/tests/test_decision_identity.py index 23c5bcc..e816a08 100644 --- a/tests/test_decision_identity.py +++ b/tests/test_decision_identity.py @@ -687,5 +687,117 @@ def test_a_decision_to_decision_dependency_survives_a_snapshot_round_trip(self): self.assertEqual(again.id, downstream.id) +class ExplicitDecisionContentCannotGrowAfterDecideTest(unittest.TestCase): + """The fingerprint covers the edges decide() writes, and is checked + against the node's actual edges. An edge of one of those kinds added + afterwards used to be accepted silently -- and then the correct snapshot + failed to load as "tampered with or corrupted", and an identical retry + of the original decide() was refused as different content.""" + + def setUp(self): + self.graph, self.source = _graph_with_source() + self.ledger = AssumptionLedger(self.graph) + self.log = DecisionLog(self.graph) + self.ground = self.ledger.assume("The shard count is stable") + + def decide(self, **extra): + return self.log.decide( + "Partition the rebuild", + "Bounded memory.", + source_id=self.source.id, + id="decision:partition", + **extra, + ) + + def test_a_later_dependency_on_an_assumption_is_refused_where_it_is_added(self): + decision = self.decide() + edges = len(self.graph.edges) + with self.assertRaisesRegex(OntologyError, "pass it to DecisionLog.decide"): + self.ledger.depends(decision.id, self.ground.id) + self.assertEqual(len(self.graph.edges), edges) + # ...so what was saved still loads, and still retries. + restored = ContextGraph.from_dict(self.graph.to_dict()) + self.assertIs(DecisionLog(restored).decide( + "Partition the rebuild", + "Bounded memory.", + source_id=self.source.id, + id="decision:partition", + ).id, decision.id) + + def test_the_same_dependency_passed_to_decide_is_its_own_content(self): + decision = self.decide(assumptions=[self.ground.id]) + ContextGraph.from_dict(self.graph.to_dict()) + # Repeating an edge the decision already has is not new content. + self.ledger.depends(decision.id, self.ground.id) + ContextGraph.from_dict(self.graph.to_dict()) + + def test_auto_minted_decisions_are_not_frozen_by_this(self): + decision = self.log.decide("Auto", "No id.", source_id=self.source.id) + self.ledger.depends(decision.id, self.ground.id) + ContextGraph.from_dict(self.graph.to_dict()) + + def test_a_decision_is_not_accepted_as_an_assumption(self): + upstream = self.log.decide("Upstream", "r", source_id=self.source.id, id="decision:up") + nodes, edges = len(self.graph.nodes), len(self.graph.edges) + with self.assertRaisesRegex(OntologyError, "takes assumption ids"): + self.decide(assumptions=[upstream.id]) + self.assertEqual((len(self.graph.nodes), len(self.graph.edges)), (nodes, edges)) + + +class ProjectionRewindsExplicitDecisionDigestTest(unittest.TestCase): + """as_of_graph keeps a decision by its own date and drops an edge whose + other end came later. The stored digest still covered that edge, so the + loader rejected the projection as tampered and the time-travel query + crashed instead of answering.""" + + def build(self, explicit): + graph = ContextGraph() + graph.build = 1 + early = graph.add_node( + NodeType.SOURCE, "2024 review", + attrs={"origin": "review", "retrieved_at": "2024-03-01"}, + ) + late = graph.add_node( + NodeType.SOURCE, "2025 review", + attrs={"origin": "review", "retrieved_at": "2025-03-01"}, + ) + claim = graph.add_node( + NodeType.CLAIM, "later evidence", provenance=Provenance(source_id=late.id) + ) + graph.add_edge(claim.id, EdgeType.DERIVED_FROM, late.id) + extra = {"id": "decision:choose-x"} if explicit else {} + decision = DecisionLog(graph).decide( + "Choose X", "because", source_id=early.id, supported_by=[claim.id], **extra + ) + return graph, decision, claim + + def test_the_projection_answers_and_keeps_the_decision_without_the_later_edge(self): + from contextmesh.temporal import as_of_graph + + graph, decision, claim = self.build(explicit=True) + projection = as_of_graph(graph, "2024-06-01") + self.assertIn(decision.id, projection.nodes) + self.assertNotIn(claim.id, projection.nodes) + # A graph the ordinary loader accepts, not a special case of it. + ContextGraph.from_dict(projection.to_dict()) + # And the live graph's own record is untouched. + self.assertEqual( + graph.node(decision.id).attrs["decision_payload_digest"], + ContextGraph.from_dict(graph.to_dict()) + .node(decision.id) + .attrs["decision_payload_digest"], + ) + + def test_a_horizon_that_keeps_every_edge_keeps_the_digest(self): + from contextmesh.temporal import as_of_graph + + graph, decision, _ = self.build(explicit=True) + projection = as_of_graph(graph, "2030-01-01") + self.assertEqual( + projection.node(decision.id).attrs["decision_payload_digest"], + graph.node(decision.id).attrs["decision_payload_digest"], + ) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_edge_binding.py b/tests/test_edge_binding.py index 1c19a94..722d7fd 100644 --- a/tests/test_edge_binding.py +++ b/tests/test_edge_binding.py @@ -29,8 +29,10 @@ def _claim(graph, label, source, *, id=None): ) -def _decision(graph, label, source, *, id=None): - return DecisionLog(graph).decide(label, "fixture", source_id=source.id, id=id) +def _decision(graph, label, source, *, id=None, assumptions=()): + return DecisionLog(graph).decide( + label, "fixture", source_id=source.id, id=id, assumptions=assumptions + ) def _evidence(graph, label="Contradiction", *, kind="disproof", id=None): @@ -129,13 +131,18 @@ def setUp(self): ) self.edge_x = self.graph.add_edge(self.claim.id, EdgeType.SUPPORTS, self.decision_x.id) - self.decision_y = _decision( - self.graph, "Adopt tenant-aware sizing", self.source, id="decision:y" - ) - self.assumption = self.ledger.assume("Shard count grows linearly with corpus size") self.ledger.justifies(self.assumption.id, self.edge_x.id) - self.graph.add_edge(self.decision_y.id, EdgeType.DEPENDS_ON, self.assumption.id) + # Y's dependency is recorded by decide() itself. Adding it afterwards + # made this graph one that failed to load (Y is an explicit-id + # decision), and is now refused. + self.decision_y = _decision( + self.graph, + "Adopt tenant-aware sizing", + self.source, + id="decision:y", + assumptions=[self.assumption.id], + ) def test_reject_kills_the_binding_and_the_dependency_but_not_x_s_endpoints(self): report = self.ledger.reject( From 0b684b10e7a9729d99282ee779735b09261e40af Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 08:56:18 +0000 Subject: [PATCH 7/8] Ship the schema inside the package that reads it package-data "../GRAPH.md" put the schema at the root of the wheel, so 0.1.1 installed it as a loose top-level site-packages/GRAPH.md. The ontology reads it at import time from parent.parent, so any other distribution that shipped or removed a file of that generic name would break `import contextmesh`. GRAPH.md moves to contextmesh/GRAPH.md, package-data names it in place, and ONTOLOGY_FILE reads it from the package directory -- the same path in a checkout and in an install. Verified by building a wheel, an sdist and a wheel from that sdist (each carries contextmesh/GRAPH.md and nothing at the top level), then importing from a clean venv outside the repo. A root GRAPH.md stays as a short pointer so existing links to it still land somewhere; nothing reads it. The README link points at the new path. Co-Authored-By: Claude Opus 5.5 --- GRAPH.md | 328 +--------------------------------------- README.md | 2 +- contextmesh/GRAPH.md | 325 +++++++++++++++++++++++++++++++++++++++ contextmesh/ontology.py | 7 +- pyproject.toml | 2 +- tests/test_ontology.py | 20 +++ 6 files changed, 357 insertions(+), 327 deletions(-) create mode 100644 contextmesh/GRAPH.md diff --git a/GRAPH.md b/GRAPH.md index 1fc58e4..601bf80 100644 --- a/GRAPH.md +++ b/GRAPH.md @@ -1,325 +1,7 @@ -# GRAPH.md — the ontology file +# GRAPH.md has moved -> Read on every write. If a write does not typecheck against this file, it does -> not enter the graph. +The graph contract now lives at [`contextmesh/GRAPH.md`](contextmesh/GRAPH.md). -This is not documentation of the code; it is the schema the code loads. See -`contextmesh/ontology.py`, which parses this file at import time. - -## Reading this file - -**Authority.** This file is authoritative for the node types, the edge types and -their legal pairs, and the rules below. Where an implementation and this file -disagree about any of those, this file is right and the implementation is a bug. - -**What is mechanically enforced.** `ontology.py` parses the `Type` and `Symbol` -columns of the node table, the `Must carry` column of the node table, and the -`Edge`, `Legal pairs` and `Invalidation` columns of the edge table; every write -is checked against them, and `tests/test_ontology.py` asserts that the -`NodeType` and `EdgeType` enums equal what this file declares. `Means` is prose -and is not parsed. The rules below are enforced, or not, one at a time — the -*Declared, not yet realized* section names the ones that currently are not. - -**What `Must carry` means.** It is a *minimum*, not an exhaustive list: a node -may carry more. It is satisfied by a node `attrs` key **or** by a `Node` field of -that name — `provenance` is a field, not an attribute. It requires the value to -be **present**, not to be well-formed: `contextmesh/execute.py` mints sources -with `retrieved_at="at plan time"`, which satisfies the presence requirement -defined here, and the temporal layer separately classifies such a source as -`UNDATED` rather than guessing a date. `ContextGraph.add_node` enforces this at -the write boundary — presence only, nothing more — and refuses a node missing a -required name with `OntologyError`. Every path that constructs a node, -including restoring a persisted snapshot, goes through `add_node`, so this is -one check rather than one per caller. - -**What edge-level assumption binding means.** `Edge.assumption_id` makes one -relationship conditional on one assumption: the edge holds only while that -assumption stands. Rejecting the assumption invalidates the bound edge -directly — `apply_invalidation` marks it regardless of what else falls — but -binding an edge is not a second way to reach a node. `src` and `dst` fall only -if rule 2's propagation reaches them along an edge of a propagating type; a -bound `mentions` or `supports` edge, say, does not pull its endpoints down -just because it was bound. A node that itself needs to fall when an -assumption falls has to say so explicitly with `depends_on` — rule 2 is the -one mechanism for node-to-node invalidation, and edge binding does not open a -second one. `AssumptionLedger.justifies` enforces the binding side of this at -the write boundary: the assumption must exist and be active, the edge must -exist and be live, binding the same pair again is a no-op, and binding an -edge already bound to a *different* assumption is refused rather than -silently replaced. `ContextGraph.add_edge` takes no `assumption_id`, so it -is not a second, unguarded way to bind one; only snapshot restoration sets -the field directly, and it validates what it restores on its own terms. - -**What walk-driven PRUNE means.** Rule 5's second half drops a node only once -an observation window has closed: `Pipeline.prune_unwalked_nodes` takes the -`Walker` that produced the telemetry, and is a no-op below -`MIN_OBSERVATION_WALKS` walks in that window, so one question failing to reach -a node is never mistaken for the corpus having judged it irrelevant. -`Node.walks` — incremented once per node an actual walk visits, in -`Walker.walk` — is the only signal a node's own eligibility is judged on; -degree, inherited unchanged from the build-time half this pass has always -shared it with, is the one other factor, and nothing about a node's label, -age, or type enters the decision beyond `source`, `assumption`, and -`evidence` nodes being preserved outright — an assumption nothing has -questioned yet is not thereby irrelevant, and evidence is what rule 7 -requires an audit be able to find. A node this pass drops cannot be revived -by a later walk: `Walker.seed` will not seed a pruned node and -`out_edges`/`in_edges` will not traverse into one, so its `walks` count is -frozen at whatever it was the moment it fell. A node already not live for a -different reason — invalidated by a rejected assumption — is left alone -rather than separately marked pruned, so this pass never creates a second -state change on top of one that already happened. The demo's export path -(`contextmesh/demo.py`) never opts in, so this pass changes nothing about the -dashboard unless a caller explicitly asks for it. - -**What a node/edge id collision means.** `slug` (`contextmesh/model.py`) truncates -its digest to keep ids short, so it is not injective: two different node -labels, or two different `(src, type, dst)` edge triples, can derive the same -id. `add_node` and `add_edge` are also the merge path for a genuine repeat -observation — the same claim text extracted from a second document, the same -`mentions` edge asserted twice — so a shared id has to be told apart from a -coincidence before it is treated as one. For a node, "the same thing" means -the existing node's `type` and `label` agree with the incoming call; `attrs` -is never part of that comparison, since a repeat observation may legitimately -carry new metadata the first one did not. For an edge, "the same thing" means -the existing edge's `(src, type, dst)` triple — not merely its id — matches. -A collision found either way is refused with `OntologyError` before anything -is written: no node's `label`/`type` is silently overwritten, no edge's -`(src, type, dst)` is silently repointed, and none of the internal indexes -(`edges`, `_edge_key`, `_out`, `_in`) are touched. `from_dict` inherits this -for free, since every node and edge it restores goes through `add_node` and -`add_edge` the same as a live write — but a snapshot's own `nodes`/`edges` -lists are also checked for two rows sharing a literal id before either method -ever runs, since `to_dict` can never produce that either. This is -collision *detection*, not a stronger id scheme: `slug`'s truncation is -unchanged, and a caller that keeps minting distinct content into the same -id space will eventually have a write refused rather than silently lose one. - -**What a decision's identity is.** A `decision` node is an immutable event, -never a content address: two calls to `DecisionLog.decide` with byte-identical -`title` and `rationale` are two decisions, not one being re-observed, because -"we chose this again" and "we never stopped choosing this" are different -facts and only the first is `supersedes`'s job to record. A call made without -an explicit `id` therefore always mints a fresh id, discriminated against -*graph* state rather than `DecisionLog`'s own history — the smallest sequence -number whose derived id is not already a node in this graph — so a repeated -title still cannot collide with its own predecessor after a fresh -`DecisionLog` is constructed over the same graph, which is exactly what a -restored `Runner` does when no `decisions=` is given. An in-memory -discriminator that instead counted this object's own records would forget -every decision minted before it existed and hand out an id that object had -never seen but the graph had — an id collision one caller reuses, not two -callers agreeing. - -An explicit `id` means something narrower: not "this is the decision this -content names" but "this exact call may have already happened — tell me if -it did." The decision node itself carries this call's evidence, in two attrs -that ride along with `rationale`: `decision_identity` (`"auto"` or -`"explicit"`) and `decision_payload_digest`, a fingerprint of the node's -graph-visible content — `title`, `rationale`, the provenance `source_id` -kept as its own field, and the deduplicated, order-independent target sets -of the edges `decide` creates beyond that source (citation, support, -dependency, production, supersession). Keeping `source_id` separate from -the additional-citation set matters: reconstructing both as one combined -set from a node's out-edges cannot tell "the edge that is the provenance -source" from "the edge that is an additional cite to the same target", so -a primary source and an additional cite could otherwise be swapped without -the fingerprint noticing — a different agreed payload producing the same -digest. This is graph state, not a `DecisionLog`-local table, so it -survives a snapshot round-trip and a fresh `DecisionLog` the same way the -id-freshness check does. - -An explicit `id` naming an existing node is an idempotent retry only when -that node is a decision minted with an explicit id of its own — naming an -auto-minted decision, or any non-decision node, is refused, because it is -not this call's event to retry. That check does not stop at reading the -`decision_identity` attr, either: every auto-minted id lands in a reserved -namespace (the `decision:auto:` slug prefix) that no explicit id may ever -use, and `decide` refuses up front to hand out a brand-new explicit id that -collides with it — so membership in that namespace is a permanent fact -about an id string, settled the moment it is minted, never re-derived and -never dependent on anything else in the graph. A decision whose id lands in -that namespace is never accepted as an explicit retry target, even when its -`decision_identity` attr is tampered to say `"explicit"`. This is what -closes the gap a digest check alone cannot: an auto-minted decision's -stored digest is already correct for its real content, so flipping only its -mode label leaves nothing for a digest comparison to disagree with. It is -also why the namespace test cannot be a search bounded by the graph's -current size — an early version of this tried exactly that (recomputing -`slug(f"{title}|{n}", "decision")` for `n` up to the node count), and a -bound that changes as the graph grows is not stable: an explicit id one -write legitimately accepted could fail the identical check moments later, -once that very write (or a snapshot round-trip of it) changed the bound, -with no tampering involved. A plain prefix has no such bound to drift. -Once identity agrees, the retry check compares -the incoming call against the fingerprint of the *existing node's actual -provenance and edges*, not against whatever its attrs claim. The same `id` -with content that produces the same fingerprint is a true no-op that -returns the existing node untouched, and the same `id` with any different -content is refused with `OntologyError` before a single node or edge is -written — the id is not free to start meaning something else partway -through a run, or after a restart. This is an idempotency key, not a -stronger identity scheme: it only protects a caller who reuses one id on -purpose, the same way `add_node`'s type/label check only protects against -`slug`'s truncation, and the two checks answer different questions (one -caller-declared, one derived) that can both apply to the same write. -Because the check reads the node's actual edges, an explicit-id decision's -fingerprinted edges are fixed once `decide` returns: `add_edge` refuses a -new `cites`, `derived_from`, `produces`, `supersedes`, or `depends_on` into -an assumption out of it, since accepting one would make the next load -refuse a correct snapshot and the next identical retry refuse the original -call. (A `depends_on` into another decision is a task ordering, not part of -the decision, and is still added freely.) A time-travel projection that -drops such an edge because its other end came later re-derives the digest -for what it kept, the same way it rewinds assumption state. - -A decision's immutability does not stop at `DecisionLog`, and neither does -the trust boundary around its identity metadata. `add_node` refuses to mint -a brand-new decision node at all — only `DecisionLog.decide` and the -snapshot-restoration path in `from_dict` may create one — so `attrs` like -`decision_identity` and `decision_payload_digest` can never be handed to a -node that did not genuinely come from `decide`; a caller with only the -public API cannot forge a decision that a later retry would mistake for a -prior event. `add_node` also refuses a second call for an *existing* -decision id even when type and label match, unlike a claim or entity, which -legitimately merge new attrs on a repeat observation — a decision has no -such second sighting, so reaching that collision means some other caller is -trying to rewrite a decision's rationale through the generic merge path, and -that is refused rather than silently allowed to succeed. And because a -snapshot is untrusted input regardless of what created it, `from_dict` -independently recomputes every `"explicit"` decision's fingerprint from its -restored provenance/edges and refuses to load a file where the stored digest -disagrees with reality, and separately refuses to load one where an -`"explicit"`-labeled decision's id could have been auto-minted for its own -title — a hand-edited snapshot cannot claim a decision holds content its -actual edges do not support, nor turn a real auto-minted event into an -explicit one by editing a single attr. - -`decide` is atomic under either path: a decision and its edges (citation, -support, dependency, production, supersession) either all land or none do, -rolled back the same way `AssumptionLedger.assume` rolls back a -partially-justified assumption. - -**Code is evidence, not authority.** A behaviour may be written into this file -when the implementation *structurally guarantees* it — when no caller can make -it false without changing the implementation's own contract. A behaviour that is -merely one unconstrained implementation choice among several does not become -normative by having been written first; it requires a decision recorded here. - -## Node types - -| Type | Symbol | Means | Must carry | -|---|---|---|---| -| Entity | `entity` | A resolved real-world thing. One id per thing, forever. | `canonical`, `aliases` | -| Claim | `claim` | A statement lifted from a source. | `provenance` | -| Source | `source` | A document, span, run, or artifact that text came from. | `origin`, `retrieved_at` | -| Decision | `decision` | A choice that was made, with the reasoning attached. | `rationale`, `provenance` | -| Assumption | `assumption` | Something taken as true so work could proceed. Versioned. | `status`, `version` | -| Evidence | `evidence` | An observation that bears on a claim, decision, or assumption. | `kind` | - -## Edge types - -An edge is legal only if the pair `(source type, target type)` appears in its row. - -`Invalidation` is rule 2's propagation direction, machine-readable: `backward` -means the edge is walked from target to source when an assumption falls (the -thing that depends falls when its ground falls), `forward` means source to -target (an artefact falls with the decision that made it), and `none` means -the edge never propagates a fall. `contextmesh/assumptions.py` reads this -column through `ontology.py` rather than restating it, so the two cannot -disagree. - -| Edge | Legal pairs | Invalidation | Means | -|---|---|---|---| -| `mentions` | source→entity, claim→entity | none | The text refers to this resolved entity. | -| `derived_from` | claim→source, decision→claim, entity→source | backward | This exists because that exists. | -| `cites` | decision→source, claim→claim | none | Explicit reference made by the author. | -| `contradicts` | claim→claim, evidence→claim, evidence→assumption | none | Both cannot hold. | -| `supports` | evidence→claim, claim→decision, evidence→decision | none | Raises confidence in the target. | -| `depends_on` | decision→assumption, decision→decision, claim→assumption | backward | Target's failure invalidates the source. | -| `produces` | decision→entity, decision→source | forward | The decision brought the target into being. | -| `supersedes` | decision→decision, assumption→assumption, claim→claim | none | Replaces, without deleting. | -| `justified_by` | decision→evidence, claim→evidence, assumption→evidence | none | The reason this was allowed to stand. | -| `resolves_to` | entity→entity | none | An alias node folding into its canonical id. | - -## Rules - -1. `untyped_edges == 0`. There is no constructor for an edge without a type. -2. Invalidation propagates along three edges and no others, and direction - matters: `depends_on` and `derived_from` are followed *backwards* (the thing - that depends falls when its ground falls), `produces` is followed *forwards* - (an artefact falls with the decision that made it). `mentions`, `cites`, - `supports` and `supersedes` never propagate — that is what makes - invalidation selective rather than a purge. -3. `supersedes` never deletes. Decision history is append-only. -4. Every `claim` and `decision` carries provenance to a `source`. A node without - a path to a source is an orphan and is reported by graph health. -5. A node is walkable only after `PRUNE`. `PRUNE` has two halves: the build-time - pass drops what nothing *linked* (degree zero), and a second pass, - `Pipeline.prune_unwalked_nodes`, drops what an observation window of walks - left untouched — see *What walk-driven PRUNE means*. -6. Re-running invalidated work never revives a `decision` — it appends a new one - that `supersedes` it, so rule 3 holds through re-execution. An `entity` *is* - revived when the decision that produces it runs again, because the artefact - was rebuilt; one the rerun stops producing stays invalidated. -7. An `assumption` is only ever rejected by `evidence` that `contradicts` it. A - caller may not mark one false directly, because "why did this fall over" has - to have an answer inside the graph. -8. A node or edge id names one semantic identity. A derived id that would - collide with a different node or edge is refused, never silently merged - into it — see *What a node/edge id collision means*. -9. A `decision` is never content-addressed. Two decisions with the same - title and rationale are two events, not one; an explicit `id` on - `decide` is an idempotency key for one call, not a way to name a - decision by its content — see *What a decision's identity is*. - -## Declared, not yet realized - -Each of these is declared here or in the type system, is modelled and in some -cases persisted and validated, and has **no production path**. They are recorded -so a reader does not mistake a declaration for a live capability, and so that -deciding their intent is a visible act rather than an accident of first use. -None of them may be relied on until it appears above this line. - -| Declared | State | Missing | -|---|---|---| -| `resolves_to` | In the edge table; in `EdgeType`; weighted `0.3` by the walker | No realized lifecycle today: no production writer creates it, and no node-level justification, contradiction, supersession, support, dependency or citation path is legal for `entity` endpoints. | - -## Open items - -Questions this file does not yet answer. Each is a decision, not a bug; none is -resolved by pointing at current behaviour. - -1. **A decision's processing-time field has no name here.** The `Must carry` - cell for Decision previously read `at_build`, which names nothing on a - decision node: the node carries `Node.build`, while `at_build` lives on - `DecisionRecord`, which is not part of the graph snapshot. The false token is - removed; naming the intended field is open. - -Resolved since the list above was first written, kept here as a record rather -than deleted, since a reader following an old reference should land somewhere -that says what happened to it: - -- **`Symbol` was unverified.** `ontology.py` now reads the column and refuses - to load if it disagrees with `Type`; see *What is mechanically enforced*. -- **Rule 2's propagation set was restated in code.** The edge table's - `Invalidation` column is now that machine-readable form, and - `contextmesh/assumptions.py` reads it through `ontology.py` instead of - restating it. -- **`AssumptionLedger.justifies` propagated further than its wording.** Its - docstring marked an *edge* as standing on an assumption, but rejection - reached through the edge to invalidate `edge.dst` too — a second, - undeclared invalidation path alongside rule 2. Binding an edge no longer - seeds its endpoints into the blast radius; see *What edge-level assumption - binding means*. A node that needs to fall with the assumption still has to - say so with `depends_on`, the same as every other node-to-node case. -- **`Must carry` had no enforcement path.** `ContextGraph.add_node` now - enforces it; see *What `Must carry` means*. -- **Walk-driven `PRUNE` had no caller.** `Pipeline.prune_unwalked_nodes` now - takes the `Walker` that produced the telemetry and runs for real, gated on - an observation window rather than firing on the first walk that happened - not to reach a node; see rule 5 and *What walk-driven PRUNE means*. - -Out of scope for this file today: how entity identity should be asserted, -recorded or retracted. `resolves_to` exists as a declaration only, and the -design that would give it a lifecycle is not settled. +It moved inside the package because it is not only documentation: `contextmesh/ontology.py` +parses it at import time, so it has to ship inside the package that reads it. This file is a +pointer for old links and is not read by anything. diff --git a/README.md b/README.md index 479f43b..c7eedc2 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ A: dead end — entity_unresolved ## The ontology is a file -[`GRAPH.md`](GRAPH.md) is not documentation of the schema. It **is** the schema: +[`GRAPH.md`](contextmesh/GRAPH.md) is not documentation of the schema. It **is** the schema: `contextmesh/ontology.py` parses it at import time, and every write typechecks against it. diff --git a/contextmesh/GRAPH.md b/contextmesh/GRAPH.md new file mode 100644 index 0000000..1fc58e4 --- /dev/null +++ b/contextmesh/GRAPH.md @@ -0,0 +1,325 @@ +# GRAPH.md — the ontology file + +> Read on every write. If a write does not typecheck against this file, it does +> not enter the graph. + +This is not documentation of the code; it is the schema the code loads. See +`contextmesh/ontology.py`, which parses this file at import time. + +## Reading this file + +**Authority.** This file is authoritative for the node types, the edge types and +their legal pairs, and the rules below. Where an implementation and this file +disagree about any of those, this file is right and the implementation is a bug. + +**What is mechanically enforced.** `ontology.py` parses the `Type` and `Symbol` +columns of the node table, the `Must carry` column of the node table, and the +`Edge`, `Legal pairs` and `Invalidation` columns of the edge table; every write +is checked against them, and `tests/test_ontology.py` asserts that the +`NodeType` and `EdgeType` enums equal what this file declares. `Means` is prose +and is not parsed. The rules below are enforced, or not, one at a time — the +*Declared, not yet realized* section names the ones that currently are not. + +**What `Must carry` means.** It is a *minimum*, not an exhaustive list: a node +may carry more. It is satisfied by a node `attrs` key **or** by a `Node` field of +that name — `provenance` is a field, not an attribute. It requires the value to +be **present**, not to be well-formed: `contextmesh/execute.py` mints sources +with `retrieved_at="at plan time"`, which satisfies the presence requirement +defined here, and the temporal layer separately classifies such a source as +`UNDATED` rather than guessing a date. `ContextGraph.add_node` enforces this at +the write boundary — presence only, nothing more — and refuses a node missing a +required name with `OntologyError`. Every path that constructs a node, +including restoring a persisted snapshot, goes through `add_node`, so this is +one check rather than one per caller. + +**What edge-level assumption binding means.** `Edge.assumption_id` makes one +relationship conditional on one assumption: the edge holds only while that +assumption stands. Rejecting the assumption invalidates the bound edge +directly — `apply_invalidation` marks it regardless of what else falls — but +binding an edge is not a second way to reach a node. `src` and `dst` fall only +if rule 2's propagation reaches them along an edge of a propagating type; a +bound `mentions` or `supports` edge, say, does not pull its endpoints down +just because it was bound. A node that itself needs to fall when an +assumption falls has to say so explicitly with `depends_on` — rule 2 is the +one mechanism for node-to-node invalidation, and edge binding does not open a +second one. `AssumptionLedger.justifies` enforces the binding side of this at +the write boundary: the assumption must exist and be active, the edge must +exist and be live, binding the same pair again is a no-op, and binding an +edge already bound to a *different* assumption is refused rather than +silently replaced. `ContextGraph.add_edge` takes no `assumption_id`, so it +is not a second, unguarded way to bind one; only snapshot restoration sets +the field directly, and it validates what it restores on its own terms. + +**What walk-driven PRUNE means.** Rule 5's second half drops a node only once +an observation window has closed: `Pipeline.prune_unwalked_nodes` takes the +`Walker` that produced the telemetry, and is a no-op below +`MIN_OBSERVATION_WALKS` walks in that window, so one question failing to reach +a node is never mistaken for the corpus having judged it irrelevant. +`Node.walks` — incremented once per node an actual walk visits, in +`Walker.walk` — is the only signal a node's own eligibility is judged on; +degree, inherited unchanged from the build-time half this pass has always +shared it with, is the one other factor, and nothing about a node's label, +age, or type enters the decision beyond `source`, `assumption`, and +`evidence` nodes being preserved outright — an assumption nothing has +questioned yet is not thereby irrelevant, and evidence is what rule 7 +requires an audit be able to find. A node this pass drops cannot be revived +by a later walk: `Walker.seed` will not seed a pruned node and +`out_edges`/`in_edges` will not traverse into one, so its `walks` count is +frozen at whatever it was the moment it fell. A node already not live for a +different reason — invalidated by a rejected assumption — is left alone +rather than separately marked pruned, so this pass never creates a second +state change on top of one that already happened. The demo's export path +(`contextmesh/demo.py`) never opts in, so this pass changes nothing about the +dashboard unless a caller explicitly asks for it. + +**What a node/edge id collision means.** `slug` (`contextmesh/model.py`) truncates +its digest to keep ids short, so it is not injective: two different node +labels, or two different `(src, type, dst)` edge triples, can derive the same +id. `add_node` and `add_edge` are also the merge path for a genuine repeat +observation — the same claim text extracted from a second document, the same +`mentions` edge asserted twice — so a shared id has to be told apart from a +coincidence before it is treated as one. For a node, "the same thing" means +the existing node's `type` and `label` agree with the incoming call; `attrs` +is never part of that comparison, since a repeat observation may legitimately +carry new metadata the first one did not. For an edge, "the same thing" means +the existing edge's `(src, type, dst)` triple — not merely its id — matches. +A collision found either way is refused with `OntologyError` before anything +is written: no node's `label`/`type` is silently overwritten, no edge's +`(src, type, dst)` is silently repointed, and none of the internal indexes +(`edges`, `_edge_key`, `_out`, `_in`) are touched. `from_dict` inherits this +for free, since every node and edge it restores goes through `add_node` and +`add_edge` the same as a live write — but a snapshot's own `nodes`/`edges` +lists are also checked for two rows sharing a literal id before either method +ever runs, since `to_dict` can never produce that either. This is +collision *detection*, not a stronger id scheme: `slug`'s truncation is +unchanged, and a caller that keeps minting distinct content into the same +id space will eventually have a write refused rather than silently lose one. + +**What a decision's identity is.** A `decision` node is an immutable event, +never a content address: two calls to `DecisionLog.decide` with byte-identical +`title` and `rationale` are two decisions, not one being re-observed, because +"we chose this again" and "we never stopped choosing this" are different +facts and only the first is `supersedes`'s job to record. A call made without +an explicit `id` therefore always mints a fresh id, discriminated against +*graph* state rather than `DecisionLog`'s own history — the smallest sequence +number whose derived id is not already a node in this graph — so a repeated +title still cannot collide with its own predecessor after a fresh +`DecisionLog` is constructed over the same graph, which is exactly what a +restored `Runner` does when no `decisions=` is given. An in-memory +discriminator that instead counted this object's own records would forget +every decision minted before it existed and hand out an id that object had +never seen but the graph had — an id collision one caller reuses, not two +callers agreeing. + +An explicit `id` means something narrower: not "this is the decision this +content names" but "this exact call may have already happened — tell me if +it did." The decision node itself carries this call's evidence, in two attrs +that ride along with `rationale`: `decision_identity` (`"auto"` or +`"explicit"`) and `decision_payload_digest`, a fingerprint of the node's +graph-visible content — `title`, `rationale`, the provenance `source_id` +kept as its own field, and the deduplicated, order-independent target sets +of the edges `decide` creates beyond that source (citation, support, +dependency, production, supersession). Keeping `source_id` separate from +the additional-citation set matters: reconstructing both as one combined +set from a node's out-edges cannot tell "the edge that is the provenance +source" from "the edge that is an additional cite to the same target", so +a primary source and an additional cite could otherwise be swapped without +the fingerprint noticing — a different agreed payload producing the same +digest. This is graph state, not a `DecisionLog`-local table, so it +survives a snapshot round-trip and a fresh `DecisionLog` the same way the +id-freshness check does. + +An explicit `id` naming an existing node is an idempotent retry only when +that node is a decision minted with an explicit id of its own — naming an +auto-minted decision, or any non-decision node, is refused, because it is +not this call's event to retry. That check does not stop at reading the +`decision_identity` attr, either: every auto-minted id lands in a reserved +namespace (the `decision:auto:` slug prefix) that no explicit id may ever +use, and `decide` refuses up front to hand out a brand-new explicit id that +collides with it — so membership in that namespace is a permanent fact +about an id string, settled the moment it is minted, never re-derived and +never dependent on anything else in the graph. A decision whose id lands in +that namespace is never accepted as an explicit retry target, even when its +`decision_identity` attr is tampered to say `"explicit"`. This is what +closes the gap a digest check alone cannot: an auto-minted decision's +stored digest is already correct for its real content, so flipping only its +mode label leaves nothing for a digest comparison to disagree with. It is +also why the namespace test cannot be a search bounded by the graph's +current size — an early version of this tried exactly that (recomputing +`slug(f"{title}|{n}", "decision")` for `n` up to the node count), and a +bound that changes as the graph grows is not stable: an explicit id one +write legitimately accepted could fail the identical check moments later, +once that very write (or a snapshot round-trip of it) changed the bound, +with no tampering involved. A plain prefix has no such bound to drift. +Once identity agrees, the retry check compares +the incoming call against the fingerprint of the *existing node's actual +provenance and edges*, not against whatever its attrs claim. The same `id` +with content that produces the same fingerprint is a true no-op that +returns the existing node untouched, and the same `id` with any different +content is refused with `OntologyError` before a single node or edge is +written — the id is not free to start meaning something else partway +through a run, or after a restart. This is an idempotency key, not a +stronger identity scheme: it only protects a caller who reuses one id on +purpose, the same way `add_node`'s type/label check only protects against +`slug`'s truncation, and the two checks answer different questions (one +caller-declared, one derived) that can both apply to the same write. +Because the check reads the node's actual edges, an explicit-id decision's +fingerprinted edges are fixed once `decide` returns: `add_edge` refuses a +new `cites`, `derived_from`, `produces`, `supersedes`, or `depends_on` into +an assumption out of it, since accepting one would make the next load +refuse a correct snapshot and the next identical retry refuse the original +call. (A `depends_on` into another decision is a task ordering, not part of +the decision, and is still added freely.) A time-travel projection that +drops such an edge because its other end came later re-derives the digest +for what it kept, the same way it rewinds assumption state. + +A decision's immutability does not stop at `DecisionLog`, and neither does +the trust boundary around its identity metadata. `add_node` refuses to mint +a brand-new decision node at all — only `DecisionLog.decide` and the +snapshot-restoration path in `from_dict` may create one — so `attrs` like +`decision_identity` and `decision_payload_digest` can never be handed to a +node that did not genuinely come from `decide`; a caller with only the +public API cannot forge a decision that a later retry would mistake for a +prior event. `add_node` also refuses a second call for an *existing* +decision id even when type and label match, unlike a claim or entity, which +legitimately merge new attrs on a repeat observation — a decision has no +such second sighting, so reaching that collision means some other caller is +trying to rewrite a decision's rationale through the generic merge path, and +that is refused rather than silently allowed to succeed. And because a +snapshot is untrusted input regardless of what created it, `from_dict` +independently recomputes every `"explicit"` decision's fingerprint from its +restored provenance/edges and refuses to load a file where the stored digest +disagrees with reality, and separately refuses to load one where an +`"explicit"`-labeled decision's id could have been auto-minted for its own +title — a hand-edited snapshot cannot claim a decision holds content its +actual edges do not support, nor turn a real auto-minted event into an +explicit one by editing a single attr. + +`decide` is atomic under either path: a decision and its edges (citation, +support, dependency, production, supersession) either all land or none do, +rolled back the same way `AssumptionLedger.assume` rolls back a +partially-justified assumption. + +**Code is evidence, not authority.** A behaviour may be written into this file +when the implementation *structurally guarantees* it — when no caller can make +it false without changing the implementation's own contract. A behaviour that is +merely one unconstrained implementation choice among several does not become +normative by having been written first; it requires a decision recorded here. + +## Node types + +| Type | Symbol | Means | Must carry | +|---|---|---|---| +| Entity | `entity` | A resolved real-world thing. One id per thing, forever. | `canonical`, `aliases` | +| Claim | `claim` | A statement lifted from a source. | `provenance` | +| Source | `source` | A document, span, run, or artifact that text came from. | `origin`, `retrieved_at` | +| Decision | `decision` | A choice that was made, with the reasoning attached. | `rationale`, `provenance` | +| Assumption | `assumption` | Something taken as true so work could proceed. Versioned. | `status`, `version` | +| Evidence | `evidence` | An observation that bears on a claim, decision, or assumption. | `kind` | + +## Edge types + +An edge is legal only if the pair `(source type, target type)` appears in its row. + +`Invalidation` is rule 2's propagation direction, machine-readable: `backward` +means the edge is walked from target to source when an assumption falls (the +thing that depends falls when its ground falls), `forward` means source to +target (an artefact falls with the decision that made it), and `none` means +the edge never propagates a fall. `contextmesh/assumptions.py` reads this +column through `ontology.py` rather than restating it, so the two cannot +disagree. + +| Edge | Legal pairs | Invalidation | Means | +|---|---|---|---| +| `mentions` | source→entity, claim→entity | none | The text refers to this resolved entity. | +| `derived_from` | claim→source, decision→claim, entity→source | backward | This exists because that exists. | +| `cites` | decision→source, claim→claim | none | Explicit reference made by the author. | +| `contradicts` | claim→claim, evidence→claim, evidence→assumption | none | Both cannot hold. | +| `supports` | evidence→claim, claim→decision, evidence→decision | none | Raises confidence in the target. | +| `depends_on` | decision→assumption, decision→decision, claim→assumption | backward | Target's failure invalidates the source. | +| `produces` | decision→entity, decision→source | forward | The decision brought the target into being. | +| `supersedes` | decision→decision, assumption→assumption, claim→claim | none | Replaces, without deleting. | +| `justified_by` | decision→evidence, claim→evidence, assumption→evidence | none | The reason this was allowed to stand. | +| `resolves_to` | entity→entity | none | An alias node folding into its canonical id. | + +## Rules + +1. `untyped_edges == 0`. There is no constructor for an edge without a type. +2. Invalidation propagates along three edges and no others, and direction + matters: `depends_on` and `derived_from` are followed *backwards* (the thing + that depends falls when its ground falls), `produces` is followed *forwards* + (an artefact falls with the decision that made it). `mentions`, `cites`, + `supports` and `supersedes` never propagate — that is what makes + invalidation selective rather than a purge. +3. `supersedes` never deletes. Decision history is append-only. +4. Every `claim` and `decision` carries provenance to a `source`. A node without + a path to a source is an orphan and is reported by graph health. +5. A node is walkable only after `PRUNE`. `PRUNE` has two halves: the build-time + pass drops what nothing *linked* (degree zero), and a second pass, + `Pipeline.prune_unwalked_nodes`, drops what an observation window of walks + left untouched — see *What walk-driven PRUNE means*. +6. Re-running invalidated work never revives a `decision` — it appends a new one + that `supersedes` it, so rule 3 holds through re-execution. An `entity` *is* + revived when the decision that produces it runs again, because the artefact + was rebuilt; one the rerun stops producing stays invalidated. +7. An `assumption` is only ever rejected by `evidence` that `contradicts` it. A + caller may not mark one false directly, because "why did this fall over" has + to have an answer inside the graph. +8. A node or edge id names one semantic identity. A derived id that would + collide with a different node or edge is refused, never silently merged + into it — see *What a node/edge id collision means*. +9. A `decision` is never content-addressed. Two decisions with the same + title and rationale are two events, not one; an explicit `id` on + `decide` is an idempotency key for one call, not a way to name a + decision by its content — see *What a decision's identity is*. + +## Declared, not yet realized + +Each of these is declared here or in the type system, is modelled and in some +cases persisted and validated, and has **no production path**. They are recorded +so a reader does not mistake a declaration for a live capability, and so that +deciding their intent is a visible act rather than an accident of first use. +None of them may be relied on until it appears above this line. + +| Declared | State | Missing | +|---|---|---| +| `resolves_to` | In the edge table; in `EdgeType`; weighted `0.3` by the walker | No realized lifecycle today: no production writer creates it, and no node-level justification, contradiction, supersession, support, dependency or citation path is legal for `entity` endpoints. | + +## Open items + +Questions this file does not yet answer. Each is a decision, not a bug; none is +resolved by pointing at current behaviour. + +1. **A decision's processing-time field has no name here.** The `Must carry` + cell for Decision previously read `at_build`, which names nothing on a + decision node: the node carries `Node.build`, while `at_build` lives on + `DecisionRecord`, which is not part of the graph snapshot. The false token is + removed; naming the intended field is open. + +Resolved since the list above was first written, kept here as a record rather +than deleted, since a reader following an old reference should land somewhere +that says what happened to it: + +- **`Symbol` was unverified.** `ontology.py` now reads the column and refuses + to load if it disagrees with `Type`; see *What is mechanically enforced*. +- **Rule 2's propagation set was restated in code.** The edge table's + `Invalidation` column is now that machine-readable form, and + `contextmesh/assumptions.py` reads it through `ontology.py` instead of + restating it. +- **`AssumptionLedger.justifies` propagated further than its wording.** Its + docstring marked an *edge* as standing on an assumption, but rejection + reached through the edge to invalidate `edge.dst` too — a second, + undeclared invalidation path alongside rule 2. Binding an edge no longer + seeds its endpoints into the blast radius; see *What edge-level assumption + binding means*. A node that needs to fall with the assumption still has to + say so with `depends_on`, the same as every other node-to-node case. +- **`Must carry` had no enforcement path.** `ContextGraph.add_node` now + enforces it; see *What `Must carry` means*. +- **Walk-driven `PRUNE` had no caller.** `Pipeline.prune_unwalked_nodes` now + takes the `Walker` that produced the telemetry and runs for real, gated on + an observation window rather than firing on the first walk that happened + not to reach a node; see rule 5 and *What walk-driven PRUNE means*. + +Out of scope for this file today: how entity identity should be asserted, +recorded or retracted. `resolves_to` exists as a declaration only, and the +design that would give it a lifecycle is not settled. diff --git a/contextmesh/ontology.py b/contextmesh/ontology.py index 1d3ff16..0222b8e 100644 --- a/contextmesh/ontology.py +++ b/contextmesh/ontology.py @@ -1,6 +1,6 @@ """The ontology is a file, not a constant. -``GRAPH.md`` at the repository root is the schema. This module parses it on +``GRAPH.md``, shipped inside this package, is the schema. This module parses it on import so that a change to the documented edge table is immediately a change to what the engine will accept. That is the whole point of the "read on every write" line in the dashboard: the ontology cannot drift away from the docs @@ -14,7 +14,10 @@ from pathlib import Path from typing import Dict, FrozenSet, Iterable, Tuple -ONTOLOGY_FILE = Path(__file__).resolve().parent.parent / "GRAPH.md" +# Inside the package, not beside it: the wheel used to install this as a +# loose top-level site-packages/GRAPH.md, where any other distribution that +# shipped or removed a file of that name would break `import contextmesh`. +ONTOLOGY_FILE = Path(__file__).resolve().parent / "GRAPH.md" _ROW = re.compile(r"^\|(?P.+)\|\s*$") _PAIR = re.compile(r"(?P[a-z_]+)\s*(?:→|->)\s*(?P[a-z_]+)") diff --git a/pyproject.toml b/pyproject.toml index 1b613d5..d869087 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ contextmesh-mcp = "contextmesh_mcp.server:main" packages = ["contextmesh", "contextmesh_mcp"] [tool.setuptools.package-data] -contextmesh = ["../GRAPH.md"] +contextmesh = ["GRAPH.md"] [tool.ruff] target-version = "py39" diff --git a/tests/test_ontology.py b/tests/test_ontology.py index f0f6b75..2d540ef 100644 --- a/tests/test_ontology.py +++ b/tests/test_ontology.py @@ -287,5 +287,25 @@ def test_empty_graph_is_truthy(self): self.assertTrue(ContextGraph()) +class SchemaShipsInsideThePackageTest(unittest.TestCase): + """0.1.1 installed the schema as a loose top-level site-packages/GRAPH.md + (package-data "../GRAPH.md"), where another distribution shipping or + removing a file of that name would break `import contextmesh`.""" + + def test_the_schema_is_read_from_inside_the_package(self): + import contextmesh + + package = Path(contextmesh.__file__).resolve().parent + self.assertEqual(Path(ONTOLOGY_FILE).parent, package) + self.assertTrue(Path(ONTOLOGY_FILE).is_file()) + + def test_package_data_does_not_reach_outside_the_package(self): + pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml" + text = pyproject.read_text(encoding="utf-8") + section = text.split("[tool.setuptools.package-data]", 1)[1].split("\n[", 1)[0] + self.assertNotIn("..", section) + self.assertIn('"GRAPH.md"', section) + + if __name__ == "__main__": unittest.main() From a141d08d151181a9152db27c98e8ba2acf8e2e45 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 08:59:45 +0000 Subject: [PATCH 8/8] Publish only the artifact that was tested, from main, with pinned actions publish.yml rebuilt from the tag with unpinned build/twine, ran twine check and a filename check, and uploaded. Nothing installed or ran what it uploaded, nothing checked the tag was on main, and the release- candidate gate only ever tests its own build on pull requests. 0.1.1 shipped two defects a single install would have shown: it reported version 0.1.0, and it put the schema loose in site-packages. The workflow now builds once and gates publication on two jobs: - test: the full suite at the tagged commit. - verify (Linux and Windows): installs the exact uploaded wheel into a fresh venv, with no checkout so the source tree cannot mask a packaging mistake, and asserts contextmesh/contextmesh_mcp report the tag's version, the schema is inside the package with nothing loose in site-packages, and the core install has no MCP SDK; runs the CLI and a session across two processes; installs the same wheel with [mcp] and runs the launcher; and installs the sdist and checks it the same way. publish needs build, test and verify, and uploads the same files verify installed. The build job also refuses a tag whose commit is not on main. build and twine are pinned (1.6.1, 7.0.0). Every action is pinned to a full commit SHA, with the release in a comment. The publish job holds the id-token PyPI Trusted Publishing accepts; pypa/gh-action-pypi-publish@release/v1 was a branch and download-artifact@v4 a movable tag. Pins stay on the majors in use (v1.14.2 is where release/v1 points today, so nothing it runs changes). Checked locally: the verify steps, run from this file against a wheel and sdist of this branch, pass; run against the published 0.1.1 wheel, they fail on its version and, separately, on its loose schema. The main-ancestry step passes the v0.1.1 commit and blocks an unmerged one. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/publish.yml | 151 ++++++++++++++++++++++++++++++++-- 1 file changed, 144 insertions(+), 7 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index afdbd13..b56c5ff 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -7,14 +7,25 @@ on: permissions: contents: read +# Every action is pinned to a full commit SHA, with the release it came from +# in the comment. A tag or branch ref can be moved by whoever controls the +# upstream repository; in the publish job that would run their code with an +# OIDC token PyPI accepts for contextmesh-graph. +# +# The artifact is built once. The files the verify job installs and exercises +# are the same files the publish job uploads: nothing is rebuilt after it has +# been tested. + jobs: build: name: Build release distributions runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + fetch-depth: 0 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.12" @@ -36,10 +47,21 @@ jobs: print(f"release identity: {project['name']} {project['version']} from {actual_tag}") PY + - name: Verify the tagged commit is on main + # A release can be cut from a tag on any commit. Only main has been + # through review and the required checks. + run: | + git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then + echo "::error::$GITHUB_REF_NAME points at $GITHUB_SHA, which is not on main" + exit 1 + fi + echo "$GITHUB_REF_NAME ($GITHUB_SHA) is on main" + - name: Build and check wheel and sdist run: | python -m pip install --upgrade pip - python -m pip install build twine + python -m pip install build==1.6.1 twine==7.0.0 python -m build python -m twine check dist/* python - <<'PY' @@ -58,15 +80,130 @@ jobs: PY - name: Upload release distributions - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: pypi-dist path: dist/ if-no-files-found: error + test: + name: Test suite at the tagged commit + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + + - name: Run the full suite + run: python -m unittest discover -s tests + + verify: + name: Exercise the built distributions (${{ matrix.os }}) + needs: build + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + # No checkout: everything below imports the installed distribution, so a + # packaging mistake cannot be hidden by the source tree on sys.path. + steps: + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + + - name: Retrieve release distributions + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: pypi-dist + path: dist + + - name: Install the wheel, core only + shell: bash + run: | + python -m venv core + if [ -x core/bin/python ]; then echo "PY_CORE=$PWD/core/bin/python" >> "$GITHUB_ENV"; else echo "PY_CORE=$PWD/core/Scripts/python.exe" >> "$GITHUB_ENV"; fi + python - <<'PY' + from pathlib import Path + import subprocess, sys + wheel = next(Path("dist").glob("*.whl")) + py = str(next(p for p in (Path("core/bin/python"), Path("core/Scripts/python.exe")) if p.exists())) + subprocess.check_call([py, "-m", "pip", "install", str(wheel)]) + PY + + - name: The installed package is the release, laid out as a package + shell: bash + run: | + "$PY_CORE" - <<'PY' + import importlib.util, os, sysconfig + from pathlib import Path + import contextmesh, contextmesh_mcp + from contextmesh.ontology import ONTOLOGY_FILE + + version = os.environ["GITHUB_REF_NAME"].removeprefix("v") + assert contextmesh.__version__ == version, (contextmesh.__version__, version) + assert contextmesh_mcp.__version__ == version, (contextmesh_mcp.__version__, version) + + package = Path(contextmesh.__file__).resolve().parent + assert Path(ONTOLOGY_FILE).resolve().parent == package, ONTOLOGY_FILE + assert Path(ONTOLOGY_FILE).is_file(), ONTOLOGY_FILE + loose = Path(sysconfig.get_paths()["purelib"]) / "GRAPH.md" + assert not loose.exists(), f"schema installed loose at {loose}" + + assert importlib.util.find_spec("mcp") is None, "core install pulled in the MCP SDK" + print(f"contextmesh {version}: schema at {ONTOLOGY_FILE}") + PY + + - name: Exercise the installed CLI and a session across processes + shell: bash + run: | + BIN="$(dirname "$PY_CORE")" + "$BIN/contextmesh" --help > /dev/null + "$PY_CORE" -c "from contextmesh_mcp.session import Session; Session.build(rounds=1).save('restart-session')" + "$PY_CORE" -c "from contextmesh_mcp.session import Session; s=Session.load('restart-session'); assert s.generation == 1 and s.describe()['persistent']; print('restart ok')" + + - name: Install the wheel with the MCP extra and exercise the launcher + shell: bash + run: | + python -m venv withmcp + python - <<'PY' + from pathlib import Path + import subprocess + wheel = next(Path("dist").glob("*.whl")).resolve() + py = str(next(p for p in (Path("withmcp/bin/python"), Path("withmcp/Scripts/python.exe")) if p.exists())) + subprocess.check_call([py, "-m", "pip", "install", f"contextmesh-graph[mcp] @ {wheel.as_uri()}"]) + PY + if [ -d withmcp/bin ]; then BIN=withmcp/bin; else BIN=withmcp/Scripts; fi + "$BIN/contextmesh-mcp" --help > /dev/null + "$BIN/contextmesh-mcp" --demo --rounds 1 --save mcp-session > /dev/null + "$BIN/contextmesh-mcp" --session mcp-session --save restored-session > /dev/null + echo "mcp extra ok" + + - name: The sdist builds and installs the same package + shell: bash + run: | + python -m venv fromsdist + python - <<'PY' + from pathlib import Path + import subprocess + sdist = next(Path("dist").glob("*.tar.gz")) + py = str(next(p for p in (Path("fromsdist/bin/python"), Path("fromsdist/Scripts/python.exe")) if p.exists())) + subprocess.check_call([py, "-m", "pip", "install", str(sdist)]) + code = ( + "import os, contextmesh; from pathlib import Path; " + "from contextmesh.ontology import ONTOLOGY_FILE; " + "assert contextmesh.__version__ == os.environ['GITHUB_REF_NAME'].removeprefix('v'); " + "assert Path(ONTOLOGY_FILE).resolve().parent == Path(contextmesh.__file__).resolve().parent; " + "print('sdist ok')" + ) + subprocess.check_call([py, "-c", code]) + PY + publish: name: Publish release to PyPI - needs: build + needs: [build, test, verify] runs-on: ubuntu-latest environment: name: pypi @@ -76,10 +213,10 @@ jobs: id-token: write steps: - name: Retrieve release distributions - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: pypi-dist path: dist/ - name: Publish release distributions to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2