From f9505df5ec859891fab1b846cf576563732ffb7e Mon Sep 17 00:00:00 2001 From: wchwawa Date: Thu, 24 Sep 2026 12:41:17 +1000 Subject: [PATCH] test(coordination): pin the aliased-root contract across both readers and the ladder #4896 fixed #4892: the journal's top-level root digest may be either spelling of the physical root, the binding, every outbox entry and every receipt carry exactly the digest the journal holds, and the Python guard now enforces the same state-to-binding equality as the TypeScript decoder. This adds the coverage the review of the superseded #4898 asked for and the ladder-level regression that first exposed the defect: - a cross-language negative pair: a journal whose top-level digest and binding digest are different spellings of the same physical root, in both directions, is rejected by the Python guard and by the TypeScript `readShadowManagementState` with `shadow_management_state_invalid`, and the journal bytes are untouched; an agreeing journal is accepted by both as the positive control. The test fails against the superseded #4898 head, whose guard accepted either spelling for the binding independently. - the `s2c2.outbox_prepared_then_committed_entries` ladder row run with a ladder root reached through a symlink, which is how macOS's default temp directory exposed the defect and which no unit test reproduces (pytest's `tmp_path` is already resolved). - `runtime_root_digest`'s docstring, which still claimed to match the TypeScript writer and that entries carry it; since #4896 it is diagnostic only. No production behaviour changes. Related to #4892, follows #4896. Signed-off-by: wchwawa --- .../local_authority_shadow_outbox.py | 9 +- tests/control_plane/test_shadow_management.py | 104 ++++++++++++++++++ .../test_shared_goal_authority_e2e.py | 18 +++ 3 files changed, 127 insertions(+), 4 deletions(-) diff --git a/loopx/control_plane/coordination/local_authority_shadow_outbox.py b/loopx/control_plane/coordination/local_authority_shadow_outbox.py index e0e3c6413e..b6f070de66 100644 --- a/loopx/control_plane/coordination/local_authority_shadow_outbox.py +++ b/loopx/control_plane/coordination/local_authority_shadow_outbox.py @@ -634,11 +634,12 @@ def next_seq( def runtime_root_digest(runtime_root: Path) -> str: - """Digest of the absolute, dot-normalized root; must match the TypeScript writer. + """Digest of the absolute, dot-normalized root as this process spells it. - Symlinks are deliberately not resolved: both runtimes normalize the string - they were given, so a root passed through the effect runtime hashes the - same on either side. + Diagnostic only (drain evidence). Outbox entries and receipts carry the + active binding's ``source_root_digest`` instead, which the TypeScript owner + derives from the resolved root, so a root reached through a symlink hashes + differently from this lexical spelling (#4892). """ return text_digest(os.path.abspath(str(runtime_root))) diff --git a/tests/control_plane/test_shadow_management.py b/tests/control_plane/test_shadow_management.py index 458f9c578d..ef71f6dceb 100644 --- a/tests/control_plane/test_shadow_management.py +++ b/tests/control_plane/test_shadow_management.py @@ -2,12 +2,17 @@ import json import hashlib +import os from pathlib import Path import subprocess import pytest +from loopx.control_plane.coordination.coordination_state_contract_generated import ( + SHADOW_MANAGEMENT_STATE_SCHEMA, +) from loopx.control_plane.coordination.shadow_management import ( + SHADOW_CAPTURE_PROFILE, ShadowManagementError, read_shadow_management_state, require_shadow_primary_write_allowed, @@ -135,3 +140,102 @@ def test_bound_source_path_never_accepts_or_repairs_a_damaged_manifest(tmp_path: with pytest.raises(ShadowManagementError, match="shadow_management_manifest_invalid"): management.read_shadow_bootstrap_source_path(w.runtime, w.goal, binding) assert {str(path.relative_to(w.runtime)): path.read_bytes() for path in w.runtime.rglob("*") if path.is_file()} == before + + +def _root_digest(path: Path, *, canonical: bool) -> str: + spelling = os.path.realpath(path) if canonical else os.path.abspath(path) + return "sha256:" + hashlib.sha256(spelling.encode("utf-8")).hexdigest() + + +def _active_journal(*, state_digest: str, binding_digest: str) -> dict: + """An active journal in the shape the TypeScript owner writes, with separately chosen root digests.""" + + return { + "schema_version": SHADOW_MANAGEMENT_STATE_SCHEMA, + "goal_id": "goal-a", + "source_root_digest": state_digest, + "status": "active", + "binding": { + "capture_profile": SHADOW_CAPTURE_PROFILE, + "capture_lineage_id": "lineage-a", + "source_root_digest": binding_digest, + "store_identity": "file:" + "a" * 32, + "bootstrap_operation_id": "bootstrap:guard", + "bootstrap_provider_revision": "file:1:" + "b" * 24, + }, + "operation": { + "kind": "bootstrap", + "operation_id": "bootstrap:guard", + "request_digest": "sha256:" + "c" * 64, + "manifest_digest": "sha256:" + "d" * 64, + "phase": "complete", + }, + "previous_operation_id": None, + "result": {}, + } + + +_TYPESCRIPT_READ = """ +import {readShadowManagementState} from './loopx/control_plane/coordination/shadow_management.ts'; +try { + const state = await readShadowManagementState(process.argv[1], 'goal-a'); + process.stdout.write(JSON.stringify({accepted: state !== null})); +} catch (error) { + process.stdout.write(JSON.stringify({accepted: false, code: error?.code ?? error?.reason_code ?? String(error)})); +} +""" + + +def _typescript_reads(root: Path) -> dict: + result = subprocess.run( + ["node", "--no-warnings", "--experimental-strip-types", "--input-type=module", "-e", _TYPESCRIPT_READ, str(root)], + check=True, capture_output=True, text=True, + ) + return json.loads(result.stdout) + + +@pytest.mark.parametrize( + ("state_canonical", "binding_canonical"), + [(False, True), (True, False)], + ids=["lexical-journal-canonical-binding", "canonical-journal-lexical-binding"], +) +def test_mixed_root_digests_are_rejected_by_both_readers_without_touching_the_journal( + tmp_path: Path, state_canonical: bool, binding_canonical: bool, +) -> None: + """Either spelling may identify the root, but journal and binding must agree exactly. + + The TypeScript decoder requires `binding.source_root_digest === state.source_root_digest`; + the Python guard must hold the same line, or a journal Python keeps writing under is one + TypeScript refuses to read back after a restart (#4892 review). + """ + + real = tmp_path / "runtime" + real.mkdir() + alias = tmp_path / "runtime-alias" + try: + alias.symlink_to(real, target_is_directory=True) + except OSError as exc: + pytest.skip(f"directory symlinks unavailable: {exc}") + assert _root_digest(alias, canonical=True) != _root_digest(alias, canonical=False) + path = shadow_management_state_path(alias, "goal-a") + path.parent.mkdir(parents=True) + + # Positive control: an agreeing journal is accepted by both readers, so the + # rejections below are about the mixture, not about the fixture. + agreeing = _root_digest(alias, canonical=True) + path.write_text(json.dumps(_active_journal(state_digest=agreeing, binding_digest=agreeing))) + assert require_shadow_primary_write_allowed(alias, "goal-a") is not None + assert _typescript_reads(alias) == {"accepted": True} + + mixed = _active_journal( + state_digest=_root_digest(alias, canonical=state_canonical), + binding_digest=_root_digest(alias, canonical=binding_canonical), + ) + path.write_text(json.dumps(mixed)) + before = path.read_bytes() + with pytest.raises(ShadowManagementError) as failure: + require_shadow_primary_write_allowed(alias, "goal-a") + assert failure.value.code == "shadow_management_state_invalid" + assert path.read_bytes() == before + assert _typescript_reads(alias) == {"accepted": False, "code": "shadow_management_state_invalid"} + assert path.read_bytes() == before diff --git a/tests/control_plane/test_shared_goal_authority_e2e.py b/tests/control_plane/test_shared_goal_authority_e2e.py index 7d92d4f2be..875a7a3ac7 100644 --- a/tests/control_plane/test_shared_goal_authority_e2e.py +++ b/tests/control_plane/test_shared_goal_authority_e2e.py @@ -250,6 +250,24 @@ def test_stage_2a_row_reports_specific_unverified_reasons_for_each_missing_input assert ladder.collect_bindings(inputs)["nokv_client_config_sha256"] is not None +@pytest.mark.stage2c_e2e +def test_stage_2c2_row_passes_when_the_ladder_root_is_reached_through_a_symlink(tmp_path: Path) -> None: + """macOS's default temp directory is a symlink; the shadow rows must not care (#4892).""" + + real = tmp_path / "real-root" + real.mkdir() + link = tmp_path / "link-root" + try: + link.symlink_to(real, target_is_directory=True) + except OSError as exc: + pytest.skip(f"directory symlinks unavailable: {exc}") + row = ladder.row_by_id("s2c2.outbox_prepared_then_committed_entries") + result = ladder.run_row(row, root=link, environ=os.environ) + if result.status == "unverified": + pytest.skip(f"unverified: {result.reason_code}") + assert result.status == "pass", (result.reason_code, result.evidence) + + def test_nokv_sdk_pin_and_fence_checks_agree_across_helper_ladder_and_probe() -> None: from loopx.control_plane.coordination import nokv_jsonl_helper as helper