Skip to content

Add SWIP-draft: BPS-lite — single-publisher brokered broadcast - #111

Open
acud wants to merge 10 commits into
masterfrom
swip-bps-lite
Open

acud wants to merge 10 commits into
masterfrom
swip-bps-lite

Conversation

@acud

@acud acud commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

A standalone single-publisher profile of the design space SWIP-60 (PR #104) covers: one broker, one publisher, N subscribers, one hop.

A cohort is a topic and a topic is a SOC address, so authorship needs no credential, no publisher list and no authenticated handshake — the ANCHOR address check re-establishes it from the signature on every message. The publisher role is bound to the opening stream and transfers on re-Open; the cohort's lifetime is that stream, which removes orphan cohorts and bounds Open as an allocation primitive.

Specified standalone rather than as a profile of SWIP-60, with the protobuf inline and no compatibility claimed in either direction. It answers three questions SWIP-60 leaves open (dedup horizon, cohort lifetime, Open as an unbounded allocation primitive) and drops closed rather than restating a confidentiality claim an unauthenticated handshake cannot support.

The two specs share the pubsub/1.0.0 protocol id, so the handshake carries the compatibility story: SWIP-60's field numbers are reserved, not reused, which is what lets a BPS-lite broker refuse a full-spec Open outright instead of silently decoding it with fields dropped.

Note the one divergence that can fail silently for implementers: BPS-lite's ANCHOR applies its address constraint unconditionally, where SWIP-60 relaxes it under an explicit publisher regime.

A standalone single-publisher profile of the design space SWIP-60 (PR #104)
covers: one broker, one publisher, N subscribers, one hop.

A cohort is a topic and a topic is a SOC address, so authorship needs no
credential, no publisher list and no authenticated handshake — the ANCHOR
address check re-establishes it from the signature on every message. The
publisher role is bound to the opening stream and transfers on re-Open; the
cohort's lifetime is that stream, which removes orphan cohorts and bounds Open
as an allocation primitive.

Specified standalone rather than as a profile of SWIP-60, with the protobuf
inline and no compatibility claimed in either direction. It answers three
questions SWIP-60 leaves open (dedup horizon, cohort lifetime, Open as an
unbounded allocation primitive) and drops `closed` rather than restating a
confidentiality claim an unauthenticated handshake cannot support.

The two specs share the pubsub/1.0.0 protocol id, so the handshake carries the
compatibility story: SWIP-60's field numbers are reserved, not reused, which is
what lets a BPS-lite broker refuse a full-spec Open outright instead of
silently decoding it with fields dropped.

Note the one divergence that can fail silently for implementers: BPS-lite's
ANCHOR applies its address constraint unconditionally, where SWIP-60 relaxes it
under an explicit publisher regime.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E99KGsCW7dyKudy2WbpRco

@zelig zelig left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

review is not yet finished. submitting it so far nonetheless for discussion.

Comment thread SWIPs/swip-draft_bps-lite.md Outdated
## Simple Summary

One broker, one publisher, many subscribers, one hop. A publisher opens a topic on a
broker and pushes single-owner chunks; everyone else connects and reads. Nothing else.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

A publisher must explicitly connect to the broker and has the exlusuve right to publish messages. Just like with all subsequent modes, messages are of a shape of single owner chunks. In this specific case these chunks must be higher-index versions (updates) of a feed with the topic (the cohort topic bound to the feed). All other joiners of the cohort are subscribers.

Comment thread SWIPs/swip-draft_bps-lite.md Outdated

## Abstract

BPS-lite is a real-time broadcast protocol in which a cohort is a topic and a topic is

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

BPS-lite is a real-time broadcast protocol a kind of push notifications on a channel that participants can subscribe to and a restricted set of participants can publish on.
Each channel defines its own cohort (a set of nodes), which are connected through a unique central broadcaster node, the broker. In BPS-lite, each node in the cohort will be directly connected to the broker, hence the moniker single-hop.

Comment thread SWIPs/swip-draft_bps-lite.md Outdated
Comment on lines +25 to +31
a single-owner chunk address. The peer that opens a cohort is its publisher for as long
as that stream lives; every other peer is a read-only subscriber. Authorship needs no
credential, no publisher list and no handshake authentication: under the `ANCHOR`
binding the topic already names the only key that can produce a chunk hashing to it, so
authorship is re-established from the signature on every message. The cohort's lifetime
is its publisher's stream, which removes orphan cohorts and bounds `Open` as an
allocation primitive.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I am not sure i understand this (allocation primitive) but yes Open (open should be interpreted in terms of the connecting node, open connection to the channel, maybe we should call it Join) can create the channel for the broker.

Each channel is uniquely identified by a cohort specification. Turning it around, the cohort specification must contain all information that can distinguish channels, and exclude stuff that can change without changing the integrity of the cohort (secondary publishers).

Open message includes the cohort spec, if someone sends an Open message to the broker node, then the cohort spec is compared to existing open channels. IF one matches, then the candidate is trying to join if not then the channel is opened.

Comment thread SWIPs/swip-draft_bps-lite.md Outdated
is its publisher's stream, which removes orphan cohorts and bounds `Open` as an
allocation primitive.

It is a deliberate subset of the design space [SWIP-60][swip60] covers, specified as

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

subset it really is , and should not deviate though.

Comment thread SWIPs/swip-draft_bps-lite.md Outdated

It is a deliberate subset of the design space [SWIP-60][swip60] covers, specified as
its own document rather than as a profile: the wire format below is complete, and where
BPS-lite differs from SWIP-60 — most importantly in making `ANCHOR`'s address

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

No, so the ANCHOR first of should not be the first of all :) IT must be feed
What is relevant here is single explicit publisher in which case the messages are (supposed to be the ordered updates of a feed.

Comment thread SWIPs/swip-draft_bps-lite.md Outdated

### Handshake

Publisher-first. The publisher fixes the cohort; subscribers join it.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

no, anyone (so a subscriber also) can open, join a channel.
Note that if the joiners are not admins, they cannot publish (if they do, protocol violation), so the channel only has minimal resource demand, in fact only an entry in the broker nodes channels map.
There must be an inactivity deadline, so there is no trolling with opening a lot of channels and squat the broker node capacity with bogus idle cohorts.
If the subsciber has no active subscriptions, it can be disconnected.
Similarly, there must also be a maximum channel number per node connection so that a node cannot overwhelm the host broker with newly joined bogus channels while keeping a bonding legit one open.

Comment thread SWIPs/swip-draft_bps-lite.md Outdated

A `Publish` is accepted iff, in order:

1. it arrived on the stream that opened the cohort;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

A published SOC received by the broker is valid if
-!!! the ID of the SOC is an int > feed cursor

  • suubstituting the ID with H(topic|int) the msg validates as a SOC
  • and its owner is one of the publishers (in our case of singleton, the admin).

Comment thread SWIPs/swip-draft_bps-lite.md Outdated
4. the wrapped CAC's BMT root matches its payload;
5. the CAC address is not in the cohort's dedup window.

Failures 1–4 are protocol violations: the message is dropped, counted per peer, and

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

all failures are protocol violations and result in stream cleanup,
disconnect and blocklisting (ONLY if you are the broker).

Comment thread SWIPs/swip-draft_bps-lite.md Outdated
already failed.

On acceptance the broker enqueues a `Broadcast` on every subscriber stream in the
cohort, and on the publisher's own stream if it requested loopback.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

there is no loopback ever

Comment thread SWIPs/swip-draft_bps-lite.md Outdated
orphan cohorts and any reclamation policy, and it bounds `Open` as an allocation
primitive: a peer can hold only as many live cohorts as it holds open streams.

### Dedup

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

no dedup , see SWIP-65 how we handle missed items

zelig and others added 2 commits September 22, 2026 16:12
…blisher over a feed

Rewrite of the first draft after review. BPS-lite is now a strict subset/base of
SWIP-60 rather than a divergent profile: the live-stream configuration with its wire
reduced to what it needs.

- cohort spec = {topic, FEED_TOPIC, admin}, and the spec is the cohort's identity
- one handshake frame, Join{CohortSpec, Auth?}: create-or-attach, anyone may do either;
  no Hello/Open/Subscribe, no UNKNOWN_TOPIC
- Auth binds the identity to the stream, not the connection; a stream whose Auth
  recovers to admin is a publisher stream, every other stream is read-only
- Ack{status} only: OK / FULL / REJECTED
- one Message{soc} frame both directions; the chunk as opaque chunk data with the bare
  index in the id slot (SWIP-65 carriage)
- validation: publisher stream, index > cursor, valid SOC under keccak256(topic||index),
  owner == admin; no dedup window, gaps allowed, retransmits counted not punished
- no loopback: deliveries to subscriber streams only
- lifetime by inactivity only — no service messages, no end-of-stream
- broker bounds: subscribers/cohort, cohorts/broker, cohorts/peer connection,
  inactivity deadline, per-stream queue
- no history, no bandwidth incentive, no Bee API, one hop, one mode; implementation
  section removed
- "Relation to SWIP-60" lists the amendments SWIP-60 needs to extend this wire
  (Join, cohorts keyed by spec, no GENESIS, regime reduced to ALL, spectators
  polarity, opaque chunk) and the one-line SWIP-65 exception
- number assigned: 74; file renamed to swip-74.md

One open point remains, marked (?): a second stream authenticating as the admin
while one is live.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…oth publisher streams

A second stream authenticating as the admin while one is live is admitted as a
publisher stream too; the per-cohort cursor arbitrates. No supersede rule, no
refusal. No (?) marks remain.

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

zelig commented Sep 22, 2026

Copy link
Copy Markdown
Member

BPS-lite: counter-proposal

@acud — thanks for the draft; the line comments above are the piecemeal version of this,
so here is the whole position in one place. The rewritten draft is on this branch as
SWIPs/swip-74.md (two commits on top of yours; the number 74 is free). Points I have not
settled are marked open — they are questions to you, not decisions.

The position

The goal of PR #111 is right: a second-implementable target a tenth the size of SWIP-60,
for the one application everybody wants first — one author broadcasting to an audience.
Two things about how it gets there are wrong, and they are the whole counter-proposal:

  1. The point in the design space. Not ANCHOR with the publisher role bound to the
    opening stream, but the live-stream configuration of SWIP-60's table: a feed topic with an explicit
    admin in the spec. One address for every message (ANCHOR) leaves messages
    distinguishable only by payload, needs a dedup window, has no order and no link to
    storage. A feed gives order, gap detection, replay-freedom and persistence for the
    price of one address in the spec and one owner check per message — and the live
    stream is the feed, which is what makes SWIP-65 and history possible later without
    changing a frame.
  2. Subset, not divergence. The draft specifies "its own document rather than a
    profile", claims no compatibility in either direction, and makes ANCHOR
    unconditional so that SWIP-60 code "would perform no address check at all". That is
    two protocols under one id. BPS-lite must be the base of the family: the fuller
    protocol extends its wire by reserved numbers and never changes it, so a lite peer at
    a full broker is simply a conformant peer, and a lite broker refuses at the handshake
    everything it does not implement.

Everything else follows. What BPS-lite is, in one paragraph:

SWIP-74, BPS-lite. Three frames on pubsub/1.0.0 — Join{CohortSpec, Auth?},
Ack{Status}, Message{soc} — and the two types they carry. The spec is
{topic, FEED_TOPIC, admin} and is the
cohort's identity: a Join creates the cohort if no live one has that spec, attaches
otherwise; anyone may do either. Auth in Join binds an identity to the stream
(not the connection); a stream whose identity is the admin is a publisher stream, every
other stream is read-only. Messages are feed updates carried with the bare index in the
id slot; the broker keeps one cursor per cohort and accepts iff index > cursor, the
chunk validates as a SOC under keccak256(topic ‖ index), and its owner is the admin;
it delivers the bytes unchanged to every subscriber stream and to no publisher stream.
No dedup window, no loopback, no
service messages, no history, no incentives, no Bee API, one hop. A cohort ends by
inactivity, and by nothing else. Broker bounds: subscribers per cohort, cohorts per
broker, cohorts per peer connection, an inactivity deadline, a per-stream queue.

The ladder: what each step adds

Capability steps, not dated releases: each is a feature combination the family wants
to support, each extends the previous one's wire without changing it, and each is its
own SWIP (or an existing one).

step adds wire where
base single publisher over a feed, one broker, one hop Join, Ack, Message SWIP-74 (this)
MVP 1 (1) service messages — the admin's control plane as SOCs on a feed it owns: end of stream, later roster; (2) the Bee API — WS bridge, GET /pubsub/{topic}, POST /pubsub/{topic}/service, client-side signing, swarm-soc-fields no new message: service SOCs travel as Message with a full 32-byte id (which is why the base drops rather than punishes such ids); open: whether Ack gains the latest service SOC in its reserved fields SWIP-60, amended
MVP 2 multiple publishers: the admin publishes a roster; a cohort is multi-publisher iff it ever does (no regime in the spec); stream upgrade — a subscriber stream whose Join carried an Auth is promoted to a publisher stream in place when the roster names its identity, and demoted per SWIP-60's two-phase revocation. Validation generalises: owner ∈ roster, one feed and one cursor per publisher; delivery to every stream except the publishing identity's own no new message: the identity was bound at Join, the roster is a service SOC; open: an Upgrade{Auth} frame for a stream that joined without Auth SWIP-60, amended
MVP 3 self-indexed feeds — SWIP-65's payload construction (whirl-only pot), gap recovery, equivocation check, publish-is-upload no new message: carriage is already the bare index; SWIP-65's "broker does not enforce monotonicity" gets the single-publisher exception the base's cursor is SWIP-65, one-line amendment
MVP 4 multihop — FCFS multicast tree, referral at capacity, dual parents new fields on Message, which the base ignores SWIP-61

Not on the ladder, orthogonal: bandwidth incentives (bps-bw-incentives, DISC: YES),
broker discovery (SWIP-59 MEX), history for non-self-indexed bindings (bps-history).

Why Auth is in Join even in the base, where only an identity that recovers to the
admin has any effect:

MVP 2's promotion. An identity bound to the stream at join time is what lets the broker
flip a stream's role in place when a roster arrives, without a rejoin and without
tying the identity to the peer connection (one node, several cohorts, several keys).

Why the base has no end-of-stream: an end that is signed by the admin is a service
message (MVP 1); an end inferred from the admin's stream makes every transport hiccup a
stream-ending event for the audience. So the base has inactivity only, and "over" versus
"paused" is the application's to carry until MVP 1.

What changes against PR #111, by section

PR #111 counter-proposal
ANCHOR, unconditional; no publisher field FEED_TOPIC; admin in the spec; owner check per message
Hello{Open | Subscribe}, Open on a live topic transfers the role one Join carrying the spec; create-or-attach; role from Auth
Open.loopback no loopback, ever: a publisher never receives its own messages
Subscribe before Open → UNKNOWN_TOPIC anyone creates; no unknown topic
Ack{status, spec} Ack{status}
Soc broken into five proto fields the chunk as opaque chunk data, validated by the ordinary SOC code
Publish / Broadcast{oneof} with reserved 2–15 one Message, both directions
validation: address == topic, BMT, dedup window validation: publisher stream, index > cursor, SOC under H(topic ‖ index), owner == admin
dedup: LRU of wrapped CACs, 1024 no window: the cursor
lifetime = the publisher's stream; teardown resets subscribers lifetime = inactivity deadline; a publisher stream going away is not an end
publisher-stream queue exemption moot without loopback
"no compatibility claimed in either direction" strict subset; SWIP-60 amended to extend this wire
test cases 1–13 replaced by SWIP-74's conformance section (items 1–11): only the reserved-field, subscriber-publishes, FULL-without-referral and queue tests survive in substance; new: cursor rule, owner rule, identity-on-stream, inactivity, interop with a full broker
implementation section dropped from the SWIP

Editorial: the file becomes SWIPs/swip-74.md; the duplicated "superset of this spec"
paragraph under Implementation goes with the section.

Extra comments and clarifications

  • SWIP-60 amendments implied. The base is only a subset if SWIP-60 is amended to
    extend it: Hello/Open/Subscribe → Join carrying the spec, with cohorts keyed
    by the whole spec rather than by topic (so pre-creating a topic under a wrong admin
    squats nothing); GENESIS dropped (with the spec in Join there is nothing left for
    it to prove) and no spec echo in Ack; the PublisherRegime enum reduced to the
    single value ALL (absent: the admin and whoever its roster ever names, so
    multi-publisher iff a roster is published); spectators dropped or reverted to
    closed
    — a proto3 bool
    whose unset value is false reads every lite spec as a closed cohort, and a full broker
    would refuse every lite subscriber; Soc as opaque chunk data. My to-do on PR SWIP-060: BPS singlehop — brokered broadcast pub/sub, base protocol #104;
    the next revision of assets/swip-60/bps.proto should be derived from SWIP-74's
    block, not the reverse.
  • FEED_TOPIC = 4. SWIP-74 keeps SWIP-60's enum number so the fuller protocol adds
    bindings without renumbering; it is the only value a lite broker accepts.
  • Opaque chunk data, not proto fields, for the SOC. Carry the chunk as bytes and let
    the SOC code validate. It removes a message type and any chance of the proto and the
    chunk format disagreeing. It is a wire change for SWIP-60's proto and for bee #5435's
    framing.
  • Several publisher streams. With the identity on the stream, nothing stops the
    admin from holding two publisher streams (two nodes, or a reconnect racing a stale
    stream). SWIP-74 allows it and lets the cursor arbitrate, rather than the "new stream
    supersedes the old" rule of the first draft: allowing it is simpler and is what MVP 2
    needs anyway. Deliveries go to subscriber streams only, so neither publisher stream
    ever sees the admin's messages.
  • A misconfigured admin is admitted read-only. An Auth that fails to recover to
    admin (wrong topic bytes, wrong key) gets OK and a subscriber stream, and its
    first publication is a violation. Ack is status only, so the client cannot learn
    this from the broker; a client library checks its own Auth recovers to the admin
    it is about to send before joining — it has everything it needs to.
  • Replay across cohorts. Auth is replayable (static preimage, as in SWIP-60), and
    a replayer also holds the admin's signed history. Within a cohort's life the cursor
    refuses it; after a reclaim, or at another broker, the base cannot tell replayed
    history from history. Subscribers keep their cursor per (topic, admin); first-time
    freshness is the payload's business (SWIP-65's timestamp key) until service messages
    exist.
  • A non-admin Auth in the base is accepted as a subscriber stream carrying an
    identity, not REJECTED — so that the same Join works unchanged when MVP 2
    promotes it, and so that a lite broker does not refuse for identity, which SWIP-60
    reserves for spectators: false.
  • Retransmits. index ≤ cursor is counted, not punished: it is what an admin
    reconnecting after a reset legitimately does when it does not know what the broker
    last accepted. Open: whether Ack should carry the cursor to the admin on rejoin —
    one field, saves a round of retransmits; left out of the base for now.
  • Number. 74 is free: no PR, branch or file in ethersphere/SWIPs uses it (the only
    "74" is PR add SWIP-39: balanced neighbourhood registry aka smart neighbourhood management #74, SWIP-39), and 73 is PR SWIP-073: Custody separation #109.

zelig added a commit that referenced this pull request Sep 22, 2026
…SIS, regime = ALL, closed, opaque chunk

SWIP-60 now extends the base wire of SWIP-74 (BPS-lite, PR #111) and changes
nothing in it. bps.proto is revision 8, derived from SWIP-74's block.

- one handshake frame, Join{CohortSpec, Auth?}; Hello/Open/Subscribe gone; cohorts
  keyed by the whole spec (create-or-attach, no UNKNOWN_TOPIC, squatting a topic
  under a wrong admin obtains nothing)
- Ack is a status; the latest service SOC is delivered as the first Message on
  every newly attached stream not bound to the admin (marked open)
- GENESIS gone: the spec is in every Join; the service feed starts at index 0 with
  the first ROSTER or END_OF_STREAM, and each service message carries its index
- publisher regime reduced to the single value ALL; unset = the admin and whoever
  its roster ever names; ADMIN_ONLY and GRANTED gone; live-stream and
  spectator-jam are one spec
- spectators (field 9) reverted to closed (field 8, unset = open audience)
- one Message{soc} frame both directions, chunk as opaque chunk data; Publish,
  Broadcast and the field-level Soc gone; deliveries to every stream not bound to
  the publishing identity
- Auth bound to the stream; a spectator carrying an identity is promoted in place
  when a roster names it; the admin's Join is admitted past the per-cohort bound
- broker validation restated: duplicates are retransmits, never invalid; service
  SOCs recognised by id before the content path; a message from a non-publishing
  stream is a violation
- the feed cursor stays SWIP-74's stricter special case; a full broker dedups on
  chunk address (several publisher feeds, SWIP-61 reordering)
- ANCHOR under explicit authorship: no address check, topic is a rendezvous
- capacity, streams and limits per cohort, not per topic; SWIP-74's bounds
- API: spec parameters on every session, auth binds an identity, worked URLs
  updated, closed replaces spectators
- conformance items 1, 2, 5, 6, 7, 8 updated; title, motivation, security and
  backwards compatibility aligned; SWIP-61 to be re-based on the Message frame

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ved challenge

- Join{CohortSpec, addr, claim?}: a peer that means to publish declares the address
  it will publish as; a returning publisher claims in the Join
- Ack{status, challenge}: S = H(S_C || S_c || addr), S_C a boot secret never
  persisted, S_c = H(S_C || H(Marshal(spec))) — nothing stored, recomputed at
  claim time, the same S for an address on a cohort from any node
- Claim{addr, index, auth}: auth = {r, s, v} over H("bps-claim:v1" || S || O_B ||
  index); O_B the broker's overlay (against challenge forwarding), index the
  publisher's cursor, signed; no reply
- Message{address, data}: the whole chunk, so the ordinary SOC validation is
  non-vacuous; no envelope — what a frame is follows from the stream's role
- cursor restated as the lowest index accepted next, initially 0; a claim sets it
  to max(cursor, index)
- one extra stream over the fan-out bound while the admin is absent, with a claim
  deadline
- security: what the claim protects (identity, not history), the case-by-case
  replay table, the transport precondition made normative
- no reserved numbers; unknown fields ignored; conformance and counters updated

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

zelig commented Sep 24, 2026

Copy link
Copy Markdown
Member

Rev 3 — the publisher role is claimed by signing a broker-derived challenge. The first draft had a challenge; rev 2 wrongly replaced it with a static signature (H("bps-join:v1" ‖ topic ‖ admin)), which is replayable by design. What a replayed signature buys is not history — replaying the admin's signed updates to a late viewer is catching it up — but an identity: a stream carried as the admin's, exempt from the fan-out bound, and in SWIP-60 admitted to a closed cohort and promoted by a roster. So:

  • Join{CohortSpec, addr, claim?}: a peer that means to publish declares the address it will publish as; a returning publisher claims right in the Join.
  • Ack{status, challenge}: for a declared address the broker derives S = H(S_C ‖ S_c ‖ addr) from a secret drawn at boot and never persisted (S_c = H(S_C ‖ H(Marshal(spec))), the spec's canonical serialisation, which also keys cohorts) — nothing stored, the same S for an address on a cohort as long as the broker runs, from any node.
  • Claim{addr, index, auth}: auth = {r, s, v} over H("bps-claim:v1" ‖ S ‖ O_B ‖ index) — O_B the overlay of the broker the claiming node talks to (against a relay forwarding the challenge), index the publisher's cursor, signed. Sent in the Join or as the subscriber stream's next frame. No reply: the publisher pipelines its first message behind it and learns the outcome from whether the stream survives.
  • Message{address, data}: the whole chunk, so the ordinary SOC validation is non-vacuous (recovering a signer always yields an address; with the address on the frame a bad signature fails).
  • No envelope: what a frame is follows from the stream's role — a subscriber stream sends at most one Claim, a publisher stream sends Message.
  • The cursor is now "the lowest index accepted next", initially 0; a claim moves it forward to what the publisher knows, never back.
  • One extra stream over the fan-out bound while the admin is absent, for a Join declaring the admin's address, disconnected if it has not claimed within a claim deadline.
  • Security: a case-by-case replay table; the transport precondition (a peer's signed address record must name the connection's authenticated peer ID) made normative — bee's handshake checks the record, not the binding, today.
  • No reserved numbers anywhere; fields this SWIP does not define are ignored.

Two things marked (?) for slashing: the index field of Claim, and the claim deadline.

zelig and others added 2 commits September 24, 2026 16:41
…n claims, counters mapped, bounds counted

- the claim's signing convention (SOC's EIP-191 prefixed digest) stated and marked (?)
- a claim in the Join that does not verify is treated as absent (the broker cannot
  tell stale from wrong); the peer notices by the S in the Ack; only a post-Ack Claim
  that fails is a violation; a Claim on a publisher stream is read as a Message
- counters: wrong_stream vs invalid_claim defined; claim_timeout added
- "first five bounds REQUIRED"; Security counts the bounds the same way
- the transport precondition names only what it rests on (O_B)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- Auth{soc}: the claim carried as a single-owner chunk — id = keccak256("bps-claim:v1"
  || topic), owner = addr, payload = S || O_B || index (span 72) — signed as any SOC is
  and verified by the ordinary SOC validation against keccak256(id || addr), then the
  payload against the broker's own S and overlay; settles the signing convention
- Join{cohort, addr, auth}; Claim and the r/s/v type are gone
- Broadcast{address, data} replaces Message, both directions
- four frames: Join, Ack, Auth, Broadcast; one carried type: CohortSpec
- wrong_stream defined structurally (not the shape of an Auth); replay table and the
  verification prose reworded for the chunk (a stale claim fails the payload check,
  not the address); rationale gains "Why the claim is a chunk"

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

zelig commented Sep 27, 2026 •

Copy link
Copy Markdown
Member

Rev 4 — the claim (Auth) has a SO chunk; Message is Broadcast.

  • Auth { bytes soc }: the claim is carried as a single-owner chunk with id = keccak256("bps-claim:v1" ‖ topic), owner = addr and payload = S ‖ O_B ‖ index, signed as any SOC is. The receiver derives the id from the topic and the expected address from the declared addr, validates the chunk against it with the ordinary SOC code — signature, digest and signer in one existing call — and checks the payload against its own S and overlay. The separator lives in the id, so a claim can never be a feed update (44-byte id preimage against a feed's 40). This settles the signing convention that rev 3 marked (?): a claim is signed exactly as a SOC. Claim and the r ‖ s ‖ v type are gone; Join is {cohort, addr, auth}.
  • Broadcast { address, data } replaces Message, both directions, as in bee. The four frames are Join, Ack, Auth, Broadcast; the one carried type is CohortSpec.
  • Rationale gains "Why the claim is a chunk"; the replay table's cross-signature row becomes the id-namespace row; conformance 4 and the counters follow.
  • Join carries an optional index (cursor) argument, for a newly joining publisher to continue genuine updates at a index >= cursor

zelig and others added 2 commits September 28, 2026 10:43
…orms the address

- Broadcast{soc}: no address on the frame. Publishing is restricted to a known owner,
  so every receiver forms keccak256(keccak256(topic || index) || admin) itself and
  validates the chunk against it with the ordinary SOC code — signature, digest and
  owner in one call, the owner forced rather than compared afterwards
- validation steps 3 and 4 merge; wrong_owner folds into invalid_soc
- Auth and Broadcast are one shape on the wire, so the subscriber-stream shape test
  is length: wrong_stream iff soc is not 177 bytes
- rationale: "Why the frame carries no address"

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…IM service message

- rev 5's 177-byte length test was wrong (a feed update can be 177 bytes too); nothing
  on the wire is told apart by shape
- the claim is a Broadcast like every other frame; its chunk keeps the id
  keccak256("bps-claim:v1" || topic) and owner addr, its payload is
  ServiceMessage{kind: CLAIM, index, challenge: S, overlay: O_B}; recognised by id and
  kind, as the family's service messages are; the id slot carries the full claim id
- Join.auth is the claim chunk's bytes; three frames Join, Ack, Broadcast; two carried
  types CohortSpec and ServiceMessage
- wrong_stream covers every invalid subscriber-stream frame; invalid_claim is gone
- a non-feed-update id on a publisher stream is dropped and counted, not a violation
  (a full-protocol admin's service SOC at a lite broker)

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

zelig commented Sep 28, 2026

Copy link
Copy Markdown
Member

Rev 6 — no Auth type; the claim is a Broadcast carrying a CLAIM service message. Rev 5's length test was wrong (a feed update can be 177 bytes too) and nothing on this wire should be told apart by shape. So the claim is a Broadcast like every other frame; its chunk keeps id keccak256("bps-claim:v1" ‖ topic) and owner addr, and its payload is ServiceMessage{kind: CLAIM, index, challenge: S, overlay: O_B} — recognised by the id and by the kind, the way SWIP-60's roster and end of stream are. Join.auth is the claim chunk's bytes. Three frames: Join, Ack, Broadcast; two carried types: CohortSpec and, as a chunk payload, ServiceMessage. wrong_stream covers every invalid subscriber-stream frame; invalid_claim is gone.

…first publication is the claim

Join carries the spec and the peer's identity only; the Ack challenge is 24 random
bytes per stream and salts the session feed, keccak256(keccak256(topic || challenge) ||
index), carried as challenge || index in the id slot; no Auth type, no service message,
no credential in Join, no broker overlay, no transport precondition. A stream declaring
the admin's address is pending — outside the fan-out bound, silent until it claims.
New bound: streams per peer connection per cohort.

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

zelig commented Oct 1, 2026

Copy link
Copy Markdown
Member

Rev 7 — the challenge is random per stream and salts the feed topic; the first publication is the claim. After talking to @acud:

  • Join { cohort, addr } and nothing else: no cursor, no credential. addr (20 bytes) is the stream's identity; only the admin's may publish. A credential that could be presented in a Join could be presented by whoever captured it, so authentication is always through the Ack challenge.
  • Ack { status, challenge }: on OK, exactly 24 bytes drawn at random for this stream, held for its life, never persisted or reused. The derived scheme (S_C / S_s / S_c) is gone — a repeatable challenge let whoever had captured the admin's claim (the node that bridged it, say) present it again once the admin dropped.
  • The session feed. For the stream's life the publisher writes the feed on topic_s = keccak256(topic ‖ challenge): the signed id is keccak256(topic_s ‖ index) — an ordinary feed on the salted topic, so a subscriber can persist the run with ordinary feed tooling — and the chunk's id slot carries challenge ‖ index (24 + 8), so every receiver reconstructs the id from the slot (SWIP-65's carriage with the 24 zero bytes replaced by the salt) and the broker additionally requires its own challenge there, before any signature is looked at. Nothing signed for one session is accepted on any other stream, at this broker or elsewhere; the channel's own feed updates (24 zero bytes) fail the same check.
  • The first Broadcast under the challenge is the claim. No Auth, no CLAIM service message, no ServiceMessage in this SWIP at all: on a pending stream, the first frame that carries the stream's challenge and validates at keccak256(id ‖ admin) upgrades the stream and is then delivered like any publication. No reply.
  • Pending streams replace the single extra slot. A Join declaring the admin's addr is admitted outside the fan-out bound, receiving nothing, until it claims or the claim deadline disconnects it. The one extra slot was the admin's only door into a full cohort once the claim-in-Join went, and anyone could hold it; a pending stream is a map entry and a challenge for 30 s, worth nothing to anyone but the admin, and a half-open old stream of the admin's no longer blocks its return. New REQUIRED bound: streams per peer connection per cohort (2 ?).
  • A frame on a pending or publisher stream whose slot does not begin with the stream's challenge is dropped and counted (wrong_challenge), not a violation — it is exactly what a reconnecting admin's queued old-session chunk, or a full-protocol admin's roster, looks like here; a publisher SHOULD discard or re-sign its queue on reset, and MUST NOT reuse an index across sessions (a subscriber cannot tell sessions apart; its cursor is its replay protection).
  • O_B and the transport precondition go with the claim payload: a relay that forwards the challenge becomes a transparent hop for the publisher's own updates, which can withhold and not author.

What it costs, stated in the Rationale: the publisher signs once per stream it holds; the live chunks are the session feed's — a publisher that wants the run persisted under the channel's own topic signs the same wrapped payloads again under keccak256(topic ‖ index). Open (?): addr required on every stream; the 24-byte challenge / slot layout; the per-connection bound; the claim deadline.

Three frames, one carried type.

…ge and index its id was derived from

Broadcast{soc, kind, challenge, index}; the challenge is 32 random bytes per stream and
still salts the topic: id = keccak256(keccak256(prefix || topic || challenge) || index),
the prefix inferred from the kind. Kinds DATA and AUTH — an empty chunk that claims a
stream; the first valid frame is the claim. A chunk that does not validate is a
violation on any stream. No challenge packed into the id slot.

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

zelig commented Oct 1, 2026

Copy link
Copy Markdown
Member

Rev 8 — an ordinary chunk; the frame says what its id was derived from. Supersedes rev 7's challenge ‖ index id slot.

enum    Kind      { KIND_UNSPECIFIED = 0; DATA = 1; AUTH = 2; }
message Broadcast { bytes soc = 1; Kind kind = 2; bytes challenge = 3; uint64 index = 4; }

prefix  = (empty)                       kind == DATA
        = "bps-service:v1" ‖ kind       any other kind, kind as one byte (?)
topic_s = keccak256(prefix ‖ topic ‖ challenge)
id      = keccak256(topic_s ‖ index)
  • The chunk is an ordinary SOC with its full id. Nothing is packed into the id slot and nothing is rewritten on receipt. The topic is still salted with the stream's challenge — now 32 bytes — and the frame carries the kind, the challenge and the index beside the chunk, because a subscriber has no other way to learn them. None of the three is taken on trust: each is in the id's preimage, so altering any gives an id that is not the chunk's.
  • The first valid frame is the claim, and because the challenge is specific to one stream, being signed under it is all a chunk has to prove — no claim chunk, no credential, no field in the payload. An AUTH is an empty chunk on a feed of its own, for a publisher that has joined and has nothing to say yet; any other frame will do as well. It is never delivered.
  • A chunk that does not validate is a violation on any stream — reset, blocklist — so a connection buys at most one signature check with a forgery. A frame under another challenge is dropped and counted, and the broker MAY reset; a kind this SWIP does not define (a full-protocol admin's roster or end of stream) is dropped and counted, not punished.
  • A subscriber derives the id from the frame and compares the frame's challenge with nothing — not with its own, not with earlier deliveries'.
  • On a pending stream a valid DATA frame below the cursor still claims (and is then a retransmit), so an admin that reconnects without knowing the cursor, or publishes from a second node, is not left pending. An accepted AUTH counts as activity for the inactivity deadline; an AUTH chunk with a payload is invalid; a Join whose addr is not 20 bytes is REJECTED.
  • Kept from rev 7: Join{cohort, addr}, the random per-stream challenge, pending streams outside the fan-out bound, the per-connection bound, the cursor, the publisher's duties (re-sign the queue on reset, never reuse a DATA index).
  • The Security table is redone for this wire. Counters: wrong_stream, unknown_kind, wrong_challenge, invalid_soc, claim_timeout, retransmit, queue_reset.

Open (?): the kind byte in the prefix (drafted so that each kind is a feed of its own and no kind can be relabelled as another); addr required on every stream; the per-connection bound (2); the claim deadline.

@acud — against the implementation: Broadcast gains three fields, Claim goes (the first valid Broadcast is the claim, an empty AUTH if there is nothing to publish), Join carries the spec and addr, and the challenge is not signed into a payload — it salts the id.

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.

2 participants