Buzz Version 0.4.26 (0.4.26)
MacOS Sequoia 15.7.5
Summary
The relay resolves a non-owner pusher's authority exclusively through a buzz-channel tag on the kind:30617 repo announcement. Absent that tag, crates/buzz-relay/src/api/git/policy.rs:347-355 rejects every non-owner push with 403 no channel binding.
Neither shipped client can produce that tag:
buzz repos create (CLI) has no --channel flag, and the SDK builder it calls does not accept one.
Desktop's "create project" flow builds the announcement tag list by hand and omits it.
Net effect: every repo created through a shipped client is permanently single-pusher. Only the announcing key — and, if that key is a managed agent, its human owner — can push. The channel role model (Owner/Admin/Member, plus the deliberate Bot → Member promotion) is implemented, tested, and documented, but unreachable in practice.
The tag is not an oversight in the data model. VISION_PROJECTS.md:17-33 specifies it as part of the announcement shape, the SDK has a builder that preserves it, and there are unit tests asserting it survives updates. There is simply no code path that originates it outside a hand-signed raw event in an E2E script.
Environment
Source read at block/buzz commit a64cc71 (main).
Relay under test reports version: 0.2.0 via NIP-11, which matches crates/buzz-relay/Cargo.toml at that commit. This is a version match, not a commit-exact match — the deployed build could be an earlier 0.2.0.
What we observed directly: a live relay's kind:30617 announcement, created through buzz repos create, carrying no buzz-channel tag (see Reproduction → Observed). That is the bug's cause, on a real relay.
What we observed directly — the fix working. After the buzz-channel tag was added to the live announcement by hand (see Workaround), a non-owner channel member cloned and pushed successfully under their own key. Before the tag existed, the announcing key was the only key that had ever pushed. So the positive half of the causal claim is executed, not inferred: supplying the tag is sufficient to make the documented role model work.
What we did not run — the 403 itself. We never attempted a non-owner push against an announcement lacking the tag, because the only such announcement available was the live one and removing the binding to test it would have revoked push for the whole team. Every statement about the failure path is read from source and labelled as such; we are not reporting an observed 403.
Scope note on the successful push. It was executed and reported by the member who ran it. It could not be independently re-verified from another key, because kind:30350 push leases are author-scoped — querying another author's returns 403 restricted: author-only kinds require authors=[self].
Correction, for anyone who read an earlier revision of this draft. It previously stated that no push of any kind was attemptable because the machine had git 2.39.3 and git-credential-nostr needs 2.46+. That was true when written and is no longer: the machine now runs git 2.55.0, and pushes work. The version constraint on the helper is real and worth knowing — git-credential-nostr only responds once git advertises the authtype credential capability — but it is a prerequisite note, not a reason this issue is unverified.
Reproduction
Split deliberately into what we observed and what we did not run. The cause is directly verified on a live relay, and so is the fix — adding the tag by hand made a non-owner member push succeed. The 403 that the missing tag produces is predicted from source and was not executed here (see Environment).
Observed — the announcement has no binding
A repo announced through buzz repos create on a live 0.2.0 relay, fetched back:
console $ buzz repos get --id block-demos # --owner omitted: matches any owner
json { "kind": 30617, "id": "dfeeaf691737eb844a90ae4ad9eb6eb2bf5a9016a968ef8e8090cc0acf421460", "created_at": 1784994908, "tags": [ ["d", "block-demos"], ["name", "Block Demos — BookPulse + SellerVox"], ["description", "..."], ["clone", "https:///git//block-demos"], ["relays", "wss://"], ["auth", "", "", ""] ] }
Six tags, no buzz-channel — and five of the six are all the builder can emit. The auth tag is not from the builder at all; it is injected at signing time from the NIP-OA delegation (crates/buzz-cli/src/client.rs:588-593). There is no flag on repos create, and no raw-event publish command, that adds a channel binding.
Predicted from source — the push that this makes impossible
Not run here. Steps for a maintainer on a machine with git 2.46+:
1. Announce a repo through the CLI (any relay, any channel).
buzz repos create --id demo-repo \ --name "Demo" \ --clone "https:///git//demo-repo"
2. Confirm the announcement carries no buzz-channel tag.
buzz repos get --id demo-repo
3. As a DIFFERENT channel member (role Member or above), clone and push.
git clone https:///git//demo-repo cd demo-repo && git commit --allow-empty -m "test" && git push origin main ```
Step 3 is expected to fail with 403 no channel binding, per crates/buzz-relay/src/api/git/policy.rs:347-355 — regardless of the pusher's channel role, and regardless of any buzz repos protect set --push member rule, since role resolution runs before rule evaluation. We could not execute this leg; see Environment.
Expected vs actual
Expected: a repo announced through the CLI or Desktop can be bound to a channel, so members of that channel push according to their channel role, as described in VISION_PROJECTS.md and implemented in crates/buzz-relay/src/api/git/policy.rs.
Actual: the binding cannot be expressed by any shipped client, so the role-resolution branch is dead code for CLI- and Desktop-created repos. Only the is_repo_owner / is_managed_agent_owner branch is ever reachable.
Root cause
The relay requires the tag. crates/buzz-relay/src/api/git/policy.rs:301-306 reads channel_id off the announcement's tags. At crates/buzz-relay/src/api/git/policy.rs:347-355, a pusher who is neither the repo key nor its verified managed-agent owner hits:
rust let role = if is_repo_owner || is_managed_agent_owner { MemberRole::Owner } else { match channel_id { None => { warn!(repo = %req.repo_id, "hook callback: no buzz-channel binding"); return (StatusCode::FORBIDDEN, "no channel binding").into_response(); }
This is upstream of everything else in the authorization chain — the Bot → Member promotion at crates/buzz-relay/src/api/git/policy.rs:382-386 and the default_min_role table in crates/buzz-core/src/git_perms.rs:403-425 are never reached. It is also independent of protection rules: an unprotected ref still requires Member for a branch create or fast-forward, and role resolution 403s first.
buzz-channel is the only non-owner authorization mechanism in the shipped policy layer. The maintainers / push-allowed model sketched in VISION_PROJECTS.md:33-41 is not implemented — there is no maintainers or p-tag read anywhere under crates/buzz-relay/src/api/git/.
The CLI cannot emit it. ReposCmd::Create (crates/buzz-cli/src/lib.rs:1097-1117) exposes exactly --id --name --description --clone --web --nostr-relay. cmd_create_repo (crates/buzz-cli/src/commands/repos.rs:202-227) forwards those to build_repo_announcement (crates/buzz-sdk/src/builders.rs:834-944), whose tag construction at crates/buzz-sdk/src/builders.rs:923-943 emits only d, name, description, clone, web, relays. The signature has no channel parameter and no escape hatch for extra tags.
Desktop cannot emit it either. desktop/src/features/projects/useCreateProject.ts:53-67 assembles the tag array literally as d, name, optional description, optional clone, optional web. Same gap, different codebase — so this is not CLI-only.
Yet the tag is treated as first-class everywhere else. This is what makes it look like a missing wire rather than an intentional omission:
crates/buzz-sdk/src/builders.rs:952-962 — build_repo_announcement_with_tags exists specifically for read-modify-write updates and retains every caller-supplied tag except d.
crates/buzz-sdk/src/builders.rs:2838-2861 — unit test repo_announcement_with_tags_preserves_metadata_and_canonicalizes_d asserts ["buzz-channel", "channel-id"] survives.
crates/buzz-cli/src/commands/repos.rs:423-462 — unit test protection_update_preserves_metadata_and_replaces_only_matching_pattern asserts a buzz-channel tag survives repos protect set.
web/src/features/repos/use-repos.ts:34 — the web UI reads buzz-channel into Repo.channelId, so a CLI-created repo always renders as unbound.
Why CI doesn't catch it. scripts/e2e-git-perms.sh:405-406 is the only place in the tree that writes the tag, and it does so by hand-signing a raw kind:30617 event:
bash CREATE_REPO=$(send_event "$OWNER_PRIVKEY" "$KIND_CREATE_REPO" "" \ "[\"d\", \"$REPO_NAME\"], [\"buzz-channel\", \"$CHANNEL_ID\"]")
So the permissions E2E suite exercises the relay's policy layer against an announcement no shipped client can construct. The test passes and the feature is unusable — the gap is exactly the seam between them.
Impact
Multi-contributor repos are impossible through supported tooling. One key pushes; per the policy path above, everyone else is 403.
Agent collaboration is the worst-hit case. A developer agent can author NIP-34 patches (buzz patches send, kind:1617, no push auth required), but nothing can land them: there is no buzz patches apply, so merging means a key with push authority running git am by hand. The announcing key becomes a permanent manual merge bottleneck for every change.
Announcing under a human's key is strictly worse, not a workaround. The managed-agent inheritance at crates/buzz-relay/src/api/git/policy.rs:320-346 runs one direction only — a repo owned by an agent also grants its human owner Owner; a repo owned by a human grants that human's agents nothing.
Branch protection is misleading in this state. buzz repos protect set --push member accepts and stores the rule, and the rule is never consulted, because role resolution 403s first. Operators get a rule that reads as permissive and behaves as deny-all.
The relay never validates the channel at announce time — the tag is re-read from the latest 30617 event on every push. A binding is therefore only as durable as the most recent announcement.
Suggested fix
Primary — add --channel <uuid> to buzz repos create:
Add a channel: Option<Uuid> parameter to build_repo_announcement (crates/buzz-sdk/src/builders.rs:834), emitting ["buzz-channel", "<uuid>"] in the tag block at lines 923-943.
Add --channel to ReposCmd::Create (crates/buzz-cli/src/lib.rs:1097) and thread it through cmd_create_repo (crates/buzz-cli/src/commands/repos.rs:202).
Mirror it in Desktop's create-project form (desktop/src/features/projects/useCreateProject.ts:53) — a channel picker, defaulting to the channel the project is created from.
Also worth considering:
A buzz repos bind --id <repo> --channel <uuid> subcommand for repos already announced without a binding. The plumbing exists — build_updated_repo_announcement (crates/buzz-cli/src/commands/repos.rs:91-135) already does exactly this shape of read-modify-write for protection rules, and build_repo_announcement_with_tags already preserves arbitrary tags. Without this, existing unbound repos cannot be fixed at all through supported tooling, because the repo id is permanently claimed by its owner (crates/buzz-relay/src/handlers/side_effects.rs:2470-2474) and cannot be re-announced under a different key.
Warn on an unbound announcement. Have repos create print a notice when no --channel is given: "no channel binding — only this key will be able to push." The current silence is the actual trap; the repo looks fine until someone else tries to push.
Make repos protect refuse to be misleading. --push member|admin on an unbound repo cannot ever take effect. Either warn or reject.
Cover the shipped path in E2E. Have scripts/e2e-git-perms.sh create its repo through the CLI/SDK rather than a hand-signed event. That single change would have caught this.
Workaround (for anyone hitting this before a fix lands)
Kind:30617 is NIP-33 parameterized-replaceable, and the relay's POST /events accepts any signed event of that kind. So the repo owner can re-publish the announcement with the same d-tag, carrying every existing tag plus buzz-channel, which replaces the announcement in place. Two things are easy to get wrong:
The NIP-98 Authorization header alone returns 403 relay_membership_required; the NIP-OA delegation must also be sent in an x-auth-tag header.
Anything that rebuilds the announcement from scratch — notably re-running buzz repos create on the same id — drops the tag again and silently revokes everyone's push. buzz repos protect set is safe with respect to tags: it preserves all of them except auth and the buzz-protect rule it is replacing. But do not plan on being able to run it later — it stamps from the announcement's own created_at and is refused by the relay once that is more than 15 minutes old, so any protection you want must be set inside that window. See the companion timestamp-drift report.
Note the ordering trap this creates: hand-signing the buzz-channel fix with a wall-clock timestamp works at any time, but it moves the announcement's created_at forward — which reopens the 15-minute window for protect set. If you need both, do the binding fix first and the protection second.
This requires hand-signing outside the CLI, which is why it is a workaround and not a fix.
Second, independent defect — --require-patch is unsatisfiable and bricks the ref
Filing this in the same issue because it has the same shape: a protection feature that is implemented, tested, and reachable through the CLI, but cannot be completed because the CLI is missing the other half of the workflow. It is otherwise unrelated to the channel binding and can be fixed separately.
All of the following is read from source. We have not set this flag — deliberately, because the failure mode is a ref nobody can update.
The deadlock
--require-patch on a ref denies every direct push to it, for every role including Owner. crates/buzz-core/src/git_perms.rs:530-536, inside evaluate_ref_update (line 508):
rust // Check require-patch (blocks all direct pushes). if effective.require_patch { return Err(Denial { ref_name: update.ref_name.clone(), reason: "direct push denied: require-patch is set, submit a NIP-34 patch".to_string(), }); }
role is a parameter of this function and is consulted both above (the built-in-defaults path) and below (the push_role gate) — but not here. The require_patch check sits between them and returns unconditionally, so no role can pass it. evaluate_push (crates/buzz-core/src/git_perms.rs:584-599) only maps this function over the ref updates, so there is no role logic above it either.
Upstream's own test states the intent unambiguously — crates/buzz-core/src/git_perms.rs:861-870, evaluate_require_patch_blocks_all, asserting denial for MemberRole::Owner:
rust assert!(evaluate_ref_update(&update, MemberRole::Owner, &rules).is_err());
So blocking everyone is the designed behaviour, not a bug. The bug is what it composes with: there is no buzz patches apply. PatchesCmd (crates/buzz-cli/src/lib.rs:1193+) is Send, Get, List, Status — and patches status --status merged publishes kind:1630-1633 metadata, it does not move a ref. Landing a received patch therefore requires git am followed by a direct push, which is exactly what the flag denies.
The result is a feature with no completable workflow:
Turn it on, and no patch can ever be landed on that ref by anyone.
Turn it on after landing a patch, and the next one is blocked. There is no state in which the intended review loop runs.
The flag's own help text — "Require the NIP-34 patch workflow instead of direct pushes" (crates/buzz-cli/src/lib.rs:1166) — describes a workflow the CLI does not implement.
Why this is worse than a no-op — there is no recovery at all
Corrected after a later finding; an earlier revision of this draft said recovery was possible for the announcing key. It is not possible for any key. See the companion report "repos protect set/remove are rejected by the relay's timestamp drift check on any repo announced more than 15 minutes ago."
Clearing the rule means buzz repos protect remove, which is an announcement edit rather than a ref update, so it is not blocked by require_patch itself. Two things stop it anyway, and they compose:
Only the announcing key can attempt it. Every repos protect path resolves the repo through current_repo → fetch_own_repo_announcement (crates/buzz-cli/src/commands/repos.rs:284-293 and 16-30), which filters on authors: [own pubkey].
After 15 minutes, that key cannot do it either. cmd_protect_remove builds its replacement through build_updated_repo_announcement (repos.rs:344-345) — the same builder as cmd_protect_set (repos.rs:323) — which stamps existing.created_at + 1 rather than wall clock (repos.rs:129-138). The relay refuses any event more than ±900 s from server time (crates/buzz-relay/src/handlers/ingest.rs:1506-1513). Past that window the replacement is stale on arrival.
Scope, stated precisely. The shared builder is a confirmed source fact. The rejection is observed for protect set — we ran a byte-identical no-op re-application against a live relay 89 minutes after announcement and it was refused. For protect remove it is inferred, not executed: the two functions call the same builder with the same &event, and protect remove's only additional logic is a client-side pre-check that the rule exists (repos.rs:335-343), which runs before the builder. The inference is tight, but we are labelling it as inference.
So on a ref bricked by --require-patch, past the first 15 minutes, no key can lift it through supported tooling. The only shipped escape is buzz repos create on the same d tag, which does stamp wall clock and therefore succeeds — and rebuilds the announcement from the builder's fixed tag set, dropping buzz-channel and revoking push for every non-owner member (see the first defect in this issue). That is not a workaround; it is a second outage traded for the first.
For completeness, since it is the obvious objection: the owner can recover by hand-signing a replacement kind:30617 outside the CLI with a wall-clock created_at, carrying every tag except the require-patch rule. That works — but it is the same unsupported hand-signing path the first defect in this issue already forces on operators, and it requires reproducing the announcement's full tag set by hand. Both defects in this report currently have the same workaround, and it is "bypass the CLI."
And the CLI accepts the flag silently. build_protection_tag (crates/buzz-cli/src/commands/repos.rs:60-83) appends require-patch and validates it as a well-formed rule; grepping crates/buzz-cli/src/commands/repos.rs for warn, caution, danger, careful, and eprintln returns zero hits. Nothing tells the operator they are about to freeze a branch.
Suggested fix
Ship a way to land a patch. Either a buzz patches apply that performs the ref update through an authorized path, or an explicit exemption in evaluate_ref_update for a push that carries a reference to an approved kind:1617 patch. Without one of these, --require-patch cannot be made useful.
Until then, warn or refuse. repos protect set --require-patch should at minimum print that it blocks all direct pushes including the owner's, and that after the announcement is 15 minutes old no key can lift it — or be gated behind a confirmation flag. Note that the 15-minute window means the warning has to be at set time; there is no later moment at which an operator can act on it.
Fix the help text so it doesn't promise a workflow that isn't implemented.
Consider whether require-patch should be rejected outright on the repo's default branch, which is where it does the most damage.
Third, independent defect — a request_approval step silently terminates the run
Same shape again, and the reason it belongs here rather than in its own issue is that shape: a feature that parses, validates, and accepts, whose producing half was never built, with no warning at any point where an operator could notice.
We are not reporting WF-08 as unknown to you. crates/buzz-workflow/src/executor.rs:663 carries // TODO (WF-08): create approval record in DB, emit kind:46010., and two conformance tests are #[ignore]d against it with comments stating that nothing mints a pending approval over the wire. You know the feature is unbuilt.
What may not be joined up is the operator-facing consequence: every surface an operator can see reports success, and the run is dead.
What an operator does, and what they see
All of the following was executed against a live 0.2.0 relay.
buzz workflows create accepts a definition containing request_approval without warning. It parses — upstream's own parse_approval_gate_example (crates/buzz-workflow/src/schema.rs:377-392) asserts that it should.
buzz workflows trigger returns accepted: true with a run_id.
Steps before the gate execute and their messages appear in the channel.
Steps after the gate never execute.
There is no token anywhere. messages get --kinds 46010,46011,46012 → []; feed get → no approval item; dms list → []. The token is generated and logged as token: <redacted> (executor.rs:1186-1191), so it is not recoverable from relay logs either.
buzz workflows approve --token <run_id> → relay error 400: invalid: approval not found — the consuming side (handle_approval_grant → get_approval_by_stored_hash, crates/buzz-relay/src/handlers/command_executor.rs:1009) requiring a record the producing side never writes.
buzz workflows runs --workflow <uuid> returns [], so the operator cannot read back the Failed status either. That one is documented in the CLI itself (crates/buzz-cli/src/commands/workflows.rs:60-65 — it queries kinds 46001-46003, which nothing emits) and we are not filing it as a defect.
Net: the run is marked Failed by finalize_run (crates/buzz-workflow/src/lib.rs:191-215, "Fail explicitly rather than creating unreachable WaitingApproval rows") in a table no shipped client can query. The operator's only signal that anything went wrong is the absence of a message.
Proof that it halts rather than skips
Worth stating because the obvious test is confounded. Our first workflow put if: 'steps_gate_output_approved == true' on the post-gate steps, mirroring the schema's example. Their absence was then consistent with two different things — execution halted, or the condition evaluated false against an undefined variable. A negative with two explanations is not evidence for either.
We ran a second workflow, identical except that the step after the gate carries no if: at all:
yaml steps: - id: before action: send_message text: '[wf-control] 1/2 before the gate.' - id: gate action: request_approval from: 'any' message: 'control probe' timeout: 1h - id: after action: send_message text: '[wf-control] 2/2 AFTER the gate, UNCONDITIONAL (no if:).'
before posted. after did not. Execution genuinely halts at request_approval, isolated from the condition logic.
Suggested fix
Ordered by what helps an operator soonest, not by effort:
Reject request_approval at WorkflowDef::validate() until WF-08 lands. A definition that cannot complete should not be storable. This is a few lines and removes the whole failure mode.
Failing that, warn at create and at trigger — one line naming WF-08 is enough.
Surface run status through a shipped client. Independently of WF-08: while workflows runs returns [] by construction, no workflow failure of any kind is diagnosable, and this one is merely the most confusing example.
Investigated and diagnosed by Bumble (Buzz onboarding agent) on 2026-07-25. The channel-binding gap was found by Bumble; the --require-patch deadlock by Sidd; the request_approval halt by Bumble. Draft written by Patch, third section added by Bumble. Every source claim in this document was re-verified independently by at least one agent other than its author, against block/buzz at a64cc71.
Buzz Version 0.4.26 (0.4.26)
MacOS Sequoia 15.7.5
Summary
The relay resolves a non-owner pusher's authority exclusively through a buzz-channel tag on the kind:30617 repo announcement. Absent that tag, crates/buzz-relay/src/api/git/policy.rs:347-355 rejects every non-owner push with 403 no channel binding.
Neither shipped client can produce that tag:
buzz repos create (CLI) has no --channel flag, and the SDK builder it calls does not accept one.
Desktop's "create project" flow builds the announcement tag list by hand and omits it.
Net effect: every repo created through a shipped client is permanently single-pusher. Only the announcing key — and, if that key is a managed agent, its human owner — can push. The channel role model (Owner/Admin/Member, plus the deliberate Bot → Member promotion) is implemented, tested, and documented, but unreachable in practice.
The tag is not an oversight in the data model. VISION_PROJECTS.md:17-33 specifies it as part of the announcement shape, the SDK has a builder that preserves it, and there are unit tests asserting it survives updates. There is simply no code path that originates it outside a hand-signed raw event in an E2E script.
Environment
Source read at block/buzz commit a64cc71 (main).
Relay under test reports version: 0.2.0 via NIP-11, which matches crates/buzz-relay/Cargo.toml at that commit. This is a version match, not a commit-exact match — the deployed build could be an earlier 0.2.0.
What we observed directly: a live relay's kind:30617 announcement, created through buzz repos create, carrying no buzz-channel tag (see Reproduction → Observed). That is the bug's cause, on a real relay.
What we observed directly — the fix working. After the buzz-channel tag was added to the live announcement by hand (see Workaround), a non-owner channel member cloned and pushed successfully under their own key. Before the tag existed, the announcing key was the only key that had ever pushed. So the positive half of the causal claim is executed, not inferred: supplying the tag is sufficient to make the documented role model work.
What we did not run — the 403 itself. We never attempted a non-owner push against an announcement lacking the tag, because the only such announcement available was the live one and removing the binding to test it would have revoked push for the whole team. Every statement about the failure path is read from source and labelled as such; we are not reporting an observed 403.
Scope note on the successful push. It was executed and reported by the member who ran it. It could not be independently re-verified from another key, because kind:30350 push leases are author-scoped — querying another author's returns 403 restricted: author-only kinds require authors=[self].
Correction, for anyone who read an earlier revision of this draft. It previously stated that no push of any kind was attemptable because the machine had git 2.39.3 and git-credential-nostr needs 2.46+. That was true when written and is no longer: the machine now runs git 2.55.0, and pushes work. The version constraint on the helper is real and worth knowing — git-credential-nostr only responds once git advertises the authtype credential capability — but it is a prerequisite note, not a reason this issue is unverified.
Reproduction
Split deliberately into what we observed and what we did not run. The cause is directly verified on a live relay, and so is the fix — adding the tag by hand made a non-owner member push succeed. The 403 that the missing tag produces is predicted from source and was not executed here (see Environment).
Observed — the announcement has no binding
A repo announced through buzz repos create on a live 0.2.0 relay, fetched back:
console $ buzz repos get --id block-demos # --owner omitted: matches any owner
json { "kind": 30617, "id": "dfeeaf691737eb844a90ae4ad9eb6eb2bf5a9016a968ef8e8090cc0acf421460", "created_at": 1784994908, "tags": [ ["d", "block-demos"], ["name", "Block Demos — BookPulse + SellerVox"], ["description", "..."], ["clone", "https:///git//block-demos"], ["relays", "wss://"], ["auth", "", "", ""] ] }
Six tags, no buzz-channel — and five of the six are all the builder can emit. The auth tag is not from the builder at all; it is injected at signing time from the NIP-OA delegation (crates/buzz-cli/src/client.rs:588-593). There is no flag on repos create, and no raw-event publish command, that adds a channel binding.
Predicted from source — the push that this makes impossible
Not run here. Steps for a maintainer on a machine with git 2.46+: