Skip to content

fix(desktop): order retained agent events by the relay's own comparator - #3752

Open
wpfleger96 wants to merge 4 commits into
mainfrom
duncan/agent-config-revert-fix
Open

fix(desktop): order retained agent events by the relay's own comparator#3752
wpfleger96 wants to merge 4 commits into
mainfrom
duncan/agent-config-revert-fix

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Jul 30, 2026

Copy link
Copy Markdown
Member

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 = 1 marks durable local intent — an edit the user made that this device has not published yet. retain_inbound_event now checks for a pending row before any timestamp or event-id comparison, and returns a distinct Deferred outcome that mutates nothing.

A newer created_at is not evidence of newer intent. The laundering vector above mints events at monotonic_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 cleared pending_sync and patched personas.json, destroying the unpublished edit before any arbitration could see it.

Callers in commands/personas/inbound.rs gate on Applied, so neither non-apply outcome reaches disk. Deferred stays distinct from Skipped so 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 equal created_at by lowest event id — it rejects an incoming event when created_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_event now applies the relay's own rule to non-pending rows.

Trusting the ordering key

event_id is stored as a real column rather than reparsed from raw_event on every compare, and it is always derived from an event that parsed and cryptographically verified — never read from the JSON's self-asserted id field. A legacy row whose stored bytes do not verify keeps event_id IS NULL and 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 / ::inbound replace 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 an event_id describing different bytes than its own raw_event — the ordering key and the payload cannot drift apart.

Schema migration

baseline_event_id and baseline_content are added alongside event_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_info probe decides whether anything is missing; only then does it open a BEGIN EXCLUSIVE transaction, re-probe inside the lock, and apply the ALTER TABLEs together with the event_id backfill. 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.

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>
@wpfleger96
wpfleger96 requested a review from a team as a code owner July 30, 2026 14:39
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 and others added 3 commits July 30, 2026 11:20
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>
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