From f57b2604b5016343a4e22e9be0452d2d301147f9 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 12:55:12 -0400 Subject: [PATCH 1/6] Pin the identity anchor in a fixture and re-derive machine moves The identity anchor lived as a literal in test_identity_uuid_map_matches_reviewed_anchor, so only a hand edit of the test could move it. Every first observation that enriches a docket placeholder or mints an identity moves it, so each such resolver append (chronicle#361, #369) merged with Arch checks red and held every other pull request into this branch until a curation PR (#363, #371) moved the literal by hand. The pin now lives in tests/fixtures/series_catalog/identity_anchor.json, unchanged (d5b88530...). A curator still moves it with move null and a dated entry in the test's history. A machine may also move it, recording from_sha256 and one event per identity tuple it changed; build_series_catalog.identity_anchor_problems re-derives from_sha256 from the current catalog and those events and accepts only mints of full identities under fresh UUIDs and enrichments of entity-free placeholders under the same UUID and concept. A remint, supersede, retire, entity change on a full identity or any unrecorded change is reported. Property tests: any sanctioned append recorded as a move is accepted, and each of eleven ways a move can misstate its append is reported. Co-Authored-By: Claude Opus 5.5 --- scripts/build_series_catalog.py | 217 +++++++++++++ .../series_catalog/identity_anchor.json | 5 + tests/test_build_series_catalog.py | 306 ++++++++++++++++-- 3 files changed, 506 insertions(+), 22 deletions(-) create mode 100644 tests/fixtures/series_catalog/identity_anchor.json diff --git a/scripts/build_series_catalog.py b/scripts/build_series_catalog.py index 2f8fb414..808823e3 100644 --- a/scripts/build_series_catalog.py +++ b/scripts/build_series_catalog.py @@ -2859,6 +2859,223 @@ def report(spelling: str, concept: str, message: str) -> None: return sorted(set(problems)) +# Who may record a machine move of the identity anchor. A move is accepted +# only when identity_anchor_problems re-derives it, so the name is +# provenance, not authority. +IDENTITY_ANCHOR_MOVERS = ("thesis-resolver",) +IDENTITY_ANCHOR_KEYS = {"comment", "sha256", "move"} +IDENTITY_ANCHOR_MOVE_KEYS = {"by", "from_sha256", "run_url", "events"} +IDENTITY_ANCHOR_EVENT_KEYS = { + "mint": {"event", "uuid", "concept", "geography", "entity"}, + "enrich": { + "event", "uuid", "concept", "geography", "entity", + "placeholder_geography", + }, +} +_SHA256_HEX = re.compile(r"[0-9a-f]{64}") + + +def identity_anchor_line( + uuid: str, concept: str, geography: dict | None, entity: dict | None, +) -> str: + """One catalog row's identity tuple as the anchor digests it.""" + return json.dumps([uuid, concept, _geo_key(geography), _entity_key(entity)]) + + +def identity_anchor_lines(series: list[dict]) -> list[str]: + """The sorted identity tuples of a catalog's rows.""" + return sorted( + identity_anchor_line( + row["uuid"], row["concept"], row.get("geography"), + row.get("entity"), + ) + for row in series + ) + + +def identity_anchor_digest(lines: list[str]) -> str: + """The anchor digest of sorted identity tuples.""" + return hashlib.sha256("\n".join(sorted(lines)).encode()).hexdigest() + + +def _geography_admits(placeholder: dict | None, observed: dict) -> bool: + # The enrichment rule of _plan_enrichment: the placeholder has no + # geography yet, or the observed level and id with no vintage or the + # same vintage. + if placeholder is None: + return True + return (placeholder.get("level"), placeholder.get("id")) == ( + observed.get("level"), observed.get("id"), + ) and placeholder.get("vintage") in (None, observed.get("vintage")) + + +def _full_identity_part(value: object, keys: tuple[str, ...]) -> bool: + return ( + isinstance(value, dict) + and set(value) == set(keys) + and all(isinstance(value[key], str) and value[key] for key in keys[:2]) + ) + + +def identity_anchor_problems(series: list[dict], anchor: dict) -> list[str]: + """Why ``series`` does not match the reviewed identity anchor. + + ``anchor`` is tests/fixtures/series_catalog/identity_anchor.json. + ``sha256`` is the identity_anchor_digest of the catalog's rows, so + changing any live binding, or adding or retiring one, moves it and must + be pinned in the same diff. ``move`` says who moved it last: + + * ``null``: a curator, who reviewed the diff. The reasons are in the + comment history of test_identity_uuid_map_matches_reviewed_anchor. + * a machine move: ``by`` (one of IDENTITY_ANCHOR_MOVERS), ``run_url``, + ``from_sha256`` and ``events``. It stands only when undoing its events + on the current tuples gives exactly ``from_sha256``, and every event is + one a first observation may cause: a ``mint`` of a full identity (a + geography and an entity) under a UUID the prior tuples do not hold, or + an ``enrich`` of a docket placeholder (no entity) whose + ``placeholder_geography`` admits the new geography, re-keyed under the + same UUID and concept. A remint, a supersede, a retire, an entity + change on a full identity or any unrecorded change is reported, so a + machine can move the anchor only by what this function re-derives. + + >>> geo = {"level": "country", "id": "US", "vintage": "current"} + >>> ent = {"name": "economy", "role": "aggregate"} + >>> before = [{"uuid": "u1", "concept": "a", "geography": None, + ... "entity": None}] + >>> after = [{"uuid": "u1", "concept": "a", "geography": geo, + ... "entity": ent}] + >>> move = {"by": "thesis-resolver", "run_url": None, + ... "from_sha256": identity_anchor_digest( + ... identity_anchor_lines(before)), + ... "events": [{"event": "enrich", "uuid": "u1", "concept": "a", + ... "geography": geo, "entity": ent, + ... "placeholder_geography": None}]} + >>> anchor = {"comment": "c", "sha256": identity_anchor_digest( + ... identity_anchor_lines(after)), "move": move} + >>> identity_anchor_problems(after, anchor) + [] + >>> move["events"][0]["concept"] = "b" + >>> identity_anchor_problems(after, anchor)[0] + 'identity anchor move: enrich of u1 (b) is not a current catalog row' + """ + if not isinstance(anchor, dict) or set(anchor) != IDENTITY_ANCHOR_KEYS: + keys = sorted(IDENTITY_ANCHOR_KEYS) + return [f"identity anchor fixture must hold exactly {keys}"] + pinned = anchor["sha256"] + if not isinstance(pinned, str) or not _SHA256_HEX.fullmatch(pinned): + return ["identity anchor sha256 is not a lowercase hex SHA-256"] + current = identity_anchor_lines(series) + problems: list[str] = [] + digest = identity_anchor_digest(current) + if digest != pinned: + problems.append( + f"identity anchor moved: the catalog digests to {digest}, the " + f"fixture pins {pinned}. Review the binding changes and pin the " + "new digest with move null, explaining it in the test's history" + ) + move = anchor["move"] + if move is None: + return problems + if not isinstance(move, dict) or set(move) != IDENTITY_ANCHOR_MOVE_KEYS: + keys = sorted(IDENTITY_ANCHOR_MOVE_KEYS) + return problems + [f"identity anchor move must hold exactly {keys}"] + if move["by"] not in IDENTITY_ANCHOR_MOVERS: + problems.append(f"identity anchor move by unknown mover {move['by']!r}") + run_url = move["run_url"] + if run_url is not None and not ( + isinstance(run_url, str) and run_url.startswith("https://github.com/") + ): + problems.append("identity anchor move run_url is not a GitHub URL") + origin = move["from_sha256"] + if not isinstance(origin, str) or not _SHA256_HEX.fullmatch(origin): + return problems + ["identity anchor move from_sha256 is not a SHA-256"] + events = move["events"] + if not isinstance(events, list) or not events: + return problems + ["identity anchor move records no events"] + current_set = set(current) + prior = set(current) + seen: set[str] = set() + order: list[tuple[str, str]] = [] + mints: list[str] = [] + for event in events: + kind = event.get("event") if isinstance(event, dict) else None + if kind not in IDENTITY_ANCHOR_EVENT_KEYS or set(event) != ( + IDENTITY_ANCHOR_EVENT_KEYS[kind] + ): + problems.append( + f"identity anchor move: malformed event {event!r}" + ) + continue + uuid, concept = event["uuid"], event["concept"] + if not (isinstance(uuid, str) and isinstance(concept, str)): + problems.append(f"identity anchor move: malformed event {event!r}") + continue + order.append((concept, uuid)) + label = f"{kind} of {uuid} ({concept})" + if uuid in seen: + problems.append(f"identity anchor move: {label} repeats a UUID") + continue + seen.add(uuid) + geography, entity = event["geography"], event["entity"] + if not ( + _full_identity_part(geography, ("level", "id", "vintage")) + and _full_identity_part(entity, ("name", "role")) + ): + problems.append( + f"identity anchor move: {label} does not land on a full " + "identity (a geography and an entity)" + ) + continue + after = identity_anchor_line(uuid, concept, geography, entity) + if after not in current_set: + problems.append( + f"identity anchor move: {label} is not a current catalog row" + ) + continue + prior.discard(after) + if kind == "mint": + mints.append(uuid) + continue + placeholder = event["placeholder_geography"] + if placeholder is not None and not _full_identity_part( + placeholder, ("level", "id", "vintage") + ): + problems.append( + f"identity anchor move: {label} has a malformed " + "placeholder_geography" + ) + continue + if not _geography_admits(placeholder, geography): + problems.append( + f"identity anchor move: {label} lands on a geography its " + "placeholder does not admit" + ) + continue + prior.add(identity_anchor_line(uuid, concept, placeholder, None)) + if order != sorted(order): + problems.append( + "identity anchor move events are not sorted by (concept, uuid)" + ) + prior_uuids = [json.loads(line)[0] for line in prior] + for uuid in mints: + if uuid in prior_uuids: + problems.append( + f"identity anchor move: mint of {uuid} reuses a UUID the " + "prior catalog holds" + ) + if len(prior_uuids) != len(set(prior_uuids)): + problems.append( + "identity anchor move: undoing its events leaves a UUID on two " + "identities" + ) + if identity_anchor_digest(sorted(prior)) != origin: + problems.append( + "identity anchor move: undoing its events does not give " + f"from_sha256 {origin}" + ) + return problems + + def git_head_bytes(path: pathlib.Path) -> bytes | None: """The file's committed HEAD content, or None when unavailable.""" try: diff --git a/tests/fixtures/series_catalog/identity_anchor.json b/tests/fixtures/series_catalog/identity_anchor.json new file mode 100644 index 00000000..64996cbc --- /dev/null +++ b/tests/fixtures/series_catalog/identity_anchor.json @@ -0,0 +1,5 @@ +{ + "comment": "The reviewed identity anchor of ledger/series_catalog.json: sha256 is build_series_catalog.identity_anchor_digest of the catalog rows' [uuid, concept, geography key, entity key] tuples, the digest the adversarial review of PR #128 started. Changing any live binding, or adding or retiring one, moves it, so the same diff must move this pin; test_identity_uuid_map_matches_reviewed_anchor checks it with identity_anchor_problems. A curator who moves the pin sets move to null and explains the move in that test's comment history. The Thesis resolver may move it in its own append proposal and records how in move: by, run_url, from_sha256 (the pin it moved from) and events, one per identity tuple the append changed. The test re-derives from_sha256 from the current catalog and those events and accepts only mints of full identities under fresh UUIDs and enrichments of entity-free docket placeholders under the same UUID and concept, so a machine move is re-checked here, never taken on trust.", + "sha256": "d5b88530f3f0c9e709dc0a0d379302db539d2600f76f60ed7fdd83f7474e5eef", + "move": null +} diff --git a/tests/test_build_series_catalog.py b/tests/test_build_series_catalog.py index 4c142160..71fa56ef 100644 --- a/tests/test_build_series_catalog.py +++ b/tests/test_build_series_catalog.py @@ -19,7 +19,8 @@ import uuid import pytest -from hypothesis import HealthCheck, given, settings +from hypothesis import HealthCheck, assume, given, settings +from hypothesis import event as hypothesis_event from hypothesis import strategies as st ROOT = pathlib.Path(__file__).resolve().parents[1] @@ -41,6 +42,11 @@ REVIEWED_STRIPS: dict[str, dict[str, dict[str, dict[str, list[str]]]]] = ( REVIEWED_STRIPPED_FIXTURE["strips"] ) +# The reviewed identity anchor (see test_identity_uuid_map_matches_reviewed_ +# anchor and build_series_catalog.identity_anchor_problems). +IDENTITY_ANCHOR_PATH = ( + ROOT / "tests" / "fixtures" / "series_catalog" / "identity_anchor.json" +) PERIOD_SEGMENTS = [ "fy2026", @@ -2297,27 +2303,18 @@ def test_identity_uuid_map_matches_reviewed_anchor() -> None: """The registry's introduction commit cannot be continuity-checked by the append-only gate (there is no prior registry to extend), so the identity->uuid map verified by the adversarial review of PR #128 is - pinned here. Changing any live binding — or adding or retiring one — - must edit this constant in the same diff, making wholesale remints - impossible to slip through as regeneration noise. - - Update the constant only alongside registry events that justify it. + pinned in IDENTITY_ANCHOR_PATH. Changing any live binding — or adding or + retiring one — must move that pin in the same diff, making wholesale + remints impossible to slip through as regeneration noise. + + Move the pin only alongside registry events that justify it: by hand, + with move null and a dated entry in the history below, or by the Thesis + resolver in its own append proposal, with a move record that + identity_anchor_problems re-derives (fresh-UUID mints and same-UUID + placeholder enrichments only). """ - import hashlib - catalog = json.loads(bsc.CATALOG.read_text(encoding="utf-8")) - lines = sorted( - json.dumps( - [ - row["uuid"], - row["concept"], - bsc._geo_key(row.get("geography")), - bsc._entity_key(row.get("entity")), - ] - ) - for row in catalog["series"] - ) - digest = hashlib.sha256("\n".join(lines).encode()).hexdigest() + anchor = json.loads(IDENTITY_ANCHOR_PATH.read_text(encoding="utf-8")) # Updated 2026-08-05 alongside five registry events: two docket # placeholders enriched by their first observations (retire+succeeds, # UUIDs preserved: bls.jolts.hires_rate, @@ -2449,8 +2446,273 @@ def test_identity_uuid_map_matches_reviewed_anchor() -> None: # previous pin exactly and the delta to 61475bf8 is those five tuples # re-keyed with the same UUIDs. No mint, no supersede; the other 216 # bindings are unchanged; 221 rows. - assert digest == ( - "d5b88530f3f0c9e709dc0a0d379302db539d2600f76f60ed7fdd83f7474e5eef" + # Moved 2026-10-19 from a literal here to IDENTITY_ANCHOR_PATH, unchanged + # (d5b88530...), so the Thesis resolver can pin the anchor its own + # first observations move in the same proposal, under a move record + # this test re-derives. Twice in three days (chronicle#361, #369) an + # append merged with this test red and held every other pull request + # into the branch until a curation PR (#363, #371) moved the literal. + # From here on, machine moves are recorded in the fixture and its git + # history; hand moves keep being explained here. + assert bsc.identity_anchor_problems(catalog["series"], anchor) == [] + + +# --- Machine moves of the identity anchor ----------------------------------- + +_ANCHOR_CONCEPTS = ["a.series", "b.series", "c.series"] +_ANCHOR_FULL_GEOS = [ + {"level": "country", "id": "US", "vintage": "current"}, + {"level": "country", "id": "US", "vintage": None}, + {"level": "state", "id": "MN", "vintage": "current"}, +] +_ANCHOR_ENTITIES = [ + {"name": "economy", "role": "aggregate"}, + {"name": "person", "role": "civilian_labor_force"}, +] +_ANCHOR_MUTATIONS = [ + "drop_event", + "wrong_origin", + "mint_reuses_uuid", + "remint_full_identity", + "enrich_full_identity", + "enrich_changes_concept", + "enrich_unadmitted_geography", + "mint_placeholder", + "unsorted_events", + "unknown_mover", + "stale_pin", +] + + +def _anchor_uuid(index: int) -> str: + return f"{index:08x}-0000-4000-8000-000000000000" + + +def _admitted_geographies(placeholder: dict | None) -> list[dict]: + return [ + geo for geo in _ANCHOR_FULL_GEOS + if placeholder is None + or ( + (placeholder["level"], placeholder["id"]) == (geo["level"], geo["id"]) + and placeholder["vintage"] in (None, geo["vintage"]) + ) + ] + + +def _anchor_append(data: st.DataObject) -> tuple[list, list, list]: + """A prior catalog, the catalog one append makes of it, and the events + a machine move records for that append: placeholder enrichments under + the same UUID and concept, and mints of full identities under fresh + UUIDs.""" + prior = [] + for index in range(data.draw(st.integers(0, 6), label="rows")): + placeholder = data.draw(st.booleans(), label="placeholder") + prior.append({ + "uuid": _anchor_uuid(index), + "concept": data.draw(st.sampled_from(_ANCHOR_CONCEPTS)), + "geography": data.draw(st.sampled_from( + [None, *_ANCHOR_FULL_GEOS] if placeholder + else _ANCHOR_FULL_GEOS + )), + "entity": None if placeholder else data.draw( + st.sampled_from(_ANCHOR_ENTITIES) + ), + }) + current = [dict(row) for row in prior] + events = [] + for row in current: + if row["entity"] is not None or not data.draw(st.booleans()): + continue + geography = data.draw( + st.sampled_from(_admitted_geographies(row["geography"])) + ) + entity = data.draw(st.sampled_from(_ANCHOR_ENTITIES)) + events.append({ + "event": "enrich", "uuid": row["uuid"], "concept": row["concept"], + "geography": geography, "entity": entity, + "placeholder_geography": row["geography"], + }) + row["geography"], row["entity"] = geography, entity + for index in range(data.draw(st.integers(0, 3), label="mints")): + mint = { + "uuid": _anchor_uuid(100 + index), + "concept": data.draw(st.sampled_from(_ANCHOR_CONCEPTS)), + "geography": data.draw(st.sampled_from(_ANCHOR_FULL_GEOS)), + "entity": data.draw(st.sampled_from(_ANCHOR_ENTITIES)), + } + current.append(mint) + events.append({"event": "mint", **mint}) + events.sort(key=lambda event: (event["concept"], event["uuid"])) + return prior, current, events + + +def _machine_anchor(prior: list, current: list, events: list) -> dict: + return { + "comment": "c", + "sha256": bsc.identity_anchor_digest(bsc.identity_anchor_lines(current)), + "move": { + "by": "thesis-resolver", + "run_url": "https://github.com/ThesisInstitute/thesis/actions/runs/1", + "from_sha256": bsc.identity_anchor_digest( + bsc.identity_anchor_lines(prior) + ), + "events": events, + }, + } + + +@settings(max_examples=300, deadline=None) +@given(data=st.data()) +def test_a_machine_move_of_sanctioned_events_is_accepted( + data: st.DataObject, +) -> None: + """Completeness: any append made only of fresh-UUID mints and + same-UUID placeholder enrichments can be recorded as a machine move + that identity_anchor_problems accepts, and recording no move at all + for it is refused (the pin moved).""" + prior, current, events = _anchor_append(data) + assume(events) + assert bsc.identity_anchor_problems( + current, _machine_anchor(prior, current, events) + ) == [] + stale = { + "comment": "c", + "sha256": bsc.identity_anchor_digest(bsc.identity_anchor_lines(prior)), + "move": None, + } + assert bsc.identity_anchor_problems(current, stale) + + +@settings( + max_examples=600, + deadline=None, + suppress_health_check=[HealthCheck.filter_too_much], +) +@given(data=st.data()) +def test_a_machine_move_cannot_hide_any_other_binding_change( + data: st.DataObject, +) -> None: + """Soundness: every way a recorded move can misstate its append — an + unrecorded change, a remint or reused UUID, an entity change on a full + identity, a concept change, an unadmitted geography, a placeholder + mint, a wrong origin, unsorted events, an unknown mover or a stale pin + — is reported.""" + prior, current, events = _anchor_append(data) + mutation = data.draw(st.sampled_from(_ANCHOR_MUTATIONS), label="mutation") + enriched = [e for e in events if e["event"] == "enrich"] + mints = [e for e in events if e["event"] == "mint"] + full = [row for row in current if row["entity"] is not None + and row["uuid"] not in {e["uuid"] for e in events}] + placeholders = [row for row in current if row["entity"] is None] + if mutation == "drop_event": + assume(events) + dropped = data.draw(st.sampled_from(events)) + events = [e for e in events if e is not dropped] + elif mutation == "wrong_origin": + assume(events) + prior = current + elif mutation == "mint_reuses_uuid": + assume(mints and prior) + mint = data.draw(st.sampled_from(mints)) + reused = data.draw(st.sampled_from(prior))["uuid"] + for row in current: + if row["uuid"] == mint["uuid"] and row["concept"] == mint["concept"]: + row["uuid"] = reused + mint["uuid"] = reused + events.sort(key=lambda e: (e["concept"], e["uuid"])) + elif mutation == "remint_full_identity": + assume(full) + row = data.draw(st.sampled_from(full)) + row["uuid"] = _anchor_uuid(999) + events.append({"event": "mint", **{k: row[k] for k in ( + "uuid", "concept", "geography", "entity")}}) + events.sort(key=lambda e: (e["concept"], e["uuid"])) + elif mutation == "enrich_full_identity": + assume(full) + row = data.draw(st.sampled_from(full)) + old_geography = row["geography"] + row["entity"] = next( + e for e in _ANCHOR_ENTITIES if e != row["entity"] + ) + events.append({ + "event": "enrich", "uuid": row["uuid"], "concept": row["concept"], + "geography": row["geography"], "entity": row["entity"], + "placeholder_geography": old_geography, + }) + events.sort(key=lambda e: (e["concept"], e["uuid"])) + elif mutation == "enrich_changes_concept": + assume(enriched) + target = data.draw(st.sampled_from(enriched)) + renamed = "z.renamed" + for row in current: + if row["uuid"] == target["uuid"]: + row["concept"] = renamed + target["concept"] = renamed + events.sort(key=lambda e: (e["concept"], e["uuid"])) + elif mutation == "enrich_unadmitted_geography": + assume(enriched) + target = data.draw(st.sampled_from(enriched)) + assume(target["placeholder_geography"] is not None) + unadmitted = [ + geo for geo in _ANCHOR_FULL_GEOS + if geo not in _admitted_geographies(target["placeholder_geography"]) + ] + assume(unadmitted) + geography = data.draw(st.sampled_from(unadmitted)) + for row in current: + if row["uuid"] == target["uuid"]: + row["geography"] = geography + target["geography"] = geography + elif mutation == "mint_placeholder": + assume(mints) + mint = data.draw(st.sampled_from(mints)) + for row in current: + if row["uuid"] == mint["uuid"]: + row["entity"] = None + mint["entity"] = None + elif mutation == "unsorted_events": + assume(len({(e["concept"], e["uuid"]) for e in events}) > 1) + events = list(reversed(events)) + elif mutation == "unknown_mover": + assume(events) + elif mutation == "stale_pin": + assume(placeholders and events) + anchor = _machine_anchor(prior, current, events) + if mutation == "unknown_mover": + anchor["move"]["by"] = "someone-else" + if mutation == "stale_pin": + row = data.draw(st.sampled_from(placeholders)) + row["entity"] = _ANCHOR_ENTITIES[0] + row["geography"] = _ANCHOR_FULL_GEOS[0] + hypothesis_event(mutation) + assert bsc.identity_anchor_problems(current, anchor), mutation + + +def test_identity_anchor_fixture_shape_is_enforced() -> None: + """A fixture with extra or missing keys, a malformed pin, or a move + without events is reported rather than read leniently.""" + series = [{"uuid": _anchor_uuid(0), "concept": "a.series", + "geography": _ANCHOR_FULL_GEOS[0], + "entity": _ANCHOR_ENTITIES[0]}] + pin = bsc.identity_anchor_digest(bsc.identity_anchor_lines(series)) + good = {"comment": "c", "sha256": pin, "move": None} + assert bsc.identity_anchor_problems(series, good) == [] + assert bsc.identity_anchor_problems(series, {**good, "extra": 1}) + assert bsc.identity_anchor_problems(series, {**good, "sha256": pin.upper()}) + no_events = {**good, "move": { + "by": "thesis-resolver", "run_url": None, "from_sha256": pin, + "events": [], + }} + assert bsc.identity_anchor_problems(series, no_events) == [ + "identity anchor move records no events" + ] + bad_url = {**good, "move": { + "by": "thesis-resolver", "run_url": "http://example.com", + "from_sha256": pin, "events": [], + }} + assert ( + "identity anchor move run_url is not a GitHub URL" + in bsc.identity_anchor_problems(series, bad_url) ) From 5d6e607a741699bf059cc7c5d5737bd2aee7fd50 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 13:35:19 -0400 Subject: [PATCH 2/6] Tie a machine anchor move to the comparison base's pin Review round 1 (in-session Opus) showed identity_anchor_problems accepts a self-consistent record with a false origin: remint (u_old, a) as (u_new, a), record "mint u_new" with from_sha256 = the current catalog minus u_new, and the record re-derives cleanly. The function only sees the current catalog, so it cannot know where the branch was. identity_anchor_continuity_problems requires a move that changes the pin to start from the base commit's pin, and refuses a mint whose UUID the base registry already holds (a revive is a curator's call). A new Arch checks step, "Identity anchor move continues the base pin", runs it against the same comparison base as the registry append-only step. The fixture comment and docstring now say exactly what each check proves. Also: a non-string event kind is reported instead of raising TypeError. Tests: the review's forged-origin case, a property that a move continues the base exactly when it starts from the base's pin, the CLI, and malformed kinds. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 41 ++++++ scripts/build_series_catalog.py | 120 +++++++++++++++++- .../series_catalog/identity_anchor.json | 2 +- tests/test_build_series_catalog.py | 113 +++++++++++++++++ 4 files changed, 270 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72593508..b0b1cdef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,6 +82,47 @@ jobs: uv run --locked python scripts/build_series_catalog.py \ --verify-registry-append-only /tmp/base-series-uuid-registry.jsonl + # The catalog tests re-derive a machine move of the identity anchor + # (tests/fixtures/series_catalog/identity_anchor.json, written by the + # Thesis resolver in its own append proposals) from the current + # catalog alone, which cannot show the move starts where the branch + # was. This ties it to the same comparison base as the registry step: + # the move must start from the base's pin, and no mint may take a UUID + # the base registry already holds. + - name: Identity anchor move continues the base pin + if: >- + github.event_name == 'pull_request' || + github.event_name == 'push' + env: + BASE_SHA: ${{ github.event_name == 'pull_request' + && github.event.pull_request.base.sha + || github.event.before }} + FALLBACK_REF: codex/thesis-ledger-facts + run: | + if [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then + git fetch --no-tags --depth=1 origin \ + "refs/heads/$FALLBACK_REF" + BASE_SHA=FETCH_HEAD + else + git fetch --no-tags --depth=1 origin "$BASE_SHA" + fi + anchor=tests/fixtures/series_catalog/identity_anchor.json + if git cat-file -e "$BASE_SHA:$anchor" 2>/dev/null; then + git show "$BASE_SHA:$anchor" > /tmp/base-identity-anchor.json + else + : > /tmp/base-identity-anchor.json + fi + if git cat-file -e "$BASE_SHA:ledger/series_uuid_registry.jsonl" \ + 2>/dev/null; then + git show "$BASE_SHA:ledger/series_uuid_registry.jsonl" \ + > /tmp/base-anchor-registry.jsonl + else + : > /tmp/base-anchor-registry.jsonl + fi + uv run --locked python scripts/build_series_catalog.py \ + --verify-anchor-move-from /tmp/base-identity-anchor.json \ + --anchor-base-registry /tmp/base-anchor-registry.jsonl + - name: Lint Arch surface run: > uv run ruff check diff --git a/scripts/build_series_catalog.py b/scripts/build_series_catalog.py index 808823e3..57f4a8b9 100644 --- a/scripts/build_series_catalog.py +++ b/scripts/build_series_catalog.py @@ -160,6 +160,9 @@ CATALOG = ROOT / "ledger" / "series_catalog.json" DOCKET_SEED = ROOT / "ledger" / "seeds" / "thesis_docket_series.json" UUID_REGISTRY = ROOT / "ledger" / "series_uuid_registry.jsonl" +IDENTITY_ANCHOR = ( + ROOT / "tests" / "fixtures" / "series_catalog" / "identity_anchor.json" +) ENTITY_FOLDS = ROOT / "ledger" / "seeds" / "entity_folds.json" ENTITY_FOLDS_SCHEMA = "chronicle_entity_folds_v1" ENTITY_FOLD_FIELDS = ( @@ -2934,9 +2937,14 @@ def identity_anchor_problems(series: list[dict], anchor: dict) -> list[str]: geography and an entity) under a UUID the prior tuples do not hold, or an ``enrich`` of a docket placeholder (no entity) whose ``placeholder_geography`` admits the new geography, re-keyed under the - same UUID and concept. A remint, a supersede, a retire, an entity - change on a full identity or any unrecorded change is reported, so a - machine can move the anchor only by what this function re-derives. + same UUID and concept. Relative to ``from_sha256``, a remint, a + supersede, a retire, an entity change on a full identity or any + unrecorded change is reported. + + This function sees only the current catalog, so it cannot tell whether + ``from_sha256`` is where the branch really was: a record that names a + false origin is consistent with itself. identity_anchor_continuity_ + problems closes that, and CI runs it against the comparison base. >>> geo = {"level": "country", "id": "US", "vintage": "current"} >>> ent = {"name": "economy", "role": "aggregate"} @@ -2999,8 +3007,10 @@ def identity_anchor_problems(series: list[dict], anchor: dict) -> list[str]: mints: list[str] = [] for event in events: kind = event.get("event") if isinstance(event, dict) else None - if kind not in IDENTITY_ANCHOR_EVENT_KEYS or set(event) != ( - IDENTITY_ANCHOR_EVENT_KEYS[kind] + if ( + not isinstance(kind, str) + or kind not in IDENTITY_ANCHOR_EVENT_KEYS + or set(event) != IDENTITY_ANCHOR_EVENT_KEYS[kind] ): problems.append( f"identity anchor move: malformed event {event!r}" @@ -3076,6 +3086,58 @@ def identity_anchor_problems(series: list[dict], anchor: dict) -> list[str]: return problems +def identity_anchor_continuity_problems( + base_anchor: dict | None, + anchor: dict, + base_registry_uuids: set[str] | None = None, +) -> list[str]: + """Why a machine move does not continue from the comparison base. + + ``base_anchor`` is the base commit's identity_anchor.json (None when it + has none) and ``base_registry_uuids`` every UUID in the base's registry. + A move that changes the pin must start from the base's pin, and none of + its mints may take a UUID the base registry already holds (a retired + UUID brought back is a revive, a curator's call). A pin the change + leaves alone carries the base's record over and needs nothing here. + + >>> base = {"sha256": "a" * 64, "move": None} + >>> moved = {"sha256": "b" * 64, "move": {"from_sha256": "c" * 64, + ... "events": []}} + >>> identity_anchor_continuity_problems(base, moved) # doctest: +ELLIPSIS + ['identity anchor move starts from ccc... but the base pins aaa...'] + """ + move = anchor.get("move") if isinstance(anchor, dict) else None + if not isinstance(move, dict): + return [] + base_pin = base_anchor.get("sha256") if isinstance(base_anchor, dict) else None + if base_pin is not None and anchor.get("sha256") == base_pin: + return [] + problems: list[str] = [] + origin = move.get("from_sha256") + if base_pin is None: + problems.append( + "identity anchor move with no base pin to continue: the base " + "has no identity anchor fixture" + ) + elif origin != base_pin: + problems.append( + f"identity anchor move starts from {origin} but the base pins " + f"{base_pin}" + ) + for event in move.get("events") or []: + if ( + isinstance(event, dict) + and event.get("event") == "mint" + and base_registry_uuids is not None + and event.get("uuid") in base_registry_uuids + ): + problems.append( + f"identity anchor move: mint of {event.get('uuid')} reuses a " + "UUID the base registry already holds" + ) + return problems + + def git_head_bytes(path: pathlib.Path) -> bytes | None: """The file's committed HEAD content, or None when unavailable.""" try: @@ -3196,8 +3258,56 @@ def main(argv: list[str] | None = None) -> int: "this against the PR base's registry), then exit" ), ) + parser.add_argument( + "--verify-anchor-move-from", + type=pathlib.Path, + default=None, + metavar="BASE_ANCHOR", + help=( + "verify a machine move in --anchor starts from BASE_ANCHOR's pin " + "(an empty file: the base has none) and mints no UUID in " + "--anchor-base-registry (CI runs this against the comparison " + "base), then exit" + ), + ) + parser.add_argument("--anchor", type=pathlib.Path, default=IDENTITY_ANCHOR) + parser.add_argument( + "--anchor-base-registry", type=pathlib.Path, default=None, + metavar="BASE_REGISTRY", + ) args = parser.parse_args(argv) + if args.verify_anchor_move_from is not None: + anchor = json.loads(args.anchor.read_text(encoding="utf-8")) + base_raw = ( + args.verify_anchor_move_from.read_text(encoding="utf-8") + if args.verify_anchor_move_from.exists() + else "" + ) + base_anchor = json.loads(base_raw) if base_raw.strip() else None + base_uuids = None + if args.anchor_base_registry is not None: + base_uuids = set() + if args.anchor_base_registry.exists(): + for line in args.anchor_base_registry.read_text( + encoding="utf-8" + ).splitlines(): + if line.strip(): + entry = json.loads(line) + if isinstance(entry, dict) and isinstance( + entry.get("uuid"), str + ): + base_uuids.add(entry["uuid"]) + problems = identity_anchor_continuity_problems( + base_anchor, anchor, base_uuids + ) + for problem in problems: + sys.stderr.write(problem + "\n") + if problems: + return 1 + print("identity anchor vs base: ok") + return 0 + if args.verify_registry_append_only is not None: registry = UuidRegistry.load(args.registry) base = ( diff --git a/tests/fixtures/series_catalog/identity_anchor.json b/tests/fixtures/series_catalog/identity_anchor.json index 64996cbc..c9695388 100644 --- a/tests/fixtures/series_catalog/identity_anchor.json +++ b/tests/fixtures/series_catalog/identity_anchor.json @@ -1,5 +1,5 @@ { - "comment": "The reviewed identity anchor of ledger/series_catalog.json: sha256 is build_series_catalog.identity_anchor_digest of the catalog rows' [uuid, concept, geography key, entity key] tuples, the digest the adversarial review of PR #128 started. Changing any live binding, or adding or retiring one, moves it, so the same diff must move this pin; test_identity_uuid_map_matches_reviewed_anchor checks it with identity_anchor_problems. A curator who moves the pin sets move to null and explains the move in that test's comment history. The Thesis resolver may move it in its own append proposal and records how in move: by, run_url, from_sha256 (the pin it moved from) and events, one per identity tuple the append changed. The test re-derives from_sha256 from the current catalog and those events and accepts only mints of full identities under fresh UUIDs and enrichments of entity-free docket placeholders under the same UUID and concept, so a machine move is re-checked here, never taken on trust.", + "comment": "The reviewed identity anchor of ledger/series_catalog.json: sha256 is build_series_catalog.identity_anchor_digest of the catalog rows' [uuid, concept, geography key, entity key] tuples, the digest the adversarial review of PR #128 started. Changing any live binding, or adding or retiring one, moves it, so the same diff must move this pin; test_identity_uuid_map_matches_reviewed_anchor checks it with identity_anchor_problems. A curator who moves the pin sets move to null and explains the move in that test's comment history. The Thesis resolver may move it in its own append proposal and records how in move: by, run_url, from_sha256 (the pin it moved from) and events, one per identity tuple the append changed. The test re-derives from_sha256 from the current catalog and those events, accepting only mints of full identities under fresh UUIDs and enrichments of entity-free docket placeholders under the same UUID and concept. CI's 'Identity anchor move continues the base pin' step then requires from_sha256 to be the comparison base's pin and every mint's UUID to be new to the base registry.", "sha256": "d5b88530f3f0c9e709dc0a0d379302db539d2600f76f60ed7fdd83f7474e5eef", "move": null } diff --git a/tests/test_build_series_catalog.py b/tests/test_build_series_catalog.py index 71fa56ef..8c282a3e 100644 --- a/tests/test_build_series_catalog.py +++ b/tests/test_build_series_catalog.py @@ -5551,3 +5551,116 @@ def test_enrichment_is_planned_exactly_when_the_grammar_admits_it( event["note"] = "generated ceremony" staged = registry.stage(_plan_events(plan)) assert bsc.registry_agreement_problems(catalog, staged) == [] + + +def test_a_malformed_event_kind_is_reported_not_raised() -> None: + series = [{"uuid": _anchor_uuid(0), "concept": "a.series", + "geography": _ANCHOR_FULL_GEOS[0], "entity": _ANCHOR_ENTITIES[0]}] + pin = bsc.identity_anchor_digest(bsc.identity_anchor_lines(series)) + for kind in (["mint"], {"x": 1}, None, 3): + anchor = {"comment": "c", "sha256": pin, "move": { + "by": "thesis-resolver", "run_url": None, "from_sha256": pin, + "events": [{"event": kind}], + }} + assert any( + "malformed event" in problem + for problem in bsc.identity_anchor_problems(series, anchor) + ), kind + + +def test_a_self_consistent_move_with_a_false_origin_is_caught_against_the_base( +) -> None: + """The 2026-10-19 review's case: the base holds (u_old, a); the change + remints it as (u_new, a) and records one "mint u_new" whose from_sha256 + is the current catalog minus u_new. identity_anchor_problems accepts the + record (it is consistent with itself); the base comparison does not.""" + geo, ent = _ANCHOR_FULL_GEOS[0], _ANCHOR_ENTITIES[0] + base_rows = [{"uuid": _anchor_uuid(1), "concept": "a.series", + "geography": geo, "entity": ent}] + current = [{"uuid": _anchor_uuid(2), "concept": "a.series", + "geography": geo, "entity": ent}] + forged = { + "comment": "c", + "sha256": bsc.identity_anchor_digest(bsc.identity_anchor_lines(current)), + "move": { + "by": "thesis-resolver", "run_url": None, + "from_sha256": bsc.identity_anchor_digest([]), + "events": [{"event": "mint", "uuid": _anchor_uuid(2), + "concept": "a.series", "geography": geo, + "entity": ent}], + }, + } + assert bsc.identity_anchor_problems(current, forged) == [] + base_anchor = { + "comment": "c", + "sha256": bsc.identity_anchor_digest(bsc.identity_anchor_lines(base_rows)), + "move": None, + } + problems = bsc.identity_anchor_continuity_problems( + base_anchor, forged, {_anchor_uuid(1)} + ) + assert problems and "but the base pins" in problems[0] + # A mint of a UUID the base registry holds is a revive, never a mint. + revived = json.loads(json.dumps(forged)) + revived["move"]["events"][0]["uuid"] = _anchor_uuid(1) + assert any( + "reuses a UUID the base registry already holds" in problem + for problem in bsc.identity_anchor_continuity_problems( + base_anchor, revived, {_anchor_uuid(1)} + ) + ) + + +@settings(max_examples=300, deadline=None) +@given(data=st.data()) +def test_a_machine_move_continues_the_base_exactly_when_it_starts_from_its_pin( + data: st.DataObject, +) -> None: + prior, current, events = _anchor_append(data) + assume(events) + anchor = _machine_anchor(prior, current, events) + base_anchor = {"comment": "c", "sha256": anchor["move"]["from_sha256"], + "move": None} + prior_uuids = {row["uuid"] for row in prior} + assert bsc.identity_anchor_continuity_problems( + base_anchor, anchor, prior_uuids + ) == [] + # Any base pin other than the move's origin (and other than the new pin, + # which would mean the change leaves the pin alone) is not continued. + other = data.draw(st.sampled_from(["f" * 64, "0" * 64])) + assume(other not in (base_anchor["sha256"], anchor["sha256"])) + assert bsc.identity_anchor_continuity_problems( + {**base_anchor, "sha256": other}, anchor, prior_uuids + ) + # With no base fixture at all, a machine move has nothing to continue. + assert bsc.identity_anchor_continuity_problems(None, anchor, prior_uuids) + # A pin the change leaves alone carries the base's record over. + assert bsc.identity_anchor_continuity_problems(anchor, anchor, prior_uuids) == [] + + +def test_the_anchor_continuity_cli(tmp_path: pathlib.Path) -> None: + geo, ent = _ANCHOR_FULL_GEOS[0], _ANCHOR_ENTITIES[0] + prior = [{"uuid": _anchor_uuid(1), "concept": "a.series", + "geography": None, "entity": None}] + current = [{"uuid": _anchor_uuid(1), "concept": "a.series", + "geography": geo, "entity": ent}] + events = [{"event": "enrich", "uuid": _anchor_uuid(1), "concept": "a.series", + "geography": geo, "entity": ent, "placeholder_geography": None}] + anchor = _machine_anchor(prior, current, events) + anchor_path = tmp_path / "anchor.json" + anchor_path.write_text(json.dumps(anchor)) + base_path = tmp_path / "base-anchor.json" + registry_path = tmp_path / "base-registry.jsonl" + registry_path.write_text(json.dumps({"uuid": _anchor_uuid(1)}) + "\n") + argv = ["--verify-anchor-move-from", str(base_path), "--anchor", + str(anchor_path), "--anchor-base-registry", str(registry_path)] + base_path.write_text(json.dumps( + {"comment": "c", "sha256": anchor["move"]["from_sha256"], "move": None} + )) + assert bsc.main(argv) == 0 + base_path.write_text(json.dumps( + {"comment": "c", "sha256": "e" * 64, "move": None} + )) + assert bsc.main(argv) == 1 + base_path.write_text("") + assert bsc.main(argv) == 1 From 6baa5a494eaf8ef1ec6160ccc7ba23eaa0b5fd64 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 19 Oct 2026 17:46:43 -0400 Subject: [PATCH 3/6] Refuse repeated rows and re-minted identities in a machine anchor move Review round 2 (hard tier, served by Opus 5.5 with the Codex lanes held): - P3-1: the digest covers the catalog's tuples as a list, duplicates included, while identity_anchor_problems undoes events on sets, so a repeated row or a UUID on two rows could hide a change. A machine move over such a catalog is now reported. - P3-2: a mint that repeats an identity the prior catalog already binds (the same concept, geography and entity under a second UUID) is reported. - P3-8: the continuity step leaves a new branch's first push to its pull request run (no comparison base of its own; a branch cut before the latest machine move would compare an older move with today's pin), and the CLI treats a missing identity anchor fixture as nothing to check. - P3-11: the soundness property is parametrized over its 13 mutations, so one Hypothesis can never exercise fails; the append generator binds each identity once, as a real catalog does. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 13 +++--- scripts/build_series_catalog.py | 26 ++++++++++++ tests/test_build_series_catalog.py | 68 ++++++++++++++++++++++++------ 3 files changed, 88 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b0b1cdef..25884724 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,15 +97,16 @@ jobs: BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }} - FALLBACK_REF: codex/thesis-ledger-facts run: | if [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then - git fetch --no-tags --depth=1 origin \ - "refs/heads/$FALLBACK_REF" - BASE_SHA=FETCH_HEAD - else - git fetch --no-tags --depth=1 origin "$BASE_SHA" + # A new branch's first push has no comparison base of its own, + # and a branch cut before the latest machine move would compare + # an older move with today's pin. Its pull request run checks + # the move against the pull request's base instead. + echo "new branch: the pull request run checks the anchor move" + exit 0 fi + git fetch --no-tags --depth=1 origin "$BASE_SHA" anchor=tests/fixtures/series_catalog/identity_anchor.json if git cat-file -e "$BASE_SHA:$anchor" 2>/dev/null; then git show "$BASE_SHA:$anchor" > /tmp/base-identity-anchor.json diff --git a/scripts/build_series_catalog.py b/scripts/build_series_catalog.py index 57f4a8b9..4b6fb388 100644 --- a/scripts/build_series_catalog.py +++ b/scripts/build_series_catalog.py @@ -3000,6 +3000,16 @@ def identity_anchor_problems(series: list[dict], anchor: dict) -> list[str]: events = move["events"] if not isinstance(events, list) or not events: return problems + ["identity anchor move records no events"] + # The digest covers the list, duplicates included, while the undo below + # works on sets, so a repeated row or a UUID on two rows would let a + # move hide a change. A machine move needs every row to be distinct. + current_uuids = [json.loads(line)[0] for line in current] + if len(set(current)) != len(current) or len(set(current_uuids)) != len( + current_uuids + ): + return problems + [ + "identity anchor move over a catalog that repeats a row or a UUID" + ] current_set = set(current) prior = set(current) seen: set[str] = set() @@ -3067,12 +3077,25 @@ def identity_anchor_problems(series: list[dict], anchor: dict) -> list[str]: "identity anchor move events are not sorted by (concept, uuid)" ) prior_uuids = [json.loads(line)[0] for line in prior] + prior_identities = {json.dumps(json.loads(line)[1:]) for line in prior} for uuid in mints: if uuid in prior_uuids: problems.append( f"identity anchor move: mint of {uuid} reuses a UUID the " "prior catalog holds" ) + for event in events: + if isinstance(event, dict) and event.get("event") == "mint": + identity = json.dumps([ + event.get("concept"), + _geo_key(event.get("geography")), + _entity_key(event.get("entity")), + ]) + if identity in prior_identities: + problems.append( + f"identity anchor move: mint of {event.get('uuid')} repeats " + "an identity the prior catalog already binds" + ) if len(prior_uuids) != len(set(prior_uuids)): problems.append( "identity anchor move: undoing its events leaves a UUID on two " @@ -3278,6 +3301,9 @@ def main(argv: list[str] | None = None) -> int: args = parser.parse_args(argv) if args.verify_anchor_move_from is not None: + if not args.anchor.exists(): + print("identity anchor vs base: ok (no identity anchor fixture)") + return 0 anchor = json.loads(args.anchor.read_text(encoding="utf-8")) base_raw = ( args.verify_anchor_move_from.read_text(encoding="utf-8") diff --git a/tests/test_build_series_catalog.py b/tests/test_build_series_catalog.py index 8c282a3e..5320574b 100644 --- a/tests/test_build_series_catalog.py +++ b/tests/test_build_series_catalog.py @@ -2481,6 +2481,8 @@ def test_identity_uuid_map_matches_reviewed_anchor() -> None: "unsorted_events", "unknown_mover", "stale_pin", + "repeated_row", + "mint_existing_identity", ] @@ -2504,10 +2506,16 @@ def _anchor_append(data: st.DataObject) -> tuple[list, list, list]: a machine move records for that append: placeholder enrichments under the same UUID and concept, and mints of full identities under fresh UUIDs.""" - prior = [] + def key(concept, geography, entity) -> str: + return json.dumps( + [concept, bsc._geo_key(geography), bsc._entity_key(entity)] + ) + + # As in a real catalog, every (concept, geography, entity) is bound once. + prior, held = [], set() for index in range(data.draw(st.integers(0, 6), label="rows")): placeholder = data.draw(st.booleans(), label="placeholder") - prior.append({ + row = { "uuid": _anchor_uuid(index), "concept": data.draw(st.sampled_from(_ANCHOR_CONCEPTS)), "geography": data.draw(st.sampled_from( @@ -2517,16 +2525,26 @@ def _anchor_append(data: st.DataObject) -> tuple[list, list, list]: "entity": None if placeholder else data.draw( st.sampled_from(_ANCHOR_ENTITIES) ), - }) + } + if key(row["concept"], row["geography"], row["entity"]) in held: + continue + held.add(key(row["concept"], row["geography"], row["entity"])) + prior.append(row) current = [dict(row) for row in prior] events = [] for row in current: - if row["entity"] is not None or not data.draw(st.booleans()): + options = [ + (geography, entity) + for geography in _admitted_geographies(row["geography"]) + for entity in _ANCHOR_ENTITIES + if key(row["concept"], geography, entity) not in held + ] + if row["entity"] is not None or not options or not data.draw( + st.booleans() + ): continue - geography = data.draw( - st.sampled_from(_admitted_geographies(row["geography"])) - ) - entity = data.draw(st.sampled_from(_ANCHOR_ENTITIES)) + geography, entity = data.draw(st.sampled_from(options)) + held.add(key(row["concept"], geography, entity)) events.append({ "event": "enrich", "uuid": row["uuid"], "concept": row["concept"], "geography": geography, "entity": entity, @@ -2540,6 +2558,9 @@ def _anchor_append(data: st.DataObject) -> tuple[list, list, list]: "geography": data.draw(st.sampled_from(_ANCHOR_FULL_GEOS)), "entity": data.draw(st.sampled_from(_ANCHOR_ENTITIES)), } + if key(mint["concept"], mint["geography"], mint["entity"]) in held: + continue + held.add(key(mint["concept"], mint["geography"], mint["entity"])) current.append(mint) events.append({"event": "mint", **mint}) events.sort(key=lambda event: (event["concept"], event["uuid"])) @@ -2583,22 +2604,23 @@ def test_a_machine_move_of_sanctioned_events_is_accepted( assert bsc.identity_anchor_problems(current, stale) +@pytest.mark.parametrize("mutation", _ANCHOR_MUTATIONS) @settings( - max_examples=600, + max_examples=120, deadline=None, suppress_health_check=[HealthCheck.filter_too_much], ) @given(data=st.data()) def test_a_machine_move_cannot_hide_any_other_binding_change( - data: st.DataObject, + mutation: str, data: st.DataObject, ) -> None: """Soundness: every way a recorded move can misstate its append — an unrecorded change, a remint or reused UUID, an entity change on a full identity, a concept change, an unadmitted geography, a placeholder - mint, a wrong origin, unsorted events, an unknown mover or a stale pin - — is reported.""" + mint, a wrong origin, unsorted events, an unknown mover, a stale pin, a + repeated row, or a mint of an identity already bound — is reported. + Parametrized, so a mutation Hypothesis can never exercise fails.""" prior, current, events = _anchor_append(data) - mutation = data.draw(st.sampled_from(_ANCHOR_MUTATIONS), label="mutation") enriched = [e for e in events if e["event"] == "enrich"] mints = [e for e in events if e["event"] == "mint"] full = [row for row in current if row["entity"] is not None @@ -2677,6 +2699,22 @@ def test_a_machine_move_cannot_hide_any_other_binding_change( assume(events) elif mutation == "stale_pin": assume(placeholders and events) + elif mutation == "repeated_row": + # A second copy of a mint's row: the digest (over the list) moves, + # the set the undo works on does not. + assume(mints) + row = data.draw(st.sampled_from( + [r for r in current if r["uuid"] in {m["uuid"] for m in mints}] + )) + current.append(dict(row)) + elif mutation == "mint_existing_identity": + assume(full) + row = data.draw(st.sampled_from(full)) + twin = {**row, "uuid": _anchor_uuid(998)} + current.append(twin) + events.append({"event": "mint", **{k: twin[k] for k in ( + "uuid", "concept", "geography", "entity")}}) + events.sort(key=lambda e: (e["concept"], e["uuid"])) anchor = _machine_anchor(prior, current, events) if mutation == "unknown_mover": anchor["move"]["by"] = "someone-else" @@ -5664,3 +5702,7 @@ def test_the_anchor_continuity_cli(tmp_path: pathlib.Path) -> None: assert bsc.main(argv) == 1 base_path.write_text("") assert bsc.main(argv) == 1 + # No identity anchor fixture at all (a base before it): nothing to check. + missing = ["--verify-anchor-move-from", str(base_path), "--anchor", + str(tmp_path / "absent.json")] + assert bsc.main(missing) == 0 From 53fd971cd4b918c5c134f93fbbd3b3bff0abac47 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 11:25:34 -0400 Subject: [PATCH 4/6] Refuse a machine anchor move over an identity bound twice Review round 3 (hard tier, Opus 5.5), P3-B: two mints of one identity in one move, or a placeholder enriched onto an identity another row already binds, left the prior catalog's identities untouched and so passed the mint-collision check. A machine move now requires every identity in the current catalog to be bound once (real catalogs are: 221 rows, 221 identities at every commit checked). Two new parametrized mutations cover both cases; each fails without the check. Co-Authored-By: Claude Opus 5.5 --- scripts/build_series_catalog.py | 10 +++++++--- tests/test_build_series_catalog.py | 26 ++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/scripts/build_series_catalog.py b/scripts/build_series_catalog.py index 4b6fb388..a4a738bc 100644 --- a/scripts/build_series_catalog.py +++ b/scripts/build_series_catalog.py @@ -3004,11 +3004,15 @@ def identity_anchor_problems(series: list[dict], anchor: dict) -> list[str]: # works on sets, so a repeated row or a UUID on two rows would let a # move hide a change. A machine move needs every row to be distinct. current_uuids = [json.loads(line)[0] for line in current] - if len(set(current)) != len(current) or len(set(current_uuids)) != len( - current_uuids + current_identities = [json.dumps(json.loads(line)[1:]) for line in current] + if ( + len(set(current)) != len(current) + or len(set(current_uuids)) != len(current_uuids) + or len(set(current_identities)) != len(current_identities) ): return problems + [ - "identity anchor move over a catalog that repeats a row or a UUID" + "identity anchor move over a catalog that repeats a row, a UUID " + "or an identity" ] current_set = set(current) prior = set(current) diff --git a/tests/test_build_series_catalog.py b/tests/test_build_series_catalog.py index 5320574b..6f0ce156 100644 --- a/tests/test_build_series_catalog.py +++ b/tests/test_build_series_catalog.py @@ -2483,6 +2483,8 @@ def test_identity_uuid_map_matches_reviewed_anchor() -> None: "stale_pin", "repeated_row", "mint_existing_identity", + "two_mints_one_identity", + "enrich_onto_bound_identity", ] @@ -2715,6 +2717,30 @@ def test_a_machine_move_cannot_hide_any_other_binding_change( events.append({"event": "mint", **{k: twin[k] for k in ( "uuid", "concept", "geography", "entity")}}) events.sort(key=lambda e: (e["concept"], e["uuid"])) + elif mutation == "two_mints_one_identity": + # Both rows are minted in this move, so neither is in the prior + # catalog the mint-collision check reads. + assume(mints) + mint = data.draw(st.sampled_from(mints)) + twin = {k: mint[k] for k in ("concept", "geography", "entity")} + twin["uuid"] = _anchor_uuid(997) + current.append(twin) + events.append({"event": "mint", **twin}) + events.sort(key=lambda e: (e["concept"], e["uuid"])) + elif mutation == "enrich_onto_bound_identity": + # A placeholder enriched onto an identity another row already binds. + assume(enriched and full) + event = data.draw(st.sampled_from(enriched)) + bound = data.draw(st.sampled_from(full)) + assume(bound["concept"] == event["concept"]) + assume(bound["geography"] in _admitted_geographies( + event["placeholder_geography"])) + for row in current: + if row["uuid"] == event["uuid"]: + row["geography"], row["entity"] = ( + bound["geography"], bound["entity"] + ) + event["geography"], event["entity"] = bound["geography"], bound["entity"] anchor = _machine_anchor(prior, current, events) if mutation == "unknown_mover": anchor["move"]["by"] = "someone-else" From 7b3c69feddeace4d7d5567629176251e7c8b1b89 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 11:26:04 -0400 Subject: [PATCH 5/6] Keep the repeated-identity message out of an implicit concatenation Co-Authored-By: Claude Opus 5.5 --- scripts/build_series_catalog.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/build_series_catalog.py b/scripts/build_series_catalog.py index a4a738bc..cee561aa 100644 --- a/scripts/build_series_catalog.py +++ b/scripts/build_series_catalog.py @@ -3010,10 +3010,11 @@ def identity_anchor_problems(series: list[dict], anchor: dict) -> list[str]: or len(set(current_uuids)) != len(current_uuids) or len(set(current_identities)) != len(current_identities) ): - return problems + [ + repeated = ( "identity anchor move over a catalog that repeats a row, a UUID " "or an identity" - ] + ) + return problems + [repeated] current_set = set(current) prior = set(current) seen: set[str] = set() From ad7f54cb3f9117e499b74513caaa4e78473336e2 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 11:48:18 -0400 Subject: [PATCH 6/6] Mark the mint-reuse checks defensive under the uniqueness rule Review round 4 nit: with rows, UUIDs and identities unique in the current catalog, the mint-reuse and mint-collision checks cannot fire; they stay as a guard in case the uniqueness rule is ever relaxed. Co-Authored-By: Claude Opus 5.5 --- scripts/build_series_catalog.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/build_series_catalog.py b/scripts/build_series_catalog.py index cee561aa..cd94b881 100644 --- a/scripts/build_series_catalog.py +++ b/scripts/build_series_catalog.py @@ -3081,6 +3081,9 @@ def identity_anchor_problems(series: list[dict], anchor: dict) -> list[str]: problems.append( "identity anchor move events are not sorted by (concept, uuid)" ) + # Defensive: with rows, UUIDs and identities unique in the current + # catalog (checked above), a mint cannot reuse a prior UUID or identity; + # these checks keep that true if the uniqueness rule is ever relaxed. prior_uuids = [json.loads(line)[0] for line in prior] prior_identities = {json.dumps(json.loads(line)[1:]) for line in prior} for uuid in mints: