fix(desktop): order retained agent events by the relay's own comparator - #3752
Open
wpfleger96 wants to merge 4 commits into
Open
fix(desktop): order retained agent events by the relay's own comparator#3752wpfleger96 wants to merge 4 commits into
wpfleger96 wants to merge 4 commits into
Conversation
The retention cache resolved an equal-`created_at` collision by skipping the inbound event unconditionally, while the relay breaks that tie by lowest event id. The two could therefore disagree about which same-second event is the head, and every later disk-vs-head compare inherited the error. Retained rows now carry the event id as a real column so the local compare can match `replace_parameterized_event`. A pending row still wins its tie regardless of id: it is durable local intent, arbitrated against a writer-consistent head by the boot pass rather than dropped by a cache compare. The id is re-derived from a parsed and verified event, never read from the JSON's self-asserted `id` field, so a legacy row whose bytes do not verify stays unresolved instead of receiving a key it could win a tie with. `RetainedEvent::pending`/`::inbound` replace the hand-built literals at every call site, which makes it structurally impossible for a row's ordering key to describe different bytes than its own `raw_event`. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
A pending row is durable local intent — an edit this device made and has not yet published. Resolving inbound events by timestamp let a strictly newer event clear `pending_sync` and patch `personas.json`, destroying that edit. Timestamps are not evidence of newer intent: the laundering vector this change targets re-signs stale content at `max(now, head + 1)`, so every laundered revert arrives strictly newer. That made the untrusted input the one that always won. The pending check now precedes every ordering rule, and the new `Deferred` outcome keeps "lost the compare" distinct from "awaiting arbitration" for the boot decision pass. Callers gate on `Applied` so neither non-apply outcome reaches disk. Until that pass lands a pending row shadows genuinely newer remote edits for its coordinate; flush normally clears it within seconds, and an edit that can still be reconciled is worth more than one that cannot be recovered. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The config-sync boot reconcile reads a persona coordinate's relay head and treats "no event" as "deleted upstream". A read replica that has not replayed the event yet returns the same empty page as a real deletion, and no staleness budget separates them: RoutePredicate bounds how far behind a served page may be, never whether one coordinate has replayed. Absence is therefore unsafe to route at all. A per-filter `sync_authoritative: true` sends that filter to `Db::query_events` (writer pool) instead of `query_events_routed`. It is opt-in because only the caller knows it is about to act on absence — pinning every bridge read would erase the replica offload, and pinning by kind would re-route display reads of the same kinds. A non-boolean value rejects with 400 rather than degrading to unpinned, matching `before_id`: silently downgrading a consistency request returns a wrong answer the caller cannot detect. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The persona sync hook's one-shot backfill runs over a WS REQ, whose historical read takes `query_events_routed` — replica-eligible. Its results feed the same sync state machine that reads a missing head as "deleted upstream", so a not-yet-replayed replica could present a live coordinate as deleted. The HTTP `/query` flag alone did not cover it. The backfill is not retired in favor of the boot barrier because it closes a gap the barrier does not: a device coming online after another has published gets no history from a live-only subscription (no since-cursor until the first live event), and the hook re-runs on identity and community switches, which are not boots. Pinning is subscription-wide rather than per-filter because one REQ yields one deduplicated result set; serving part of it from a replica would leave the caller unable to say which events were consistent. The live subscription stays unpinned — it is fan-out of new writes, not a historical read. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
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.
Agent system prompts, models, and thinking/effort settings revert on their own when more than one Buzz build shares an identity against the same relay. Agent configs sync as NIP-33 replaceable events (30175 definitions, 30176 teams, 30177 agent instances) resolved last-write-wins on
created_at, and every desktop boot re-signs whatever its local store holds with a timestamp guaranteed to supersede the relay head. A stale store therefore launders old content into a winning head at every launch.This is the first of two PRs. It fixes the ordering primitive the rest of the fix builds on; the boot decision pass that stops the laundering follows in the second.
Pending local intent outranks ordering
pending_sync = 1marks durable local intent — an edit the user made that this device has not published yet.retain_inbound_eventnow checks for a pending row before any timestamp or event-id comparison, and returns a distinctDeferredoutcome that mutates nothing.A newer
created_atis not evidence of newer intent. The laundering vector above mints events atmonotonic_created_at = max(now, head + 1), so every laundered revert arrives strictly newer than the edit it reverts. Resolving inbound events by timestamp alone therefore handed the win to precisely the untrusted input: a strictly newer event clearedpending_syncand patchedpersonas.json, destroying the unpublished edit before any arbitration could see it.Callers in
commands/personas/inbound.rsgate onApplied, so neither non-apply outcome reaches disk.Deferredstays distinct fromSkippedso the second PR can tell "lost the ordering compare" from "still owes arbitration."Accepted tradeoff: until the boot decision pass lands, a pending row shadows genuinely newer remote edits for its coordinate. A normal flush clears the flag within seconds, and an edit that can still be reconciled is worth more than one that cannot be recovered.
The ordering bug
For non-pending rows, the retention cache and the relay disagreed about which of two same-second events is the head.
replace_parameterized_event(crates/buzz-db/src/lib.rs) breaks an equalcreated_atby lowest event id — it rejects an incoming event whencreated_at == accepted_ts && incoming_id >= accepted_id. The desktop cache skipped every equal-timestamp inbound event instead. Nostr timestamps are seconds-granularity, so this collision is reachable in normal use, and once the cache held a head the relay disagreed with, every later disk-vs-head comparison inherited the error.retain_inbound_eventnow applies the relay's own rule to non-pending rows.Trusting the ordering key
event_idis stored as a real column rather than reparsed fromraw_eventon every compare, and it is always derived from an event that parsed and cryptographically verified — never read from the JSON's self-assertedidfield. A legacy row whose stored bytes do not verify keepsevent_id IS NULLand is treated as unorderable: the retained row stands and nothing is decided from a fabricated key. Handing such a row a self-asserted id would let it win a tie it should lose.RetainedEvent::pending/::inboundreplace the hand-built struct literals at all 13 call sites. Every event-derived field is read from the single event passed in, so a row built through them cannot carry anevent_iddescribing different bytes than its ownraw_event— the ordering key and the payload cannot drift apart.Schema migration
baseline_event_idandbaseline_contentare added alongsideevent_id; they are unused here and carry the provenance the second PR's decision table reads.The migration is additive and crash-safe. A cheap read-only
pragma_table_infoprobe decides whether anything is missing; only then does it open aBEGIN EXCLUSIVEtransaction, re-probe inside the lock, and apply theALTER TABLEs together with theevent_idbackfill. Two processes opening the same database concurrently are safe by construction — the loser waits on the write lock, re-probes, and finds nothing to do. A crash mid-migration rolls back whole, so a column can never exist with its backfill half-applied.Behavior deliberately unchanged
Interactive save paths keep force-publish semantics, and the boot reconcile still re-signs past the retained head. Those are the second PR's subject. Nothing here changes what gets published — only which of two same-second events the local cache considers current.
Related: this is the state-model half of the config-revert fix; the boot barrier, decision table, writer-pinned relay seam, publication gate, and parked-conflict surface land in the follow-up PR against this branch.