Skip to content

fix(federation): entity events must not blank a peer's metadata - #43

Open
DarrenZal wants to merge 1 commit into
regen-prodfrom
fix/entity-federation-metadata-clobber
Open

fix(federation): entity events must not blank a peer's metadata#43
DarrenZal wants to merge 1 commit into
regen-prodfrom
fix/entity-federation-metadata-clobber

Conversation

@DarrenZal

Copy link
Copy Markdown
Contributor

The bug

Both /register-entity emit sites inlined a payload literal hardcoding "aliases": [] / "metadata": {}, and the subscriber's UPSERT did SET metadata = EXCLUDED.metadata.

So an inbound entity event did not merely fail to teach the peer anything — it erased what the peer had computed locally, on every federation round-trip.

Measured before the fix: 644 rows on the MacBook node and 964 on the NUC had first_seen_rid IS NOT NULL (proof store_new_entity ran locally and always writes a populated metadata JSON) yet held metadata = '{}'. There is no audit table, so those are unrecoverable and the count is a lower bound. NUC rows carried updated_at timestamps from minutes before the fix — it was still firing.

Found while investigating why a person indexed through Dobby was unfindable (DarrenZal/dobby#7). The specific clobber event was still in the queue: the thin copy echoed back and blanked {"context":null,"mentions":["Ash"],"confidence":1.0}, and the content-free stub had already gone out to three external peers.

Producer

New _build_entity_federation_payload reads the authoritative entity_registry row and sends real aliases / metadata / description / phonetic_code. Falls back to caller-supplied name/type if the row vanished between write and emit (e.g. a merge), so a federation emit can never crash the request path.

Deliberately does not ship the embedding vector. Measured on the live queue:

domain events / 30d avg payload
entity 22,186 241 bytes
fact/episode (ships vectors) 620 ~1 MB

A 3072-float vector is ~60 kB, which would take the entity domain from ~5 MB to ~1.3 GB/month. The fact domain gets away with it at 620 events; entity events are 36× more frequent. Receiving nodes fill embedding_3072 via scripts/backfill_entity_embeddings.py, which also composes richer text (name + context + description) than the register path's bare name.

Subscriber

_apply_entity now merges instead of replacing:

field rule
metadata jsonb concat — incoming keys win, absent keys survive, {} is a no-op
aliases set union — the array can never shrink
description keep local unless the incoming value is non-empty
phonetic_code fill-if-missing (drives Tier-1.x phonetic matching)
embedding fill-if-missing; never overwrites a local vector

Identity fields (entity_text / entity_type / normalized_text) still replace outright — unchanged behaviour, so a legitimate rename or retype still converges.

Also accepts an optional inbound vector via the same embedding_column / embedding_value discriminator the fact path uses (nothing emits one today; honoured rather than silently dropped, and unknown column names are rejected rather than interpolated).

Verification

Unit tests are necessary but not sufficient here — jsonb || and array_agg(DISTINCT ...) semantics cannot be faked. So this was verified against both live databases:

  • a thin event no longer blanks local metadata / aliases / description / phonetic_code
  • a rich event merges without dropping local keys; aliases union to 3 distinct
  • an empty incoming description does not clobber a populated local one
  • NEW arrivals still insert

Then end-to-end over the wire: an entity updated on the MacBook arrived on the NUC carrying its real description and metadata.context, merged non-destructively.

14 new unit tests in tests/unit/test_entity_federation_merge.py, including an AST guard so no emit site can reintroduce the empty-dict literal, and a guard for the fact that this module aliases stdlib json as json_module_global and never binds bare json (a json.loads there is a runtime NameError that py_compile misses — it bit me while writing this).

Pre-existing failures unaffected: 10 in tests/unit, identical with and without this change.

🤖 Generated with Claude Code

Both /register-entity emit sites inlined a payload literal hardcoding
`"aliases": []` / `"metadata": {}`, and the subscriber's UPSERT did
`SET metadata = EXCLUDED.metadata`. So an inbound entity event did not
merely fail to teach the peer anything — it ERASED what the peer had
computed locally, on every federation round-trip.

Measured blast radius before the fix: 644 rows on the MacBook node and
964 on the NUC had `first_seen_rid IS NOT NULL` (proof store_new_entity
ran locally and wrote a populated metadata JSON) yet held `metadata =
'{}'`. There is no audit table, so those are unrecoverable and the count
is a lower bound. NUC rows carried updated_at timestamps from minutes
before the fix, i.e. it was still firing.

Producer: _build_entity_federation_payload reads the authoritative
entity_registry row and sends real aliases/metadata/description/
phonetic_code. Deliberately does NOT ship the embedding vector — entity
events run ~22k/30d at ~241 bytes, and a 3072-float vector is ~60 kB,
which would take the domain from ~5 MB to ~1.3 GB/month. (The fact
domain does ship vectors, but it is only ~620 events/30d.) Receiving
nodes fill embedding_3072 via scripts/backfill_entity_embeddings.py.

Subscriber: _apply_entity now merges rather than replaces — jsonb concat
for metadata, set union for aliases, keep-local-unless-non-empty for
description, fill-if-missing for phonetic_code and any inbound vector.
Identity fields still replace outright so renames/retypes converge.

Verified against both live databases (not just unit tests): a thin event
no longer blanks local data, a rich event merges without dropping local
keys, aliases union to 3 distinct, an empty description does not clobber,
and NEW arrivals still insert.

Pre-existing and untouched: 10 unit failures in test_knowledge_router.py
and test_federation_bridge.py::test_insert_new_entity (that one asserts
'TP' in aliases but normalize_alias_list lowercases to 'tp').
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