diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72593508..25884724 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,6 +82,48 @@ 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 }} + run: | + if [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then + # 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 + 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 2f8fb414..cd94b881 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 = ( @@ -2859,6 +2862,313 @@ 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. 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"} + >>> 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"] + # 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] + 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) + ): + 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() + order: list[tuple[str, str]] = [] + mints: list[str] = [] + for event in events: + kind = event.get("event") if isinstance(event, dict) else None + 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}" + ) + 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)" + ) + # 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: + 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 " + "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 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: @@ -2979,8 +3289,59 @@ 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: + 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") + 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 new file mode 100644 index 00000000..c9695388 --- /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, 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 4c142160..6f0ce156 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,337 @@ 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", + "repeated_row", + "mint_existing_identity", + "two_mints_one_identity", + "enrich_onto_bound_identity", +] + + +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.""" + 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") + row = { + "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) + ), + } + 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: + 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, 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, + "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)), + } + 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"])) + 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) + + +@pytest.mark.parametrize("mutation", _ANCHOR_MUTATIONS) +@settings( + 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( + 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, 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) + 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) + 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"])) + 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" + 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) ) @@ -5289,3 +5615,120 @@ 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 + # 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