fix(lease): refuse an incomplete law binding instead of silently shrinking it
build_construction skipped any normative source it could not read
(`except OSError: continue`) and promoted whatever remained. That is not a
degraded binding, it is a forged smaller one: the broker recomputes h_source /
h_payload from the fragments it is SENT (daemon.py:602-616), so an omitted
fragment is internally consistent and PAYLOAD_BINDING_MISMATCH cannot fire. A
partial law promotes exactly like a complete one and nothing downstream can tell
the difference.
Measured before this change, against a seeded home: with only USER.md readable,
the client produced a one-fragment construction with promotion=True. Removing
CONSTITUTION.md, STANDARDS.md or the runtime contract likewise promoted.
The classification mirrors the framework's own file ownership rather than
inventing one:
* CONSTITUTION.md / AGENTS.md / STANDARDS.md are framework-owned and
reconciled every upgrade (install.sh FRAMEWORK_OWNED,
config/file-adapter.ts FRAMEWORK_OWNED_FILES), as is the per-runtime
RUNTIME.md. Absent => IncompleteBinding. A deployment missing one is broken,
not minimal.
* SOUL.md / USER.md are deliberately not seeded by install.sh ("generated by
`mosaic init`") and TOOLS.md is seeded on first install only, so their
absence is legitimate. It is reported on stderr, never silent.
Unreadable is handled separately from absent for EVERY source, optional ones
included: a file that will not open is not a file that was never configured, and
collapsing the two is what let a permission change quietly shrink the law.
Also corrects this module's own docstring, which asserted that a VERIFIED lease
means "this agent is running THIS law". It does not. Both sides of the broker's
comparison originate in this client, so it detects corruption in transit and
nothing else. That overstatement is where the belief spread from; the stronger
claim needs the broker re-reading on-disk sources against a manifest the agent
cannot rewrite.
Test: promotion_binding_unittest.py, enumerated in test:framework-shell (the
enumeration guard's population is *test*.sh and does not cover Python, so an
unenumerated test here would simply never run). Falsifier executed: defeating the
guard while leaving the module API intact turns the suite red (12 failures);
restoring it returns green.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EHYXhcCQsL3J1Lnm7EraGq
This commit is contained in:
co-authored by
Claude Opus 5
parent
f1761c91be
commit
e949fa3767
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env python3
|
||||
"""The promotion client must never build a binding narrower than it claims.
|
||||
|
||||
RED-first against a real defect: ``build_construction`` skipped any normative
|
||||
source it could not read (``except OSError: continue``) and promoted whatever
|
||||
remained. That is not a degraded binding, it is a forged smaller one — the
|
||||
broker recomputes ``h_source`` / ``h_payload`` from the fragments it is *sent*
|
||||
(``daemon.py:602-616``), so an omitted fragment is internally consistent and
|
||||
``PAYLOAD_BINDING_MISMATCH`` cannot fire. Measured before the fix: with only
|
||||
``USER.md`` readable (964 bytes on the live host), the client produced a
|
||||
one-fragment construction with ``promotion=True``.
|
||||
|
||||
The classification under test mirrors the framework's own file ownership, and
|
||||
must keep mirroring it:
|
||||
|
||||
* framework-owned, reconciled every upgrade (``install.sh`` FRAMEWORK_OWNED /
|
||||
``config/file-adapter.ts`` FRAMEWORK_OWNED_FILES) plus the per-runtime
|
||||
contract — absence is a broken deployment, so it is REFUSED;
|
||||
* ``SOUL.md`` / ``USER.md`` — install.sh deliberately does not seed them
|
||||
("generated by `mosaic init`"), so absence is legitimate and ALLOWED.
|
||||
|
||||
Unreadable is treated separately from absent for *every* source, optional ones
|
||||
included: a file that will not open is not a file that was never configured, and
|
||||
collapsing the two is what let a permission change quietly shrink the law.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
TOOLS = Path(__file__).parents[2] / "framework/tools/lease-broker"
|
||||
sys.path.insert(0, str(TOOLS))
|
||||
|
||||
import lease_promote # noqa: E402
|
||||
|
||||
RUNTIME = "pi"
|
||||
RUNTIME_CONTRACT = f"runtime/{RUNTIME}/RUNTIME.md"
|
||||
ALL_SOURCES = (*lease_promote.FRAGMENT_SOURCES, RUNTIME_CONTRACT)
|
||||
REQUIRED = frozenset(lease_promote.REQUIRED_SOURCES) | {RUNTIME_CONTRACT}
|
||||
# Derived, never listed: a hand-kept second copy is exactly the drift this file
|
||||
# exists to catch.
|
||||
OPTIONAL = tuple(s for s in ALL_SOURCES if s not in REQUIRED)
|
||||
|
||||
|
||||
class PromotionBindingTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._previous_home = os.environ.get("MOSAIC_HOME")
|
||||
self._temporary = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self._temporary.name)
|
||||
for source_id in ALL_SOURCES:
|
||||
path = self.root / source_id
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(f"# {source_id}\nnormative bytes\n".encode())
|
||||
os.environ["MOSAIC_HOME"] = str(self.root)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
for path in self.root.rglob("*"):
|
||||
if path.is_file():
|
||||
path.chmod(0o644)
|
||||
self._temporary.cleanup()
|
||||
if self._previous_home is None:
|
||||
os.environ.pop("MOSAIC_HOME", None)
|
||||
else:
|
||||
os.environ["MOSAIC_HOME"] = self._previous_home
|
||||
|
||||
def reset_home(self) -> None:
|
||||
"""Discard the current home and seed a fresh complete one.
|
||||
|
||||
Each subTest mutates the tree destructively, so it needs a clean start —
|
||||
and the old one must be released, not orphaned.
|
||||
"""
|
||||
self.tearDown()
|
||||
self.setUp()
|
||||
|
||||
def build(self):
|
||||
return lease_promote.build_construction(RUNTIME)
|
||||
|
||||
def source_ids(self) -> list[str]:
|
||||
construction, _ = self.build()
|
||||
return [f["source_id"] for f in construction["fragments"]]
|
||||
|
||||
# --- the binding is complete when the deployment is complete -------------
|
||||
|
||||
def test_complete_deployment_binds_every_source(self) -> None:
|
||||
construction, result = self.build()
|
||||
self.assertEqual([f["source_id"] for f in construction["fragments"]], list(ALL_SOURCES))
|
||||
self.assertTrue(result.promotion)
|
||||
|
||||
# --- absence: refused for framework-owned, allowed for operator-owned ----
|
||||
|
||||
def test_absent_required_source_is_refused(self) -> None:
|
||||
for source_id in sorted(REQUIRED):
|
||||
with self.subTest(source=source_id):
|
||||
self.reset_home()
|
||||
(self.root / source_id).unlink()
|
||||
with self.assertRaises(lease_promote.IncompleteBinding) as caught:
|
||||
self.build()
|
||||
self.assertIn(source_id, str(caught.exception))
|
||||
|
||||
def test_absent_operator_source_still_binds_the_rest(self) -> None:
|
||||
for source_id in OPTIONAL:
|
||||
with self.subTest(source=source_id):
|
||||
self.reset_home()
|
||||
(self.root / source_id).unlink()
|
||||
bound = self.source_ids()
|
||||
self.assertNotIn(source_id, bound)
|
||||
for required in lease_promote.REQUIRED_SOURCES:
|
||||
self.assertIn(required, bound)
|
||||
|
||||
# --- unreadable is never the same as absent -----------------------------
|
||||
|
||||
def test_unreadable_source_is_refused_even_when_optional(self) -> None:
|
||||
for source_id in ALL_SOURCES:
|
||||
with self.subTest(source=source_id):
|
||||
self.reset_home()
|
||||
(self.root / source_id).chmod(0o000)
|
||||
with self.assertRaises(lease_promote.IncompleteBinding) as caught:
|
||||
self.build()
|
||||
self.assertIn(source_id, str(caught.exception))
|
||||
|
||||
# --- the exact measured regression --------------------------------------
|
||||
|
||||
def test_single_readable_source_cannot_promote(self) -> None:
|
||||
"""The observed failure: only USER.md readable produced a valid binding."""
|
||||
for source_id in ALL_SOURCES:
|
||||
if source_id != "USER.md":
|
||||
(self.root / source_id).chmod(0o000)
|
||||
with self.assertRaises(lease_promote.IncompleteBinding):
|
||||
self.build()
|
||||
|
||||
def test_no_source_readable_cannot_promote(self) -> None:
|
||||
for source_id in ALL_SOURCES:
|
||||
(self.root / source_id).chmod(0o000)
|
||||
with self.assertRaises(lease_promote.IncompleteBinding):
|
||||
self.build()
|
||||
|
||||
# --- the classification must not drift from the framework's -------------
|
||||
|
||||
def test_required_set_excludes_only_the_unseeded_sources(self) -> None:
|
||||
"""`install.sh` decides which files exist; this list must follow it.
|
||||
|
||||
If a source moves between framework-owned and operator-generated
|
||||
upstream, this fails and forces the classification to be re-read rather
|
||||
than silently inherited.
|
||||
"""
|
||||
self.assertEqual(
|
||||
set(lease_promote.REQUIRED_SOURCES),
|
||||
{"CONSTITUTION.md", "AGENTS.md", "STANDARDS.md"},
|
||||
"REQUIRED_SOURCES changed — re-read install.sh FRAMEWORK_OWNED and "
|
||||
"config/file-adapter.ts FRAMEWORK_OWNED_FILES before accepting it",
|
||||
)
|
||||
self.assertTrue(
|
||||
set(lease_promote.REQUIRED_SOURCES) <= set(lease_promote.FRAGMENT_SOURCES),
|
||||
"a required source is not in the binding order",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user