Relay-primary private managed-agent config - #4999
Conversation
Keep encrypted runnable configuration in an owner-scoped relay event while preserving local records as migration state. Validate inbound payloads before retention, expose fresh-device records through an ephemeral scoped overlay, and retain public/private heads and tombstones atomically. Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz> Co-authored-by: Mongo <5c25403eab7271f9f94ddd4f2b270e8cac2c92e2c830c51877cca6ec974ffb3f@buzz.block.builderlab.xyz> Co-authored-by: Princess Donut <68157ebd23b3897c1991015c3038658ea916200c67d3a54620b0754d1b92f6e0@buzz.block.builderlab.xyz> Signed-off-by: Wes <wesbillman@users.noreply.github.com>
wesbillman
left a comment
There was a problem hiding this comment.
Reviewing and commenting on Wes's behalf. Blocking verdict: changes requested. The codec, owner-only relay classification, inbound validation-before-retention, scope-clear serialization, and atomic public/private retention all look sound. However, the new overlay start branch creates lifecycle holes that make relay-restored agents unsafe or impossible to manage. Please route resolved overlay records through a lifecycle that preserves the existing transition/preflight/provider/persistence invariants and supports stop/delete for truly relay-only records.
This PR changes behavioral tests and adds codec, inbound, overlay, and reconciliation coverage; none of the added overlay tests exercises a real start → stop/delete lifecycle or concurrent shutdown. Add regressions for a disk-backed overlaid agent and a fresh-device relay-only agent.
Focused checks at this exact clean head: cargo test -p buzz-core private_managed_agent --lib (13 passed), cargo test -p buzz-relay ingest --lib (161 passed; filter selected the ingest module plus one dependent test), and git diff --check passed. Desktop Core and Desktop E2E checks were still running when reviewed; existing CI covers the broad suites.
| .map_err(|error| error.to_string())? | ||
| .contains(&pubkey) | ||
| { | ||
| return crate::managed_agents::private_config_overlay::start_relay_only_agent( |
There was a problem hiding this comment.
P1 — Do not divert every overlaid disk record around its existing start lifecycle. Presence in this in-memory overlay is not equivalent to being relay-only: resolved_record can start from an existing disk-backed record and then apply the patch. This branch therefore bypasses start_local_agent_with_preflight (including effective-config/relay-mesh validation, persona handling, saving start metadata, retained publication, and profile reconciliation), and it bypasses provider deployment entirely because the helper rejects any resolved provider backend. Merely receiving a valid private event can thus make an ordinary local/provider agent follow a materially weaker or unusable path. Distinguish true relay-only records from disk-backed records and route the latter through the normal resolved local/provider pipeline; add a regression proving an overlaid disk record retains those lifecycle guarantees.
| .managed_agent_processes | ||
| .lock() | ||
| .map_err(|e| e.to_string())?; | ||
| start_managed_agent_process(app, &mut record, &mut runtimes, Some(owner_hex))?; |
There was a problem hiding this comment.
P1 — A fresh-device relay-only runtime started here cannot be stopped or deleted through the normal agent API. This spawns from a temporary record and stores only the runtime pair. stop_managed_agent reloads disk records and fails at find_managed_agent_mut; delete_managed_agent likewise requires a disk record and returns agent … not found. The frontend's normal local actions call those commands, so a relay-only agent can start successfully and then become uncontrollable until broader shutdown/process exit. This direct spawn also does not hold managed_agents_store_lock or the documented runtime-transition boundary, so it can run after a workspace/identity overlay clear or race shutdown and register a new child after shutdown's protected snapshot. Implement stop/delete and scope-transition behavior for relay-only records (or materialize an appropriate lifecycle record) and serialize spawn consistently; cover start → stop and start → delete on a device with no local record.
Route disk-backed overlay agents through the established preflight, provider, profile, persistence, and runtime transition paths. Materialize fresh-device local records before start so stop, delete, and shutdown can manage them. Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz> Signed-off-by: Wes <wesbillman@users.noreply.github.com>
c38fc0f to
a9b648b
Compare
…blish
Relay-primary managed-agent config (kind:30179) delivered config for exactly
one launch and could overwrite a newer device's config with an audit-clean
event. Three defects, each measured with a probe before being fixed.
Overlay boot rehydration. `PrivateConfigOverlay` was in-memory only and its
single writer fired only when an inbound 30179 was strictly newer than the
retained row. On every second-and-later launch the backfill re-delivered the
same event, retention deduped it to Skipped, and the overlay stayed empty for
the whole session, so every read silently fell back to stale disk. Rebuild the
overlay from the retained rows on the `run_event_sync` boot seam, which already
runs post-identity-resolution with the resolved owner keys and scoped db path.
Adds `get_retained_events_of_kind` (nothing read rows back by kind before).
Boot publication on default keyring builds. `reconcile_agents_in_dir_at` read
`managed-agents.json` raw, so on a default `system-keyring` build the nsec was
keyring-resident and `retain_private_agent_record`'s empty-nsec skip fired for
every untouched agent: zero 30179s published on first boot. Hydrate keys in the
reconcile path. The doc comment asserted the bug ("keys are never needed here")
and is corrected, so the next reader is not re-licensed to reintroduce it.
Stale-disk republish at three write sites. `private_payload_from_record`
serializes every config field, so retaining a disk-derived record on a device
following a newer relay head republished the other fields from stale disk;
`monotonic_created_at` then floored the write at head+1, so it won LWW, bumped
the generation, and chained `prev` to the head it destroyed — a validly-chained
successor indistinguishable from a legitimate edit. Resolve the overlay before
the local mutation at `agent_models.rs` (edit), `personas/update.rs` (persona
rename) and `agents.rs` (pair-start snapshot re-apply).
Ordering is the fix at each site, and it differs per site, which is why this is
not centralized in `retain_managed_agent_pending`:
- edit: resolve before the user's patch, or the patch is discarded
- rename: resolve for the payload only; the `name != old_display_name` gate must
keep reading disk state, and name/display_name are re-applied after
- pair-start: resolve before `apply_persona_snapshot`, so the definition quad
stays definition-authoritative
Tests. Regression coverage for all three defects plus three probes that pin the
wrong fixes (centralized resolve discards edits; resolve-before-gate skips the
rename; swapped pair-start ordering lets the overlay clobber the persona quad),
each with positive and negative controls.
`write_site_resolve_guard` is a source-level assertion, added because the
behavioural tests cannot see the production wiring: every write site is inside a
`#[tauri::command]` needing a live `AppHandle`, so the tests call
`retain_agent_record` directly and stayed green with the production resolve
deleted (measured: 2261 passed / 0 failed). The guard fails when a site loses
its resolve or a new site is added without one, and carries its own vacuity
control.
File-size ratchet. `agent_models.rs` sat at the 1000-line cap before this
change (1024 lines), so the ratchet allows it zero growth, and
`reconcile/tests.rs` crossed the cap. Two verbatim moves, following the seams
each file already uses: `normalize_agent_models` to
`agent_models_normalize.rs` (`#[path]` submodule, like the databricks/
openrouter/discovery helpers) and the stale-republish test family to
`reconcile/tests/stale_republish_tests.rs` (like
`personas/update/name_propagation_tests.rs`). Both moved blocks are
byte-identical to their pre-move bytes apart from one visibility line
(`pub(super)` -> `pub(crate)`, required by E0364 on the re-export), and the
write-site guard's deletion mutant was re-run after the move: still dead.
Item 3 from the review (collapsing the 27-field `PrivateConfigPatch` mirror,
~169 deletable lines) is deliberately not in this commit; it is an overlay
refactor and lands separately.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
klopez4212
left a comment
There was a problem hiding this comment.
Reviewing and requesting changes on Kenny Lopez's behalf.
Blocking: boot reconcile destroys the retained relay head before the overlay is hydrated.
run_event_sync calls reconcile_agents_to_events first and only then calls hydrate_private_config_overlay (desktop/src-tauri/src/event_sync.rs:17-20). The reconcile reads raw managed-agents.json records and passes them directly to retain_agent_record (desktop/src-tauri/src/managed_agents/reconcile.rs:91-119); it cannot consult the overlay because the overlay has not been rebuilt yet.
On the exact restart this commit intends to fix, where disk has config A and retention contains a newer relay config B, boot therefore does this:
- build a private payload from stale disk A;
- compare it with retained B, see a difference, and retain A as generation B+1 /
pending_sync = true; - hydrate the overlay from that newly overwritten A row.
The valid relay head is lost locally and the stale successor is queued to overwrite it remotely. In other words, restart persistence still fails, now through the boot writer rather than the interactive writers.
Hydrate/validate the retained overlay before any disk→private reconcile, and make boot reconcile resolve each disk record against that hydrated head before retaining it (while preserving the intended behavior for records with no retained private head). Add an end-to-end unit seam around the real boot ordering: seed stale disk A plus newer retained B, run the boot reconcile/hydration path, and assert retention and the overlay remain B with no stale private republish queued. The current standalone hydration and stale-republish model tests do not exercise this production ordering.
klopez4212
left a comment
There was a problem hiding this comment.
Blocking on a boot-order regression in the restart recovery path. The codec, inbound validation, scope clearing, lifecycle materialization, and stale-write-site fixes otherwise look carefully constructed. I reviewed clean head c80c4c17b047d7580d50ee5261897e0fb192bb2d; git diff --check origin/main...HEAD is clean. I did not duplicate the broad suites already reported/covered by CI.
…er head Boot reconcile is a fourth stale-disk republish site, in the same class as the three write sites fixed in the previous commit but worse: it fires at launch, unprompted, for every agent on a device that follows another device's config. `reconcile_agents_in_dir_at` reads `managed-agents.json` raw and cannot resolve the private-config overlay -- `hydrate_private_config_overlay` runs after this leg (`event_sync.rs:19-20`) and reads the rows this leg writes. Inbound kind:30179 updates the overlay and retention but never the JSON, so on a follower disk is stale by construction. Rebuilding the 30179 projection from disk then republishes every stale field over device A's newer head as a validly chained gen+1 successor, and `monotonic_created_at` floors it at head+1 so it wins LWW. Measured: gen 5 -> 6, `prev` = the clobbered head, `created_at` = head+1 against a head 10,000s in the future, `pending_sync` set, and every field (name, system_prompt, parallelism, env_vars) taken from stale disk. It also does not self-heal. A second boot is a clean no-op because disk now matches the head it wrote, but each new head device A publishes re-arms it: measured 16 -> 1, no-op, then 24 -> 1. The follower's disk wins every round and the user on A sees their edit silently revert. The previous commit's keyring hydration is what makes this reachable. Before it, `retain_private_agent_record`'s empty-nsec skip returned early for every keyring-resident record, so boot never built a 30179 at all -- the skip was incidentally protecting this path. Hydrating keys is still correct (an untouched agent must publish its first 30179 on a default build), but it exposed everything downstream of the guard it removed. A control arm with an absent nsec confirms the head survives, pinning the causal line. Fix: `retain_agent_record_at_boot` publishes the 30179 only when no retained head exists, and is used by boot reconcile alone. That keeps the requirement boot exists to serve -- an agent whose nsec lives in the keyring gets its FIRST private config published -- while leaving an existing head to the interactive edit paths, which resolve the overlay before retaining and so author from relay-fresh state. The kind:30177 identity leg is untouched, so the upgrade republish waves keep working. Resolving the overlay at boot instead was rejected and is pinned by a permanent wrong-fix probe: an offline local edit lives on disk and in an unflushed `pending_sync` 30179, so resolving disk through an overlay hydrated from the older head would discard it -- the centralized-resolve failure from the previous commit, with boot's blast radius. Tests (4): the fix verification asserts the head is byte-identical after boot and nothing is enqueued; two requirement-preservation arms (first 30179 still published when no head exists; 30177 still republishes when a private head is present) so the fix cannot be satisfied by never publishing at boot or by gating at the wrong level; and the wrong-fix probe. Three mutants, each killed by a different arm: gate deleted, gate inverted, gate applied to the whole record instead of the private leg. Mutants re-run after cargo fmt. Desktop lib suite 2360 passed / 0 failed / 15 ignored (--all-features); cargo fmt --check, cargo clippy --workspace --all-targets --all-features -D warnings, and the desktop file-size ratchet (against the CI base) all clean -- the ratchet verified live with a padding control that fails it. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Dismissed at Kenny Lopez’s request after reviewing aa39d72. The new boot-only head-presence gate prevents stale disk from replacing or queuing over an existing retained 30179 while preserving first private publication and public 30177 reconciliation.
klopez4212
left a comment
There was a problem hiding this comment.
Re-reviewing and approving on Kenny Lopez's behalf at aa39d72aac57aeda49cb3c38db7c9c8ed4af24f1.
My boot-order blocker is resolved. The boot-only retention path now leaves any existing kind:30179 head byte-for-byte intact and only creates the private event when no retained head exists. This prevents stale disk state from becoming a newer successor, while preserving first private publication and the independent kind:30177 public reconciliation path. The added regressions cover all three requirements and pin why overlay resolution at boot would discard an unflushed offline edit.
Focused review: the four added boot tests directly exercise the gate and its preservation cases; git diff --check origin/main...HEAD passed at this exact clean head. I attempted the focused Rust tests locally, but this checkout lacks the required generated sidecar desktop/src-tauri/binaries/buzz-acp-aarch64-apple-darwin, so the Tauri build script stopped before compiling the test target. Existing CI is the broad validation authority.
|
Reviewing and requesting changes on Wes Billman's behalf at Blocking: launch auto-start bypasses the relay-primary overlay and races its hydration.
Therefore, on a follower where disk has private config A and retention/relay has newer config B, an auto-start agent can launch with A: stale prompt/model/provider/runtime, env vars, allowlist, relay mesh, auth tag, or agent nsec. The new boot retention gate correctly prevents A from being republished over B, but does not prevent A from executing. Waiting only for overlay hydration would still be insufficient on a fresh device until backfill has delivered B; the startup contract needs an explicit authoritative-state/readiness decision rather than two racing best-effort tasks. This contradicts the PR's relay-primary behavior at the most consequential read site. Please serialize auto-start behind the authoritative private-config bootstrap and resolve each candidate through the overlay before preflight/spawn, with a safe offline policy. Add a production-seam regression that seeds stale disk A plus retained newer B, runs launch restore, and proves the spawned snapshot uses B; include a delayed-hydration race arm. A test that only exercises Behavior-changing tests: the PR adds strong codec/inbound/retention and stale-republish regressions, including restart overlay hydration and the new boot-only 30179 head-preservation gate. However, none covers launch auto-start using the relay-resolved record or the ordering between
|
…erlay The overlay only ever learned config from events this device RECEIVED. Both fill paths are inbound-only: `insert_patch` on an `Applied` inbound event (`personas/inbound.rs:228`) and boot hydration (`hydrate_from_retention`). Neither fires for an event this device authored -- the relay's echo of our own event dedupes to `Skipped` in `retain_inbound_event`, because the row is already retained. So the overlay stays pinned at the last received generation for the whole session. `update_managed_agent` resolves that patch onto the disk record, applies the user's edit, saves, and retains -- correct for ONE edit. The SECOND edit in the same session resolves the same stale patch onto the now fresher disk record, reverting the first edit, and publishes the reversion as an audit-clean gen+1 successor that wins LWW. The resolve result is written back to disk, so the revert is durable, not just in-flight. That is Max's live gate red on a single backend: gen 3 published parallelism 19, the following rename returned 17 and published 17 as gen 4. It stands independent of the Device-A/B attribution he retracted. Fix: after `retain_agent_record` commits, read the just-retained kind:30179 head back out of the same connection and `insert_patch` it. One seam -- `retain_managed_agent_pending` -- covers all five writers (create, settings, edit/rename, start, rename-rollback) with no per-caller copies and no new-writer trap. `reconcile.rs` is untouched: `retain_agent_record` takes `conn`+`keys` and threading `AppState` through it would drag the boot reconcile into the diff for nothing. No new lock edge: callers already hold `managed_agents_store_lock` and the overlay lock is taken under it, the same order `resolved_local_record` uses. Decode is factored into `patch_from_retained_row`, shared with boot hydration, so both learn config through exactly one path. Absorbing unconditionally (not gated on the retain reporting a change) keeps the overlay from ever running ahead of retention: every insert comes from a row read back out of the database. A missing or undecodable head leaves the current entry alone rather than clearing it. Coverage. `sami_second_edit_in_one_session_preserves_the_first_edit` runs the two-edit sequence through the real retention engine, seeding gen 2 via `retain_inbound_event` + `hydrate_from_retention` so the overlay is populated exactly as boot populates it. Its negative control runs the same sequence without the write-through and asserts the revert to 17, so the main assertion cannot pass vacuously. `absorb_retained_head_leaves_the_overlay_alone_when_ there_is_no_head` pins the no-clear contract with a positive control proving the same call DOES update on a present head. Both behavioural tests model the helper body -- every caller is inside a `#[tauri::command]` needing a live `AppHandle`, so deleting the production call leaves them green (the failure mode already documented on `write_site_resolve_guard`). Three source guards close that: exact call count of 1, source order retain-before-absorb, and a negative control proving the searched literals are load-bearing. Mutants, 4/4 killed: (1) `absorb_retained_head` body no-oped -> the red test fails with exactly the live symptom, `left: Some(17) right: Some(19)`; (2) production call deleted -> both source guards fail; (3) production call moved before the retain -> the order guard fails; (4) test helper's order swapped -> the behavioural test fails. Gate at this tree: desktop lib suite 2272 passed / 0 failed / 14 ignored, `cargo clippy --workspace --all-targets` clean, `cargo fmt --check` clean, `check-file-sizes` rc=0 with CHECK_FILE_SIZES_BASE pinned to the merge-base with origin/main. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
…#5133) ## What Relay-only carve-out of the ingest half of #4999: generic EVENT ingest now accepts kind:30179 (NIP-PMA private managed-agent config). One file, `crates/buzz-relay/src/handlers/ingest.rs`, 16 insertions / 15 deletions; **two semantic lines**, byte-identical to the ingest hunk of #4999 at `6f486e88`: 1. `required_scope_for_kind`: 30179 requires `Scope::UsersWrite` — same arm as its public sibling 30177 and the other owner-authored NIP-AP kinds. 2. `is_global_only_kind`: 30179 is owner-global, keyed `(pubkey, kind, d-tag)`; a stray `h` tag must not channel-scope it. The rest is import reflow plus replacing the guard test with a positive one (`private_managed_agent_kind_is_owner_scoped_global_user_data`: asserts UsersWrite scope, global-only, no h-channel scope). ## Why the guard test can be retired The removed test (`private_managed_agent_kind_remains_rejected_until_atomic_ingest_exists`) pinned a stated precondition: *"must not enter generic EVENT ingest before privacy and aggregate CAS deploy."* Both halves are resolved: - **Privacy** — the author-only read gates for 30179 shipped to main with #4593: `AUTHOR_ONLY_KINDS` membership, `req.rs` pre-filter + result gates, `count.rs`, `event.rs` fanout, and the bridge pre-filter (`bridge.rs:999-1000` returns `restricted: author-only kinds require authors=[self]` / 403). Only the author can read the event back. - **Aggregate CAS** — #4999 settled generation as **advisory**: the `g` tag is shape-validated, never relay-enforced. Last-write-wins per coordinate is the contract of record (see the kind:30179 contract blurb in #4999), so no CAS mechanism is pending on the relay side. ## Why this is inert to existing relays and clients - No production desktop code on main authors kind:30179 — the codec (`private_managed_agent.rs`) has zero non-test callers. This PR accepts a kind nobody can produce yet. - Content is opaque NIP-44 ciphertext to the relay; the relay never decrypts it. - Reads remain author-only via the already-shipped gates above. - Storage is the standard parameterized-replaceable path already exercised by kinds 30175–30178. No schema, config, or migration changes. ## Testing - Full `buzz-relay` package suite at this commit: 859 passed, 1 failed — `api::mesh_demo::tests::demo_join_forwarded_arm_round_trips_echo` (504 vs 200), which **reproduces identically on clean main `769ac70b`** with this change stashed; pre-existing/environmental, not introduced here. - New positive ingest test passes. - Pre-push hooks green (branch-skew, rust-tests, desktop-tauri-checks). ## Relationship to #4999 #4999 (relay-primary agent config, desktop half) stays DO-NOT-MERGE pending live relay receipts + real CI; once this lands and deploys, its live test simplifies to plain `desktop-standalone` against the real relay, and #4999 rebases to drop its now-duplicate ingest hunk (identical bytes → trivial rebase). Originating thread: buzz://message?channel=06f13ed3-0557-4ac2-922c-1545dd00bf97&id=2a43b3b4933a2ea78b77088619251c061355f9b7b6dc29ea0d702193f2344149 ## Brownfield FTS note (review findings, operator-ruled non-blocking for this PR) Max and Sami independently identified that the FTS privacy skip-set is regime-dependent: migration 0008 installs the positive allowlist (`kind IN (0, 9, 40002, 45001, 45003)`) **only on an empty events table**; an already-populated database keeps the 0001/0005 negative skip-list (wrapped by 0014 to add 30350), which omits 30179 — so on such an installation this PR admits 30179 rows whose NIP-44 ciphertext gets indexed by `to_tsvector`. Sami measured both regimes against real Postgres (brownfield: 30179 INDEXED; fresh: NULL) and demonstrated the existing drift test only exercises the fresh regime. `schema/schema.sql:222`'s canonical literal is also the negative list and omits 30179. Migration dates put any relay deployed with data before 0008 landed (2026-07-13) in the brownfield class. **Scope of exposure (Sami's trace):** not a content leak — `event_visible_to_reader` / `is_author_only_event` gates hold on both search surfaces (`req.rs:725`, `bridge.rs:1770`), so foreign readers receive nothing. Lost is the storage-level NULL-tsv backstop plus FTS page budget burned on post-filtered hits. **Operator ruling (Tyler, events `1472e5b6`, `cbd368ed`):** ship this PR without an exclusion migration. Safety argument that makes this sound rather than merely accepted: main has **zero non-test 30179 writers** until #4999's desktop half deploys — no 30179 rows can exist, so nothing can be indexed in any regime while this PR is the only half live. **Additional review characterizations (Sami, non-blocking, on the record):** - *Behavioral delta enumerated:* routing triple (`required_scope_for_kind` / `is_global_only_kind` / `requires_h_channel_scope`) compared for all 65,536 kinds at base `769ac70b` vs head `77eeba6e` — exactly one row differs (30179). No other kind or client changes behavior. - *"SQL visibility before LIMIT" (NIP-PMA step 2):* no `AUTHOR_ONLY_KINDS` pushdown clause exists in `buzz-db` (only `SHARED_GATED_KINDS` has one). Author-only kinds are protected by the pre-filter (`author_only_filters_authorized`) plus post-filter omission; mixed-kind filters can burn candidate-page budget on discarded rows. Pre-existing and identical for 30300/30350 — not introduced here; noted so the NIP's step-2 checkbox is not read as fully ticked. - *Envelope validation gap:* 30179 is the only parameterized-replaceable kind at ingest with no per-kind envelope validator (codec grammar checks run in the desktop writer, not the relay). Generic limits only (256 KiB, ±15 min, pubkey==identity, d-tag bound). Self-inflicted footgun bounded to the author's own coordinate — candidate companion to the exclusion migration in the #4999 rebase, deliberately not added here. **Bound follow-up (required before/with the #4999 desktop half):** a 0014-shape additive migration (`pg_get_expr` capture + `CASE WHEN kind = 30179 THEN NULL ELSE (<existing>) END` wrap), add 30179 to the `schema/schema.sql:221` literal, and a brownfield-regime variant of the FTS drift test, per Sami's finding. Deploy-time spot check if ever wanted: `SELECT pg_get_expr(d.adbin, d.adrelid) FROM pg_attrdef d JOIN pg_attribute a ON a.attrelid = d.adrelid AND a.attnum = d.adnum WHERE d.adrelid = 'events'::regclass AND a.attname = 'search_tsv';` Signed-off-by: Tyler Longwell <tlongwell@block.xyz> Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Absorbs the relay half that shipped separately in #5133 (squash commit ad92335): the kind:30179 ingest acceptance hunk in crates/buzz-relay/src/handlers/ingest.rs was byte-identical on both sides, so this merge removes all relay-side changes from this PR's diff. #4999 now carries only the desktop + buzz-core codec half. No rebase, no force-push — history preserved per operator instruction. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz> * origin/main: fix(bench): mention the orchestrator by pubkey when posting the task (#5136) feat(relay): accept kind:30179 private managed-agent events at ingest (#5133) fix(media): require authenticated reads (#4610) fix(desktop): preserve authoritative agent avatars (#4984) fix(desktop): next/back navigation during key creation onboarding (#4978) Alert community owners and admins when a new key joins (#4900) fix(desktop): prevent sidebar prefs from reverting on stale-localStorage boot (#5086) chore(hooks): run desktop typecheck in pre-push (#5110) feat(identity): recover desktop identity from a signed-in phone (#4845) fix(buzz-agent): classify read timeouts distinctly in LLM error messages (#4959) Refine agent runtime controls (#5026) test(desktop): await thread scroll anchor (#3174) Improve desktop mobile pairing flow (#5024) feat(desktop): show selected community in rail (#5000) fix(desktop): stop rate-limited reconnect backfill from tearing down the authenticated socket (#4990) fix(desktop): skip native notifications outside app bundles (#5004) ci: prove the relay-driven mesh lifecycle — discover, join, infer, deny — with real nodes (#3862) fix(desktop): virtualize channel member lists (#4991) Signed-off-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Summary
materialize_relay_only_agent— this is the one deliberate disk write derived from relay statesystem-keyring) builds publishes each agent's 30179 instead of silently skipping every untouched agentThis is the minimal replacement for the closed #4940 implementation.
Review fixes (
c80c4c17,aa39d72a,6f486e88)Adversarial review (Eva/Sami) found and fixed five defects in the original head
a9b648b4, each pinned by regression tests:Overlay never rehydrated after restart — relay-primary config worked for exactly one launch, then every site silently read stale disk. Fixed: boot rehydration from the retention DB (owner-scoped; negative control asserts another owner's keys hydrate nothing).
Boot reconcile published zero 30179s on default keyring builds — records were read without keyring hydration, so the empty-nsec skip fired for every untouched agent. Fixed: hydrate keys in the reconcile path.
Stale-disk republish clobbered a newer relay head at three write sites (
agent_models.rsedit,personas/update.rsrename,agents.rspair-start snapshot re-apply). The corruption was a validly-chained gen+1 successor — indistinguishable from a legitimate edit. Fixed: resolve-before-mutate at each site. The resolve is deliberately per-site, not centralized in the retain helper — centralizing it silently discards user edits, and at the rename site a naive resolve-first loses the rename (the gate keys on diskname). Both wrong fixes are pinned by permanent probe tests, and a source-level guard asserts the exact resolve-call count per file (the write sites are#[tauri::command]s, so unit tests cannot observe the production wiring directly).Boot reconcile republished stale disk over a newer relay head — a fourth site in the same class as (3), but firing at launch, unprompted, for every agent on a device that follows another device's config.
reconcile_agents_in_dir_atreadsmanaged-agents.jsonraw and cannot resolve the overlay:hydrate_private_config_overlayruns after this leg (event_sync.rs:19-20) and reads the rows it writes. Inbound 30179 updates the overlay and retention but never the JSON, so on a follower disk is stale by construction. Measured: gen 5 → 6,prev= the clobbered head,created_atfloored at head+1 bymonotonic_created_atso it wins LWW against a head 10,000s in the future,pending_syncset, every field taken from stale disk. It does not self-heal — a second boot is a clean no-op, but each new head the follower receives re-arms it (measured 16 → 1, no-op, 24 → 1), so the follower's disk wins every round and the user's edit silently reverts. Fixed inaa39d72a:retain_agent_record_at_bootpublishes the 30179 only when no retained head exists, and is used by boot reconcile alone; the interactive edit paths keep republishing over heads (they resolve the overlay first), and the kind:30177 identity leg is untouched so the upgrade republish waves keep working. Three mutants — gate deleted, gate inverted, gate applied to the whole record instead of the private leg — each killed by a different arm.Self-authored config never reached the in-memory overlay — the write-side twin of (3).
PrivateConfigOverlayhas exactly two fill paths and both are inbound-only:insert_patchon an Applied inbound event (commands/personas/inbound.rs) and boot hydration (event_sync.rs::hydrate_private_config_overlay). Neither fires for an event this device authored, because the relay's echo of our own event dedupes toSkippedinretain_inbound_event(strictly-newer guard). So after any local edit the overlay stays pinned at the last received generation, and the next same-session edit resolves that stale patch on top of a fresher disk record and publishes a silent revert — again as a validly-chained successor, indistinguishable from a real edit. Live symptom: an edit setsparallelism17 → 19, the immediately following rename republishes 17. Note that the resolve-before-mutate fix from (3) is what makes this reachable in the reverting direction: the sites now trust an overlay that no longer tracks this device's own writes. Fixed in6f486e88: one write-through at one seam —retain_managed_agent_pending(commands/agents.rs), afterretain_agent_recordcommits, reads the just-retained 30179 head back out of the same open conn and inserts it into the overlay (PrivateConfigOverlay::absorb_retained_head). A sharedpatch_from_retained_row()decode helper is refactored out ofhydrate_from_retention, so hydration and write-through share exactly one decode path. The single seam covers all five interactive writers (edit/rename, create, settings, start, rename-rollback) with no per-caller copies;reconcile.rsis deliberately untouched (retain_agent_recordtakes conn+keys, noAppState), andpersonas/snapshot/import.rs's inline retain writes only kind:30177, so it has no overlay concern. Two deliberate design calls, both the conservative side of their trade: absorb unconditionally rather than only when the retain reported a change (the overlay can never outrun retention), and never clear on a missing or undecodable head — leave the existing entry alone (pinned with a positive control). The row is read back from retention rather than inserting the in-memory record; Eva independently derived the same design before seeing the diff, so that choice is load-bearing on two derivations. Four mutants, each killed: absorb made a no-op (reproduces the live symptom exactly —left: Some(17), right: Some(19)), the production absorb call deleted, absorb ordered before the retain instead of after, and a fourth ordering variant.Contract of record for kind:30179 writes — two clauses, not one. Review corrected an earlier draft of (5) that implied every 30179 write funnels through the seam; that is false at a named line.
retain_private_agent_recordhas two non-test callers:retain_agent_record(reconcile.rs:190, the seam's path) and the siblingretain_agent_record_at_boot(reconcile.rs:163), which never passes throughretain_agent_recordand so never reaches the write-through. Defect (4)'s own fix is what made that sibling a 30179 writer, which is why a caller-count on the funnel symbol could not see it. The class is closed by two independent mechanisms: (a) interactive writers are funnelled through the seam, guarded by the exact-count source guard; (b) the boot writer is gated head-absent (reconcile.rs:157-163), guarded by defect (4)'s three mutants — head-absent means there is no relay state to be stale against, so that write cannot be a revert and an absent overlay entry for it is correct rather than stale. Backstop:run_event_syncruns reconcile thenhydrate_private_config_overlay(event_sync.rs:19-20), and hydration is a wholesale*overlay = hydratedreplacement, so boot's own writes reach the overlay one line later regardless. Residual, accepted pre-merge: a future writer callingretain_private_agent_recorddirectly, or a third boot-ish sibling, lands outside both mechanisms and outside both guards (the exact-count guard only readscommands/agents.rs) — the same residual shapewrite_site_resolve_guardalready accepts and documents.Causal note, on the record: defect (4) was made reachable by the fix for (2). Before keyring hydration,
retain_private_agent_record's empty-nsec skip returned early for every keyring-resident record, so boot never built a 30179 at all — the skip was incidentally protecting this path. (2) is still correct (an untouched agent must publish its first 30179 on a default build), but it removed a guard that was load-bearing for something else. A control arm with an absent nsec confirms the head survives, pinning the line.Coverage honesty: (2)'s original regression test asserted the harmful act — it pinned that boot publishes a 30179, which at the time meant publishing stale disk over a head. A green suite ratified the clobber. Chosen fix keeps that test meaningful by scoping it to the head-absent case.
Known follow-ups, out of scope here:
resolved_local_record/resolved_records), butrestore.rscontains none ofresolved_local_record,resolved_records,resolve_local_record, orprivate_managed_agent_overlay— it loads rawload_managed_agents(restore.rs:49) and Phase B spawns those records. On a follower,start_on_app_launchagents therefore run on stale private config. Independent of the (4) fix, which stops the republish, not the spawn. (Its tworesolve_effective_*references atrestore.rs:41/245are the persona→global fallback resolver, a different mechanism.) Related, unmeasured:spawn_event_sync(workspace.rs:240) andrestore_managed_agents_on_launch(workspace.rs:277/291) are spawned without either awaiting the other, so even an overlay-aware restore could read the overlay before hydration populates it.resolve_effective_configcallers (runtime.rs:200/437,agents_deploy.rs:141,spawn_snapshot.rs:248) each take arecordthe caller may or may not have overlaid — needs a per-caller ruling.PrivateConfigPatchcollapse (−169 LOC) lands as a separate commit after live E2E.Validation
6f486e88: 2365 passed / 0 failed / 15 ignored (--all-features) — the 2360 ataa39d72aplus the 5 new tests for defect (5); 2360 was in turn the 2356 baseline atc80c4c17(reproduced independently by two reviewers in separate worktrees) plus the 4 tests for defect (4)cargo fmt --checkclean;cargo clippy --workspace --all-targets -D warningscleanc80c4c17,aa39d72a, and6f486e88(six at6f486e88): branch skew, desktop-check (file-size ratchet), rust-tests, mobile-test, desktop-test (4387 frontend tests / 0 failed), desktop-tauri-checks. Nothing pushed with--no-verify.aa39d72ais not vacuousc80c4c176f486e88; arm attribution atc80c4c17was calibration only, since defect (4) made arms 1 and 3 structurally unattributable. This gate is still open and is the remaining merge blocker: the defining 19 → rename discriminator for (5) passes live on the real Tauri backend, but the preserved test agent's scoped keyring secret is unavailable to the fresh process, so the rename profile-sync and pair-start arms refuse at their key boundary and the fresh lane has not yet produced new relay 30179 receipts.6f486e88— Eva, in her own worktree: full diff read, suite + fmt + clippy, and a personal re-run of the absorb-no-op and absorb-before-retain mutants rather than taking the author's kill list on faithNotes
Temporary Tauri sidecar placeholders used for local builds are ignored and not committed.