Skip to content

fix(cli): stop set-add-policy erasing the agent profile - #6576

Draft
cyberzero000 wants to merge 1 commit into
block:mainfrom
cyberzero000:fix/cli-set-add-policy-profile
Draft

fix(cli): stop set-add-policy erasing the agent profile#6576
cyberzero000 wants to merge 1 commit into
block:mainfrom
cyberzero000:fix/cli-set-add-policy-profile

Conversation

@cyberzero000

@cyberzero000 cyberzero000 commented Aug 22, 2026

Copy link
Copy Markdown

buzz channels set-add-policy publishes a kind:10100 whose content is only
{"channel_add_policy": ...} (crates/buzz-cli/src/commands/channels.rs). The
kind is replaceable, so that one field becomes the entire profile body.

The relay projects channel_add_policy into a column
(handle_agent_profile, crates/buzz-relay/src/handlers/side_effects.rs:1219)
and stores the rest only as event content. Clients read the remaining fields
straight out of that content: agents_from_events
(desktop/src-tauri/src/nostr_convert.rs:445) parses name, display_name,
agent_type, capabilities and status from the profile body, and those are
what a policy change destroys. The agent then falls back to its npub as a
display name and shows empty capabilities in the directory until someone
republishes the profile by hand.

Scoping this accurately, since it is narrower than it first looks: channel_ids
is not affected. relay_agents_from_legacy_events
(desktop/src-tauri/src/nostr_convert/agent_directory.rs:50) clears it on every
kind:10100 entry — "channel membership is authoritative only in relay-signed
kind:39002" — and the directory refills it from membership events, so mention
eligibility never depended on the profile copy. respond_to is likewise not a
kind:10100 field; it comes from owner-signed kind:30177 records.

Worth flagging for review: set-add-policy is currently the only writer of
kind:10100 in this repo, so the fields it preserves are ones other publishers
put there. That is an argument for the merge, not against it — a command that
owns one field should not be the reason every other publisher's field
disappears.

Now it reads the current profile, merges the field, and republishes.

Three decisions the merge has to get right

Each is a pure function with tests, because getting any of them wrong
reintroduces the wipe.

A result the command cannot fully read is an error, not an empty profile.
parse_stored_profile returns an empty body only when the identity genuinely
has no profile. A body that is not a JSON object is refused rather than
replaced, and so is a result row missing a readable content or created_at
treating either as absent would republish a single-field profile and cause the
exact wipe this fixes.

The write out-bids the stored copy's created_at rather than tying it.
Db::replace_addressable_event breaks a same-second replaceable tie by lowest
event id, so publishing at now is a coin flip against a peer republishing the
profile in the same second, and a deterministic loss against a copy stamped
later by a skewed peer. Retrying at now cannot win either.

The lead is bounded with headroom under the relay's 900s tolerance. Stamping
stored + 1 is only ever one second ahead of whichever writer produced the
stored copy, so the lead measured against our own clock is just this host's skew
from that writer's — and refusing on a few seconds of it made the command
unusable wherever a peer's clock ran ahead of the operator's. The relay's window
is measured against its clock and this budget against ours, so the two are not
directly comparable: a copy accepted at S when relay time was R proves only
S - R <= 900, and out-bidding it at S + 1 can sit up to 901s from relay
time. The 600s budget leaves room for that overshoot.

Confirming the write

Read-modify-write on a replaceable event has no compare-and-set, so this
re-reads after publishing. duplicate: means the relay took the write and
rolled it back — it arrives as accepted: true, so reading only accepted
reports success for a discarded write.

Comparing the stored head's id to ours is not enough on its own. That read
cannot be pinned to the primary: kind:10100 is global, so the filter carries
neither a channel pin nor an until, which makes it RoutePredicate::Bounded
and lets the relay serve it from a read replica whenever
BUZZ_REPLICA_READ_MAX_AGE_MS is set. That budget bounds how recently the
replica proved its replay position, not whether a write from a moment ago is
visible — so a read issued straight after the publish can legitimately return
the pre-write event.

The relay's own replace rule separates that lag from a real loss: a stored copy
that loses the created_at/lowest-id comparison to the event we just published
cannot have replaced it. So the read-back is classified three ways:

outcome meaning result
Landed our event is the stored head success, exit 0
Replaced a copy that beats ours is stored re-merge onto it and publish again
Unconfirmed the read cannot see our write, or it failed success with a warning, exit 0

Unconfirmed is a success because the relay accepted the event and nothing that
could have replaced it is stored. Calling it a conflict instead would republish
a byte-identical event, draw duplicate:, and return exit 5 with
retryable: true for a policy change that was already stored — a caller
treating 5 as "retry me" would loop on a landed write. Its stdout keeps the
documented {event_id, accepted, message} shape with an additive warning key,
the same way the create commands inject a new entity id.

A confirmation read that fails outright is Unconfirmed too, not the command's
error: the mutation has already happened by then, so borrowing the read's error
would report a stored policy change as a network failure.

Exit 5 is still returned when the profile is genuinely replaced out from under
us on every attempt.

The window is narrowed, not closed: a peer publishing between our read and our
write still loses its change, because a replaceable write carries the whole body
and nothing records what we replaced.

Error classification

Profile-read failures keep their CliError variant instead of flattening to
Other. exit_code and is_retryable_error classify by variant, and those
codes are the CLI's agent-facing contract, so flattening reported a retryable
503 as exit 4 / retryable: false and an expired BUZZ_AUTH_TAG as exit 4
instead of 3 — telling an agent to abandon both as permanent.

Deliberately not done

Three things this PR leaves alone on purpose, so a reviewer does not have to ask:

  • No --force for a malformed stored profile. The relay does not validate
    kind:10100 content shape (handle_agent_profile is a side effect whose
    failures are only logged), so another client can leave a non-object body
    stored, which this command then refuses. A flag is a wider change than the
    bug warrants; the refusal message now names a recovery path that exists
    (sign a corrected kind:10100 and submit it to POST /events).
  • created_at is guarded with content, not separately. One check covers
    the whole unreadable-row case rather than two independent guards for one
    malformed response.
  • MAX_PROFILE_PUBLISH_LEAD_SECS stays at 600. The 901s worst case above
    needs a stored copy ~10 minutes in the relay's future plus skew at the exact
    boundary. The constant is fine; only the comment claiming it "sits well
    inside" the relay window was wrong.

Testing

cargo test -p buzz-cli — 375 passed, 0 failed. Twelve new tests.

Mutation-checked, each mutation caught by exactly the test that covers it:

mutation test that fails
any id mismatch on the read-back is a conflict (the behavior before this fix) a_read_that_cannot_see_our_write_is_not_a_conflict
flatten every profile-read error to CliError::Other a_transient_profile_read_failure_keeps_its_exit_code
restore the unwrap_or defaults for content and created_at a_result_row_we_cannot_read_is_refused_rather_than_treated_as_absent
treat a non-object body as absent an_unreadable_profile_is_refused_rather_than_replaced
publish at now instead of out-bidding a_merge_out_bids_the_stored_copy_instead_of_tying_it, a_stored_stamp_past_the_relay_window_is_refused_not_out_bid

cargo clippy -p buzz-cli --all-targets -- -D warnings and cargo fmt --check
clean. just file-size-check clean.

Against a live relay

Unit tests cover the merge as a pure function; this is the end-to-end check
against a running relay, run twice from the same seeded state — once with the
binary built from 4baccd539 (this PR's merge-base) and once with this branch.

A throwaway keypair was seeded with a populated kind:10100 signed directly to
POST /events, since no CLI subcommand writes that kind except the one under
test:

{"channel_add_policy":"anyone","channel_ids":["aaaaaaaa-0000-4000-8000-000000000001"],"name":"probe","respond_to":["mention"]}

Then buzz channels set-add-policy --policy owner_only, and the profile read
back:

// 4baccd539
{"channel_add_policy":"owner_only"}

// this branch
{"channel_add_policy":"owner_only","channel_ids":["aaaaaaaa-0000-4000-8000-000000000001"],"name":"probe","respond_to":["mention"]}

Also exercised on the same relay:

  • a key with no stored kind:10100 — first write still produces
    {"channel_add_policy":"nobody"}, exit 0, so the merge does not break the
    empty-profile path;
  • two set-add-policy calls back to back inside one second — both accepted,
    both exit 0, last policy stored, no spurious exit 5.

Note

Split out of #5806, which found this while debugging why an agent answered
@mentions in one channel and was silent in another. It stands alone and has no
dependency on the rest of that branch.

@cyberzero000
cyberzero000 requested a review from a team as a code owner August 22, 2026 18:31
@cyberzero000
cyberzero000 marked this pull request as draft August 23, 2026 18:37
@cyberzero000
cyberzero000 force-pushed the fix/cli-set-add-policy-profile branch from 1cbc8af to 5e74b42 Compare August 24, 2026 00:47
@cyberzero000
cyberzero000 marked this pull request as ready for review August 24, 2026 00:55
@cyberzero000
cyberzero000 marked this pull request as draft August 26, 2026 18:13
@cyberzero000
cyberzero000 force-pushed the fix/cli-set-add-policy-profile branch 2 times, most recently from 10f6e21 to ad52699 Compare August 28, 2026 23:38
@github-actions

github-actions Bot commented Aug 28, 2026

Copy link
Copy Markdown

🔐 Codex Security Review

Status: review required for the current range.

The current range is 00e61eafa917d296104006576b7a2ddbfd58bb5a...aa9f5da2d9cfe7ad6ca6df8f9fc78608aef23b84.
A new review must complete for this exact range. When manual authorization
is required, a Block organization member must comment exactly
@buzz-security-review aa9f5da2d9cfe7ad6ca6df8f9fc78608aef23b84 to authorize a new review.
Any previous review applies only to its recorded range.

@cyberzero000
cyberzero000 force-pushed the fix/cli-set-add-policy-profile branch from ad52699 to 727336f Compare August 29, 2026 03:28
`cmd_set_add_policy` published a kind:10100 whose content was only
`{"channel_add_policy": ...}`. The kind is replaceable, and the relay
projects just that one field into a column
(`crates/buzz-relay/src/handlers/side_effects.rs`) — `channel_ids`, `name`,
`display_name` and `respond_to` live solely in the event body clients read.
One policy change wiped the rest of the profile, which took the agent out of
every channel's @mention picker until an operator republished by hand.

Read the current profile, merge the field, republish. Three things the merge
has to get right, each covered by a test:

- A result this command cannot fully read is an error rather than an empty
  profile. A body that is not a JSON object is refused, and so is a result
  row missing a readable `content` or `created_at`. Treating either as
  absent would republish a single-field profile and cause the exact wipe
  this fixes.
- The write out-bids the stored copy's `created_at` instead of tying it.
  `Db::replace_addressable_event` breaks a same-second tie by lowest event
  id, so publishing at `now` is a coin flip against a peer republishing the
  profile concurrently, and a deterministic loss against a copy stamped
  later by a skewed peer.
- The lead carries headroom under the relay's 900s tolerance. That window is
  measured against the relay's clock and this budget against ours, so a copy
  accepted at `S` when relay time was `R` proves only `S - R <= 900`, and
  out-bidding it at `S + 1` can sit up to 901s from relay time.

Read-modify-write on a replaceable event has no compare-and-set, so this
re-reads after publishing. Comparing the stored head's id to ours is not
enough on its own: kind:10100 is global, so the filter carries neither a
channel pin nor an `until`, which makes it `RoutePredicate::Bounded` and lets
the relay serve it from a read replica whenever
`BUZZ_REPLICA_READ_MAX_AGE_MS` is set. That budget bounds how recently the
replica proved its replay position, not whether a write from a moment ago is
visible, so a read issued straight after the publish can return the pre-write
event.

The relay's own replace rule separates that lag from a real loss: a stored
copy that loses the `created_at`/lowest-id comparison to the event we just
published cannot have replaced it. So the read-back is classified three ways:

- Landed — our event is the stored head. Success.
- Replaced — a copy that beats ours is stored. Re-merge onto it and publish
  again, and return exit 5 if every attempt is replaced.
- Unconfirmed — the read cannot see our write, or it failed. The relay
  accepted the event and nothing that could have replaced it is stored, so
  this is a success we could not confirm: exit 0 with the documented
  `{event_id, accepted, message}` plus an additive `warning`. Reporting a
  conflict here would tell a caller to retry a change that had already
  landed, and the retry would rebuild the same body at the same stamp, come
  back `duplicate:`, and look like a conflict again.

A confirmation read that fails outright is Unconfirmed rather than the
command's error: the mutation has already happened by then, so borrowing the
read's error reported a stored policy change as a network failure.

Profile-read failures also keep their `CliError` variant instead of
flattening to `Other`. `exit_code` and `is_retryable_error` classify by
variant, so flattening reported a retryable 503 as exit 4 with
`retryable: false`, and an expired `BUZZ_AUTH_TAG` as exit 4 instead of 3 —
telling an agent to abandon both as permanent.

The window is narrowed, not closed: a peer publishing between our read and
our write still loses its change, because a replaceable write carries the
whole body and nothing records what we replaced.

Signed-off-by: cyberzero000 <user1@cyberzerosystems.com>
@cyberzero000
cyberzero000 force-pushed the fix/cli-set-add-policy-profile branch from 727336f to aa9f5da Compare August 29, 2026 03:58
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