The scope-of-record for what ships when. DESIGN.md is how each part works and why; this is which parts are in v1 and what is deliberately held back. When the two disagree about scope, this file wins.
v1 does exactly one thing: holder-decided directory voting gated by a soulbound ERC-721 (the 5chan Pass), one Pass = one vote. Everything that is not on that path is deferred, even where the code or design already exists. Keeping the shipped surface this small is the point — it is the smallest thing that solves 5chan's competitive directory system, and every deferred item below is a real capability the architecture already leaves room for, not a rewrite.
Concretely, v1 is:
- Gate (
gate):erc5192-min-balance(hold ≥ N of the Pass, on a contract that declares ERC-5192 — the lock assertion is what makes "one Pass = one vote" true; see DESIGN.md, Does one Pass mean one vote?). - Weight:
constant(1 Pass = 1 vote) — no balance reads, so the tally does zero chain reads. - Voting: upvote-only, one vote per topic (
maxVotesPerAddress: 1), approval-style across contests. - Built-in rule registry: exactly
erc5192-min-balance+constant. Nothing else is registered — including the plainerc721-min-balance, which gates on a transferable asset.
Token-weighting, ERC-20 in either slot, and multi-chain resolution are out of v1 (see Deferred).
The engine, client lifecycle, and live-delta transport are implemented and unit-tested, and the two-node gossipsub integration test now exercises them against real @libp2p/gossipsub; the host-side registration gap has closed too (pkc-js registers gossipsub + @libp2p/fetch on the shared node as of 0.0.63), leaving only the pkc-js accessor and tuning follow-ups below. Accurate as of this file's commit — verify against the tree, not this list, if they drift.
- zod schemas (criteria, votes, wire primitives) and canonical dag-cbor encoding
- topic derivation (
topic = "bitsocial-votes/" + CID(dag-cbor(criteria))) and manifest → per-contest criteria derivation - EIP-712 ballot signer + frozen conformance vector (the cross-client wire spec)
- verify pipeline: signature +
address-recovery + criteria constraints + on-chain gate (rule) + community-name resolution, with a per-bundle verdict cache - state-based CRDT: LWW winner-set keyed by wallet, binary dag-cbor bundle codec (frozen byte vector), in-memory node store; forward-gate anti-amplification caches (gate-result + accepted-dedup)
- tally: deterministic per-contest aggregation over pre-validated bundles, rolling-seed tiebreak
- transport validate-before-forward gossip gate (
src/transport/gossip-validator.ts) over the two-kind pubsub union (one inline bundle per message with the criteria-derived size cap, or a root record): the full pipeline runs on the inlined bytes in an async topic validator beforeforwardMessage— no fetch on the live path - root-record checkpoint sync: on-demand checkpoint encode (dirty-flag cached, blocks blockstore-backed), the suppressed 10-min root heartbeat, the libp2p-fetch responder +
k = 4cold-join requester (MissingFetchErrorconstruction guard) (re-armed on gossipsubsubscription-changefor one heartbeat interval after join, closing the join-races-subscription-gossip gap — issue #15), and the bounded directed-bitswap chase of divergent roots (src/transport/chase.ts) - checkpoint-snapshot persistence (issue #14): each joined contest's last fully-verified checkpoint persists under
dataPath(src/checkpoint/snapshot.ts+ the non-LRUSnapshotStorageinsrc/storage/, one atomic blob per topic) — written debounced 10 s on winner-set changes, skipped while any deferred check is pending, flushed onleave(), reloaded atjoin()through the chase's decode+verify path — so a seeder restart with no other peer online keeps the tally (measured:npm run bench:warm-restart, zero gate RPCs on reload) - chain bucket math and the chainId → chain-client seam (
ChainClientFactory) - RPC endpoints are client settings, not consensus content: the document names its chain once, as
bucketChainId(a numeric chain id; strict schemas, so a document still carrying the olderrequires.chainsmap or the pre-v1rpcUrlsfails loudly instead of silently deriving a different topic), so an operator can swap a dead or rate-limited gateway without forking the topic. The host'schainsfactory owns the chainId → RPC mapping (one shared client per chain, wrapped by the cross-contest read coalescer); a chain the factory cannot resolve throwsMissingChainClientErrorat the create seam (recuse, not miscount), and an RPC read that throws stays infra-class everywhere (gossip gateignore, background verifier retry) — see DESIGN.md "Criteria document" for the gateway requirements - rules:
erc5192-min-balance+constant(registered);erc721-min-balance(exported) anderc20-balancepresent in-tree and unit-tested but not registered — both gate on an asset that can move (see Deferred) - reactive facade:
PubsubVoter(factory) mintingContest(createContest→update()+update/errorevents +tally) andContestVote(createContestVote→publish()+publishingstatechange/error);PubsubVoter.stop/destroylifecycle (nostart()— a seeder just creates + updates every contest; the fetch responder registers lazily on the first topic join). Republishing a live vote is the client's job — the library publishes each vote once and exportsrepublishIntervalBuckets(criteria)so the client can schedule its own refreshes (no scheduler, no persistence; see DESIGN.md "Republishing is the client's job") - two-node gossipsub integration test (
src/transport/integration/, run vianpm run test:integration, excluded from the unitnpm test): two real loopback libp2p + Helia nodes on@libp2p/gossipsub16.0.3pinning what a fake cannot — an invalid inline bundle is not forwarded and its sender isreject-scored (P₄); a valid one is forwarded and merges; a verify past the deadline yieldsignorewith no penalty and is re-evaluable (uncached); a converged pair's matching root triggers no chase; a divergent root is chased over directed bitswap to convergence. It surfaced a latent bug — the injected Heliablockstore.getis an async generator, now normalised byadaptBlockstore(src/transport/helia.ts)
Nothing in this repository. The required host-side service registrations have landed in pkc-js; the follow-ups below are non-blocking.
- A documented, version-stable accessor for the shared Helia node — done in pkc-js
0.0.72(Libp2pJsClient.heliaNode, pkc-js#221 / PR #223): returns the node carryinglibp2p.services.pubsub,blockstore, thefetchservice, andlibp2p.contentRouting, semver-covered. The pinned devDependency and both pkc-js host tests use it; consumers no longer reach through the private._heliafield. - Adding
@libp2p/fetchto the shared node's construction — done in pkc-js0.0.63(fetch: libp2pFetch()alongsidepubsub: gossipsub()inhelia-for-pkc). This library registers its own lookup function and runs its own requester againstlibp2p.services.fetch(see Done, root-record checkpoint sync);PubsubVoterstill throwsMissingFetchErrorat construction on a node without the service.
Filed as pkc-js#183, closed 2026-07 — the service registrations landed (gossipsub 16.0.2, above the >= 15.0.23 floor, plus @libp2p/fetch); the version-stable accessor followed in 0.0.72. Still open upstream: score tuning; and this library pins gossipsub 16.0.3, so a follow-up asks the host to match.
These are decided in principle and the architecture leaves room for each without an engine rewrite, but none ships in v1. Kept here so v1 stays small and the intent is not lost.
-
A transferable NFT gate (
erc721-min-balance). In-tree, exported, unit-tested, and unregistered: a barebalanceOfsays nothing about whether the asset can move, and a gating asset that moves backs one concurrent live vote per transfer inside an expiry window (see DESIGN.md, Does one Pass mean one vote?; vector insrc/crdt/amplification.test.ts). v1 shipserc5192-min-balanceinstead — the same read plus asupportsInterface(0xb45a3c0e)assertion at the same pinned block. A host that wants a transferable gate registers it through therulesoverride map; the library declines to bless it, it does not forbid it. #27. -
Balance-derived / token-weighted voting.
erc20-balance(Pass gate + BSO weight) stays in-tree and unit-tested but is unregistered, so a criteria naming it recuses viaUnknownRuleErrorrather than silently enabling token-weighting. Re-registering it inbuiltinRegistry+ re-exporting fromsrc/index.tsis the whole re-ship, but it must land with the open question below resolved — and with the Sybil amplification closed. See DESIGN.md, Future improvements.- Blocker — the amplification a fungible gate reopens. Every bundle is verified at its own pinned block and the winner set is LWW-keyed per wallet, so one balance walked through several wallets inside a single expiry window backs several concurrent votes (see DESIGN.md, Does one Pass mean one vote?; vector in
src/crdt/amplification.test.ts). The NFT path closes this by requiring a gating asset that is non-transferable and declares it on-chain (erc5192-min-balance); a fungible balance is neither that nor keyable by token id, so it needs a hold-duration guard —minat the pinned block and atpinned − voteExpiryBuckets × blocksPerBucket, so two live votes would require two wallets to have held the balance simultaneously. A shorter hold windowHonly bounds the amplification to⌊E/H⌋ + 1. The likeliest blocker is archive depth: the second read is a full expiry window back (30 days on the live manifest), which public endpoints frequently do not serve. Tracked in #28. - Open question — lazy-tally upper bounds for non-constant weight. The bound-based early stop in "Tally" assumes each unverified vote has a cheap ceiling — trivially
1forconstant. A balance-derived weight likeerc20-balancederives magnitude from the chain read, so it carries no free wire-side bound; lazy tally there needs a self-declared, verify-down balance, or it degrades to verifying every ranking-relevant vote. The{ score }result object exists precisely to grow aceilingfield for this without another signature break. Decide when the first weighted contest ships — and, if a weight reduction ever lands, alongside it.
- Blocker — the amplification a fungible gate reopens. Every bundle is verified at its own pinned block and the winner set is LWW-keyed per wallet, so one balance walked through several wallets inside a single expiry window backs several concurrent votes (see DESIGN.md, Does one Pass mean one vote?; vector in
-
Gate combining — DONE.
criteria.gatecomposes rules withall/any(a tree, not the flat AND-array first planned), folded bysrc/rules/gate.ts;checkEligibilityreports one entry per rule plus the blame set. See DESIGN.md, The gate is a tree. -
Multi-chain gating — a rule reading a chain other than the contest's. Today a contest names one chain (
criteria.bucketChainId) and every gate rule and the weight rule read it; a rule carries nochainoption at all. Letting a leaf name its own chain is additive — it would accept documents refused today, so it re-topics nothing and can land in any release — but the field is the easy half. The hard half is what block such a rule is handed:wallet.sampleBlockis a number on the bucket chain and means nothing on another. The three candidate answers (head-only; bucket-timestamp → nearest block; per-chain bucket math in the document) are written out with their costs in DESIGN.md, Open questions. Head-only is the front-runner precisely because it needs no further document field. Whichever wins also settles the tie-break seed chain and pinned name resolution below, so decide all three together. -
Weight combining — a weight-only, non-recursive reduction. For multi-asset additive weight (e.g.
#Passes + BSO/1000), asumover weight-slot terms. Requires the lazy-tally upper-bound question above. -
Pinned-block name resolution across chains. Community-name verification currently resolves at head. Pinning it to a canonical historical block needs a per-bucket block on the registry's chain (Ethereum for
.bso), which differs from the criteria's chain (Base in the 5chan example) — the same multi-chain block-selection problem as the tie seed and multi-chain gating. The resolver API already accepts an optionalblockNumber; v1 leaves it unset and accepts a transient disagreement window around a name re-point. See DESIGN.md, Open questions. -
Account-activity gate rules (e.g.
min-nonce). A well-formed pure gate rule — readgetTransactionCount(wallet)at the bucket block, score> 0nonly above a threshold — unlike a rate/timestamp check, which is stateful/temporal and so cannot be a consensusreject(see DESIGN.md, Can valid votes clog the topic?). Not on the Pass v1 path: it is redundant with the non-transferable Pass gate, ~free to bypass (one dust tx per wallet on an L2), and would reject legitimate gasless-minted Pass holders whose wallet is nonce 0 (MintPass mints server-side to the holder's address). Kept for a future token-gated config where a bare balance gate wants a cheap activity signal; the nonce is always read from chain, never carried on the wire.
Unlike the deferred items above, these are not designed — only acknowledged so the intent is not lost. Neither ships in v1 or v1.x, and both are breaking wire changes (the topic is the CID of the criteria document, so any criteria-shape change forks every topic), so they must land together in a single v2 criteria revision.
-
Abstract the criteria document away from web3 (drop block-based expiry from the top level). Today web3 primitives are baked into the criteria's top-level shape:
blocksPerBucketandvoteExpiryBucketsdefine freshness in chain blocks,requires.chainsis a per-chainchainIdmanifest (RPC endpoints are already client settings, out of the document), and every bundle is stamped with ablockNumber. A v2 revision should move time/freshness behind an abstract epoch notion (with the block-bucket scheme as one pluggable implementation, chosen by the rule that needs it) so the top-level document carries nothing chain-specific — only therule/weightrefs would name web3 when the contest actually gates on a chain. Part of the seam already exists — the CRDT and verify pipeline consume freshness through an injectedbucketMathrather than reading the chain themselves — but the criteria shape, the bundle's on-wireblockNumberstamp, and the block-derived tie seed are all chain-block-native today, which is what makes this a v2 wire revision rather than an internal refactor. -
TODO — investigate web2 voting mechanisms (e.g. whitelist). Explore gate rules that need no chain at all: a static allowlist of addresses baked into the criteria (the document already travels in full, so a whitelist is just rule options), or other non-chain eligibility signals. The rule registry already supports chain-free rules (
constantdoes zero reads), but a genuinely web2 gate is blocked on the abstraction above — without it every contest still drags in block buckets and a chain manifest it doesn't use. Open questions to investigate: what replaces the block-derived expiry and rolling tie seed for a chain-free contest, and whether signature recovery stays EIP-712/secp256k1 when the identity is no longer a wallet.