Skip to content

SWIP-060: BPS singlehop — brokered broadcast pub/sub, base protocol - #104

Open
zelig wants to merge 9 commits into
masterfrom
swip-60-bps-singlehop
Open

SWIP-060: BPS singlehop — brokered broadcast pub/sub, base protocol#104
zelig wants to merge 9 commits into
masterfrom
swip-60-bps-singlehop

Conversation

@zelig

@zelig zelig commented Aug 3, 2026

Copy link
Copy Markdown
Member

Base SWIP of the Broadcast Pub/Sub (BPS) family — the decomposition of the monolithic PubSub SWIP (#93) into work-package-sized SWIPs.

What it specifies: the smallest complete BPS protocol — one broker per topic, direct long-lived p2p streams, an explicit per-topic connection cap, SOC-only messages verified end-to-end. A cohort is fully described by a CohortSpec of genesis parameters; modes are parameter combinations, not an enum. Companion wire spec: assets/swip-60/bps.proto (singlehop concrete; multihop control frames reserved).

Deliberately out of scope (own SWIPs): multihop relaying/referral, reorganisation policies (SWATCH, SPORE), bandwidth incentives, broker discovery (SWIP-59 MEX, #103), history delivery, implicit-publisher event sourcing.

Relation to #93: this SWIP absorbs its Milestone 1 plus the mode system (reframed as genesis parameters); Milestone 3 was already extracted as SWIP-59 (#103). Implementation groundwork: bee #5435, bee-js #1151.

🤖 Generated with Claude Code

Base SWIP of the Broadcast Pub/Sub (BPS) family — the decomposition of the
monolithic PubSub SWIP (PR #93) into work-package-sized SWIPs. Companion
wire spec: assets/swip-60/bps.proto (singlehop concrete, multihop control
frames reserved).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@zelig zelig mentioned this pull request Aug 3, 2026
@zelig zelig self-assigned this Aug 3, 2026
Comment thread SWIPs/assets/swip-60/bps.proto Outdated

// What the topic binds to (see epic: "What does the topic bind to?").
enum TopicBinding {
TOPIC_BINDING_UNSPECIFIED = 0;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what does this semantically mean? why is this a legitimate value that can be used?

Comment thread SWIPs/assets/swip-60/bps.proto Outdated

// Who may author (see epic: genesis dimensions).
enum PublisherRegime {
PUBLISHER_REGIME_UNSPECIFIED = 0;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what does this semantically mean? why is this a legitimate value that can be used?

Comment thread SWIPs/assets/swip-60/bps.proto Outdated
TopicBinding binding = 2;
PublisherRegime publishers = 3;
bool history = 4; // deliver matching chunks from the local store
bytes admin = 5; // 20-byte eth address; set iff EXPLICIT_*

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if EXPLICIT_LIST is this then a concatenated list of ethereum keys?

Comment thread SWIPs/assets/swip-60/bps.proto Outdated
EXPLICIT_SINGLE = 1; // opener is admin and sole publisher (live streaming)
EXPLICIT_LIST = 2; // admin dictates who the other publishers are
IMPLICIT = 3; // authorship implied by the topic binding (PO constraint)
ALL = 4; // every peer publishes (gossipsub-equivalent cohort)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we need this?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

either ALL or EXPLICIT list needed. HOnestly I do not find it very natural that you can edit a file ith 3 other random people :) you want to restrict, explicitly list those that do .

Comment thread SWIPs/assets/swip-60/bps.proto Outdated
bool history = 4; // deliver matching chunks from the local store
bytes admin = 5; // 20-byte eth address; set iff EXPLICIT_*
uint32 po_min = 6; // proximity order for implicit bindings (default 16)
uint32 cap = 7; // max direct streams the broker accepts for this topic (0 = broker default)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why should a 3rd party be able to control the number of connections on a remote peer? what if the number is too large for the remote node to accept?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fair enough i dont think it should

Comment thread SWIPs/assets/swip-60/bps.proto Outdated
message Broadcast {
oneof frame {
Soc handshake = 1; // first frame on a stream: full SOC identity
DataFrame data = 2; // subsequent frames: signature ‖ span ‖ payload only

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why split the same chunk to multiple messages? you're also overloading the protocol code to do the message sequencing/buffering/etc... seems really unnecessary. also if you assume just one stream per topic then essentially you're coercing the applications to manage multiple streams between the same two peers continuously - why not multiplex everything over the same stream?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

while the single/multiple stream part is debatable, i'm not sure we need to skimp out on these few bytes that the chunk carries - it really doesn't save much, and then if we want to do single/multi stream management, we don't have to break the message format

Comment thread SWIPs/assets/swip-60/bps.proto Outdated
oneof frame {
Soc handshake = 1; // first frame on a stream: full SOC identity
DataFrame data = 2; // subsequent frames: signature ‖ span ‖ payload only
Ping ping = 3; // keepalive; parent measures RTT off the echo

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

who needs this information? we already measure rtt using other means. not sure why this message is needed

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fair, it is not

Comment thread SWIPs/swip-60.md

## Out of scope (deliberately)

Multihop relaying and referral (bps-multihop), reorganisation policies (SWATCH, SPORE —

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i would also add to this: remove multi-publisher setup from this iteration. it can be added later and just adds more review surface to deal with at this moment.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

funnily i think a single one only is more complex to implement since you need to authenticate.

Comment thread SWIPs/assets/swip-60/bps.proto Outdated
enum PublisherRegime {
PUBLISHER_REGIME_UNSPECIFIED = 0;
EXPLICIT_SINGLE = 1; // opener is admin and sole publisher (live streaming)
EXPLICIT_LIST = 2; // admin dictates who the other publishers are

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i would get rid of this for a first iteration

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright , but then you cannot get rid of ALL, otherwise you cannot have collab eediting..see below

// ---------------------------------------------------------------------------

// What the topic binds to (see epic: "What does the topic bind to?").
enum TopicBinding {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: i find this whole thing really confusing and not very approachable and i wonder if this even makes sense to do in a first iteration. "pubsub" is very dumb in this sense - it usually does not give you different topic semantics. here, a topic could have different semantics and input validation according to its "type" which makes for a much more complex API surfaces for users later on...

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • confusing, not very approachable, does not make sense, very dumb, no topic semantics, hmmm, thats a lot of negative things to asspciated to something that could have different semantics according to its type which makes for a... complex API surfaces? hhwhhat?

@acud acud Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i meant the concept of pubsub usually does not offer different semantics over the concept of a topic. i would appreciate you not hijacking my words and initial intention as this is really counter productive and aggressive. thanks

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I quoted your words which indeed were unnecessarily agressive.
As for your original intention, what was it?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure the semantics of topic or pubsub changes here, I thinkk the various bindings merely link the updates on a topic differently to each other as well as allow for multiple sources

- Connect split into Open (opener fixes CohortSpec) / Subscribe (topic only,
  no cohort metadata); broker Ack echoes the spec to subscribers for
  end-to-end verification; Role enum gone
- broker capacity removed from CohortSpec: broker-side policy, not a cohort
  parameter; jam-cohort seat bound now = genesis publisher list
- EXPLICIT_LIST mechanics specified: repeated publisher_list fixed at
  genesis; dynamic grants/revocations deferred (out of scope)
- every frame carries the full SOC: handshake/data split dropped;
  stream-model rationale added (per-topic streams, mux-migration safe)
- Ping dropped: liveness/RTT are transport concerns
- *_UNSPECIFIED enum zero values documented as invalid on the wire

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@zelig

zelig commented Aug 4, 2026

Copy link
Copy Markdown
Member Author

Revision 2 pushed (25f6f08), addressing the review:

Taken:

  • Connect split into Open (the opener fixes the CohortSpec) and Subscribe (topic only — subscribers carry no cohort metadata). The broker's Ack echoes the spec back so subscribers can verify every message end-to-end. The Role enum is gone with the split.
  • Capacity is out of CohortSpec — agreed a cohort shouldn't dictate a remote node's connection count. It's broker-side policy now; FULL semantics unchanged. The jam-cohort seat bound comes from the genesis publisher list instead.
  • Handshake/data frame split dropped — every frame carries the full SOC, so no sequencing state and no format break if the stream model changes later. Kept one stream per (peer, topic) for now, with the rationale spelled out in the wire section (per-cohort flow control/teardown, bee protocol idiom; mux migration stays format-compatible).
  • Ping dropped — liveness/RTT are transport concerns.
  • *_UNSPECIFIED = 0: documented in the proto header — proto3 requires a zero value; it is deliberately invalid on the wire so nothing can rely on a default.

Specified (was a gap): EXPLICIT_LIST is a repeated publisher_list fixed at genesis — not concatenated bytes. Dynamic grants/revocations are explicitly out of scope for this iteration, which trims the review surface without dropping multi-publisher.

Kept, per the discussion above: TopicBinding and the multi-publisher regimes (EXPLICIT_LIST/ALL) — the closed collab-editing cohort is a headline use case, and single-publisher-only wouldn't even be simpler, since publisher authentication is needed regardless.

🤖 Generated with Claude Code

@zelig
zelig marked this pull request as ready for review August 4, 2026 23:17
Copilot AI lite review requested due to automatic review settings August 4, 2026 23:17
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds SWIP-60 as the base specification for the Broadcast Pub/Sub (BPS) “singlehop” protocol, including its cohort-genesis parameters, roles/capacity semantics, framing model, and a companion protobuf wire definition to enable interoperable implementations.

Changes:

  • Introduces the SWIP-60 markdown specification describing singlehop brokered broadcast pub/sub semantics and conformance criteria.
  • Adds bps.proto defining the protocol messages/types for pubsub/1.0.0 (Open/Subscribe/Ack + SOC-only Publish/Broadcast).

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
SWIPs/swip-60.md New SWIP-60 spec text describing cohort parameters, singlehop flow, and conformance expectations.
SWIPs/assets/swip-60/bps.proto New protobuf schema for the SWIP-60 wire messages and cohort specification.
Suppressed comments (1)

SWIPs/assets/swip-60/bps.proto:131

  • The comment says "2–15 reserved", which can be read as protobuf reserved (meaning the numbers must never be used) even though the intent appears to be "kept for future multihop fields". Rewording avoids confusion for readers and implementers generating code from the schema.
    // 2–15 reserved: multihop control plane (Beacon, Reparent, Expect,
    // DcutrSignal, SwapProposal) — named to fix intent, not final.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread SWIPs/swip-60.md Outdated
| `publishers` | `EXPLICIT_SINGLE` / `EXPLICIT_LIST` / `IMPLICIT` / `ALL` | who may author |
| `admin` + `publisher_list` | eth addresses | set iff explicit publishers; with `EXPLICIT_LIST` the full publisher set is **fixed at genesis** (dynamic grants/revocations are deferred to a later revision) |
| `history` | bool | deliver matching chunks already in the local store (mechanism in bps-history; a singlehop broker MAY refuse) |
| `po_min` | uint (default 16) | proximity constraint for implicit bindings: `PO(socAddr, anchor) ≥ po_min` |

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

po_min should be a constant not. a param

Comment thread SWIPs/assets/swip-60/bps.proto Outdated
bytes admin = 5; // 20-byte eth address; set iff EXPLICIT_*
repeated bytes publisher_list = 6; // 20-byte eth addresses, excl. admin;
// set iff EXPLICIT_LIST
uint32 po_min = 7; // proximity order for implicit bindings (default 16)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@zelig zelig changed the title add SWIP-60: BPS singlehop — brokered broadcast pub/sub, base protocol SWIP-060: BPS singlehop — brokered broadcast pub/sub, base protocol Aug 7, 2026
… constant PO_MIN

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@zelig

zelig commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

Revision 3 pushed (22e8325):

  • API section specified — was a placeholder pointing at bee feat: pubsub bee#5435. The gap surfaced while drafting SWIP-61 (SWIP-061: BPS multihop — FCFS multicast tree #105). Shape follows #5435 generalised from its hardcoded GSOC-ephemeral mode: GET /pubsub/{topic} WS upgrade, roles by parameter presence (cohort params ⇒ opener sends Open; owner ⇒ publisher; bare ⇒ Subscribe, spec learned from the Ack echo). Serialization adopts the SOC-subscription-family conventions — swarm-soc-fields / swarm-cache-wrapped-chunk per feat(gsoc): fine grained API bee#5497 — making /mic///moc (feat: mic moc bee#5486) the storage-fed counterparts of the OWNER/SOC_ID bindings. Key-holding rule: signing is client-side only, the node never holds publisher keys. New conformance item 5: the bridge round-trips both worked configurations.
  • po_min is out of CohortSpec — now protocol constant PO_MIN = 16 (per the thread above: Copilot's proto3 unset-equals-0 footgun, and no use case varies it). Field 7 reserved.

🤖 Generated with Claude Code

…ained under explicit regimes (SWIP-65 pointer); worked API calls

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wrapped-CAC dedup under ANCHOR guards against unsolicited
republication of old SOCs, and is sound only if the application
guarantees distinct payloads - i.e. includes some index in the payload
(per the SWIP-65 discussion: without self-indexing the sequence
requirement moves above the protocol, unspecified). The two
'SWIP-65 (forthcoming)' anchors now link PR #106.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@zelig

zelig commented Aug 9, 2026

Copy link
Copy Markdown
Member Author

Amendment pushed (98e8918), companion to SWIP-65 (#106):

  • ANCHOR dedup soundness: wrapped-CAC dedup is the guard against unsolicited republication of old SOCs, and is sound only under an application-level requirement — payloads distinct, i.e. the application includes some index in the payload. Without self-indexing the sequence requirement doesn't disappear; it moves above the protocol, unspecified.
  • The two "SWIP-65 (forthcoming)" anchors now link SWIP-065: Self-indexed feeds #106.

🤖 Generated with Claude Code

@acud

acud commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

comments courtesy of claude regarding the part of the implementation that is already on my branch
2026-08-11-swip-60-feedback.md

…oved Auth

Revision after implementation feedback from the bee prototype (acud, PR #104
comment of 2026-08-17) and a restructuring pass.

## Cohort spec carries immutable policy; the roster does not

Five configurations, distinguished by three fields:

  jam            admin  GRANTED     spectators:false
  spectator-jam  admin  GRANTED     spectators:true
  live-stream    admin  ADMIN_ONLY  spectators:true
  group-chat     admin  ALL         spectators:true
  implicit       no admin                            SOC shape decides

- New binding `MNEMONIC`: the topic names the cohort and constrains nothing —
  any SOC from any owner. This is what ALL needs; authorship there is
  unrestricted but never unattributable, since every message is still
  SOC-signed, so a group chat knows who said what without an authorised set to
  check against.
- `publishers` = ADMIN_ONLY / GRANTED / ALL. ADMIN_ONLY is an immutable promise
  ("this stream will never have a second author"), not a roster that happens to
  be empty.
- `spectators` replaces the previous `closed` and is enforceable, because Auth
  is recovered rather than asserted. It is the only refusal for identity in the
  protocol; openers MUST set it true under ALL and implicit, where every
  attached peer is already a potential author.
- The admin is always in the publisher set. A non-publishing moderator is just
  an admin that never sends — being a publisher obliges nobody to publish.
- `publisher_list`, `po_min` and `closed` are reserved in CohortSpec.

## The service feed — the admin's control plane

  owner = admin    id = keccak256("bps-service:v1" || topic || index)

  index 0   GENESIS        the CohortSpec, signed by the admin
  index n   ROSTER         the full publisher set at version n
  last      END_OF_STREAM  the admin closes the cohort, attributably

The roster is dynamic and the spec is immutable, so the roster cannot live in
it; grantee identities are also not public the way an admin's is. A feed rather
than one constant-id slot: overwriting in place would make a stale roster
undetectable, reintroducing forging-by-omission at the one point that decides
who may write. Sequential indices make gaps visible, so withholding stays a
liveness fault (SWIP-65 carries the construction).

Ack now delivers the echoed CohortSpec, the admin-signed genesis SOC and the
latest service SOC with its index, so a joiner verifies the cohort and its
roster against the admin rather than the broker. END_OF_STREAM separates "over"
from "the broker stopped relaying".

Revocation is two-phase, and the boundary is the moment the reduced roster
reaches subscribers. Before it the revoked peer cannot know, so its frames are
dropped and TOLERATED — no penalty, no teardown, because it is not
misbehaving. After it the peer has been told on the same feed as everyone
else, so publishing is a protocol violation and the connection is broken. The
announcement is what converts an unknowing publisher into a violating one:
disconnecting first would punish a peer for a rule it had not been given, and
never publishing the roster leaves the violation unable to begin at all, which
is an ordinary visible withholding fault. It also makes the revocation legible
to the rest of the cohort, which learns why a publisher fell silent from an
admin-signed message rather than from an unattributable disconnection.

## Wire

- `Open` and `Subscribe` wrapped in a `Hello` envelope. As bare frames they are
  byte-indistinguishable (length-delimited field 1 + optional Auth in field 2)
  and proto3's permissive unmarshalling makes a wrong guess succeed silently,
  misread the frame, and answer with a Status describing the wrong problem.
  (acud, finding 1.)
- `Auth` carries a signature and no address: owner = ecrecover over
  H("bps-join:v1" || topic || admin), so identity and proof arrive in one
  operation and the handshake stays one frame each way. No libp2p peer id in
  the preimage — binding to the node would weld the publishing identity to the
  node holding the stream and leak an eth-identity/peer-id link on every join.
  The preimage is therefore static and replayable, which costs nothing: a
  replayed role is worthless without the signing key. The "bps-join:v1"
  separator keeps the join-signature space disjoint from the SOC-signature
  space the same keys serve. (acud, finding 2.)
- Dedup horizon: implementation-defined but MUST be bounded; replay of an
  evicted message by a legitimate publisher is the accepted consequence.
- Cohort lifetime: broker-side, not tied to the opener, reclaimable when
  unattached — except by END_OF_STREAM, which is attributable.
- A conformant broker bounds how many cohorts it will create; `Open` is
  otherwise an unbounded allocation primitive. (acud, finding 3.)

## Prose

New "Security considerations": the admin and the cohort are authenticated by
the genesis message; the publisher role is proved, not asserted; defence in
depth is the real guarantee, so no challenge round trip; audience control
exists only as `spectators` and is not confidentiality — BPS offers none at any
layer, and a bounded audience is payload encryption, application-side.

"Why not gossipsub" gains both halves of the trade: rootward-then-leafward
carries each edge exactly once, so a single-parented tree needs no duplicate
suppression at all and beats a mesh on closely knit topologies — and the
concession that a genuinely gossip-shaped use case should just use libp2p
gossipsub.

Publishers' direct attachment to the broker is now stated as a consequence of
depth = 1 rather than a protocol invariant: bps-multihop forwards Publish
rootward from the leaves, which is what lets an everyone-publishes cohort
outgrow one broker. (SWIP-61 needs the matching change.)

API: `publishers`/`spectators` query parameters, no publisher list, `auth`
replacing `owner`, and POST /pubsub/{topic}/service for the admin's grants,
revocations and close.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@zelig

zelig commented Aug 30, 2026

Copy link
Copy Markdown
Member Author

Both wire findings taken, and the second one pushed the design further than the fix —
the whole publisher/audience model was rebuilt around it. Rev 4 pushed.

1. Open / Subscribe indistinguishable — taken, envelope

Exactly right, and the silent-success part is the sharp end of it: a receiver that guesses
wrong doesn't fail, it misreads the frame and then answers with a Status describing a
different problem. Adopted the envelope your prototype uses:

message Hello {
  oneof handshake {
    Open      open      = 1;
    Subscribe subscribe = 2;
  }
}

Two protocol ids would also have worked, but splitting one-stream-per-(peer, topic) across
two stream names costs more than the oneof does. Noted in the framing section so the next
reader doesn't rediscover it.

This also closes your earlier point on the old Connect: the establishment message is now
explicitly its own thing, and a subscriber sends nothing but a topic.

2. PublisherAuth unauthenticated — taken, and closed is gone

Your finding was right and the diagnosis was better than the fix would have been. closed
was never meant as confidentiality — it was meant to say "every member publishes, there is
no audience" — but the spec's wording did promise access control, and the frame couldn't
deliver it. Rather than qualify the sentence, the model changed:

Auth now carries a signature and no address. The owner is the ecrecover output, so
presenting it is possession of a key rather than a claim about one — identity and proof in
one operation, no challenge round trip:

owner = ecrecover( H("bps-join:v1" ‖ topic ‖ admin), signature )

Two things worth flagging in that preimage:

  • No libp2p peer id in it, deliberately. Binding to the node would make the credential
    unreplayable, but it would weld the publishing identity to the node holding the stream —
    the same key unusable from a second node without re-signing, and every join publishing an
    eth-identity/peer-id link. A static preimage is replayable instead, which costs nothing:
    a replayed role is worthless without the signing key, since every frame is still validated
    at Publish. Authorship rests on the message signature, never on the handshake.
  • "bps-join:v1" is load-bearing. The same secp256k1 keys sign SOCs over
    id ‖ wrappedAddress; without separation a join signature could be reinterpreted as a
    chunk signature, or the reverse.

closed is removed and replaced by spectators, which is enforceable precisely because
Auth is recovered rather than asserted. It bounds attendance at a broker and nothing more:
BPS still offers no confidentiality at any layer — the broker holds plaintext — and that
is now said plainly in a new Security considerations section instead of being implied away.
A jam is private because it encrypts, not because it refuses spectators.

3. The smaller notes — all three taken

  • Dedup horizon: implementation-defined but MUST be bounded, with the consequence stated
    — a legitimate publisher can overrun the window and replay an evicted message. Applications
    that can't tolerate that carry their own sequencing, which SWIP-65 gives for free.
  • Cohort lifetime: broker-side, not tied to the opener, reclaimable when unattached. Plus
    a deliberate ending — see END_OF_STREAM below, which separates "over" from "the broker
    stopped relaying".
  • Cohort count: a conformant broker now bounds how many cohorts it will create, not only
    the streams within one. Open was indeed an unbounded allocation primitive.

What else changed, since it touches your branch

The publisher roster left the cohort spec. It is dynamic — an admin grants and revokes
while a cohort runs — and the spec is immutable, so it couldn't live there without making
every roster change a new cohort. Grantee identities also aren't public the way an admin's
is: the owner of a stream or a co-edited file is naturally known to subscribers; the other
grantees are not.

It travels instead as admin-signed service messages on a feed the admin owns:

owner = admin   id = keccak256("bps-service:v1" ‖ topic ‖ index)

index 0   GENESIS        the CohortSpec, signed by the admin
index n   ROSTER         the full publisher set at version n
last      END_OF_STREAM  the admin closes the cohort, attributably

A feed rather than one constant-id slot overwritten in place, because overwriting makes a
stale roster undetectable — which would put forging-by-omission back at the one point that
decides who may write. Ack now carries the genesis SOC and the latest service SOC with its
index, so a joiner verifies the cohort and the roster against the admin's key, never the
broker's word.

Revocation is two-phase, and the boundary is the moment the reduced roster reaches
subscribers. Before it, the revoked peer has no way to know — its frames are dropped and
tolerated, no penalty, connection untouched, because it isn't misbehaving. After it, the
peer has been told on the same feed as everyone else, so publishing is a protocol violation
and the connection is broken. The announcement is what converts an unknowing publisher into a
violating one; disconnecting first would punish a peer for a rule it hadn't been given, and
never publishing the roster leaves the violation unable to begin at all — an ordinary,
visible withholding fault. It also makes the revocation legible to everyone else, who learn
why a publisher fell silent from an admin-signed message rather than from an unattributable
disconnection.

What remains in the spec is immutable policy, and it now names five configurations
explicitly: jam, spectator-jam, live-stream, group-chat, implicit.

Back to your review of 4 August — two threads still open

Topic bindings. Your point was that pubsub normally gives no semantics to a topic, and
that per-binding validation makes a larger API surface for users. I think rev 4 answers it
better than the argument did: there is now a MNEMONIC binding where the topic names the
cohort and constrains nothing — any SOC from any owner. That is the plain pubsub case, it is
the default shape for group chat, and every other binding is opt-in. So the "dumb" semantics
you were asking for is a first-class option rather than something to be recovered by not
using the feature. The other bindings don't change what a topic is; they say how updates on
one are linked and whether more than one source can write to it.

"Remove multi-publisher from this iteration." I argued in August that a single-publisher
cohort is the harder one, because it's the one that needs authentication. That's now built —
Auth plus the genesis message — so the argument has expired and the scoping question is
open on its merits. Worth deciding against the five configurations rather than against the
old spec: ADMIN_ONLY (live-stream) and implicit need no roster and no service feed at all,
so a first iteration could be exactly those two, with GRANTED/ALL and the service feed as
the second. Say if that split matches what's on your branch and I'll mark the spec that way.

What this means for the bps branch

Your transport layer survives intact: the Hello envelope (identical oneof), Soc /
Publish / Broadcast, Status, self-contained full-SOC frames, po_min as a constant with
field 7 reserved, and your 12 August relaxation of the ANCHOR address check under explicit
regimes. frame.go and the SOC path shouldn't move at all.

What changes is the policy layer: PublisherRegime values, publisher_list (field 6, now
reserved), closed (field 8, now reserved — spectators is field 9), PublisherAuth
Auth with a recovered signature, Ack carrying the two service SOCs, and the service feed
itself, which is entirely new surface. So binding.go, cohort.go, publisher.go and
session.go take most of it, and bridge.go needs the service endpoint plus client-side
signing.

One deliberate courtesy: MNEMONIC is appended as TopicBinding = 5, not inserted at 1,
so ANCHOR/SOC_ID/OWNER/FEED_TOPIC keep the numbering you already implement. The proto
says so in a comment, in case a later editor is tempted to tidy it.

If you'd rather cut the surface for a first iteration, the split falls out cleanly:
ADMIN_ONLY and implicit need no roster and no service feed at all, which is close to what
you already have. GRANTED / ALL and the service feed would then be the second pass.


Pushed as 10df5e9.

🤖 Generated with Claude Code

@zelig

zelig commented Aug 30, 2026

Copy link
Copy Markdown
Member Author

@acud one implementation note that came out of revising SWIP-61 alongside this — relevant if Ack grows in the prototype.

Ack fields 3, 4 and 5 are taken by rev 4: genesis, service and index (the admin-signed service SOCs a joiner verifies the cohort and roster against). SWIP-61's candidates field, which an earlier draft of it numbered 3, is now field 6 for exactly that reason — PR #105, rev 2 just pushed.

The other thing worth knowing before you touch the multihop side: SWIP-61 no longer keeps publishers at the root. Publish travels rootward now, so the depth-1 attachment your prototype inherits from SWIP-60 is a property of singlehop rather than a rule to preserve. Nothing in the current branch needs to change for that today — it only matters once relaying lands.

🤖 Generated with Claude Code

@zelig

zelig commented Aug 30, 2026

Copy link
Copy Markdown
Member Author

@acud — separate ask: would you open a draft PR for the bps branch?

I went through it properly and it deserves a review surface. Right now it exists only as a branch, so there is nowhere to comment and nobody but me can see that the spec has a working implementation behind it — which is the strongest argument this SWIP has.

Things I wanted to say inline and couldn't:

  • TestHostileBrokerCannotForge, TestOpenRefusesTamperedSpecEcho, TestPublisherAuthIsNotACredential — testing the security properties adversarially rather than the happy path. That last one arrives at the same framing rev 4 now writes down: the handshake declaration is an early refusal, and the real gate is the recovered owner at Publish. Good to see it reached from the implementation side independently.
  • The serve teardown reasoning — writeCtx bridging both quit channels so a peer that stops draining its flow-control window cannot park the goroutine, and Reset rather than FullClose because bee's FullClose reads and would give you two concurrent readers. Explained in place, which is what makes it reviewable.
  • Declining to label metrics by peer address, so a remote peer cannot mint unbounded time series. Right call, and the reason is worth keeping in the comment.
  • SpecEqual comparing the publisher list as a set, for two clients assembling the same invite in different orders. The spec never said that and should have.

Two things a review would actually be about:

  1. Blocklisting. SWIP-60 says repeated invalid ⇒ disconnect; the branch drops and logs, and your comment notes the policy wants per-peer state in memory. Fair, but it is a conformance gap worth tracking rather than leaving in a comment.
  2. A spec question your code exposes. Under an explicit regime anchorBinding.qualifies returns nil unconditionally — the topic becomes a mere rendezvous. With rev 4's new MNEMONIC binding, ANCHOR + roster and MNEMONIC + roster now differ only in the dedup rule: wrapped CAC versus chunk address. That may be exactly the right distinction, or ANCHOR-under-explicit may now be redundant. I would rather decide that with you than assert it in the spec.

And it is the natural place to settle the scoping question from my previous comment — which configurations land first. ADMIN_ONLY and implicit need no roster and no service feed at all, which is close to what you already have.

🤖 Generated with Claude Code

SWIP-0 specifies `type: Standards Track` with the subcategory in a separate
`category:` header (one of Core / Networking / Interface), as swip-19 and
swip-20 do. This file carried the category inside `type:`, which is the only
form in the repo and may break tooling that parses the front matter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.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.

3 participants