Skip to content

fix(graph): an approved merge was reverted by the next registry load - #42

Merged
sturlese merged 3 commits into
mainfrom
fix/bughunt-registry-merge-revert
Jul 25, 2026
Merged

fix(graph): an approved merge was reverted by the next registry load#42
sturlese merged 3 commits into
mainfrom
fix/bughunt-registry-merge-revert

Conversation

@sturlese

@sturlese sturlese commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Ninth iteration of the autonomous bughunt sweep. A human-approved identity decision, silently undone by the next load.

Bug

apply_merge folded the absorbed names into the new canonical but left the entity that already owned them in place. save_registry then wrote a file where two entities each claim the same alias, and load_registry resolves that by last-writer-wins over entities iteration order — which, because save sorts by cid, is alphabetical cid order rather than the human's decision:

before                 : {'globex': 'globex-industries', 'globex industries': 'globex-industries'}
in-memory after approve: {'globex': 'globex',            'globex industries': 'globex'}
after save + reload    : {'globex': 'globex-industries', 'globex industries': 'globex-industries'}
entities in the file   : ['globex', 'globex-industries']

A human approved a merge into globex; the next load silently undid it.

The other sort direction is not better — the merge stands, but the curated entity's own display name is orphaned from its alias and build_entities emits two nodes for what a human declared one entity. ADR 008 wants identity decisions "auditable in one file's git history"; a file whose meaning flips on cid sort order is neither auditable nor decided.

Fix

The resolution depends on what the merge claimed, because this file is human-owned and removing a record needs more warrant than one shared string:

  • the other entity's own name or id is claimed → the merge is asserting they are the same entity → absorb it (its id and names become aliases) and remove it;
  • only one of its aliases is claimed → they are still distinct entities that happened to share a spelling → retarget that alias to the canonical and leave the entity otherwise alone.

Its old id is folded in on absorption. normalize keeps hyphens, so the slug globex-industries is a different key from the display name globex industries — dropping it would stop a reference written as the old id from resolving, and an absorbed entity should keep answering to everything it used to.

My first version absorbed on any overlap, and the gate refuted it. Two genuinely distinct entities can share a short alias, and it deleted one of them:

before        : ['acme-bank', 'acme-foods']     # acme-bank is a person, aliased "Acme"
after (v1)    : ['acme-foods']
ABG resolves  : acme-foods                      # a person's alias, now a food company

That folded a curated record's id, display name, type and every other alias into an unrelated entity, and vanished its graph node. Three more defects came from the same over-reach: a merge with absorbs: [] still deleted an entity; several proposals made the surviving identity whichever ran last (main was order-independent); and the canonical inherited the absorbed entity's other aliases, so a third entity claiming those recreated this very bug one hop out.

Nothing destructive is silent. apply_merge takes a log callable and merges approve passes print, because absorbing or retargeting somebody else's entity is a change the approver did not name:

note: alias(es) ['Acme'] moved from 'acme-bank' ('Acme Bank') to 'acme-foods';
      'acme-bank' is otherwise unchanged

by_alias is now derived in one place (_reindex), used by both load_registry and apply_merge. Built incrementally it kept entries pointing at ids that no longer exist — and merges approve applies every chosen proposal against one in-memory registry before saving once, so a later proposal would reason about a phantom entity. That is pinned in memory, not only across a save/load, because it is the live path:

assert reg.canonical_id("globex-industries") == "globex"
assert reg.entities.get("globex-industries") is None

Test

All three new tests confirmed to fail with the source change stashed.

  • test_apply_merge_absorbs_an_already_registered_entity — the headline case, asserted both in memory and across a save/load round trip, including that the old id still resolves.
  • test_apply_merge_absorbing_the_other_direction_also_settles — the mirror, where the absorbed id sorts first: the merge "survived" on main but left a contradictory file, so both names must resolve to the approved canonical.
  • test_apply_merge_leaves_unrelated_entities_alone — the guard against over-absorbing: an entity sharing no name with the merge keeps its id, aliases and resolution. Without it, "absorb everything" would pass the other two.

Mutation-tested, all six caught — including the refuted v1 design itself (absorb on any overlap: 3 failures), never absorbing (4), dropping the old id (1), skipping the reindex (3), claimed omitting the canonical's own id/name (1), and retargeting without stripping (3).

Two of those initially survived and both were my tests' fault rather than the code's: the realistic reindex mutation (keep main's incremental update, don't refactor) passed because my first tests all went through save/load, which reindexes anyway; and claimed omitting the canonical's own id/name passed because I asserted "nothing was deleted" rather than "the contradiction is resolved".

graph 47 (+7) · clean 256 · corpus 48 · slack 13 · fetch 33 · answer 66 · benchmark 3 — bare pytest (CI mode). ruff clean; golden scorecard 25/25, including the graph: canonical entity nodes metric.

Scope note

The existing test_apply_merge_and_save_roundtrip only ever merged into an empty registry, which is why this survived: the absorbed-existing-entity case was the untested half of the same function. Its idempotent-re-apply assertion still holds unchanged.

Deliberately not fixed

  • A registry already damaged by the old approve is not repaired. It still holds two entities claiming one alias and still resolves by cid sort order; only future merges are correct. A merges doctor that reports contradictions would be the honest remedy — backlog.
  • A contradiction a human hand-authors between entities no merge touches is carried forward, not silently resolved. Resolving someone else's ambiguity is not a merge's business.
  • Two contradictory proposals approved in one run are last-wins — deterministic given the human's approval order rather than an arbitrary sort, which is the improvement, but arguably it should refuse.

sturlese and others added 3 commits July 25, 2026 06:49
`apply_merge` folded the absorbed names into the new canonical but left the entity
that already owned them in place. `save_registry` then wrote a file where two
entities each claim the same alias, and `load_registry` resolves that by
last-writer-wins over `entities` iteration order — which, because save sorts by cid,
is alphabetical cid order rather than the human's decision:

    before                 : {'globex': 'globex-industries', 'globex industries': 'globex-industries'}
    in-memory after approve: {'globex': 'globex',            'globex industries': 'globex'}
    after save + reload    : {'globex': 'globex-industries', 'globex industries': 'globex-industries'}
    entities in the file   : ['globex', 'globex-industries']

So the human approved a merge into `globex` and the next load silently undid it. The
other sort direction is not better: the merge stands but the curated entity's own
display name is orphaned from its alias, and `build_entities` emits TWO nodes for what
a human declared one entity. ADR 008 wants identity decisions "auditable in one file's
git history"; a file whose meaning flips on cid sort order is neither auditable nor
decided.

apply_merge now absorbs the conflicting ENTITY, not just its names: any other entity
sharing a normalized name with the merge is folded in and deleted. Its old id goes in
too — normalize keeps hyphens, so the slug `globex-industries` is a different key from
the name `globex industries`, and an absorbed entity should keep answering to
everything it used to. Entities sharing no name with the merge are untouched.

`by_alias` is now derived in one place (`_reindex`) used by both load and apply_merge.
Built incrementally it kept entries pointing at ids that no longer exist, and
`merges approve` applies every chosen proposal against one in-memory registry before
saving once — so a later proposal would reason about a phantom entity. Pinned in
memory, not only across a save/load, because that is the live path.

Mutation-tested, all four caught: never absorbing (3 failures), absorbing every other
entity (1), dropping the old id from the fold (1), and skipping the reindex (1 — and
this one only bites once the in-memory assertion exists, which is why it is there).

graph 43 (+3). scorecard 25/25 (graph canonical-nodes metric included), ruff clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The guarantee is not "entities get absorbed" — it is that a merge never leaves two
entities claiming the same normalized name, because that is the state whose meaning
depends on cid sort order at load time. Asserted directly with a conflict detector
rather than inferred from a round trip.

Also pins the SCOPE, which I checked before claiming it: a merge into a clean file
leaves it clean, and a conflict a human hand-authored between entities the merge does
not touch is carried FORWARD, not silently resolved. Verified both ways — absorption is
one-hop (`claimed` is computed before the loop, so it cannot cascade) and independent
of `reg.entities` iteration order.

That leaves one thing this fix deliberately does not do: police contradictions a human
writes directly into the file, which still resolve by cid order on load. Separate
concern, recorded on the backlog rather than smuggled in here.

graph 44 (+1).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…records

Gate review REFUTED the previous approach, and it was right: absorbing on any
normalized-name overlap made `apply_merge` delete records from a human-owned identity
file, silently, on the strength of one shared string. Two genuinely distinct entities
can carry the same short alias:

    before        : ['acme-bank', 'acme-foods']        # acme-bank is a person, aliased "Acme"
    after (v1)    : ['acme-foods']
    ABG resolves  : acme-foods                         # a person's alias, now a food company

Its id, display name, type and every other alias were folded into an unrelated entity,
its graph node `entities/person/acme-bank` vanished, and `merges approve` printed output
byte-identical to before. That is worse than the bug it fixed. Three more defects fell
out of the same over-reach: a merge with `absorbs: []` still deleted an entity (the
canonical's own id/name are always in `claimed`); with several proposals the surviving
identity became whichever ran LAST, where main was order-independent; and the canonical
inherited the absorbed entity's OTHER aliases, so a third entity claiming those
recreated the exact cid-sort-order contradiction one hop out — the headline symptom,
reproduced on the fix.

The resolution now depends on WHAT the merge claimed, which is the warrant question:

- the other entity's own NAME or ID is claimed -> the merge is asserting they are the
  same entity, so absorb and remove it (as before).
- only one of its ALIASES is claimed -> they are still distinct entities that shared a
  spelling. Retarget that alias to the canonical and leave the entity otherwise alone.

Verified across all four: the person survives with its type and ABG intact, the genuine
same-entity case still absorbs and its old id still resolves, a no-op merge deletes
nothing, and the one-hop-out case now survives a reload because the canonical claims
only what the merge named.

And nothing destructive is silent any more. `apply_merge` takes `log`, and `merges
approve` passes `print`, so an absorption or a retarget appears in the operator's
output rather than only in the file's git diff:

    note: alias(es) ['Acme'] moved from 'acme-bank' ('Acme Bank') to 'acme-foods';
          'acme-bank' is otherwise unchanged

Mutation-tested, all six caught — including the refuted v1 design itself (absorb on any
overlap: 3 failures), and the two the reviewer found surviving: skipping the reindex,
and dropping the canonical's own id/name from `claimed`. The second needed a stronger
assertion than "nothing was deleted" — that the contradiction is actually resolved.

graph 47 (+7). scorecard 25/25, ruff clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sturlese

Copy link
Copy Markdown
Owner Author

Gate verdict

VERDICT: REFUTED — and the reviewer was right on every count. My fix was worse than the bug it fixed. Redesigned in 346f2ed.

The core error: I gave apply_merge authority to delete curated records

The registry is a human-owned identity file (ADR 008). My version absorbed any entity whose normalized names overlapped the merge's — and two genuinely distinct entities can carry the same short alias:

before        : ['acme-bank', 'acme-foods']     # acme-bank is a person, aliased "Acme"
after (v1)    : ['acme-foods']
ABG resolves  : acme-foods                      # a person's alias, now a food company

Its id, display name, type person and every other alias were folded into an unrelated company; its graph node entities/person/acme-bank vanished and links to it dangled. The judgment upstream was "these spellings are the same entity", not "delete Acme Bank".

Three more defects fell out of the same over-reach, all verified:

  • A merge absorbing nothing still deleted an entityclaimed always includes the canonical's own id and name, so absorbs: [] was enough.
  • New order dependence — with several proposals the surviving canonical identity became whichever ran last. Main was order-independent here; I traded sensitivity to cid sort order for sensitivity to approval order.
  • The headline symptom survived one hop out — the canonical inherited the absorbed entity's other aliases, so a third entity claiming those recreated the exact cid-sort-order contradiction. The reviewer reproduced my own before/after table on the fix.

The redesign

Resolution now depends on what the merge claimed, which is the warrant question:

  • the other entity's own name or id is claimed → the merge is asserting they are the same entity → absorb and remove it, as before;
  • only one of its aliases is claimed → they are still distinct entities that shared a spelling → retarget that alias to the canonical and leave the entity otherwise alone.

That fixes all four findings at once. The person survives with its type and ABG intact; the genuine same-entity case still absorbs and its old id still resolves; a no-op merge deletes nothing; and the one-hop case survives a reload because the canonical now claims only what the merge named.

Nothing destructive is silent any more

The reviewer's point that byte-identical CLI output while destroying a record fails the minimum bar was fair. apply_merge takes a log callable and merges approve passes print:

note: alias(es) ['Acme'] moved from 'acme-bank' ('Acme Bank') to 'acme-foods';
      'acme-bank' is otherwise unchanged

Mutation testing, including the refuted design

All six caught now, where two survived before:

mutant result
never absorb / never retarget 4 failed
absorb on ANY overlap (the refuted v1) 3 failed
drop the old id from the fold 1 failed
skip the reindex in apply_merge 3 failed
claimed omits the canonical's own id/name 1 failed
retarget but don't strip the alias 3 failed

Two corrections to my earlier claims the reviewer forced. My mutation report said skipping the reindex was caught — the realistic form (keep main's incremental update) survived; it's caught now. And claimed omitting the canonical's id/name survived because my test only asserted "nothing was deleted"; it now asserts the contradiction is actually resolved, which is the property that matters.

Confirmed sound, and worth recording

  • _reindex is behaviour-preserving for load_registry: on a pre-existing shared alias, main's incremental build and _reindex produce identical maps.
  • The phantom-entity resurrection across save/load that main had is genuinely fixed.
  • by_alias is written in exactly one place now; no sibling writer missed.
  • Absorption is one-hop and independent of reg.entities iteration order (claimed is computed before the loop).

Recorded, not fixed

  • Registries already damaged by main are not repaired. A file written by the old approve still holds two entities claiming one alias and still resolves by cid sort order. Only future merges are correct. A merges doctor that reports contradictions would be the honest remedy — backlog.
  • A contradiction a human hand-authors between entities no merge touches still resolves by cid order at load. Separate, pre-existing.
  • Two contradictory proposals approved in one run are still last-wins. Deterministic given the human's approval order rather than an arbitrary sort, which is an improvement, but arguably it should refuse.

graph 47 (+7) · clean 256 · corpus 48 · slack 13 · fetch 33 · answer 66 · benchmark 3, bare pytest (CI mode) · ruff clean · scorecard 25/25.

@sturlese
sturlese merged commit 2c08602 into main Jul 25, 2026
10 checks passed
@sturlese
sturlese deleted the fix/bughunt-registry-merge-revert branch July 25, 2026 05:03
sturlese added a commit that referenced this pull request Jul 25, 2026
…e store (#44)

* fix(facts): a doc re-judged `minimal` left its previous numbers in the store

factstore states the contract at the top of the file: "Idempotent per document: a
reprocess REPLACEs the document's observations atomically". The `skipped` path enforces
it explicitly — "a doc downgraded to noise must not leave stale numbers behind" — but
that is one of two ways a pass can produce no facts.

A prose document re-judged `minimal` takes the other: the extraction branch declines,
`fact_rows` stays None, and neither replace_facts nor delete_facts runs. The previous
pass's rows stay in the store carrying a page_path that points at a page which no
longer contains those figures — so the answer layer can serve a number whose own
citation does not support it, which is precisely what the trust layer exists to prevent.

The clear is deliberately conditional on the extractor being CONFIGURED. Producing no
rows because the document's content declined is a statement about the document; producing
none because CLEAN_FACTS_PROSE is switched off is a statement about the config, and a
config change must not silently wipe the store. Those rows go stale instead, which turning
the extractor back on repairs — deletion would not.

I narrowed it to that before the gate had to: the first version cleared whenever no rows
appeared, which would have made turning the extractor off destroy every fact it had ever
extracted. That is the same over-reach pattern as PR #42, where absorbing on any name
overlap deleted curated records.

Both directions pinned. `test_process_one_minimal_representation_clears_stale_facts`
mirrors the `skipped` sibling and fails today (the pass-1 rows survive);
`test_switching_the_extractor_off_does_not_wipe_the_store` fails against the over-broad
version. Mutation-tested: no clear at all, clear-whenever-no-rows, and checking the wrong
processor each fail exactly one.

clean 258 (+2). scorecard 25/25 with all three facts metrics unchanged, ruff clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(facts): the rule is what the PAGE can support, not whether the extractor is on

Gate review confirmed the behaviour but demolished the reasoning, and the reasoning was
load-bearing — it decided the condition.

I claimed `minimal` means "the content no longer carries facts". agents.py says the
opposite: `minimal` is "raw data without narrative, or a low-value doc -> pointer (title
+ type + source link)" — the numbers-heavy class. The SOURCE may still be full of
figures; it is the PAGE that stops carrying them. Since every row cites a page via
page_path, the actual invariant is that a pointer page cannot support a figure.

That reframing fixes the condition. It is not "is the extractor configured" —
config has nothing to do with what a page contains — it is `representation == "minimal"`.
Which also resolves an incoherence the reviewer found and I had shipped: with
CLEAN_FACTS_PROSE off (a reachable config), the `skipped` path I cite as precedent
deletes unconditionally, while my carve-out refused to. Either both gate on config or
neither does. Neither does: a skipped doc has no page at all, a minimal doc has a pointer
page, and neither can support a citation. Same reasoning, one step apart.

The full/digest-with-extractor-off case is still deliberately untouched, now for a reason
that survives inspection: that page carries its narrative and supports its rows.

Deleted a false claim from the shipped comment. I wrote that config-off rows are
"recoverable by turning it back on" — verified false: classify_pending keys on the content
hash and never on a config change, so flipping the extractor back on re-runs nothing. The
comment now says they go stale until the document's bytes change, which is what happens.

Also from the review: the deletion was the only destructive facts operation with no audit
trail. It now reports `cleared` and main surfaces it on the pass line. And the sheet half
of my old condition was unreachable — the extraction guard is byte-identical to it — so it
is gone rather than left as dead code with a comment.

Two mutants that previously survived now fail: the carve-out applied config-gating (my own
earlier version is now a failing mutant), and `if fact_rows:` instead of `is not None`,
which would silently stop clearing when an extraction runs and keeps nothing. The latter is
pinned by a new sheet test, since the reviewer showed the suite did not cover it.

Noted, not claimed: the golden evals cannot exercise this branch at all — the fake backend
never emits `representation="minimal"` — so "scorecard unchanged" is true but not evidence.
The two unit tests are.

clean 259 (+3). scorecard 25/25, ruff clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant