fix(federation): entity events must not blank a peer's metadata - #43
Open
DarrenZal wants to merge 1 commit into
Open
fix(federation): entity events must not blank a peer's metadata#43DarrenZal wants to merge 1 commit into
DarrenZal wants to merge 1 commit into
Conversation
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').
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
Both
/register-entityemit sites inlined a payload literal hardcoding"aliases": []/"metadata": {}, and the subscriber's UPSERT didSET 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(proofstore_new_entityran locally and always writes a populated metadata JSON) yet heldmetadata = '{}'. There is no audit table, so those are unrecoverable and the count is a lower bound. NUC rows carriedupdated_attimestamps 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_payloadreads the authoritativeentity_registryrow and sends realaliases/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:
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_3072viascripts/backfill_entity_embeddings.py, which also composes richer text (name + context + description) than the register path's bare name.Subscriber
_apply_entitynow merges instead of replacing:metadata{}is a no-opaliasesdescriptionphonetic_codeIdentity 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_valuediscriminator 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 ||andarray_agg(DISTINCT ...)semantics cannot be faked. So this was verified against both live databases:NEWarrivals still insertThen end-to-end over the wire: an entity updated on the MacBook arrived on the NUC carrying its real
descriptionandmetadata.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 asjson_module_globaland never binds barejson(ajson.loadsthere is a runtimeNameErrorthatpy_compilemisses — it bit me while writing this).Pre-existing failures unaffected: 10 in
tests/unit, identical with and without this change.🤖 Generated with Claude Code