The participant-pool marketplace (design note)
This note records the design decisions (2026-07-16) for the participant-pool marketplace: advertisers and survey researchers search a pool of opted-in participant profiles (demographics/interests/skills) and offer reply bounties to an eligible group to encourage responses to ads and surveys. This is the settled shape the implementation items build toward, and the record of why the alternatives were rejected. The on-chain beacon (item 43) is implemented as of v0.9.0 — see the program reference for its instructions, PDA seed, and error codes. The CLI campaign tree (item 44) and the web surface (item 45) have both since landed — see the campaign CLI reference and the shipped web surface.
The persona and the economics
The buying side is a funded campaign wallet: an advertiser funds one wallet, then signs N bountied sends programmatically — the in-page-wallet path or a CLI batch. Bounty authoring is already settled as a direct-signed surface: each send escrows its bounty from the sender’s own wallet, and the relay stays bounty-less.
This is exactly the cohort the protocol’s economics want to charge. The standing positioning principle — the system must feel free to use and be profitable for recipients, with the cost burden on senders — maps cleanly: a participant profits twice per campaign message (postage on delivery, bounty on reply), and the campaign wallet pays every cost (message rent, postage, bounty escrow, fees).
Decision 1 — profiles are an on-chain beacon plus a sealed detail blob
A participant opts in by creating a participant beacon: a small program-owned account, one per wallet (PDA seeded on the wallet, like a mailbox). The beacon carries only:
- a coarse tag bitmap — self-attested categories drawn from a fixed,
append-only vocabulary defined in
mail_modelconstants (interests, skills, broad demographic bands, preferred languages, and coarse roles — never precise values); - an optional detail CID — the content address of an encrypted rich profile the participant pinned to IPFS;
- created/updated timestamps.
Every field is fixed-size, so the borsh layout is stable and rent is a constant (the listing-account precedent). Closing the beacon reclaims its rent — opting out is free and complete: the account’s existence is the opt-in.
Coarse-by-construction is the privacy design. A wallet-linked on-chain record is public forever, so the public layer is restricted to category bits that are individually low-information; anything expressive lives only in the sealed detail blob. There is no free text on chain.
Decision 2 — detail access is mail-native key handout
The detail blob is encrypted with a random symmetric key and pinned; the beacon publishes only its CID. An advertiser who wants the detail mails the participant — paying normal postage, optionally attaching a bounty to sweeten the request — and the participant replies with the key if they choose to share.
The request/grant flow is therefore SithBit mail itself: no new protocol, no access-control machinery, and the participant is paid for the attention either way. The accepted caveat: a handed-out key can be re-shared, so the detail blob should contain nothing whose onward disclosure would be harmful; rotation is re-encrypt + update the CID.
Decision 3 — two search paths, one layout
- Web panes search through a chain-derived
account-api index route (
/v1/chain/participants?tags=…), mirroring the existing listings/sales index pattern: the JWT authenticates the request, the index is global. - The CLI campaign tree (decision 4) is chain-direct like every
other
sithbitcommand: it scans trustlessly withgetProgramAccounts+ memcmp filters over the tag bitmap.
The layout rule that keeps both honest: the tag bitmap sits at a fixed account offset, so memcmp filtering works without deserializing and the index route stays a convenience, never a gatekeeper.
Decision 4 — group offers author through the CLI first
The first authoring surface is a sithbit campaign command tree run by
the campaign wallet:
- search — filter participants by tags (trustless scan);
- quote — price the campaign before sending: each recipient’s live state (standing frombox price, prepaid stamps, or the reputation-scaled first-contact price) plus the shared legs, summed per class;
- send — execute N direct-signed bountied sends.
A marketplace-pane “Participants” tab with a group-offer flow rides the
same library core later — the same path alias/domain sell/buy took
from CLI primitive to browser pane.
Decision 5 — the advertised price is default_postage
A participant’s participation price is their mailbox’s
default_postage. Opting in means setting your default postage to
what campaigns must pay — a participant is inviting cold mail from
strangers, which is precisely what the default price governs. Campaigns
pay through the normal frombox prepay flow.
This makes the advertised price authoritative by identity rather than by enforcement: there is no second price field to drift, no send-path ABI change, and the spam-economics stay intact — a wallet that never opted in still prices cold mail at the spam floor.
Out of scope — the operator’s campaign service
A managed campaign service (deposits, billing, dashboards, audience management) is a separate product surface an operator may build on top of these primitives. The protocol is untouched either way (recorded 2026-07-15 with the item’s creation).
Rejected alternatives
- Off-chain-only registry — no ABI change, but operator-centralized: profiles aren’t portable across operators and campaigns must trust one index. The beacon keeps the pool a protocol surface.
- Rich profiles on chain — permanent public demographics linked to a wallet is a privacy failure regardless of consent phrasing.
- Per-requester re-sealing of the detail blob — private, but then the public CID serves no purpose; it collapses back to beacon-only with mail attachments, and loses the one-blob/one-pin simplicity.
- An enforced beacon price honored by the send path — requires
SendMail/frombox ABI changes and a protocol definition of “campaign send”; rejected in favor of thedefault_postageidentity.
What would reopen this
- Tag-vocabulary exhaustion — the bitmap filling up forces a wider field or a versioned account tier (the postoffice tiered-read precedent applies).
- Key-resharing abuse in practice — would revisit per-requester sealing or third-party attestations for the detail layer.
- Regulatory treatment of demographic categories — could force vocabulary changes or geographic gating of the search surface.
- Index scale — if participant counts outgrow
getProgramAccounts, a dedicated indexer (the alias/sales SQLite precedent) takes over the read path.
Implementation items spawned
Recorded as HANDOFF items, in dependency order — the beacon is the surface the other two consume:
- Item 43 — the on-chain participant beacon (LANDED, v0.9.0):
mail_modelaccount + tag vocabulary constants (32-byte bitmap, 24 starter tags — since grown to 71 by the append-only expansion of 2026-08-31, which added 47 tags and the language/role groups with no program redeploy; bits not validated on-chain),mail_programcreate/update/close instructions (discriminants 39–41, errors 86–90 — an additive public-ABI change, hence the MINOR bump), program tests on the compiled.so. Create requires the wallet’s mailbox to exist — the advertised price is itsdefault_postage(decision 5). - Item 44 —
sithbit campaign+ profile authoring in the CLI (LANDED, v0.9.0): beacon create/update/close commands, detail-blob encrypt+pin, campaign search/quote/send. See the campaign CLI reference. - Item 45 — the web surface (LANDED, v0.9.0): account-api participants index route and the marketplace pane’s Participants tab. See the shipped web surface.
The shipped web surface (item 45)
The read path of decision 3 is now live in the browser: the name marketplace grows a Participants tab alongside its For sale / Expired / Sold tabs, so the same pane that browses aliases and domains also browses the opted-in participant pool.
The tab’s browse half is a plain read, riding the same account-api pattern as the listings and sales tabs (loaded lazily the first time it is opened):
- The pane requests
GET /v1/chain/participants?tags=…on the authenticated account-api/v1/chainsurface with the session JWT — the index is global, the JWT only authenticates the request. - account-api proxies that read to
mail-grpc’sListParticipantsRPC, which runs the trustless on-chain beacon scan (getProgramAccounts+ a memcmp filter over the fixed-offset tag bitmap — the layout rule from decision 3). A beacon must carry every filtered tag bit to match.
Each row shows the participant’s wallet, its self-attested tags by
name, and whether it published an off-chain detail document (the
sealed detail CID of decision 2). Filtering takes a comma-separated mix
of tag names and numeric bit positions (e.g. interest.technology,10),
applied with Apply.
Tag names are shared with the CLI by construction. The browser
clients carry their own copy of the vocabulary
(webclients/shared/tag-vocabulary.js), a hand-maintained mirror of the
mail_model TAG_* constants
(program reference) that derives each name by
the same rule the CLI uses, so the names the tab shows and the
sithbit campaign --tag
names are identical (TAG_INTEREST_TECHNOLOGY → interest.technology,
TAG_AGE_18_24 → age.18_24). No Rust→JS export of the constants
exists; the hand-maintained mirror fenced by drift-guard tests on both
sides — the JS coverage test and the CLI’s own vocabulary test — is the
accepted mechanism. Because the vocabulary is append-only, a beacon
written by a newer client can carry a bit the page’s mirror does not
know yet: it renders as its numeric position (bit 71) rather than
vanishing, and the filter accepts bare bit numbers alongside names
(case-insensitively) for the same reason. A filter name outside the
vocabulary is rejected in the page before any request is sent. On the
wire nothing changed: the participants route still takes bit positions,
and names resolve client-side.
Beacon authoring is in the browser too. The tab is no longer
read-only: a My beacon flow below the pool list authors the
connected wallet’s own beacon — create, wholesale update, and close,
signing with the external wallet the way buys and listings do — plus a
pane-local disable/re-enable convention: disable is an update
writing the zero bitmap (the account and its rent stay on chain; the
beacon just matches no tag search), with the prior tag set remembered
per wallet in browser storage — never on chain — so re-enable can
restore it. The detail CID stays paste-only in the page; encrypting and
pinning a profile remains the CLI’s --profile-file capability. See
Managing your own beacon
for the user-facing flow. The group-offer (quote/send) authoring flow
of decision 4 stays CLI-only.