,] [--max-price ]
+ip file keep-hire --days [--max-price ]
+ip file status # manifest, revs, peers seen, bytes served, earnings
+```
+
+`ip file add` prints the file key, both infohashes, the manifest id and
+the `magnet:` and `ip://` forms. With `--json` it prints the manifest.
+
+### 2.3 Payment
+
+```
+ip pass buy --hub (--file | --publisher ) --cap [--grant] [--days ]
+ip pass list
+ip pass show
+ip payee register --hub --pay-to :
+ip payee balance [--hub ]
+ip voucher redeem [--all | --swarm ] # normally automatic; manual for audit
+ip hub info
+```
+
+`ip pass buy` performs the x402 exchange with the key from
+`X402_PRIVATE_KEY` or `--key-file`, the same as `x402 pay`.
+
+### 2.4 Audio
+
+```
+ip audio publish [--release ] [--renditions ]
+ [--on c0mpute --max-price ] [--royalty :]... [file add flags]
+ip audio play [--rendition ]
+ip audio rss --gateway --out
+```
+
+### 2.5 Video
+
+```
+ip video publish [--ladder default | ] [--subtitle :]...
+ [--thumbnails] [--chapters ] [--on c0mpute --max-price ] [file add flags]
+ip video play
+ip video hls --gateway # prints the master playlist URL
+```
+
+### 2.6 Live
+
+```
+ip live create [--latency normal|low] [--segment-ms ] [--per-gib ] [--key-price ]
+ [--rendition ::x:]... [--relays any | ]
+ip live start --input [--record] [--max-downstream ]
+ip live stop
+ip live watch [--rendition ] [--out ]
+ip live relay [--max-downstream ]
+ip live relay-hire --hours --relays [--max-downstream ] [--max-price ]
+```
+
+### 2.7 Catalogue
+
+```
+ip db put [--feed ]
+ip db del [--feed ]
+ip db get
+ip db query [--type ] [--feed []... [--where '] ']... [--order :asc|desc] [--limit ]
+ip db follow
+ip db unfollow
+ip db head []
+ip db segment [--feed ] # seal a segment now
+ip db index-hire ... --days [--max-price ]
+```
+
+`--where` takes one clause per flag; the value is JSON (`'"Ada"'`, `120000`).
+
+### 2.8 Names
+
+```
+ip name set [--gateway ] [--hub ]... [--mtp-pin ] [--feed []...
+ip name resolve ]
+ip name pin # prints the registry pin value and the TXT record
+```
+
+### 2.9 Node
+
+```
+ip node status
+ip node hello # diagnostic: handshake and print the peer's hello
+```
+
+A c0mpute worker embeds the same library; `c0mpute worker start --openswarm`
+is the daemon form and `ip` is the operator's tool.
+
+## 3. Output examples
+
+```
+$ ip file add ./interview.flac --per-gib 0.01 --key-price 0.50
+file ed25519:0d87e09c7fea3ad6ba6c2f3e027ea47f5b245452899910948470906704c5295d
+manifest sha256:41d10f45e705e0526c9eeedf62b32dda3daaf552dd9b6a7b09e01776b05813bb
+infohash v1 a3ce2180413415d7cf4268fb892b8ffd539e8459
+ v2 4b74eb43677e4d03af5fb0856333f9aa21d9a5a3bbf944b13aa7eef379c7a342
+size 734003200 (700 pieces of 1048576)
+magnet magnet:?xs=urn:btpk:0d87e09c7fea3ad6ba6c2f3e027ea47f5b245452899910948470906704c5295d&s=ipfile
+url ip://ed25519:0d87e09c7fea3ad6ba6c2f3e027ea47f5b245452899910948470906704c5295d?file
+seeding yes (2 trackers, dht)
+```
+
+```
+$ ip file get ed25519:0d87e0...295d --out ./interview.flac
+pass reused sha256:39655d...02fd (cap 2.000000, spent 0.000000)
+peers 3 (2 paid, 1 gateway)
+progress 734003200 / 734003200 verified plainRoot ok
+vouchers 3 signed, 0.006836 USD
+key granted by ed25519:d2d05f...6c94 (keeper)
+```
+
+## 4. Environment
+
+| Variable | Meaning |
+| --- | --- |
+| `IP_HOME` | Overrides the config directory. |
+| `IP_HUB` | Default hub URL. |
+| `X402_PRIVATE_KEY` | EVM key used to buy passes, shared with `x402-client`. |
+| `IP_TRACKERS` | Comma list of default trackers for `file add`. |
+| `MOSHPIT_RESOLVE_MODE` | Passed through to name resolution (`ipname` §4.2). |
+
+## 5. Conformance
+
+A CLI claiming this contract implements every command in §2 with these
+exit codes, prints the fields in §3 for `add` and `get`, and never writes a
+seed or a content key to stdout unless asked with an explicit `--reveal`.
diff --git a/docs/openswarm/conformance.md b/docs/openswarm/conformance.md
new file mode 100644
index 0000000..089d2cc
--- /dev/null
+++ b/docs/openswarm/conformance.md
@@ -0,0 +1,157 @@
+# OpenSwarm Conformance
+
+Version: **0.1** (draft)
+
+Conformance is claimed per **profile**. An implementation names the
+profiles it meets; it does not have to meet all of them. Each profile is
+a checklist over the member documents plus the fixture suite that will
+ship with the reference implementation.
+
+## 1. Profiles
+
+| Profile | Who | Documents |
+| --- | --- | --- |
+| `core` | Anything that reads or writes records | spec.md |
+| `publisher` | Creates manifests, tracks, titles, channels, feeds | spec, ipfile §3 and §8, ipdb §4, plus the media documents it emits |
+| `seeder` | Serves `ipfile` pieces for payment | spec, ipfile §5 and §6, ippay §4.4 |
+| `leecher` | Fetches and pays | spec, ipfile §5 and §10, ippay §3 and §4 |
+| `keeper` | Grants keys | ipfile §5.5, ippay §5.4 |
+| `gateway` | HTTP webseed, bridges, queries | ipfile §6.4, ippay §7, ipaudio §7, ipvideo §5, ipdb §6.3 |
+| `hub` | Sells passes, redeems vouchers, pays out | ippay §3, §5, §6 |
+| `origin` | Runs a live channel | iplive §3, §4, §6 |
+| `relay` | Relays live segments | iplive §4, §5 |
+| `viewer` | Watches live | iplive §7 |
+| `replica` | Holds and queries feeds | ipdb §3, §5, §6 |
+| `resolver` | Resolves names | ipname §4 |
+| `cli` | The `ip` command | cli.md |
+
+## 2. Checklists
+
+### 2.1 `core`
+
+- [ ] C1. Canonicalises with RFC 8785 and computes record ids per spec §3.3.
+- [ ] C2. Verifies `ed25519` signatures with the domain prefix of spec §3.4.
+- [ ] C3. Ignores `mldsa65` entries it cannot verify without failing the record.
+- [ ] C4. Preserves unknown fields on round trip.
+- [ ] C5. Derives file, feed, channel and box keys per spec §4 so that the
+ fixture seed produces the fixture public keys.
+- [ ] C6. Computes BEP 52 pieces roots and hybrid infohashes per spec §5
+ matching the fixture torrent.
+- [ ] C7. Reads and writes the BEP 44 pointer of spec §7.2.
+
+### 2.2 `publisher`
+
+- [ ] P1. Emits `ipfile.manifest` with both signatures, correct roots and a
+ `keys.owner` the fixture seed can open.
+- [ ] P2. Revisions increase `rev`, keep `fileId`, and metadata-only
+ revisions keep `cipherRoot` and `keyId`.
+- [ ] P3. Appends an `ipdb.entry` for every record it creates, with no
+ sequence gap.
+- [ ] P4. Media records reference only files it also published, with
+ matching `size`.
+- [ ] P5. Audio and video renditions are aligned (ipaudio §4.3, ipvideo §3.1).
+
+### 2.3 `seeder`
+
+- [ ] S1. Sends `hello` before any other `ipfile` message and refuses
+ messages from a peer that has not.
+- [ ] S2. Keeps a peer choked until a valid pass arrives, or treats it as
+ vanilla per `policy.vanilla`.
+- [ ] S3. Never exceeds the credit window unpaid; sends `credit` when it
+ chokes for that reason.
+- [ ] S4. Validates vouchers per ippay §4.4 including recomputing `usd`.
+- [ ] S5. Rejects a voucher not signed to its own identity.
+- [ ] S6. Serves the plaintext piece layer on `layer_req`.
+- [ ] S7. Redeems the latest voucher per triple at the hub.
+
+### 2.4 `leecher`
+
+- [ ] L1. Verifies the manifest against the file key it started from.
+- [ ] L2. Buys or reuses a pass in scope before sending `pass`.
+- [ ] L3. Signs vouchers only for verified bytes, on the interval, with
+ cumulative `bytes` and correct `usd`.
+- [ ] L4. Verifies the piece layer against `plainRoot` before trusting an
+ entry; verifies every decrypted piece.
+- [ ] L5. Checks `sha256(key) == keyId` on every grant.
+- [ ] L6. Sends `bye done`.
+
+### 2.5 `keeper`
+
+- [ ] K1. Grants only against a valid pass with the file in `grants`, or a
+ delegating grant.
+- [ ] K2. Seals to the requester's `box` from its `hello`.
+- [ ] K3. Reports every grant to the hub.
+
+### 2.6 `gateway`
+
+- [ ] G1. Serves `/swarm//{manifest,layer,data}` with Range.
+- [ ] G2. Applies the pass and voucher headers of ippay §7 and returns
+ `X-OpenSwarm-Credit` on every 206.
+- [ ] G3. If a keeper: audio and video bridges decrypt only for a valid pass.
+- [ ] G4. If a replica: answers `POST /db/query` with `asOf`.
+
+### 2.7 `hub`
+
+- [ ] H1. Publishes a signed hub record at the well-known path.
+- [ ] H2. Sells passes over x402 with the CoinPay v2 offer, binds the payer
+ by `X-OpenSwarm-Payer`, and records nonce to resource.
+- [ ] H3. Redeems vouchers with the arithmetic of ippay §4.2 and §5.3
+ exactly; the fixture receipts match byte for byte.
+- [ ] H4. Enforces the cap per pass across payees and returns `partial` and
+ `cap-exceeded` correctly.
+- [ ] H5. Records grants once per pass and file.
+- [ ] H6. Pays out on schedule and emits the events of ippay §8.
+
+### 2.8 `origin`, `relay`, `viewer`
+
+- [ ] O1. Signs a head per announced unit, chained by `prev`.
+- [ ] O2. Rotates the content key per epoch and announces one segment early.
+- [ ] O3. Writes a final head with `live: false`.
+- [ ] R1. Verifies a segment root against a signed head before forwarding.
+- [ ] R2. Honours `window` and never serves below `live - buffer`.
+- [ ] R3. Answers `deny rate` with the current head when full.
+- [ ] V1. Drops to the live edge rather than stalling.
+- [ ] V2. Vouchers every peer that served it.
+- [ ] V3. Requests a new grant on `keyId` change.
+
+### 2.9 `replica`
+
+- [ ] D1. Verifies chains and embedded signatures; refuses a `subject`
+ mismatch.
+- [ ] D2. Detects forks and freezes state until resolved.
+- [ ] D3. Replicates by segments and gossip.
+- [ ] D4. Implements every query operator of ipdb §6.1.
+
+### 2.10 `resolver`
+
+- [ ] N1. Applies clearnet-first before any registry call.
+- [ ] N2. Verifies the pin, the signature and the `name` match.
+- [ ] N3. Reports the anchor kind.
+
+## 3. Fixture suite
+
+The reference implementation will publish, under `packages/openswarm/
+fixtures/`, a deterministic set built from one seed:
+
+| Fixture | Contents |
+| --- | --- |
+| `seed.json` | A 32-byte seed and every public key derived from it. |
+| `file-small/` | A 3 MiB plaintext, its content key, ciphertext, piece layers, hybrid `.torrent`, and manifest. |
+| `records/` | One valid record of every registered type, plus one invalid variant each (bad signature, bad chain, bad `usd`). |
+| `session.jsonl` | A scripted `ipfile` session: handshake dicts, `hello`s, pass, three vouchers, grant, in order. |
+| `receipts.json` | The receipts a hub must return for `session.jsonl`. |
+| `queries.json` | Queries over `records/` and their exact results. |
+| `names.json` | Registry responses and the expected resolutions, including a pin mismatch. |
+
+A profile is conformant when every checklist item is met and the relevant
+fixtures pass. Until the reference implementation exists, the checklists
+are the whole of conformance, and a claim is a claim.
+
+## 4. Interoperability
+
+As with OpenCreds, a round trip inside one implementation proves only that
+the code agrees with itself. The interop test that matters is: a swarm
+seeded by implementation A, paid for by a leecher from implementation B,
+with vouchers redeemed at hub C, decrypts to `plainRoot`. The first three
+implementations to exist should be tested that way before any of them
+claims 1.0.
diff --git a/docs/openswarm/faq.md b/docs/openswarm/faq.md
new file mode 100644
index 0000000..ee23321
--- /dev/null
+++ b/docs/openswarm/faq.md
@@ -0,0 +1,89 @@
+# OpenSwarm FAQ
+
+**Is this IPFS?**
+No. IPFS is content addressing plus a DHT plus its own wire protocol, with
+no payment and no encryption. OpenSwarm is BitTorrent plus payment plus
+encryption, carried as BEP 10 extension messages, so every existing
+torrent client, tracker and crawler still understands the swarm. A file's
+identifier is a BEP 52 pieces root rather than a CID because that is the
+tree a BitTorrent v2 client already verifies.
+
+**Why not a blockchain, a token or a payment channel?**
+Because a 16 KiB block at the reference price is worth 0.00000015 USD and
+no chain settles that. Passes are bought on chain once (x402, USDC);
+vouchers are off-chain IOUs redeemed at a hub; payouts are batched. The
+hub is trusted like a payment processor is trusted, and the spec says so
+rather than pretending otherwise.
+
+**Who runs a hub?**
+Anyone. CoinPay is the proposed reference because it already does the x402
+verify and settle half. A manifest lists the hubs its publisher accepts,
+and a seeder refuses passes from any other. Two hubs on a manifest is the
+recommended posture.
+
+**Can a seeder get paid for bytes it did not send?**
+No. A voucher is signed by the payer for bytes the payer already verified.
+
+**Can a leecher get bytes without paying?**
+One credit window's worth, per identity, per session. Then it is choked.
+That is the design: bounded loss instead of a trustless protocol nobody
+would implement.
+
+**What happens to a vanilla client that finds the swarm?**
+It sees a one-file torrent named by a hex string. If the publisher set
+`policy.vanilla` to `ciphertext`, it can download the ciphertext for free
+and cannot read it. If `deny`, it is choked. It never receives an
+extension message.
+
+**Does this work in a browser?**
+Yes. WebTorrent supports BEP 10 extensions, WebCrypto has Ed25519, X25519
+and AES-CTR, and MSE plays CMAF. bittorrented.com's player plus three
+additions is the reference browser peer. A browser can only reach WebRTC
+peers, so a swarm needs one hybrid seeder that announces to a wss tracker.
+
+**Why derive file keys from one publisher key?**
+So there is one thing to back up. A publisher seed plus any manifest
+recovers that file's private key and its content key. A standalone key is
+available for a file that will change hands.
+
+**What if I lose the seed?**
+Nothing becomes unreadable and nothing can be changed: prices, payout
+address and trackers are frozen at the last signed manifest. Store the
+seed in an OpenCreds vault.
+
+**Can a key be revoked after delivery?**
+No. A delivered key is held by the peer. Re-encryption under a new key
+protects future readers only. Live streams rotate keys every epoch for
+exactly this reason.
+
+**Why AES-CTR and not an AEAD?**
+Length-preserving and seekable: a block decrypts without the rest of the
+piece. Integrity comes from two signed merkle trees, which is stronger than
+a per-piece tag because it is bound to the publisher's signature.
+
+**How does a Moshpit name fit?**
+A name's owner registers a pin of kind `openswarm` over its publisher key
+and serves a signed record at `/.well-known/openswarm.json`. A resolver
+checks the pin, then the signature. Ordinary domains use a TXT record.
+Clearnet wins by default, as everywhere in Moshpit.
+
+**How does c0mpute fit?**
+A node bids on pin, keep, gateway, transcode, relay and index jobs, and is
+paid by vouchers for every byte it serves regardless of jobs. See
+[c0mpute.md](./c0mpute.md).
+
+**Is the catalogue private?**
+A feed is public by construction. A private catalogue is a feed segment
+published as a paid `ipfile`, readable only by pass holders.
+
+**Why is the family called OpenSwarm and the protocols `ip*`?**
+The family name follows the LogicSRC convention and names the shared
+object. The protocol names are the ones the tools will be called by. The
+working name was IPMedia; see the overview for why it changed.
+
+**What exists today?**
+The x402 sale and CoinPay settlement, the browser player and hybrid seeder,
+the DHT crawl, CloudEvents and Standard Webhooks, the MTP/1 transport, the
+Moshpit registry, and c0mpute's store, transcode, gateway and live-stream
+design. Everything that mentions a pass, a voucher, a grant, a manifest, a
+feed or a head is new.
diff --git a/docs/openswarm/ipaudio.md b/docs/openswarm/ipaudio.md
new file mode 100644
index 0000000..aaefc24
--- /dev/null
+++ b/docs/openswarm/ipaudio.md
@@ -0,0 +1,313 @@
+# ipaudio: Audio on ipfile Swarms
+
+Version: **0.1** (draft)
+Status: draft. Extends the OpenSwarm core ([spec.md](./spec.md)) and rides
+on [ipfile](./ipfile.md), [ippay](./ippay.md) and [ipdb](./ipdb.md).
+Nothing below is implemented.
+
+## 1. Scope
+
+`ipaudio` defines the records that make an `ipfile` playable as music or
+speech:
+
+1. A **track**: renditions (each one an `ipfile`), a seek index, gapless
+ trim values, contributors and royalty split.
+2. A **release**: an ordered set of tracks with artwork and release metadata.
+3. **Streaming rules**: how a player maps time to pieces, buffers, switches
+ bitrate and plays two tracks without a gap.
+4. A **gateway bridge** for players that only speak HTTP.
+
+Everything about bytes, keys, payment and discovery is `ipfile` and `ippay`.
+This document adds no wire messages.
+
+### Non-goals
+
+A player. Loudness normalisation. A recommendation system. DRM beyond
+`ipfile` key delivery. Radio (a continuous stream is `iplive`).
+
+## 2. Terminology
+
+| Term | Meaning |
+| --- | --- |
+| **rendition** | One encoding of a track, stored as one `ipfile`. |
+| **anchor** | A `[timeMs, byteOffset]` pair at which decoding can start. |
+| **seek index** | The list of anchors for a rendition. |
+| **gapless trim** | Encoder delay and end padding in samples, so tracks join without silence. |
+
+## 3. Data model
+
+### 3.1 Track
+
+```json
+{
+ "openswarm": "0.1",
+ "type": "ipaudio.track",
+ "publisher": "ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e",
+ "title": "Interview with Ada",
+ "artist": "Ada",
+ "artists": [{ "name": "Ada", "role": "primary" }],
+ "durationMs": 2941200,
+ "isrc": null,
+ "language": "en",
+ "artwork": "ed25519:d2d05fcad07ecbee6ff87c95159ec9969fce6c0fb9f41ff78c2c07ebe8a06c94",
+ "renditions": [
+ {
+ "id": "opus-96",
+ "codec": "opus",
+ "container": "ogg",
+ "bitrateKbps": 96,
+ "sampleRate": 48000,
+ "channels": 2,
+ "file": "ed25519:0d87e09c7fea3ad6ba6c2f3e027ea47f5b245452899910948470906704c5295d",
+ "size": 35294400,
+ "gapless": { "encoderDelay": 312, "endPadding": 1216 },
+ "index": [[0, 0], [2000, 24012], [4000, 48160], [6000, 72301]]
+ },
+ {
+ "id": "flac",
+ "codec": "flac",
+ "container": "flac",
+ "bitrateKbps": 0,
+ "sampleRate": 48000,
+ "channels": 2,
+ "file": "ed25519:00bc9c01ca00938016e276fe5b5748cfe13fd02bc53cc651535682c8dd5c83be",
+ "size": 734003200,
+ "gapless": { "encoderDelay": 0, "endPadding": 0 },
+ "index": [[0, 8192], [2000, 507904], [4000, 1007616], [6000, 1503232]]
+ }
+ ],
+ "royalties": [
+ { "label": "Ada", "network": "eip155:8453", "address": "0xCC3b072391AE7A8d10cF00DdC5F61DB2cA5541E5", "bps": 8000 },
+ { "label": "Producer", "network": "eip155:8453", "address": "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf", "bps": 2000 }
+ ],
+ "createdAt": "2026-09-05T18:05:00.000Z",
+ "sigs": [
+ {
+ "alg": "ed25519",
+ "key": "ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e",
+ "sig": "RGRH7pDpXYcagULqj+8z05pLH99Lc6yLmfdbYgtGeRpjX5l0NcT3QSq9AWBtLw+5+JJiKoZ3s4ty74q/zBSE8w=="
+ }
+ ]
+}
+```
+
+(`index` is truncated to four anchors here. A real one has one anchor per
+two seconds of audio.)
+
+| Field | Rule |
+| --- | --- |
+| `publisher` | Signs the record. MUST equal the `publisher` of every referenced `ipfile` manifest. |
+| `durationMs` | Of the plaintext audio. |
+| `artwork` | File key of an `ipfile` holding a JPEG or PNG. MAY be null. |
+| `renditions[].codec` | One of `opus`, `aac`, `flac`, `mp3`. |
+| `renditions[].container` | `ogg` for Opus, `mp4` for AAC (fragmented, CMAF), `flac`, `mp3`. |
+| `renditions[].bitrateKbps` | Target bitrate; `0` for lossless. |
+| `renditions[].file` | The `ipfile` manifest's file key. |
+| `renditions[].size` | MUST equal that manifest's `size`. |
+| `renditions[].gapless` | Samples to trim at the start and end, as the encoder reported. `0` for FLAC. |
+| `renditions[].index` | Anchors, ascending by time, first at `[0, o]` where `o` is the byte offset of the first decodable unit after headers. |
+| `royalties` | How the publisher's share of every voucher is split (§5). |
+
+A track MUST have at least one rendition. Every rendition of a track MUST
+have anchors at the same set of `timeMs` values (§4.3).
+
+### 3.2 Release
+
+```json
+{
+ "openswarm": "0.1",
+ "type": "ipaudio.release",
+ "publisher": "ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e",
+ "title": "Conversations, Volume 1",
+ "artist": "Ada",
+ "kind": "podcast",
+ "releasedAt": "2026-09-05",
+ "upc": null,
+ "artwork": "ed25519:d2d05fcad07ecbee6ff87c95159ec9969fce6c0fb9f41ff78c2c07ebe8a06c94",
+ "tracks": [
+ {
+ "position": 1,
+ "subject": "sha256:d6c3f8285b7871d6a400cba14408288a9acde679f12e1e7dc276f29ca7c493ff",
+ "feed": "ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e/default"
+ }
+ ],
+ "description": "Long-form interviews.",
+ "createdAt": "2026-09-05T18:06:00.000Z",
+ "sigs": [
+ {
+ "alg": "ed25519",
+ "key": "ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e",
+ "sig": "RGRH7pDpXYcagULqj+8z05pLH99Lc6yLmfdbYgtGeRpjX5l0NcT3QSq9AWBtLw+5+JJiKoZ3s4ty74q/zBSE8w=="
+ }
+ ]
+}
+```
+
+`kind` is one of `album`, `ep`, `single`, `podcast`, `audiobook`,
+`compilation`. `tracks[].subject` is the record id of the track's first
+revision, which is also its `ipdb` key (`track:`), so a track can be
+revised without breaking the release.
+
+### 3.3 Where records live
+
+Both records are `ipdb` entries in the publisher's feed under
+`track:` and `release:`. The renditions are `ipfile` manifests under
+`file:` in the same feed. A client that finds a release has
+everything it needs in one feed.
+
+## 4. Streaming
+
+### 4.1 Time to pieces
+
+To play from time `t` on rendition `r`:
+
+```
+anchor = the last index entry with timeMs <= t
+offset = anchor.byteOffset
+piece = floor(offset / manifest.pieceLength)
+```
+
+Request pieces from `piece` onward in order. Decrypt each as it completes
+(`ipfile` §4.1), verify against the plaintext piece layer, and feed the
+decoder from `offset`. The first decodable unit is guaranteed at the anchor
+because the index was built from container boundaries (Ogg page, MP4
+fragment, MP3 frame, FLAC frame).
+
+### 4.2 Buffering
+
+A player SHOULD hold the pieces covering the next 30 seconds and request
+the next piece when the buffer drops below 15 seconds. It SHOULD send
+vouchers on the `ipfile` schedule regardless of playback state; a paused
+player that has received bytes still owes for them.
+
+With 256 KiB pieces and a 96 kbps rendition a piece is about 21 seconds of
+audio. That is why `ipaudio` recommends 256 KiB: one piece is one buffer
+step.
+
+### 4.3 Adaptive bitrate
+
+Because every rendition shares the same anchor times, a switch is: finish
+the current anchor interval on rendition A, start the next interval on
+rendition B from its anchor at the same `timeMs`. The publisher guarantees
+alignment by encoding every rendition with the same segment duration
+(2000 ms recommended). A player picks the highest `bitrateKbps` whose piece
+download rate over the last 10 seconds exceeds 1.5 times the bitrate.
+
+Switching renditions means joining a second swarm. The pass is per file, so
+a pass scoped by `publishers` rather than `files` is what a player wants
+(`ippay` §3.1).
+
+### 4.4 Gapless
+
+When track A ends and track B begins, the player trims `endPadding` samples
+from A's decoded tail and `encoderDelay` samples from B's decoded head, and
+concatenates the sample streams. It MUST have B's first piece decrypted
+before A's last anchor interval starts, which means it holds a grant for B
+already. A release player therefore requests the grant for track `n + 1`
+when track `n` starts.
+
+## 5. Royalties
+
+An `ipfile` manifest names one `pricing.payTo` for the publisher's share.
+`ipaudio` splits that share further with `royalties`: a list of recipients
+and basis points summing to 10000. A hub applies the split when it can see
+the track record for a file; the `ipfile` manifest's `meta.track` field
+carries the track's record id so the hub can look it up in `ipdb`. Where
+the hub cannot find the track, the whole publisher share goes to `payTo`
+and a `com.logicsrc.openswarm.ippay.split.unresolved.v1` event is emitted.
+
+A contributor is paid per byte served of any rendition of the track. That
+is the whole royalty model: no plays counted, no per-stream rate, no
+minimum. It is a consequence of `ippay`, not a separate system.
+
+## 6. Publishing procedure
+
+`ip audio publish ` where the input names source files:
+
+1. For each rendition requested (`--renditions opus-96,flac`), transcode
+ with ffmpeg or submit an `ipaudio.transcode` job to c0mpute
+ (`c0mpute.md` §3). Record `encoderDelay` and `endPadding` from the
+ encoder.
+2. Build the seek index by walking container boundaries at 2000 ms steps.
+3. `ip file add` each rendition with `--piece-length 262144`,
+ `--meta.track `; collect file keys.
+4. Write the track record, sign, `ip db put track:`. Revise each
+ rendition manifest's `meta.track` to the track id (metadata-only revision).
+5. Optionally write or update a release.
+
+## 7. Gateway bridge
+
+A gateway that is also a keeper (holds the content key under a delegating
+grant) MAY serve decrypted audio to pass holders:
+
+```
+GET /audio//
+Authorization: Bearer
+Range: bytes=0-
+```
+
+It returns `206` with the plaintext bytes, `Content-Type` from the
+container, `Accept-Ranges: bytes`, and `X-OpenSwarm-Credit` per `ippay` §7.
+This is what lets a podcast app, a car, or bittorrented.com's existing
+`` (which plays a plain URL) consume an `ipaudio` track. The
+gateway is the payee for those bytes and is paid by voucher like any
+seeder; the client is the pass holder's HTTP library, which sends vouchers
+in `X-OpenSwarm-Voucher`.
+
+A gateway MAY also render an RSS 2.0 feed for a `podcast` release with
+`` URLs of the form above, so a release is subscribable in any
+podcast client that can send a bearer header, and previewable in any
+client at all if the publisher sets `perGib` and `keyUsd` to zero.
+
+## 8. Recommended encodings
+
+| Rendition id | Codec | Container | Settings |
+| --- | --- | --- | --- |
+| `opus-64` | Opus | Ogg | 64 kbps VBR, 48 kHz, speech |
+| `opus-96` | Opus | Ogg | 96 kbps VBR, 48 kHz |
+| `opus-160` | Opus | Ogg | 160 kbps VBR, 48 kHz, music |
+| `aac-128` | AAC-LC | fragmented MP4 | 128 kbps, for Safari and CarPlay |
+| `flac` | FLAC | FLAC | Lossless, source sample rate |
+
+Ogg Opus and FLAC carry their own seek structure; the index is still
+REQUIRED so a player never has to read the container to find a piece.
+
+## 9. Security and privacy
+
+Nothing beyond `ipfile`. Two notes: a rendition list reveals bitrates and
+sizes to anyone who has the track record, and a seek index reveals the
+container's page structure; neither reveals audio. A publisher who does
+not want the catalogue public uses a paid `ipdb` segment (`ipdb` §9).
+
+## 10. Events
+
+| Type | Emitted by |
+| --- | --- |
+| `com.logicsrc.openswarm.ipaudio.track.published.v1` | publisher |
+| `com.logicsrc.openswarm.ipaudio.release.published.v1` | publisher |
+
+Play counts are not events in this family. A client MAY report them to a
+publisher's webhook as `com.logicsrc.openswarm.ipaudio.play.v1` with
+`{ "track", "rendition", "ms" }`; nothing in the payment loop depends on it.
+
+## 11. Implementations
+
+| Piece | Exists | Where | What is new |
+| --- | --- | --- | --- |
+| Plain-URL audio player, range streaming, HLS fallback | Yes | `media-streamer` ``, `/api/stream`, `/api/stream/hls` | The gateway bridge is what it would point at |
+| Podcast catalogue and RSS | Yes | `p0dcasters.com`, `media-streamer` podcasts lib | Rendering RSS from a release record |
+| Per-rendition transcode on the network | Yes (video) | `c0mpute` `ffmpeg.transcode` | An audio preset with gapless values in the result |
+| Track and release records, seek index, gapless join, royalty split | No | | All of it |
+
+## 12. Conformance
+
+An `ipaudio` player maps time to pieces per §4.1, switches only at shared
+anchors per §4.3, trims per §4.4, and holds a valid pass for every rendition
+it fetches. A publisher emits records per §3 with aligned anchors. A gateway
+implementing §7 applies `ippay` §7 headers.
+
+## 13. Version history
+
+| Version | Date | Change |
+| --- | --- | --- |
+| 0.1 | 2026-09-05 | Initial draft. |
diff --git a/docs/openswarm/ipdb.md b/docs/openswarm/ipdb.md
new file mode 100644
index 0000000..75292c7
--- /dev/null
+++ b/docs/openswarm/ipdb.md
@@ -0,0 +1,386 @@
+# ipdb: The Replicated Catalogue
+
+Version: **0.1** (draft)
+Status: draft. Extends the OpenSwarm core ([spec.md](./spec.md)). Nothing
+below is implemented.
+
+## 1. Scope
+
+`ipdb` is the catalogue: the place a client asks "what is this swarm", "what
+has this publisher released", "what is in this playlist". It defines:
+
+1. A **feed**: a signed, hash-linked, append-only log owned by one key.
+2. An **entry**: one log item that puts or deletes a record under a key.
+3. **Heads**: how the latest state of a feed is announced on the DHT.
+4. **Replication**: how feeds move between peers, as `ipfile` swarms and as
+ gossip on swarm connections.
+5. **Queries**: a JSON query shape a local replica and a gateway both answer.
+
+It is a database of records, not of bytes. A manifest is in `ipdb`; the
+file it describes is in an `ipfile` swarm.
+
+### Non-goals
+
+Multi-writer documents. A feed has one writer. Two people editing one
+playlist is two feeds and a view over both, or a future CRDT record type.
+Global search ranking. Consistency across feeds; each feed is consistent
+with itself and that is all that is promised.
+
+## 2. Terminology
+
+| Term | Meaning |
+| --- | --- |
+| **feed** | A log identified by `(feed key, name)`. |
+| **feed key** | An Ed25519 key. A publisher's default feed uses the publisher key itself. |
+| **entry** | A record of type `ipdb.entry`, sequenced and chained. |
+| **key** | A string unique within a feed that later entries overwrite. |
+| **head** | The latest entry of a feed. |
+| **segment** | A batch of entries stored as one file for replication. |
+| **replica** | A peer holding some feeds, verified, queryable. |
+| **follow** | A replica's decision to keep a feed current. |
+
+## 3. Data model
+
+### 3.1 Feed keys
+
+The default feed is the publisher key with name `default`. Named feeds
+derive from the publisher seed the same way files do:
+
+```
+feedSeed = HKDF-SHA256(ikm = publisher seed, salt = utf8(name), info = "openswarm:feed:v1")
+feed key = Ed25519 keypair from feedSeed
+```
+
+A feed reference is written `ed25519:/`; the default feed is
+`ed25519:/default`. Names match `^[a-z0-9][a-z0-9-]{0,63}$`.
+
+### 3.2 Entry
+
+```json
+{
+ "openswarm": "0.1",
+ "type": "ipdb.entry",
+ "feed": "ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e",
+ "name": "default",
+ "seq": 12,
+ "prev": "sha256:ba8cd1a97b865a05cdc5840c63b086dd463624a831874c1ba0d7c4ce6f806f7a",
+ "op": "put",
+ "key": "file:ed25519:0d87e09c7fea3ad6ba6c2f3e027ea47f5b245452899910948470906704c5295d",
+ "kind": "ipfile.manifest",
+ "subject": "sha256:41d10f45e705e0526c9eeedf62b32dda3daaf552dd9b6a7b09e01776b05813bb",
+ "record": {
+ "openswarm": "0.1",
+ "type": "ipfile.manifest",
+ "file": "ed25519:0d87e09c7fea3ad6ba6c2f3e027ea47f5b245452899910948470906704c5295d",
+ "rev": 1,
+ "createdAt": "2026-09-05T18:00:00.000Z",
+ "sigs": []
+ },
+ "createdAt": "2026-09-05T18:00:05.000Z",
+ "sigs": [
+ {
+ "alg": "ed25519",
+ "key": "ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e",
+ "sig": "RGRH7pDpXYcagULqj+8z05pLH99Lc6yLmfdbYgtGeRpjX5l0NcT3QSq9AWBtLw+5+JJiKoZ3s4ty74q/zBSE8w=="
+ }
+ ]
+}
+```
+
+(The embedded manifest is abbreviated here; in a real entry it is the
+complete record with its signatures.)
+
+| Field | Rule |
+| --- | --- |
+| `feed`, `name` | The feed. The entry MUST be signed by `feed`. |
+| `seq` | 1 for the first entry, then `prev.seq + 1`. |
+| `prev` | Record id of entry `seq - 1`, or `null` for `seq` 1. |
+| `op` | `put` or `del`. |
+| `key` | The logical key. Convention: `:`, e.g. `file:ed25519:...`, `track:sha256:...`, `playlist:my-mix`. |
+| `kind` | The embedded record's `type`. Absent for `del`. |
+| `subject` | The embedded record's id. Absent for `del`. A replica MUST recompute it and refuse a mismatch. |
+| `record` | The complete embedded record. Absent for `del`. Its own signatures MUST verify. |
+
+An entry is at most 256 KiB. A record larger than that is published as an
+`ipfile` and the entry embeds an `ipfile.manifest` pointing at it, with
+`kind` naming the payload type in `record.meta.kind`.
+
+### 3.3 State
+
+The state of a feed is the map `key -> latest put record` after applying
+entries in `seq` order, with `del` removing the key. A replica exposes both
+the log (for audit and replication) and the state (for queries). A record
+that was overwritten stays in the log; that is what makes a manifest
+revision history free.
+
+Across feeds there is no merge. A query over several feeds returns matches
+from each; the `feed` of every result is returned with it.
+
+### 3.4 Head
+
+```json
+{
+ "openswarm": "0.1",
+ "type": "ipdb.head",
+ "feed": "ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e",
+ "name": "default",
+ "seq": 12,
+ "head": "sha256:41d10f45e705e0526c9eeedf62b32dda3daaf552dd9b6a7b09e01776b05813bb",
+ "segments": [
+ { "from": 1, "to": 10, "file": "ed25519:d2d05fcad07ecbee6ff87c95159ec9969fce6c0fb9f41ff78c2c07ebe8a06c94" }
+ ],
+ "createdAt": "2026-09-05T18:00:05.000Z",
+ "sigs": [
+ {
+ "alg": "ed25519",
+ "key": "ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e",
+ "sig": "RGRH7pDpXYcagULqj+8z05pLH99Lc6yLmfdbYgtGeRpjX5l0NcT3QSq9AWBtLw+5+JJiKoZ3s4ty74q/zBSE8w=="
+ }
+ ]
+}
+```
+
+`segments` lists the `ipfile` swarms that hold sealed ranges of the log
+(§5.1). Entries after the last segment are fetched by gossip (§5.2).
+
+## 4. Publishing
+
+`ip db put ` appends an entry. `ip file add` appends one
+automatically under `file:` in the default feed. `ip audio
+publish`, `ip video publish` and `ip live create` do the same for their
+records. A publisher never edits a feed by hand; every tool that creates a
+record appends it.
+
+Rules for the writer:
+
+- `seq` MUST be strictly sequential. A gap is a fork.
+- Two entries with the same `seq` from the same feed are a **fork**. A
+ replica that observes a fork MUST keep both, mark the feed forked from that
+ `seq`, and stop applying either branch to state until the feed publishes
+ an `ipdb.entry` with `op: "put"`, `key: "ipdb:resolve"`, and a record
+ naming the surviving branch head. Forks happen when a seed is restored on
+ two machines; they are detected, not prevented.
+- A `del` of a key that was never put is valid and does nothing.
+
+## 5. Replication
+
+### 5.1 Segments as ipfile swarms
+
+Every 1000 entries, or daily, the writer seals entries `from..to` as one
+file: newline-delimited canonical JSON, one entry per line. It publishes the
+file as an `ipfile` with `pricing.perGib` and `keyUsd` at `0.000000` and
+`keys.public` set to the content key, so the swarm is readable by anyone
+while staying an ordinary `ipfile` for seeders, gateways and c0mpute pin
+jobs. The head lists the segment. Readers verify each line's chain as they
+apply it, so a segment served by a stranger is safe.
+
+### 5.2 Gossip on swarm connections
+
+Peers that advertise `ipdb` in the BEP 10 handshake exchange heads on any
+swarm connection:
+
+| `t` | Payload | Meaning |
+| --- | --- | --- |
+| `heads` | `{ "f": [ { "k": <32 bytes>, "n": string, "s": int } ] }` | Feeds I follow and the seq I have. At most 64 per message. |
+| `entries_req` | `{ "k": <32 bytes>, "n": string, "from": int, "to": int }` | Send me entries in the range. At most 100. |
+| `entries` | `{ "r": [bytes] }` | Entries as JSON, in seq order. |
+| `head` | `{ "r": bytes }` | An `ipdb.head` record, when the sender has a newer one. |
+
+A peer sends `heads` once after `hello` and again whenever a followed feed
+advances. A peer that receives a `heads` with a higher `s` for a feed it
+follows sends `entries_req`. Gossip fills the tail after the last segment
+and keeps replicas close to real time without any peer being special.
+
+### 5.3 DHT announcement
+
+The head is a BEP 44 mutable item (core §7.3):
+
+```
+k = feed key
+salt = "ipdb:" + name
+seq = head seq
+v = bencoded { "h": , "s": }
+```
+
+A client with only a feed reference does `get` on the DHT, obtains `h`,
+then asks any peer or gateway for the head record by id, then segments and
+tail. A gateway also serves `GET /db/feeds///head`.
+
+### 5.4 Following
+
+`ip db follow ed25519:/default` marks a feed followed: the replica
+keeps its head current by DHT polling (every 10 minutes, or on `head`
+gossip), fetches segments, and applies entries. A c0mpute node with role
+`index` follows every feed it is paid to follow (`c0mpute.md` §3).
+
+## 6. Queries
+
+### 6.1 Shape
+
+```json
+{
+ "type": "ipaudio.track",
+ "feeds": ["ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e/default"],
+ "where": [
+ ["record.artist", "==", "Ada"],
+ ["record.durationMs", ">=", 120000]
+ ],
+ "orderBy": [["record.createdAt", "desc"]],
+ "limit": 20,
+ "cursor": null
+}
+```
+
+| Field | Rule |
+| --- | --- |
+| `type` | The embedded record type to match. REQUIRED. |
+| `feeds` | Feed references to search. `null` means every followed feed. |
+| `where` | Conjunction of `[path, op, value]`. Paths dot into the entry; `record.` reaches the embedded record. |
+| `orderBy` | List of `[path, "asc"|"desc"]`. |
+| `limit` | 1 to 200. |
+| `cursor` | Opaque, from a previous result. |
+
+Operators: `==`, `!=`, `<`, `<=`, `>`, `>=`, `in`, `contains` (array
+membership or substring), `prefix`. No joins, no aggregation in 0.1; a
+rating average is computed by the caller from the entries it gets back.
+
+### 6.2 Result
+
+```json
+{
+ "results": [
+ {
+ "feed": "ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e/default",
+ "key": "track:sha256:d6c3f8285b7871d6a400cba14408288a9acde679f12e1e7dc276f29ca7c493ff",
+ "seq": 14,
+ "subject": "sha256:d6c3f8285b7871d6a400cba14408288a9acde679f12e1e7dc276f29ca7c493ff",
+ "record": { "openswarm": "0.1", "type": "ipaudio.track", "sigs": [] }
+ }
+ ],
+ "cursor": "eyJzZXEiOjE0fQ",
+ "asOf": { "ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e/default": 27 }
+}
+```
+
+`asOf` reports the head seq of each feed the answer was computed from, so a
+caller can tell a stale replica from an empty one.
+
+### 6.3 Over HTTP
+
+`POST /db/query` on a gateway with the query as the body. A gateway MAY
+require a pass (`Authorization: Bearer`) and MAY sell one over x402 for
+queries the way a hub sells swarm passes; the reference price is
+`0.000100` USD per query. A public gateway with no charge is the common case
+and is what bittorrented.com's `/dht` browse would become for `ipfile`
+swarms it can resolve.
+
+## 7. Record kinds carried
+
+`ipdb` carries any record type. These are the ones the family defines and
+the keys they use:
+
+| Kind | Key convention | Defined in |
+| --- | --- | --- |
+| `ipfile.manifest` | `file:` | ipfile |
+| `ipaudio.release` | `release:` | ipaudio |
+| `ipaudio.track` | `track:` | ipaudio |
+| `ipvideo.title` | `title:` | ipvideo |
+| `iplive.channel` | `channel:` | iplive |
+| `ipname.pin` | `name:` | ipname |
+| `ipdb.playlist` | `playlist:` | this document §7.1 |
+| `ipdb.rating` | `rating:` | this document §7.2 |
+
+### 7.1 Playlist
+
+```json
+{
+ "openswarm": "0.1",
+ "type": "ipdb.playlist",
+ "slug": "late-night",
+ "title": "Late night",
+ "items": [
+ { "kind": "ipaudio.track", "subject": "sha256:d6c3f8285b7871d6a400cba14408288a9acde679f12e1e7dc276f29ca7c493ff", "feed": "ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e/default" }
+ ],
+ "createdAt": "2026-09-05T19:00:00.000Z",
+ "sigs": []
+}
+```
+
+A playlist item references a record by id and the feed it was found in, so
+a reader can fetch it even if the playlist author's replica is gone. The
+media-streamer `collections` table maps onto this one to one.
+
+### 7.2 Rating
+
+```json
+{
+ "openswarm": "0.1",
+ "type": "ipdb.rating",
+ "subject": "sha256:d6c3f8285b7871d6a400cba14408288a9acde679f12e1e7dc276f29ca7c493ff",
+ "value": 4,
+ "createdAt": "2026-09-05T19:05:00.000Z",
+ "sigs": []
+}
+```
+
+`value` is 1 to 5. One rating per subject per feed, by the key convention.
+An aggregate is `query type ipdb.rating where record.subject == ...` over
+the feeds a caller trusts; a gateway MAY cache the count and mean.
+
+## 8. Local storage
+
+A replica stores entries in a local database. The reference layout is
+SQLite with tables `feeds(key, name, seq, head, forked_at)`,
+`entries(feed, name, seq, id, prev, op, key, kind, subject, json)`,
+`state(feed, name, key, seq, kind, subject)`, and one FTS table over
+`record.title`, `record.artist`, `record.name` for the `contains`
+operator. This is the layout the `ip` CLI would use; a gateway on Postgres
+does the same with jsonb. Nothing in the wire depends on it.
+
+## 9. Security notes
+
+- **Authenticity.** Every entry is signed by the feed key and chained. A
+ replica cannot insert, reorder or drop an entry without breaking the chain
+ to the announced head.
+- **Freshness.** The DHT head carries `seq`; BEP 44 refuses a lower `seq`.
+ A replica that shows an old head is stale, not lying, and `asOf` says so.
+- **Embedded records** verify on their own signatures. A feed cannot put
+ someone else's manifest under its own key and have it trusted as theirs;
+ the manifest's `publisher` signature is the authority, the feed entry is
+ the announcement.
+- **Spam.** A gateway that follows any feed anyone asks for will be filled.
+ Following is a decision; `c0mpute` index jobs are paid per followed feed
+ for that reason.
+- **Privacy.** A feed is public by construction. A private catalogue is a
+ segment published as a paid `ipfile` (no `keys.public`), and the entries
+ are then readable only by pass holders. The head still reveals the seq.
+
+## 10. Events
+
+| Type | Emitted by |
+| --- | --- |
+| `com.logicsrc.openswarm.ipdb.entry.appended.v1` | writer |
+| `com.logicsrc.openswarm.ipdb.head.published.v1` | writer |
+| `com.logicsrc.openswarm.ipdb.fork.detected.v1` | replica |
+
+## 11. Implementations
+
+| Piece | Exists | Where | What is new |
+| --- | --- | --- | --- |
+| Infohash catalogue with browse and search | Yes (central) | `media-streamer` `dht_torrents`, `/api/dht/browse`, `services/dht-search-api` | Learning what an infohash is from a signed feed instead of BEP 9 names |
+| Collections, playlists, favourites | Yes (central) | `media-streamer` `collections`, `collection_items` | The `ipdb.playlist` record as the portable form |
+| Signed append-only log | Partly | Moshpit `moshpit_tld_log` (server side, `seq` cursor) | The feed as a client-verifiable chain with DHT heads |
+| Entry, head, segments, gossip, query shape | No | | All of it |
+
+## 12. Conformance
+
+A replica verifies chains and embedded signatures per §3, detects forks
+per §4, replicates by segments and gossip per §5, and answers every
+operator in §6.1. A writer never emits a gap. A gateway serves `POST
+/db/query` and the head route in §5.3.
+
+## 13. Version history
+
+| Version | Date | Change |
+| --- | --- | --- |
+| 0.1 | 2026-09-05 | Initial draft. |
diff --git a/docs/openswarm/ipfile.md b/docs/openswarm/ipfile.md
new file mode 100644
index 0000000..f4c3ac8
--- /dev/null
+++ b/docs/openswarm/ipfile.md
@@ -0,0 +1,545 @@
+# ipfile: Paid, Encrypted File Swarms
+
+Version: **0.1** (draft)
+Status: draft. Extends the OpenSwarm core ([spec.md](./spec.md)). Nothing
+below is implemented.
+
+## 1. Scope
+
+`ipfile` is BitTorrent with three additions carried as extension messages:
+
+1. **Encryption.** The swarm carries ciphertext. The content key is sold
+ separately and delivered only to a peer that has paid.
+2. **Payment.** A seeder serves pieces inside a bounded credit window and a
+ leecher signs a cumulative voucher for every verified piece batch. Whoever
+ seeds gets paid.
+3. **Ownership.** Every file has a key pair. The manifest that names the
+ swarm, the price and the payout address is signed by it.
+
+It is not a new transport. The wire is BEP 3, the extension is BEP 10, the
+hashes are BEP 52, discovery is BEP 5 and BEP 44, browsers are WebTorrent. A
+client that knows none of this can still join the swarm and, if the manifest
+allows, download ciphertext it cannot read.
+
+### Non-goals
+
+Plaintext swarms (use a torrent). Hiding the size of a file. Hiding that a
+swarm exists (use `swarm.private` to keep it off the DHT, but a tracker still
+knows). Revoking a delivered key without re-encrypting. Streaming media
+semantics (those are `ipaudio`, `ipvideo`, `iplive`).
+
+## 2. Terminology
+
+Core terms apply. In addition:
+
+| Term | Meaning |
+| --- | --- |
+| **manifest** | The `ipfile.manifest` record: identity, roots, swarm, price, key delivery, policy. |
+| **revision** | A manifest with a higher `rev` under the same file key. |
+| **keeper** | A peer holding the content key with a `delegate: true` grant, allowed to grant it onward. |
+| **credit window** | Bytes a seeder will serve beyond the last voucher before choking. |
+| **voucher interval** | Bytes after which a leecher owes a new voucher. |
+| **vanilla peer** | A peer that did not advertise `ipfile` in its extension handshake. |
+
+## 3. Data model
+
+### 3.1 The manifest
+
+```json
+{
+ "openswarm": "0.1",
+ "type": "ipfile.manifest",
+ "file": "ed25519:0d87e09c7fea3ad6ba6c2f3e027ea47f5b245452899910948470906704c5295d",
+ "box": "x25519:4c4055604fe6bd8781cab6835093ad896f68f676b472d5bd6811e52f28cb18c3",
+ "publisher": "ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e",
+ "fileId": "165aa20f2088f71a1880fe42c48d211b",
+ "keyDerivation": "derived",
+ "rev": 1,
+ "name": "interview-2026-09-05.flac",
+ "size": 734003200,
+ "contentType": "audio/flac",
+ "pieceLength": 1048576,
+ "plainRoot": "sha256:d6c3f8285b7871d6a400cba14408288a9acde679f12e1e7dc276f29ca7c493ff",
+ "cipherRoot": "sha256:ba8cd1a97b865a05cdc5840c63b086dd463624a831874c1ba0d7c4ce6f806f7a",
+ "cipher": {
+ "alg": "aes-256-ctr",
+ "keyId": "sha256:41d10f45e705e0526c9eeedf62b32dda3daaf552dd9b6a7b09e01776b05813bb"
+ },
+ "swarm": {
+ "infohashV1": "sha1:a3ce2180413415d7cf4268fb892b8ffd539e8459",
+ "infohashV2": "sha256:4b74eb43677e4d03af5fb0856333f9aa21d9a5a3bbf944b13aa7eef379c7a342",
+ "private": false,
+ "trackers": [
+ "wss://tracker.openwebtorrent.com",
+ "wss://tracker.webtorrent.dev",
+ "udp://tracker.opentrackr.org:1337/announce"
+ ],
+ "webseeds": [
+ "https://gw.c0mpute.com/swarm/4b74eb43677e4d03af5fb0856333f9aa21d9a5a3bbf944b13aa7eef379c7a342/data"
+ ]
+ },
+ "visibility": "public",
+ "policy": {
+ "vanilla": "ciphertext",
+ "creditBytes": 4194304,
+ "voucherBytes": 1048576
+ },
+ "pricing": {
+ "currency": "USD",
+ "perGib": "0.010000",
+ "keyUsd": "0.500000",
+ "split": { "publisherBps": 7000, "seederBps": 2500, "hubBps": 500 },
+ "keeperBps": 1000,
+ "hubs": ["https://coinpayportal.com/api/openswarm"],
+ "payTo": { "network": "eip155:8453", "address": "0xCC3b072391AE7A8d10cF00DdC5F61DB2cA5541E5" }
+ },
+ "keys": {
+ "modes": ["peer", "url"],
+ "url": "https://keys.example.com/openswarm",
+ "keepers": ["ed25519:d2d05fcad07ecbee6ff87c95159ec9969fce6c0fb9f41ff78c2c07ebe8a06c94"],
+ "owner": "HjYr1barG9BmJK6DEBSm9Rl8iGeYTZFGwfyG8AJy5g3Wk8uP9nyG1IeDaHuOT/VgYoNMmGvlCnaWw4olY6xM/0GKdzr05DqWt49benOyvAs="
+ },
+ "replication": { "min": 3, "erasure": { "k": 10, "parity": 4 } },
+ "meta": { "duration": 2941.2, "language": "en" },
+ "createdAt": "2026-09-05T18:00:00.000Z",
+ "updatedAt": "2026-09-05T18:00:00.000Z",
+ "sigs": [
+ {
+ "alg": "ed25519",
+ "key": "ed25519:0d87e09c7fea3ad6ba6c2f3e027ea47f5b245452899910948470906704c5295d",
+ "sig": "RGRH7pDpXYcagULqj+8z05pLH99Lc6yLmfdbYgtGeRpjX5l0NcT3QSq9AWBtLw+5+JJiKoZ3s4ty74q/zBSE8w=="
+ },
+ {
+ "alg": "ed25519",
+ "key": "ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e",
+ "sig": "RGRH7pDpXYcagULqj+8z05pLH99Lc6yLmfdbYgtGeRpjX5l0NcT3QSq9AWBtLw+5+JJiKoZ3s4ty74q/zBSE8w=="
+ }
+ ]
+}
+```
+
+| Field | Rule |
+| --- | --- |
+| `file` | The file public key. REQUIRED. The record's first signature MUST be by this key. |
+| `box` | The file box public key (core §4.2). REQUIRED. |
+| `publisher` | The publisher identity. REQUIRED. A second signature by this key is REQUIRED. |
+| `fileId` | 16 bytes hex. REQUIRED. Stable across revisions. |
+| `keyDerivation` | `derived` (from the publisher seed, core §4.2) or `standalone`. |
+| `rev` | Integer, starts at 1, strictly increasing per file key. |
+| `predecessor`, `successor` | File keys, for rotation (core §4.5). OPTIONAL. |
+| `name` | Display name. Visible to anyone who obtains the manifest. MAY be empty. |
+| `size` | Plaintext byte length. Ciphertext has the same length. |
+| `contentType` | Media type of the plaintext. |
+| `pieceLength` | Power of two, at least 16384. |
+| `plainRoot`, `cipherRoot` | BEP 52 pieces roots (core §5.1). |
+| `cipher.alg` | `aes-256-ctr` in this version. |
+| `cipher.keyId` | `sha256:` of the content key. Identifies which key a grant carries without revealing it. |
+| `swarm.infohashV1`, `swarm.infohashV2` | Of the hybrid torrent whose one file is the ciphertext (core §5.3). |
+| `swarm.private` | BEP 27 flag. |
+| `swarm.trackers`, `swarm.webseeds` | Announce URLs and BEP 19 URLs. |
+| `visibility` | `public`: any peer gets the manifest on request. `pass`: only a peer presenting a valid pass in scope. |
+| `policy.vanilla` | `ciphertext`: serve pieces to vanilla peers free. `deny`: choke them. |
+| `policy.creditBytes` | Credit window. Default 4194304. |
+| `policy.voucherBytes` | Voucher interval. Default 1048576. MUST be at most `creditBytes / 2`. |
+| `pricing` | See §7. `keeperBps` is taken from `keyUsd`, the `split` is applied to per-byte vouchers. |
+| `keys.modes` | Any of `peer` (a key holder in the swarm grants on request), `url` (an HTTPS key service). |
+| `keys.keepers` | Identities delegated to grant. Each MUST hold an `ipfile.grant` with `delegate: true` signed by the file key. |
+| `keys.owner` | The content key sealed to `box`. Lets the owner recover the key from seed plus manifest. |
+| `keys.public` | OPTIONAL. The content key itself, hex. Set only when the publisher wants anyone to decrypt (a free release, an `ipdb` segment). With it present, `keyUsd` MUST be `0.000000` and no grant is ever needed. |
+| `replication` | Publisher's request: at least `min` independent seeders. `erasure` is advice to storage nodes, never a wire format (§9). |
+| `meta` | Free-form. Member protocols define what they put here: `ipaudio` sets `meta.track`, `ipvideo` sets `meta.title`, `ipdb` sets `meta.kind` on segment files. |
+
+`bps` values are basis points; the three in `split` MUST sum to 10000.
+
+### 3.2 The grant
+
+A grant carries the content key to one recipient:
+
+```json
+{
+ "openswarm": "0.1",
+ "type": "ipfile.grant",
+ "file": "ed25519:0d87e09c7fea3ad6ba6c2f3e027ea47f5b245452899910948470906704c5295d",
+ "keyId": "sha256:41d10f45e705e0526c9eeedf62b32dda3daaf552dd9b6a7b09e01776b05813bb",
+ "grantor": "ed25519:d2d05fcad07ecbee6ff87c95159ec9969fce6c0fb9f41ff78c2c07ebe8a06c94",
+ "grantee": "x25519:de3c5a1791131231f797726a59b46b5b877628f0d10ae97fb5f119faa1ad1dc5",
+ "pass": "sha256:39655de63a29b02c90b236fa870fe6f8dfad1f52a8119fe4df85e88cd6ab02fd",
+ "delegate": false,
+ "expiresAt": null,
+ "sealed": "KrGWWxD4blHwoh7UmJedgv/CdxWz2J9cbRZpMY+09mm6FT7exyU7KjEAXmWRTLh2Xr8INCGKhv6dpjScKFGCkCrPZDyfAHE2s4E5/VXc2zQ=",
+ "createdAt": "2026-09-05T18:01:30.000Z",
+ "sigs": [
+ {
+ "alg": "ed25519",
+ "key": "ed25519:d2d05fcad07ecbee6ff87c95159ec9969fce6c0fb9f41ff78c2c07ebe8a06c94",
+ "sig": "RGRH7pDpXYcagULqj+8z05pLH99Lc6yLmfdbYgtGeRpjX5l0NcT3QSq9AWBtLw+5+JJiKoZ3s4ty74q/zBSE8w=="
+ }
+ ]
+}
+```
+
+| Field | Rule |
+| --- | --- |
+| `grantor` | The file key, or a keeper listed in the manifest. A grant by anyone else is invalid. |
+| `grantee` | The recipient's box public key. |
+| `pass` | Record id of the pass that paid `keyUsd`. `null` only when `delegate` is true. |
+| `delegate` | True makes the grantee a keeper. Only the file key MAY sign a delegating grant. |
+| `expiresAt` | Keeper delegation SHOULD expire. A paid grant is perpetual: the peer holds the key. |
+| `sealed` | `crypto_box_seal(contentKey, grantee)`, base64. 80 bytes for a 32-byte key. |
+
+A grantee decrypts `sealed` with its box private key and checks
+`sha256(key) == keyId` before using it.
+
+## 4. Encryption
+
+### 4.1 Piece encryption
+
+The plaintext is split into pieces of `pieceLength`. Piece `i` is encrypted
+independently under AES-256-CTR:
+
+```
+iv(i) = HKDF-SHA256(ikm = contentKey, salt = "", info = "openswarm:ipfile:iv:v1:" + decimal(i), L = 12) || 0x00000000
+cipher(i) = AES-256-CTR(contentKey, iv(i), plain(i))
+```
+
+The 32-bit counter is big-endian and starts at zero, which is the WebCrypto
+`AES-CTR` shape with `length: 32`. A piece is at most 2^32 blocks of 16
+bytes, so the counter never wraps below 64 GiB per piece.
+
+CTR is chosen over an AEAD because it is length-preserving and seekable: a
+16 KiB block at offset `o` in piece `i` decrypts from counter `o / 16`
+without touching the rest of the piece. Integrity does not come from the
+cipher. It comes from two merkle trees:
+
+- The swarm verifies every block of ciphertext against `cipherRoot` through
+ the torrent's own v2 piece layers. A tampered block is rejected by any
+ BitTorrent v2 client before this specification is involved.
+- The decrypting client verifies every plaintext piece against the plaintext
+ piece layer (§5.6), whose root is `plainRoot` in the signed manifest.
+
+A malleable cipher whose output is pinned by a signed hash tree is not
+malleable in practice: any bit flip fails one of the two checks.
+
+### 4.2 The content key
+
+32 random bytes per file version. A new `cipherRoot` MUST use a new content
+key; reusing a key across two plaintexts under the same IV derivation would
+leak their XOR. The key never leaves a peer except inside a grant's sealed
+box, over a key service response, or in `keys.owner`.
+
+## 5. Wire protocol
+
+### 5.1 Extension handshake and `hello`
+
+After the BitTorrent handshake, both peers send the BEP 10 extension
+handshake. A peer that supports this specification includes `ipfile` in `m`
+and `"openswarm": "0.1"`. Immediately after, it sends `hello`:
+
+```
+{ "t": "hello",
+ "v": 1,
+ "id": <32 bytes, Ed25519 public key>,
+ "box": <32 bytes, X25519 public key>,
+ "nonce": <16 bytes>,
+ "roles": ["seed"],
+ "hubs": ["https://coinpayportal.com/api/openswarm"],
+ "credit": 4194304,
+ "interval": 1048576,
+ "mtp": ,
+ "sig": <64 bytes> }
+```
+
+`sig` is Ed25519 over:
+
+```
+"openswarm:hello:v1" || infohashV2 (32 bytes) || own peer_id (20 bytes) || remote peer_id (20 bytes) || nonce
+```
+
+Binding both `peer_id`s and the infohash ties the identity to this
+connection in this swarm; a captured `hello` replays nowhere. `roles` is any
+of `seed`, `leech`, `keeper`, `gateway`. `credit` and `interval` are the
+window and the voucher interval this seeder will apply, which MUST be at
+least the manifest's `policy` values.
+
+A peer MUST NOT send any other `ipfile` message before it has sent `hello`
+and received a valid one.
+
+### 5.2 Messages
+
+All payloads are bencoded dictionaries. `r` carries a record as UTF-8 JSON.
+
+| `t` | Direction | Payload | Meaning |
+| --- | --- | --- | --- |
+| `hello` | both | §5.1 | Identity and terms. |
+| `manifest_req` | any | `{ "rev": int or absent }` | Ask for the manifest, optionally a specific revision. |
+| `manifest` | any | `{ "r": bytes }` | The manifest record. Sent unsolicited by a seeder after `hello` when `visibility` is `public`. |
+| `layer_req` | leecher | `{}` | Ask for the plaintext piece layer. |
+| `layer` | seeder | `{ "d": bytes }` | 32 bytes per piece, concatenated (§5.6). |
+| `pass` | leecher | `{ "r": bytes }` | Present an `ippay.pass`. |
+| `credit` | seeder | `{ "unpaid": int, "limit": int, "owed": string }` | Bytes served since the last accepted voucher, the window, and the amount owed. |
+| `voucher` | leecher | `{ "r": bytes }` | An `ippay.voucher`. |
+| `key_req` | leecher | `{ "keyId": bytes }` | Ask a key holder for a grant. |
+| `grant` | key holder | `{ "r": bytes }` | An `ipfile.grant` sealed to the requester's `box`. |
+| `deny` | any | `{ "what": string, "reason": string }` | Refuse `pass`, `key_req` or `manifest_req` with a registered reason. |
+| `bye` | any | `{ "reason": string }` | About to disconnect. |
+
+Registered `deny` and `bye` reasons: `no-pass`, `pass-invalid`,
+`pass-expired`, `pass-scope`, `pass-cap`, `unpaid`, `voucher-invalid`,
+`voucher-stale`, `not-keeper`, `no-key`, `visibility`, `rate`, `done`.
+
+### 5.3 The paid session
+
+```
+leecher seeder
+ | BT handshake, BEP 10 handshake |
+ |---------------------------------------->|
+ |<----------------------------------------|
+ | hello |
+ |---------------------------------------->|
+ |<------------------------------- hello |
+ |<---------------------------- manifest | (public visibility)
+ | pass |
+ |---------------------------------------->| seeder verifies hub signature,
+ |<----------------------------- unchoke | scope, expiry, cap
+ | request / piece ... (vanilla) |
+ |<=======================================>| up to `credit` bytes unpaid
+ |<------------------------------ credit | unpaid >= interval: seeder asks
+ | voucher (cumulative) |
+ |---------------------------------------->| seeder verifies, unpaid resets
+ | request / piece ... |
+ |<=======================================>|
+ | key_req |
+ |---------------------------------------->| if seeder is a key holder
+ |<------------------------------- grant |
+ | bye done |
+ |---------------------------------------->|
+```
+
+Rules:
+
+1. A seeder MUST keep a leecher choked until a valid `pass` arrives, unless
+ `policy.vanilla` is `ciphertext`, in which case an unpaid peer is treated
+ as vanilla (§6) and gets no priority.
+2. A seeder MUST NOT serve more than `credit` bytes beyond the bytes covered
+ by the last accepted voucher. When the window is full it chokes and sends
+ `credit`. It MAY disconnect with `bye unpaid` after 60 seconds choked.
+3. A leecher MUST send a voucher when unpaid bytes reach `interval`, and
+ only for bytes whose blocks it has verified against the v2 piece layer. A
+ voucher is cumulative for this (pass, payee, swarm) triple (`ippay` §4).
+4. A seeder MUST verify the voucher's signature, its monotonic `seq`, that
+ `bytes` is at least the previous voucher's `bytes`, and that the increase
+ does not exceed what it actually served. A voucher that claims more than
+ served is accepted for what was served and flagged; one that claims less
+ is `voucher-stale` and rejected.
+5. A leecher MUST NOT sign a voucher for bytes it has not verified. It MAY
+ sign for fewer than it has received; the seeder then chokes sooner. That
+ is the only lever a leecher has against a slow seeder, and it costs
+ nothing but throughput.
+6. Vouchers cover ciphertext bytes. Payment does not depend on the leecher
+ ever obtaining the key.
+
+### 5.4 Who may pay and who is paid
+
+The voucher's `payer` is the pass's `payer`. The voucher's `payee` is the
+seeder's `hello.id`. A seeder MUST reject a voucher whose `payee` is not its
+own identity. The hub pays whoever redeems a voucher signed to them, so an
+identity is a payout account. A seeder that wants to be paid registers its
+identity and payout address at the hub once (`ippay` §6).
+
+### 5.5 Key delivery
+
+**Peer mode.** Any peer whose `roles` include `keeper`, or that is the file
+key itself, answers `key_req`. It MUST verify that the requester's pass is
+valid, in scope for this file, and has `grant: true` (meaning `keyUsd` was
+paid). It then signs and sends a grant sealed to the requester's `box`. A
+keeper MUST report the grant to the hub (`ippay` §5.4) to earn `keeperBps`
+and to let the hub mark the pass's grant as consumed; a pass buys one grant
+per file.
+
+**URL mode.** `GET /grant?file=&box=`
+with `Authorization: Bearer ` returns the grant record as JSON.
+Without a pass it returns 402 with an x402 offer for `keyUsd`, so a client
+that has no hub relationship can still buy a key directly (`ippay` §3.4).
+
+**Owner recovery.** The owner decrypts `keys.owner` with the file box private
+key derived from the seed.
+
+### 5.6 The plaintext piece layer
+
+The plaintext v2 piece layer is the sequence of per-piece SHA-256 merkle
+roots (32 bytes each) whose root is `plainRoot`. A seeder holds it because it
+had the plaintext or received it with the swarm. A leecher fetches it with
+`layer_req` (or from a gateway at `/swarm//layer`) and MUST
+verify that it hashes to `plainRoot` before trusting a single entry. After
+decrypting a piece the leecher checks it against the corresponding entry.
+For a 1 GiB file with 1 MiB pieces the layer is 32 KiB.
+
+## 6. Coexistence with vanilla BitTorrent
+
+A vanilla client sees a hybrid torrent with one file named by a hex string.
+It can download it if seeders allow. Specifically:
+
+- The extension handshake without `ipfile` marks the peer as vanilla.
+- With `policy.vanilla: "ciphertext"`, a seeder treats it as any BitTorrent
+ peer: normal choking algorithm, no credit accounting, no payment. It gets
+ bytes it cannot decrypt. This is a free choice a publisher makes to widen
+ the swarm; vanilla peers that then seed are unpaid redundancy.
+- With `policy.vanilla: "deny"`, a seeder keeps it choked and MAY disconnect.
+ It still answers `ut_metadata` so the peer can learn it has nothing to gain.
+- A vanilla peer never receives an `ipfile` message, because BEP 10 forbids
+ sending an extension a peer did not advertise.
+- The DHT, trackers and PEX carry infohashes only. A public tracker learns a
+ swarm exists and how many peers it has, exactly as today.
+- WebTorrent peers are vanilla until the page loads the `ipfile` extension.
+ bittorrented.com's player would add it with `wire.use()`.
+
+Encryption of the wire itself (the RC4 "message stream encryption" some
+clients offer) is neither required nor relied on. Confidentiality of content
+comes from piece encryption; confidentiality of the link, where wanted, comes
+from the MTP/1 tunnel (core §6.5).
+
+## 7. Pricing and settlement hook
+
+The manifest's `pricing` is the whole price list:
+
+| Field | Meaning |
+| --- | --- |
+| `perGib` | USD per GiB of ciphertext served. Vouchers are computed from it (`ippay` §4.2). |
+| `keyUsd` | USD for one grant. `0.000000` makes the key free to any pass holder. |
+| `split` | Where each voucher's value goes: publisher, the seeder who served, the hub. |
+| `keeperBps` | Share of `keyUsd` paid to a keeper that grants. The rest of `keyUsd` goes to the publisher. |
+| `hubs` | Hubs the publisher accepts passes from. A seeder MUST reject a pass from a hub not listed. |
+| `payTo` | Where the publisher's share is paid. CAIP-2 network and an address. |
+| `royalties` | OPTIONAL. A list of `{ "label", "network", "address", "bps" }` summing to 10000 that splits the publisher's share further. `ipaudio` and `ipvideo` records carry the same list; when both exist the manifest's wins, because the manifest is what the file key signed. |
+
+Passes are bought over x402 exactly as `@profullstack/x402-gateway` sells
+crawl passes today: a 402 whose body is CoinPay's x402 v2 offer, a proof in
+`X-PAYMENT`, verify and settle through CoinPay. `ippay` §3 has the exchange.
+A publisher who wants no payment at all sets `perGib` and `keyUsd` to
+`0.000000`, and seeders serve for free with vouchers that carry zero; the
+accounting still runs so the publisher sees who served what.
+
+## 8. Publishing procedure
+
+`ip file add ` does, in order:
+
+1. Load the publisher seed. Generate `fileId`. Derive the file key and file
+ box (core §4.2), or generate a standalone seed with `--standalone-key`.
+2. Generate the content key. Compute `plainRoot` and the plaintext piece
+ layer. Encrypt piece by piece; compute `cipherRoot`.
+3. Build the hybrid torrent: one file named by `cipherRoot` hex, `piece
+ length` as chosen, `private` per flag, `announce-list` from
+ configuration, `url-list` for any gateway. Compute both infohashes.
+4. Seal the content key to the file box (`keys.owner`).
+5. Write the manifest with `rev: 1`. Sign with the file key, then the
+ publisher key.
+6. Publish the BEP 44 pointer under the file key (core §7.2).
+7. Append an `ipdb.entry` for the manifest to the publisher's default feed
+ (`ipdb` §4).
+8. Start seeding. Announce to trackers. If any keeper is configured, send it
+ a delegating grant.
+
+A revision repeats steps 2 to 7 with `rev + 1` and the same `fileId`. A
+metadata-only revision (price, trackers, keepers) skips step 2 and keeps
+`cipherRoot`, `keyId` and the swarm.
+
+## 9. Storage and serving on a c0mpute node
+
+A c0mpute storage node stores whatever it likes internally: today that is
+blake3-addressed chunks with Reed-Solomon 10 data + 4 parity shards spread
+over hosts. On the wire it MUST present whole ciphertext pieces verified
+against `cipherRoot`. Erasure coding is a storage policy. `replication.
+erasure` in the manifest is the publisher saying what durability it is
+paying for in a pin job; it never changes what a peer sends.
+
+A node that holds only shards reconstructs the piece before serving it. A
+node advertises `c0mpute:role:storage` and bids on `ipfile.pin` jobs
+(`c0mpute.md` §3). Pin income and voucher income are separate: the pin job
+pays for holding the bytes for a period, vouchers pay for serving them.
+
+## 10. Retrieval procedure
+
+`ip file get `:
+
+1. Resolve to a manifest: BEP 44 `get` under the file key, or `manifest_req`
+ from a peer, or a gateway.
+2. Verify both signatures. Verify `sha256` of the canonical bytes matches
+ any record id the caller supplied.
+3. Choose a hub from `pricing.hubs` the client has an account with. Buy or
+ reuse a pass in scope (`ippay` §3). Include `grant: true` if the key is
+ needed.
+4. Join the swarm. For each peer: exchange `hello`, send `pass`, download
+ with sequential priority for streaming or rarest-first for bulk, sign
+ vouchers on schedule.
+5. Fetch and verify the plaintext piece layer. Obtain a grant from a key
+ holder or the key URL. Decrypt pieces as they complete; verify each
+ against the layer.
+6. On completion, verify the whole against `plainRoot`. Send `bye done`.
+7. Keep seeding if configured. The client is now a seeder and earns.
+
+## 11. Security notes specific to ipfile
+
+- **Freeloading leecher.** Bounded by `creditBytes`. A seeder loses at most
+ one window per leecher identity per session and MAY refuse an identity it
+ has seen freeload (`ippay` §5.5 reputation).
+- **Freeloading seeder.** Cannot happen: a voucher is only for bytes already
+ received and verified. A seeder that stops after being paid was paid for
+ what it sent.
+- **Voucher inflation.** A seeder cannot forge a voucher; it lacks the
+ payer's key. A payer that over-states `bytes` pays more than it must; that
+ is its own loss.
+- **Double spend across seeders.** A pass has a `capUsd`. A payer could sign
+ vouchers to many seeders totalling more than the cap. The hub honours
+ redemptions in order until the cap is reached and reports the rest as
+ `cap-exceeded`; each seeder's exposure is still one window. A payer that
+ does this is reported and its passes are refused (`ippay` §5.5).
+- **Content key exposure.** A paying peer holds the key. Any peer can leak
+ it. This family prices access, it does not prevent copying. Rotation is the
+ remedy for a leaked key going forward, and only forward.
+- **Metadata.** Size, piece length and both infohashes are public. `name`
+ and `meta` are visible to anyone who obtains the manifest; `visibility:
+ "pass"` restricts that to pass holders.
+- **Manifest substitution.** The BEP 44 pointer is signed by the file key
+ and carries `seq`; a stale pointer is refused by the DHT itself. A
+ manifest from a peer is verified against the file key the client started
+ from.
+
+## 12. Events
+
+| Type | Emitted by | `data` |
+| --- | --- | --- |
+| `com.logicsrc.openswarm.ipfile.manifest.published.v1` | publisher | `record` |
+| `com.logicsrc.openswarm.ipfile.manifest.revised.v1` | publisher | `record` |
+| `com.logicsrc.openswarm.ipfile.grant.issued.v1` | key holder | `record` (with `sealed` removed) |
+| `com.logicsrc.openswarm.ipfile.session.closed.v1` | seeder | `{ "swarm", "peer", "bytes", "vouchers", "reason" }` |
+
+## 13. Implementations
+
+| Piece | Exists | Where | What is new |
+| --- | --- | --- | --- |
+| Hybrid seeder announcing to wss trackers, browser WebTorrent player, range streaming | Yes | `media-streamer` (`src/lib/streaming/streaming.ts`, `src/hooks/use-webtorrent.ts`) | The `ipfile` extension in `wire.use()`, piece decryption in the player |
+| DHT observation of infohashes | Yes | `media-streamer/services/dht-search-api` (bitmagnet), `dht-infohash-crawler` | Nothing; an `ipfile` swarm is an opaque infohash to a crawler |
+| x402 pass sale and verification | Yes | `x402-gateway`, CoinPay `/api/x402/verify` and `/settle` | Pass scope and vouchers (`ippay`) |
+| Content-addressed chunk store, erasure coding, gateway by hash | Yes | `c0mpute` (`c0mpute-store`, `c0mpute-gateway`) | Serving pieces by index verified by SHA-256, the pin workload |
+| Sealed box key wrapping | Yes | `logicsrc credentials` team vaults, OpenCreds `team` profile | The grant record |
+| Manifest, file key derivation, piece encryption, `hello`, credit window, voucher exchange | No | | All of it |
+
+## 14. Conformance
+
+An `ipfile` implementation:
+
+1. Produces manifests per §3.1 with both signatures and a correct BEP 44
+ pointer.
+2. Encrypts and decrypts per §4 and verifies against both roots.
+3. Speaks every message in §5.2, refuses out-of-order messages per §5.1, and
+ enforces §5.3 rules 2 to 5.
+4. Treats vanilla peers per §6 according to `policy.vanilla`.
+5. Issues grants only against a valid pass or a delegating grant per §5.5.
+6. Passes the published fixture swarm end to end: manifest, pass, three
+ vouchers, grant, decrypted output matching `plainRoot`.
+
+## 15. Version history
+
+| Version | Date | Change |
+| --- | --- | --- |
+| 0.1 | 2026-09-05 | Initial draft. |
diff --git a/docs/openswarm/iplive.md b/docs/openswarm/iplive.md
new file mode 100644
index 0000000..f3db2a3
--- /dev/null
+++ b/docs/openswarm/iplive.md
@@ -0,0 +1,378 @@
+# iplive: Live Streams over Paid Peers
+
+Version: **0.1** (draft)
+Status: draft. Extends the OpenSwarm core ([spec.md](./spec.md)). Reuses
+`ipfile`'s encryption, grants and credit window and `ippay`'s passes and
+vouchers. Adds one BEP 10 extension, `iplive`. Nothing below is
+implemented.
+
+## 1. Scope
+
+`iplive` moves a live stream from one origin to many viewers through peers
+that are paid to relay it:
+
+1. A **channel** record: identity, renditions, latency mode, price, relay
+ policy.
+2. A **head** record: signed every segment, naming the segments that exist
+ and the key epoch in force.
+3. The **`iplive` extension**: announcing, requesting and fanning out
+ encrypted segments with backpressure.
+4. **Roles**: origin, relay, viewer, and how a relay is paid.
+5. **Recording**: how a finished stream becomes an `ipvideo` title.
+
+### Non-goals
+
+Ingest protocols (RTMP and SRT are the encoder's business; c0mpute's DIP
+0019 covers them). Sub-second latency; the floor is one part duration.
+Chat. Ads.
+
+## 2. Terminology
+
+| Term | Meaning |
+| --- | --- |
+| **channel** | A named live source with a key pair derived from the publisher seed. |
+| **channel id** | 20 bytes: SHA-256 of `"openswarm:iplive:v1" || channel public key`, truncated. Used where the DHT and trackers want an infohash. |
+| **origin** | The peer that encodes, encrypts, signs heads and holds the content key. |
+| **relay** | A peer that forwards segments it has verified and is paid per byte. |
+| **viewer** | A peer that plays. It MAY also relay. |
+| **segment** | One CMAF fragment, 2000 ms by default, encrypted as one unit. |
+| **part** | In low-latency mode, a sub-segment chunk (500 ms default) announced before the segment completes. |
+| **key epoch** | The span of segments encrypted under one content key. |
+| **live edge** | The newest segment the origin has announced. |
+
+## 3. Data model
+
+### 3.1 Channel
+
+The channel key derives like a feed:
+
+```
+channelSeed = HKDF-SHA256(ikm = publisher seed, salt = utf8("live:" + name), info = "openswarm:live:v1")
+```
+
+```json
+{
+ "openswarm": "0.1",
+ "type": "iplive.channel",
+ "channel": "ed25519:0d87e09c7fea3ad6ba6c2f3e027ea47f5b245452899910948470906704c5295d",
+ "box": "x25519:4c4055604fe6bd8781cab6835093ad896f68f676b472d5bd6811e52f28cb18c3",
+ "publisher": "ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e",
+ "name": "courtside",
+ "title": "Courtside, live",
+ "latency": "low",
+ "segmentMs": 2000,
+ "partMs": 500,
+ "epochSegments": 900,
+ "dvrSegments": 1800,
+ "renditions": [
+ { "id": "720p", "codecs": "avc1.64001f,mp4a.40.2", "width": 1280, "height": 720, "frameRate": 30, "bitrateKbps": 2800 },
+ { "id": "360p", "codecs": "avc1.64001e,mp4a.40.2", "width": 640, "height": 360, "frameRate": 30, "bitrateKbps": 800 }
+ ],
+ "swarm": {
+ "trackers": ["wss://tracker.openwebtorrent.com", "udp://tracker.opentrackr.org:1337/announce"],
+ "private": false
+ },
+ "relays": { "allow": "any", "maxDownstream": 8, "minRelays": 2 },
+ "policy": { "vanilla": "deny", "creditBytes": 8388608, "voucherBytes": 2097152 },
+ "pricing": {
+ "currency": "USD",
+ "perGib": "0.020000",
+ "keyUsd": "1.000000",
+ "split": { "publisherBps": 6000, "seederBps": 3500, "hubBps": 500 },
+ "keeperBps": 0,
+ "hubs": ["https://coinpayportal.com/api/openswarm"],
+ "payTo": { "network": "eip155:8453", "address": "0xCC3b072391AE7A8d10cF00DdC5F61DB2cA5541E5" }
+ },
+ "keys": { "modes": ["peer"], "keepers": [] },
+ "createdAt": "2026-09-05T20:00:00.000Z",
+ "sigs": [
+ {
+ "alg": "ed25519",
+ "key": "ed25519:0d87e09c7fea3ad6ba6c2f3e027ea47f5b245452899910948470906704c5295d",
+ "sig": "RGRH7pDpXYcagULqj+8z05pLH99Lc6yLmfdbYgtGeRpjX5l0NcT3QSq9AWBtLw+5+JJiKoZ3s4ty74q/zBSE8w=="
+ },
+ {
+ "alg": "ed25519",
+ "key": "ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e",
+ "sig": "RGRH7pDpXYcagULqj+8z05pLH99Lc6yLmfdbYgtGeRpjX5l0NcT3QSq9AWBtLw+5+JJiKoZ3s4ty74q/zBSE8w=="
+ }
+ ]
+}
+```
+
+| Field | Rule |
+| --- | --- |
+| `channel`, `box` | Channel signing and box keys. Signed by both channel and publisher, as an `ipfile` manifest is. |
+| `latency` | `normal`: segments announced when complete. `low`: parts announced as they are encoded. |
+| `segmentMs`, `partMs` | 2000 and 500 by default. 4000 is allowed and matches c0mpute DIP 0019. |
+| `epochSegments` | Segments per content key. 900 at 2 s is 30 minutes. |
+| `dvrSegments` | Segments the origin and relays keep for late joiners and scrubbing. |
+| `relays.allow` | `any`, or a list of identities allowed to pull from the origin. |
+| `relays.maxDownstream` | Peers one relay will feed at once. |
+| `relays.minRelays` | Relays the origin wants before it stops feeding viewers directly. |
+| `policy`, `pricing`, `keys` | As `ipfile` §3.1 and §7, with the same fields and meanings. `seederBps` pays relays. |
+
+The record lives in `ipdb` under `channel:`.
+
+### 3.2 Head
+
+Signed by the channel key every time a segment (or, in low-latency mode,
+a part) is announced:
+
+```json
+{
+ "openswarm": "0.1",
+ "type": "iplive.head",
+ "channel": "ed25519:0d87e09c7fea3ad6ba6c2f3e027ea47f5b245452899910948470906704c5295d",
+ "seq": 4187,
+ "prev": "sha256:ba8cd1a97b865a05cdc5840c63b086dd463624a831874c1ba0d7c4ce6f806f7a",
+ "epoch": 4,
+ "keyId": "sha256:41d10f45e705e0526c9eeedf62b32dda3daaf552dd9b6a7b09e01776b05813bb",
+ "live": true,
+ "segments": [
+ {
+ "seq": 4187,
+ "t": 8374000,
+ "d": 2000,
+ "renditions": [
+ { "id": "720p", "root": "sha256:d6c3f8285b7871d6a400cba14408288a9acde679f12e1e7dc276f29ca7c493ff", "size": 702113, "parts": [175528, 175530, 175527, 175528] },
+ { "id": "360p", "root": "sha256:4b74eb43677e4d03af5fb0856333f9aa21d9a5a3bbf944b13aa7eef379c7a342", "size": 200418, "parts": [50104, 50105, 50104, 50105] }
+ ]
+ }
+ ],
+ "init": [
+ { "id": "720p", "root": "sha256:c9f17d4eaf2b28122b46d111cef6697d2c3f708a19d4628bce5c0304481b355b", "size": 1524 },
+ { "id": "360p", "root": "sha256:00bc9c01ca00938016e276fe5b5748cfe13fd02bc53cc651535682c8dd5c83be", "size": 1498 }
+ ],
+ "relays": ["ed25519:d2d05fcad07ecbee6ff87c95159ec9969fce6c0fb9f41ff78c2c07ebe8a06c94"],
+ "createdAt": "2026-09-05T22:19:34.000Z",
+ "sigs": [
+ {
+ "alg": "ed25519",
+ "key": "ed25519:0d87e09c7fea3ad6ba6c2f3e027ea47f5b245452899910948470906704c5295d",
+ "sig": "RGRH7pDpXYcagULqj+8z05pLH99Lc6yLmfdbYgtGeRpjX5l0NcT3QSq9AWBtLw+5+JJiKoZ3s4ty74q/zBSE8w=="
+ }
+ ]
+}
+```
+
+| Field | Rule |
+| --- | --- |
+| `seq` | Segment number of the live edge. Starts at 1 when the stream starts. |
+| `prev` | Id of the previous head, so a viewer can detect a skipped announcement. |
+| `epoch`, `keyId` | Which content key encrypts segments from this seq on. A key change is announced one full segment before it applies. |
+| `segments` | The newest segment, or the newest few. A head is small: relays fill history from `have`. |
+| `segments[].renditions[].root` | BEP 52 pieces root of the segment ciphertext, with the segment as one piece. That is what a relay verifies before forwarding. |
+| `segments[].renditions[].parts` | Byte lengths of each part, in order, for low-latency requests. |
+| `init` | Roots and sizes of each rendition's init segment, which is not encrypted. |
+| `relays` | Identities the origin currently feeds; a viewer prefers these. |
+| `live` | `false` in the final head, which marks the end of the stream. |
+
+Heads are not stored in `ipdb`. The final head is, under
+`channel::end:`, alongside the recording (§8).
+
+### 3.3 Segment encryption
+
+As `ipfile` §4.1, with the segment as a single piece and the IV derived with
+`info = "openswarm:iplive:iv:v1:" + decimal(seq)`. The content key changes
+each epoch; a grant carries `keyId` and a viewer requests a new grant when
+the head announces a new `keyId`. A pass expiring mid-stream stops at the
+next epoch, which is why epochs are short.
+
+## 4. Wire protocol
+
+### 4.1 Finding peers
+
+Peers announce the channel id to the DHT (`announce_peer`) and to the
+channel's trackers exactly as they would an infohash. Vanilla clients that
+see it find a swarm whose `ut_metadata` returns nothing; they leave.
+Browsers find WebRTC peers through wss trackers as with any WebTorrent
+swarm.
+
+### 4.2 Handshake
+
+The BitTorrent handshake uses the channel id as the infohash. The BEP 10
+handshake advertises `iplive`. Both sides send `hello` with the `ipfile`
+§5.1 fields (identity, box, nonce, roles, hubs, credit, interval, sig)
+where `roles` is any of `origin`, `relay`, `viewer`, and the signature
+covers the channel id in place of `infohashV2`.
+
+### 4.3 Messages
+
+| `t` | Payload | Meaning |
+| --- | --- | --- |
+| `hello` | as `ipfile` | Identity and terms. |
+| `channel` | `{ "r": bytes }` | The channel record. Sent by origin and relays after `hello`. |
+| `head` | `{ "r": bytes }` | A head record. Forwarded to every downstream peer within 100 ms of receipt. |
+| `have` | `{ "from": int, "to": int, "r": [string] }` | Segment range and rendition ids the sender holds. |
+| `window` | `{ "buffer": int, "live": int, "down": int, "max": int }` | Segments buffered, live edge, downstream count, `maxDownstream`. Sent after `hello` and whenever it changes. |
+| `want` | `{ "seq": int, "r": string, "part": int or absent }` | Request one segment, or one part. |
+| `seg` | `{ "seq": int, "r": string, "part": int or absent, "o": int, "d": bytes }` | A 16 KiB chunk of the requested unit at offset `o`. |
+| `init` | `{ "r": string, "d": bytes }` | An init segment. |
+| `pass`, `credit`, `voucher`, `key_req`, `grant`, `deny`, `bye` | as `ipfile` | Payment and keys, unchanged. |
+
+`seg` chunks are 16 KiB so that a WebRTC data channel and an MTP/1 record
+both carry one without fragmentation.
+
+### 4.4 Session
+
+```
+viewer relay
+ | hello |
+ |---------------------------------------->|
+ |<------------------------------- hello |
+ |<----------------------------- channel |
+ |<------------------------------ window | buffer=30 live=4187 down=3 max=8
+ | pass |
+ |---------------------------------------->|
+ |<-------------------------------- head | seq 4187
+ |<-------------------------------- have | 4158..4187
+ | want 720p init |
+ |---------------------------------------->|
+ |<-------------------------------- init |
+ | want 4185 720p |
+ |---------------------------------------->|
+ |<--------------------------------- seg | x 43 chunks
+ | want 4186 720p ... |
+ | key_req |
+ |---------------------------------------->| relay is not a keeper:
+ |<-------------------------------- deny | not-keeper; ask origin or keys.url
+ | voucher |
+ |---------------------------------------->|
+```
+
+Rules:
+
+1. A viewer starts `segmentMs * 2` behind the live edge in `normal` mode
+ and one part behind in `low` mode.
+2. A peer MUST NOT `want` a seq below `live - buffer` of the peer it asks.
+ If it has fallen further behind it jumps to `live - 1` and discards.
+ Stalls are resolved by dropping, never by holding a relay's buffer.
+3. A relay forwards a segment only after verifying its ciphertext against
+ the `root` in a head signed by the channel key. A relay never needs the
+ content key.
+4. A relay serving `down >= max` answers new `hello`s with `deny rate` and
+ the current `head`, whose `relays` list points elsewhere.
+5. Payment is `ipfile` §5.3 rules 2 to 6, with a relay as payee. The
+ default window is larger (8 MiB) because a segment is bigger than a
+ piece and a choke at the live edge is a stall.
+6. Keys come from the origin or a keeper listed in the channel, never from
+ a plain relay. A relay MAY be a keeper if the publisher delegates to it.
+
+### 4.5 Low latency
+
+In `low` mode the origin announces a head as soon as the first part of a
+segment is encoded, with `parts` filled in as they complete (lengths for
+finished parts, `0` for pending). A viewer requests parts by index and
+appends them to MSE as they arrive. A part is encrypted as part of the
+segment's CTR stream: part `k` begins at counter `offset(k) / 16`, so a
+relay can forward parts without waiting for the segment and a viewer can
+decrypt them in order. The segment `root` is only known when the segment
+completes; a relay in `low` mode forwards parts on the strength of the
+signed head naming the part lengths and verifies the root when the last
+part lands, disconnecting any upstream whose completed segment fails.
+
+## 5. Roles and payment
+
+| Role | Holds key | Pays | Is paid |
+| --- | --- | --- | --- |
+| Origin | Yes | Nothing | Publisher share of every voucher, `keyUsd` per grant |
+| Relay | No | Vouchers to its upstream for bytes it pulled | Vouchers from downstream for bytes it served |
+| Viewer | Yes, after a grant | Vouchers to whoever served it | Vouchers if it also relays |
+
+A relay's margin is `seederBps` of what it serves minus what it pays
+upstream. With `perGib = 0.020000`, a relay feeding eight viewers from one
+upstream pull earns `8 * 0.35 * 0.02 = 0.056` and pays `0.02` per GiB
+pulled, before the hub's share. That is the incentive to relay, and a
+c0mpute node bids on `iplive.relay` jobs for the guaranteed floor
+(`c0mpute.md` §3).
+
+The origin is a peer that pays nobody; `relays.allow` and `minRelays` are
+how it limits its own upload. A publisher who wants more reach than its
+relays provide buys pins on c0mpute.
+
+## 6. Publishing procedure
+
+`ip live create courtside --latency low` writes the channel record and
+puts it in `ipdb`. `ip live start courtside --input srt://:9000` then:
+
+1. Derives the channel key. Generates epoch 1's content key.
+2. Runs the encoder (ffmpeg) producing CMAF segments per rendition, and
+ for `low` mode, parts.
+3. For each completed unit: encrypts, computes the root, signs a head,
+ announces to the DHT and trackers, sends `head` to every connected peer.
+4. Answers `key_req` with grants for pass holders.
+5. Rotates the content key every `epochSegments`, announcing the new
+ `keyId` one segment early.
+6. On stop: signs a final head with `live: false`, and if `--record`,
+ assembles the recording (§8).
+
+## 7. Viewer procedure
+
+`ip live watch ed25519:` or a browser page:
+
+1. Resolve the channel record from `ipdb` or a peer.
+2. Buy or reuse a pass scoped to the channel key with `grant: true`.
+3. Find peers; prefer identities in the latest head's `relays`; connect to
+ two.
+4. Get the init segment, the head, a grant. Start at the live edge minus
+ the offset in §4.4 rule 1.
+5. Pull each segment from the connected peer with the smaller `live - seq`
+ lag; on a `deny rate` or a stall over one segment duration, replace the
+ peer.
+6. Send vouchers on schedule to each peer that served bytes.
+
+## 8. Recording
+
+With `--record`, the origin keeps every segment's plaintext, concatenates
+each rendition into one CMAF file, and publishes an `ipvideo.title` whose
+segment index is exactly the head history. Because segments were 2 or 4
+seconds and keyframe-aligned, the recording is a valid `ipvideo` ladder
+with no re-encode. The title's `ipdb` entry references the channel in
+`meta.channel` and the final head's id.
+
+## 9. Security and privacy
+
+- **Relays see ciphertext and heads.** They learn segment sizes and
+ timing, never content.
+- **Head forgery** needs the channel key. A relay forwarding an unsigned or
+ wrongly signed head is dropped by every downstream peer.
+- **Freeloading** is bounded per `ipfile` §11 with the larger window.
+- **Denial by relay count**: an origin with `relays.allow: any` can be
+ surrounded by relays that pull and never serve. It pays them nothing (it
+ pays nobody) and they earn nothing; the cost is its upload. `maxDownstream`
+ on the origin is the cap.
+- **Late key**: a viewer whose pass expires keeps the current epoch's key
+ and loses the next. Epoch length is the publisher's tolerance.
+
+## 10. Events
+
+| Type | Emitted by |
+| --- | --- |
+| `com.logicsrc.openswarm.iplive.channel.created.v1` | publisher |
+| `com.logicsrc.openswarm.iplive.stream.started.v1` | origin |
+| `com.logicsrc.openswarm.iplive.stream.ended.v1` | origin, with the final head id and recording title id |
+| `com.logicsrc.openswarm.iplive.relay.joined.v1` | origin |
+
+## 11. Implementations
+
+| Piece | Exists | Where | What is new |
+| --- | --- | --- | --- |
+| RTMP/SRT ingest to HLS segments, segment swarming in the DHT, relay nodes rebuilding playlists, LL-HLS opt-in | Designed | `c0mpute` `dips/0019-live-stream-plugin.md` | Encrypted segments, signed heads, paid relays: the same design with `ippay` attached |
+| Live TV playback in the browser (hls.js, mpegts.js) | Yes | `media-streamer` `hls-player-modal.tsx`, tipoffwatch and genrewatch players | MSE append of decrypted CMAF parts from peers instead of an HLS URL |
+| Pay-per-game passes with live session caps | Yes (central) | `media-streamer` IPTV resale (`iptv_share_sessions`) | Concurrency is no longer the owner's provider line; it is the origin's `maxDownstream` |
+| Channel and head records, `iplive` messages, relay economics, recording to `ipvideo` | No | | All of it |
+
+## 12. Conformance
+
+An origin signs a head per unit, rotates keys per epoch, and announces a
+key change one segment early. A relay verifies roots before forwarding,
+honours `window` rules, and never serves below `live - buffer`. A viewer
+drops rather than stalls, vouchers every peer that served it, and requests
+a grant on a `keyId` change.
+
+## 13. Version history
+
+| Version | Date | Change |
+| --- | --- | --- |
+| 0.1 | 2026-09-05 | Initial draft. |
diff --git a/docs/openswarm/ipname.md b/docs/openswarm/ipname.md
new file mode 100644
index 0000000..0f52d72
--- /dev/null
+++ b/docs/openswarm/ipname.md
@@ -0,0 +1,187 @@
+# ipname: Names for Publishers
+
+Version: **0.1** (draft)
+Status: draft. A thin bridge between the OpenSwarm core
+([spec.md](./spec.md)) and the Moshpit registry. Nothing below is
+implemented.
+
+## 1. Scope
+
+`ipname` answers one question: given a name a person can type, what
+publisher key and what catalogue does it mean? It defines:
+
+1. An `ipname.pin` record a publisher signs, served from the name's host.
+2. A Moshpit registry pin kind, `openswarm`, that anchors that record.
+3. A DNS TXT form for ordinary domains.
+4. The `ip://` URL.
+
+It does not define a naming system. Moshpit names exist (`label.tld`,
+registry at `pit.moshcode.sh`) and DNS exists. `ipname` maps both to keys.
+
+## 2. Terminology
+
+| Term | Meaning |
+| --- | --- |
+| **name** | A Moshpit name (`chovy.hacker`) or a DNS host (`example.com`). |
+| **pin** | In the Moshpit registry, `base64(SHA-256(SubjectPublicKeyInfo))` of a key, with a `kind`. |
+| **openswarm pin** | A registry pin of kind `openswarm` over the publisher's Ed25519 SPKI (RFC 8410). |
+| **name record** | The `ipname.pin` record at `/.well-known/openswarm.json`. |
+
+## 3. The name record
+
+```json
+{
+ "openswarm": "0.1",
+ "type": "ipname.pin",
+ "name": "chovy.hacker",
+ "publisher": "ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e",
+ "feeds": [
+ "ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e/default",
+ "ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e/podcasts"
+ ],
+ "gateway": "https://gw.c0mpute.com",
+ "hubs": ["https://coinpayportal.com/api/openswarm"],
+ "mtp": "sK8m2eLwG4l3iF5oT9uYQ6c1Vb0nZxA7hE2dR8pJkLw=",
+ "createdAt": "2026-09-05T18:30:00.000Z",
+ "sigs": [
+ {
+ "alg": "ed25519",
+ "key": "ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e",
+ "sig": "RGRH7pDpXYcagULqj+8z05pLH99Lc6yLmfdbYgtGeRpjX5l0NcT3QSq9AWBtLw+5+JJiKoZ3s4ty74q/zBSE8w=="
+ }
+ ]
+}
+```
+
+| Field | Rule |
+| --- | --- |
+| `name` | The name this record is served for. A record served under a different host MUST be refused. |
+| `publisher` | The key that signs the record and every feed it lists. |
+| `feeds` | Feed references (`ipdb` §3.1). The first is the default. |
+| `gateway` | An HTTP gateway that serves this publisher's swarms and `POST /db/query`. MAY be null. |
+| `hubs` | Hubs the publisher sells passes through. |
+| `mtp` | The publisher's MTP/1 pin, if it runs a Moshpit transport endpoint. MAY be null. |
+
+The record is also put in `ipdb` under `name:` in the default feed,
+so a replica that has the feed can answer the reverse question: what names
+does this key claim.
+
+## 4. Resolution
+
+### 4.1 Moshpit names
+
+A Moshpit name has exactly two labels of letters and digits. The registry
+holds a `target` host and a list of pins per name. `ipname` uses both:
+
+1. `GET https://pit.moshcode.sh/api/moshpit/pins?name=chovy.hacker` returns
+ `{ "name", "pins": [...], "target" }`. Pins are objects or strings
+ depending on the registry version; the resolver looks for entries of
+ kind `openswarm`.
+2. Fetch `https:///.well-known/openswarm.json` (through
+ `moshpit-proxy` when the target is itself a Moshpit name, plain HTTPS
+ when it is a clearnet host).
+3. Compute `base64(SHA-256(SPKI(record.publisher)))` and require it to equal
+ an `openswarm` pin. Verify the record signature. Require `record.name`
+ to equal the queried name.
+
+The registry pin is the anchor. The registry is not trusted for the key
+itself; it is trusted to say which hash the name's owner registered, and
+the owner's key is whatever hashes to that. This is the same argument
+`moshpit-proxy` makes for TLS pins and `qrypt.chat` makes for `mtp` pins,
+and the pin format is byte-identical so one registry field carries all
+three kinds.
+
+Writing the pin is one registry call by the name's owner:
+
+```
+POST https://app.moshcode.sh/api/moshpit/tlds/hacker/pins
+Authorization: Bearer
+{ "label": "chovy", "pin": "base64...", "kind": "openswarm", "note": "OpenSwarm publisher key" }
+```
+
+`kind: "openswarm"` does not exist in the registry today; adding it is a
+one-line change to the accepted kinds and is listed as an open question.
+
+### 4.2 House policy: clearnet first
+
+Moshpit's `prefer` rule applies unchanged: a name that resolves on the
+public DNS root is a clearnet host by default, and the registry is
+consulted only in `fallback` or `moshpit` mode. `ipname` MUST use
+`@moshcoder/moshpit-resolve`'s `decideResolution` (or an equivalent) before
+touching the registry, so that `example.com` is never redirected to a pit
+record because someone claimed `.com` as an ending.
+
+### 4.3 DNS hosts
+
+For a clearnet host, the anchor is a TXT record:
+
+```
+_openswarm.example.com. IN TXT "v=openswarm1; key=ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e"
+```
+
+The resolver fetches `https://example.com/.well-known/openswarm.json` and
+requires `record.publisher` to equal the TXT key. Without the TXT record
+the resolver MAY accept the well-known record on the strength of TLS alone
+and MUST report the lower assurance to the caller (`"anchor": "tls"`
+rather than `"anchor": "dns"` or `"anchor": "moshpit"`).
+
+### 4.4 Caching
+
+A resolved name is cached for the shorter of 300 seconds and the registry's
+own TTL, matching `moshpit-proxy`'s pin cache. A negative result is cached
+for 5 seconds.
+
+## 5. The ip URL
+
+```
+ip://[/[/]]
+```
+
+| Example | Meaning |
+| --- | --- |
+| `ip://chovy.hacker` | The publisher's default feed. |
+| `ip://chovy.hacker/podcasts` | A named feed. |
+| `ip://chovy.hacker/default/track:sha256:d6c3f8285b7871d6a400cba14408288a9acde679f12e1e7dc276f29ca7c493ff` | One record by `ipdb` key. |
+| `ip://ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e` | The same publisher by key; no name resolution. |
+| `ip://ed25519:0d87e09c7fea3ad6ba6c2f3e027ea47f5b245452899910948470906704c5295d?file` | A file by its file key: BEP 44 lookup, no feed. |
+
+A gateway maps `ip://` to HTTPS: `https:///ip//...`
+with the same path. A `magnet:` link with `xs=urn:btpk:&s=ipfile`
+is the BitTorrent-native equivalent of the last row and opens in any BEP 46
+client, which then needs the `ipfile` extension to do anything with it.
+
+## 6. Security notes
+
+- **Registry compromise** can change a pin. A resolver that has seen a name
+ before SHOULD keep the last verified publisher key and warn on change, the
+ way `moshpit-proxy` handles a pin rotation with `MOSHPIT_PROXY_TOFU`.
+- **Host compromise** can serve a different record, which fails the pin.
+- **Name squatting** is the registry's problem, and only the ending owner
+ can mint names under it.
+- **Privacy**: resolving a name tells the registry which names a client
+ asks about, as DNS does. A client that already holds a key never resolves.
+
+## 7. Events
+
+None. Name changes surface as `ipdb.entry.appended` for `name:`.
+
+## 8. Implementations
+
+| Piece | Exists | Where | What is new |
+| --- | --- | --- | --- |
+| Registry with per-name pins of kind `tls` and `mtp` | Yes | `pit.moshcode.sh`, `moshpit_name_pins` | The `openswarm` kind |
+| Pin lookup, cache, TOFU handling | Yes | `moshpit-proxy` `lib/pins.ts` | Reuse as the resolver's registry client |
+| Clearnet-first decision | Yes | `@moshcoder/moshpit-resolve` | Called before any registry lookup |
+| Name record, TXT form, `ip://` URL | No | | All of it |
+
+## 9. Conformance
+
+A resolver implements §4.1 to §4.4, refuses a record whose `name` or pin
+does not match, and reports the anchor kind. A publisher serves the record
+at the well-known path over HTTPS and keeps it in `ipdb`.
+
+## 10. Version history
+
+| Version | Date | Change |
+| --- | --- | --- |
+| 0.1 | 2026-09-05 | Initial draft. |
diff --git a/docs/openswarm/ippay.md b/docs/openswarm/ippay.md
new file mode 100644
index 0000000..721b23b
--- /dev/null
+++ b/docs/openswarm/ippay.md
@@ -0,0 +1,464 @@
+# ippay: Passes, Vouchers and Settlement
+
+Version: **0.1** (draft)
+Status: draft. Extends the OpenSwarm core ([spec.md](./spec.md)). The x402
+half exists (`@profullstack/x402-gateway`, CoinPay); the pass and voucher
+half does not.
+
+## 1. Scope
+
+`ippay` defines how money moves in an OpenSwarm swarm:
+
+1. A **pass**: a hub-signed record that lets one payer spend up to a cap on
+ named files, bought over x402 in USDC.
+2. A **voucher**: a payer-signed, cumulative IOU to one payee for verified
+ bytes, exchanged on the swarm wire or over HTTP.
+3. A **hub**: the settlement service that sells passes, redeems vouchers,
+ splits the value between publisher, payee and itself, and pays out.
+4. **Reputation**: the minimum a hub records so a seeder can bound its risk.
+
+It reuses the x402 v2 offer and proof exactly as CoinPay speaks them and as
+`x402-gateway` already sells crawl passes. It does not define a token, a
+chain, or an on-chain payment channel.
+
+### Non-goals
+
+Trustless settlement. A hub is trusted to pay out what it accepted, the way
+a payment processor is. Anyone may run a hub; a manifest lists which hubs a
+publisher trusts. Per-request on-chain payments: a 16 KiB block is worth
+about 0.00000015 USD at the reference price, and no chain settles that.
+
+## 2. Terminology
+
+| Term | Meaning |
+| --- | --- |
+| **payer** | The identity that bought a pass and signs vouchers. Usually a leecher or viewer. |
+| **payee** | The identity a voucher is signed to. A seeder, relay, keeper or gateway. |
+| **cap** | The most a pass can spend across all payees. |
+| **standing** | The hub's record of a payer's or payee's behaviour. |
+
+## 3. Passes
+
+### 3.1 Record
+
+```json
+{
+ "openswarm": "0.1",
+ "type": "ippay.pass",
+ "hub": "ed25519:c9f17d4eaf2b28122b46d111cef6697d2c3f708a19d4628bce5c0304481b355b",
+ "payer": "ed25519:00bc9c01ca00938016e276fe5b5748cfe13fd02bc53cc651535682c8dd5c83be",
+ "scope": {
+ "files": ["ed25519:0d87e09c7fea3ad6ba6c2f3e027ea47f5b245452899910948470906704c5295d"],
+ "publishers": []
+ },
+ "capUsd": "2.000000",
+ "grants": ["ed25519:0d87e09c7fea3ad6ba6c2f3e027ea47f5b245452899910948470906704c5295d"],
+ "paidUsd": "2.500000",
+ "payment": {
+ "network": "eip155:8453",
+ "nonce": "0x2f0a9c1b8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a"
+ },
+ "issuedAt": "2026-09-05T18:00:40.000Z",
+ "expiresAt": "2026-10-05T18:00:40.000Z",
+ "createdAt": "2026-09-05T18:00:40.000Z",
+ "sigs": [
+ {
+ "alg": "ed25519",
+ "key": "ed25519:c9f17d4eaf2b28122b46d111cef6697d2c3f708a19d4628bce5c0304481b355b",
+ "sig": "RGRH7pDpXYcagULqj+8z05pLH99Lc6yLmfdbYgtGeRpjX5l0NcT3QSq9AWBtLw+5+JJiKoZ3s4ty74q/zBSE8w=="
+ }
+ ]
+}
+```
+
+| Field | Rule |
+| --- | --- |
+| `hub` | The hub's signing key. A seeder verifies the signature against the key in the hub record (§6.1), fetched once and cached. |
+| `payer` | The identity that will sign vouchers. Bound at purchase time (§3.3). |
+| `scope.files` | File keys this pass may spend on. `["*"]` means any file whose manifest lists this hub. |
+| `scope.publishers` | Publisher keys; any file by one of them is in scope. Either list MAY be empty; at least one MUST be non-empty. |
+| `capUsd` | Spending cap across all vouchers. |
+| `grants` | Files for which `keyUsd` was paid. A pass buys one grant per listed file. |
+| `paidUsd` | What was paid: `capUsd` plus each granted file's `keyUsd`. |
+| `payment.nonce` | The EIP-3009 nonce of the proof that bought it. Lets the pass be traced to a settlement. |
+| `expiresAt` | Hubs SHOULD issue 30 day passes. Unspent cap is not refunded in 0.1. |
+
+A pass is self-contained. Presented over HTTP it is
+`Authorization: Bearer `.
+A verifier needs the hub's public key and a clock; no round trip.
+
+### 3.2 Purchase over x402
+
+A client asks the hub for a pass. The hub answers 402 with CoinPay's x402 v2
+offer. The client pays and retries. This is the same exchange
+`@profullstack/x402-gateway` runs for `/crawl`.
+
+```
+GET /passes?payer=00bc9c01ca00938016e276fe5b5748cfe13fd02bc53cc651535682c8dd5c83be
+ &file=0d87e09c7fea3ad6ba6c2f3e027ea47f5b245452899910948470906704c5295d
+ &cap=2.000000&grant=1
+Host: coinpayportal.com
+Accept: application/json
+```
+
+```
+HTTP/1.1 402 Payment Required
+Content-Type: application/json; charset=utf-8
+Cache-Control: no-store
+Vary: Accept, X-Payment
+
+{
+ "x402Version": 2,
+ "accepts": [
+ {
+ "scheme": "exact",
+ "network": "eip155:8453",
+ "amount": "2500000",
+ "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
+ "payTo": "0xCC3b072391AE7A8d10cF00DdC5F61DB2cA5541E5",
+ "resource": "https://coinpayportal.com/api/openswarm/passes?payer=00bc9c01ca00938016e276fe5b5748cfe13fd02bc53cc651535682c8dd5c83be&file=0d87e09c7fea3ad6ba6c2f3e027ea47f5b245452899910948470906704c5295d&cap=2.000000&grant=1",
+ "description": "OpenSwarm pass: 2.000000 USD cap plus 1 key grant",
+ "mimeType": "application/json",
+ "maxTimeoutSeconds": 300,
+ "extra": { "name": "USD Coin", "version": "2" }
+ }
+ ],
+ "pass": { "capUsd": "2.000000", "keyUsd": "0.500000", "expiresInDays": 30 }
+}
+```
+
+`amount` is in the asset's smallest unit: 2.5 USDC is `2500000`. The client
+signs an EIP-3009 `transferWithAuthorization` for that amount to `payTo`
+(the `@profullstack/x402-client` `pay()` call, or CoinPay Wallet in a
+browser) and retries with the proof:
+
+```
+GET /passes?payer=...&file=...&cap=2.000000&grant=1
+X-PAYMENT:
+PAYMENT-SIGNATURE:
+```
+
+The hub calls CoinPay `/api/x402/verify` with `expected: { amount, resource,
+payTo, asset }` from the entry it offered, then `/api/x402/settle`. On
+`settled: true` it returns `200` with the pass record as the body and
+`X-OpenSwarm-Pass: ` as a header. A replayed proof returns
+the pass it already bought, with `replayed: true` in a wrapper, and never a
+second pass.
+
+The `payer` key is in the resource URL on purpose. EIP-3009 cannot bind a
+resource; a proof for one resource can be presented for another from the
+same merchant at the same or lower price. The hub therefore records
+`nonce -> resource` at verify time and refuses a nonce for any other
+resource, which closes the gap for passes.
+
+### 3.3 Binding the payer
+
+The pass names an Ed25519 `payer`. The buyer proves control of it by signing
+the request: header `X-OpenSwarm-Payer: `. Without it the hub MUST refuse to
+issue; otherwise anyone who observed a proof could bind the pass to their
+own key.
+
+### 3.4 Direct key purchase
+
+A key service (`ipfile` §5.5, URL mode) MAY sell a grant without a hub: its
+402 offers `keyUsd` alone. That is a publisher running the x402-gateway
+pattern on its own key endpoint. It gets no per-byte income that way; it is
+the choice for a publisher who pays for distribution with free webseeds.
+
+## 4. Vouchers
+
+### 4.1 Record
+
+```json
+{
+ "openswarm": "0.1",
+ "type": "ippay.voucher",
+ "pass": "sha256:39655de63a29b02c90b236fa870fe6f8dfad1f52a8119fe4df85e88cd6ab02fd",
+ "payer": "ed25519:00bc9c01ca00938016e276fe5b5748cfe13fd02bc53cc651535682c8dd5c83be",
+ "payee": "ed25519:d2d05fcad07ecbee6ff87c95159ec9969fce6c0fb9f41ff78c2c07ebe8a06c94",
+ "file": "ed25519:0d87e09c7fea3ad6ba6c2f3e027ea47f5b245452899910948470906704c5295d",
+ "swarm": "sha256:4b74eb43677e4d03af5fb0856333f9aa21d9a5a3bbf944b13aa7eef379c7a342",
+ "seq": 3,
+ "bytes": 268435456,
+ "usd": "0.002500",
+ "createdAt": "2026-09-05T18:04:02.000Z",
+ "sigs": [
+ {
+ "alg": "ed25519",
+ "key": "ed25519:00bc9c01ca00938016e276fe5b5748cfe13fd02bc53cc651535682c8dd5c83be",
+ "sig": "RGRH7pDpXYcagULqj+8z05pLH99Lc6yLmfdbYgtGeRpjX5l0NcT3QSq9AWBtLw+5+JJiKoZ3s4ty74q/zBSE8w=="
+ }
+ ]
+}
+```
+
+| Field | Rule |
+| --- | --- |
+| `pass` | Record id of the pass. |
+| `payer` | MUST equal the pass's `payer`. The signature MUST be by this key. |
+| `payee` | The identity being paid. From the peer's `hello`. |
+| `file`, `swarm` | The file key and `infohashV2` the bytes belong to. |
+| `seq` | Starts at 1 per (pass, payee, swarm) and increases by one. |
+| `bytes` | Cumulative verified ciphertext bytes received from this payee on this swarm under this pass. Never decreases. |
+| `usd` | Cumulative value, computed per §4.2 from `bytes` and the manifest's `perGib`. |
+
+A voucher is cumulative, so only the latest one per (pass, payee, swarm)
+matters. A payee stores the latest and discards the rest. Redemption is one
+record per triple, however long the session was.
+
+### 4.2 Value
+
+```
+perGibMicro = perGib * 1_000_000 (exact: the string has 6 places)
+usdMicro = floor(bytes * perGibMicro / 2^30)
+usd = usdMicro formatted with 6 places
+```
+
+Integer arithmetic only. For `perGib = "0.010000"` and `bytes = 268435456`
+(256 MiB): `268435456 * 10000 / 1073741824 = 2500`, so `"0.002500"`. A
+payee MUST recompute `usd` and reject a voucher whose `usd` differs from the
+computed value, so a payer cannot understate by editing the number.
+
+### 4.3 Exchange
+
+On the swarm wire a voucher travels in the `ipfile` `voucher` message
+(`ipfile` §5.2) or the `iplive` `voucher` message. Over HTTP it travels as
+`X-OpenSwarm-Voucher: ` on the request that would push
+unpaid bytes past the interval (§7).
+
+### 4.4 Validation by the payee
+
+1. Signature by `payer`; `payer` equals the pass's payer.
+2. Pass signature by a hub in the manifest's `pricing.hubs`, pass not
+ expired, file in scope.
+3. `payee` is this peer. `swarm` is this swarm.
+4. `seq` is exactly previous `seq + 1` (or 1 for the first).
+5. `bytes` is at least previous `bytes` and at most bytes actually served.
+6. `usd` recomputes.
+
+A voucher failing 1 to 4 or 6 is rejected with `voucher-invalid`. One
+failing 5 by claiming too much is accepted at the served amount and the
+excess is reported to the hub as `overclaim`. One claiming less than
+previous is `voucher-stale`.
+
+## 5. The hub
+
+### 5.1 Hub record
+
+Published at `GET /.well-known/openswarm-hub.json`:
+
+```json
+{
+ "openswarm": "0.1",
+ "type": "ippay.hub",
+ "key": "ed25519:c9f17d4eaf2b28122b46d111cef6697d2c3f708a19d4628bce5c0304481b355b",
+ "name": "CoinPay",
+ "base": "https://coinpayportal.com/api/openswarm",
+ "networks": ["eip155:8453", "eip155:137", "eip155:1"],
+ "assets": {
+ "eip155:8453": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
+ "eip155:137": "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359",
+ "eip155:1": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
+ },
+ "minHubBps": 300,
+ "payout": { "minUsd": "1.000000", "schedule": "daily" },
+ "passDays": 30,
+ "createdAt": "2026-09-05T00:00:00.000Z",
+ "sigs": [
+ {
+ "alg": "ed25519",
+ "key": "ed25519:c9f17d4eaf2b28122b46d111cef6697d2c3f708a19d4628bce5c0304481b355b",
+ "sig": "RGRH7pDpXYcagULqj+8z05pLH99Lc6yLmfdbYgtGeRpjX5l0NcT3QSq9AWBtLw+5+JJiKoZ3s4ty74q/zBSE8w=="
+ }
+ ]
+}
+```
+
+The record is self-signed; trust in the hub key comes from the manifest that
+lists the hub's `base`, from TLS to that base, and optionally from an MTP/1
+pin. `minHubBps` is the least `split.hubBps` a manifest may set for this hub
+to sell passes for it.
+
+### 5.2 API
+
+All bodies are JSON. Records are verified on receipt.
+
+| Method and path | Auth | Purpose |
+| --- | --- | --- |
+| `GET /.well-known/openswarm-hub.json` | none | Hub record. |
+| `GET /passes?payer&file|publisher&cap&grant` | x402 + `X-OpenSwarm-Payer` | Buy a pass (§3.2). |
+| `GET /passes/` | none | `{ "pass", "spentUsd", "grantsUsed": [...], "status": "active|expired|revoked" }`. |
+| `POST /vouchers` | none | Body: one voucher or an array. Returns receipts (§5.3). |
+| `POST /grants` | none | Body: `{ "grant": , "manifest": }`. Keeper reports a grant. |
+| `POST /payees` | signed | Body: `{ "payee", "payTo": { "network", "address" }, "sig" }`, `sig` by the payee key over `"openswarm:payee:v1" || address`. |
+| `GET /payees/` | none | `{ "payTo", "balanceUsd", "paidOutUsd", "standing" }`. |
+| `GET /payers//standing` | none | `{ "passes", "overclaims", "capExceeded", "freeloads", "since" }`. |
+| `POST /reports` | signed | `{ "kind": "freeload", "payer", "swarm", "unpaidBytes", "reporter", "sig" }`. |
+| `POST /webhooks` | publisher signed | Register a CloudEvents endpoint for a publisher key. |
+
+### 5.3 Redemption and receipts
+
+For each voucher the hub:
+
+1. Validates §4.4 steps 1, 2 and 6 (it cannot check 3 to 5; those were the
+ payee's job, and the payee signed nothing, so an inflated voucher only
+ hurts the payer).
+2. Loads the latest accepted voucher for the triple. Accepts only the
+ increase in `usd` since it, so re-submitting an old voucher pays nothing.
+3. Applies the cap: `spentUsd + increase <= capUsd`. If not, accepts the
+ remainder up to the cap with status `partial`, or `cap-exceeded` if
+ nothing remains.
+4. Fetches the manifest for `file` (from `ipdb`, a gateway, or the DHT) and
+ applies `split`.
+5. Credits the payee, the publisher's `payTo`, and itself.
+6. Returns a receipt:
+
+```json
+{
+ "openswarm": "0.1",
+ "type": "ippay.receipt",
+ "voucher": "sha256:d6c3f8285b7871d6a400cba14408288a9acde679f12e1e7dc276f29ca7c493ff",
+ "pass": "sha256:39655de63a29b02c90b236fa870fe6f8dfad1f52a8119fe4df85e88cd6ab02fd",
+ "payee": "ed25519:d2d05fcad07ecbee6ff87c95159ec9969fce6c0fb9f41ff78c2c07ebe8a06c94",
+ "status": "accepted",
+ "accepted": { "bytes": 268435456, "usd": "0.002500" },
+ "split": { "publisherUsd": "0.001750", "payeeUsd": "0.000625", "hubUsd": "0.000125" },
+ "createdAt": "2026-09-05T18:10:00.000Z",
+ "sigs": [
+ {
+ "alg": "ed25519",
+ "key": "ed25519:c9f17d4eaf2b28122b46d111cef6697d2c3f708a19d4628bce5c0304481b355b",
+ "sig": "RGRH7pDpXYcagULqj+8z05pLH99Lc6yLmfdbYgtGeRpjX5l0NcT3QSq9AWBtLw+5+JJiKoZ3s4ty74q/zBSE8w=="
+ }
+ ]
+}
+```
+
+Split arithmetic is in micro-USD, integer, with the remainder after
+`floor` for each share going to the publisher. `2500 * 7000 / 10000 = 1750`,
+`2500 * 2500 / 10000 = 625`, `2500 * 500 / 10000 = 125`.
+
+### 5.4 Key grants
+
+`POST /grants` lets a keeper collect `keeperBps` of `keyUsd` and marks the
+grant on the pass as used. The hub verifies the grant signature is by the
+file key or a keeper in the manifest, that the pass listed the file in
+`grants`, and that no grant was recorded for that pass and file before. A
+second grant for the same pass and file is refused; the peer already holds
+the key.
+
+### 5.5 Standing
+
+A hub keeps, per payer: passes issued, overclaims, cap-exceeded redemptions,
+freeload reports. Per payee: vouchers redeemed, overclaims accepted against
+it. A seeder MAY consult `GET /payers//standing` before extending a
+window larger than the manifest minimum, and MUST NOT refuse a peer for
+standing alone when the manifest's `creditBytes` window is what it offers.
+Standing is advisory; the credit window is the guarantee.
+
+### 5.6 Payout
+
+The hub pays each payee's balance to its registered `payTo` in USDC on the
+payee's network when it reaches `payout.minUsd`, on `payout.schedule`. The
+publisher's share goes to the manifest's `pricing.payTo`. The hub keeps its
+share. A payout emits `com.logicsrc.openswarm.ippay.payout.sent.v1`.
+
+Where the hub is CoinPay: today's x402 rail pays a merchant's own wallet
+directly and collects no platform fee. A hub is different: it is the
+merchant for the pass sale, holds the balance, and pays out. This is a
+custodial position and is flagged as an open question in the PR.
+
+## 6. Payees
+
+### 6.1 Registering
+
+A seeder, relay, keeper or gateway that wants to be paid registers once:
+
+```json
+{
+ "payee": "ed25519:d2d05fcad07ecbee6ff87c95159ec9969fce6c0fb9f41ff78c2c07ebe8a06c94",
+ "payTo": { "network": "eip155:8453", "address": "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf" },
+ "sig": "RGRH7pDpXYcagULqj+8z05pLH99Lc6yLmfdbYgtGeRpjX5l0NcT3QSq9AWBtLw+5+JJiKoZ3s4ty74q/zBSE8w=="
+}
+```
+
+Vouchers redeemed before registration accumulate and pay out once an
+address exists. A c0mpute node registers its libp2p-adjacent OpenSwarm
+identity this way at first start (`c0mpute.md` §2).
+
+### 6.2 Relationship to the file key
+
+A publisher's payout address is in its signed manifest and can only be
+changed by a manifest revision, that is, by the file key. A payee's payout
+address is registered at the hub by the payee key. The two never mix: a
+publisher that also seeds its own file is paid twice, once as publisher and
+once as payee, to two addresses it may set to the same value.
+
+## 7. HTTP presentation
+
+A gateway or key service applies the same rules over HTTP:
+
+| Header | Direction | Meaning |
+| --- | --- | --- |
+| `Authorization: Bearer ` | request | The pass. |
+| `X-OpenSwarm-Voucher: ` | request | A voucher covering bytes received so far. |
+| `X-OpenSwarm-Credit: unpaid=; limit=; owed=` | response | Sent with every 206 so the client knows when to voucher. |
+| `402` with `X-OpenSwarm-Credit` and no x402 body | response | Window exhausted; send a voucher and retry. |
+
+The gateway's `payee` is its identity key, published in the hub record it
+registered with and returned in `X-OpenSwarm-Payee` on every response.
+
+## 8. Events
+
+| Type | Emitted by |
+| --- | --- |
+| `com.logicsrc.openswarm.ippay.pass.issued.v1` | hub |
+| `com.logicsrc.openswarm.ippay.voucher.redeemed.v1` | hub |
+| `com.logicsrc.openswarm.ippay.voucher.rejected.v1` | hub |
+| `com.logicsrc.openswarm.ippay.grant.recorded.v1` | hub |
+| `com.logicsrc.openswarm.ippay.payout.sent.v1` | hub |
+| `com.logicsrc.openswarm.ippay.report.filed.v1` | hub |
+
+Publishers subscribe with `POST /webhooks`. Delivery is per core §8.2.
+
+## 9. Security notes
+
+- **Hub compromise.** A hub key signs passes. A stolen key mints free
+ passes until the manifest's `hubs` list is revised. Hubs SHOULD keep the
+ signing key in an HSM and publish a successor key in the hub record before
+ rotating.
+- **Pass theft.** A pass is bearer for HTTP presentation but useless without
+ the payer's private key on the swarm, since vouchers must be signed by
+ `payer`. A gateway that accepts a pass without a voucher is serving one
+ window on trust, same as a seeder.
+- **Voucher replay.** Cumulative and per-triple; a replay pays nothing.
+- **Cap races.** Two seeders redeem at once past the cap: the hub serialises
+ per pass, so exactly one gets the remainder. Each seeder's exposure was one
+ window regardless.
+- **Payee address change.** Signed by the payee key. A hub SHOULD hold
+ payouts for 24 hours after a change and notify the previous address's
+ webhook.
+
+## 10. Implementations
+
+| Piece | Exists | Where | What is new |
+| --- | --- | --- | --- |
+| x402 v2 offer, `X-PAYMENT` proof, verify and settle, HMAC passes | Yes | `x402-gateway` (`src/x402.js`, `src/pass.js`), CoinPay `/api/x402/*` | The pass record replaces the HMAC token; scope, cap, grants |
+| Node payer with EIP-3009 signing | Yes | `x402-client` | Nothing; it can buy a pass unchanged |
+| Browser payer | Yes | CoinPay Wallet, `@profullstack/coinpay` `x402-browser.js` | Nothing |
+| Pay-per-pass grants with concurrency caps | Yes (central) | `media-streamer` IPTV and seedbox rails | The swarm version does not need the session table; the window is the cap |
+| Vouchers, receipts, split, payees, standing, payout batching | No | | All of it |
+
+## 11. Conformance
+
+A payee implementation validates vouchers per §4.4 and redeems per §5.2.
+A payer implementation computes `usd` per §4.2 and never signs for bytes it
+has not verified. A hub implements every row of §5.2, the receipt of §5.3,
+and the arithmetic of §4.2 and §5.3 exactly.
+
+## 12. Version history
+
+| Version | Date | Change |
+| --- | --- | --- |
+| 0.1 | 2026-09-05 | Initial draft. |
diff --git a/docs/openswarm/ipvideo.md b/docs/openswarm/ipvideo.md
new file mode 100644
index 0000000..2997f27
--- /dev/null
+++ b/docs/openswarm/ipvideo.md
@@ -0,0 +1,271 @@
+# ipvideo: Video on Demand on ipfile Swarms
+
+Version: **0.1** (draft)
+Status: draft. Extends the OpenSwarm core ([spec.md](./spec.md)) and rides
+on [ipfile](./ipfile.md), [ippay](./ippay.md) and [ipdb](./ipdb.md).
+Nothing below is implemented.
+
+## 1. Scope
+
+`ipvideo` defines the records that make a set of `ipfile` swarms playable
+as a film, an episode or a clip:
+
+1. A **title**: renditions (CMAF fragmented MP4, one `ipfile` each), a
+ segment index, subtitles, thumbnails, chapters, artwork.
+2. **Playback rules**: segment-aligned adaptive bitrate over pieces, MSE in
+ the browser.
+3. A **gateway bridge** that renders HLS for players that speak nothing
+ else.
+4. **Transcode on c0mpute**: how the renditions get made.
+
+Bytes, keys, payment and discovery are `ipfile` and `ippay`. No new wire
+messages.
+
+### Non-goals
+
+Live (that is `iplive`). Interactive video. Ad insertion (c0mpute's DAI
+DIPs may reference a title; this document does not). DRM in the device
+sense.
+
+## 2. Terminology
+
+| Term | Meaning |
+| --- | --- |
+| **rendition** | One encoding ladder step, stored as one `ipfile` in CMAF form. |
+| **segment** | A fragmented MP4 `moof + mdat` pair, starting with a keyframe. |
+| **init segment** | The `ftyp + moov` bytes a decoder needs before any segment. |
+| **segment index** | The list of `{ t, d, o, l }` for every segment of a rendition. |
+| **ladder** | The set of renditions of a title, aligned on segment boundaries. |
+
+## 3. Data model
+
+### 3.1 Title
+
+```json
+{
+ "openswarm": "0.1",
+ "type": "ipvideo.title",
+ "publisher": "ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e",
+ "title": "The Interview",
+ "kind": "movie",
+ "series": null,
+ "season": null,
+ "episode": null,
+ "year": 2026,
+ "durationMs": 5400000,
+ "language": "en",
+ "artwork": {
+ "poster": "ed25519:d2d05fcad07ecbee6ff87c95159ec9969fce6c0fb9f41ff78c2c07ebe8a06c94",
+ "backdrop": null
+ },
+ "renditions": [
+ {
+ "id": "1080p",
+ "container": "cmaf",
+ "codecs": "avc1.640028,mp4a.40.2",
+ "width": 1920,
+ "height": 1080,
+ "frameRate": 24,
+ "bitrateKbps": 5000,
+ "file": "ed25519:0d87e09c7fea3ad6ba6c2f3e027ea47f5b245452899910948470906704c5295d",
+ "size": 3375000000,
+ "init": { "o": 0, "l": 1524 },
+ "segmentsFile": "ed25519:00bc9c01ca00938016e276fe5b5748cfe13fd02bc53cc651535682c8dd5c83be"
+ },
+ {
+ "id": "480p",
+ "container": "cmaf",
+ "codecs": "avc1.64001e,mp4a.40.2",
+ "width": 854,
+ "height": 480,
+ "frameRate": 24,
+ "bitrateKbps": 1400,
+ "file": "ed25519:d2d05fcad07ecbee6ff87c95159ec9969fce6c0fb9f41ff78c2c07ebe8a06c94",
+ "size": 945000000,
+ "init": { "o": 0, "l": 1498 },
+ "segments": [
+ { "t": 0, "d": 4000, "o": 1498, "l": 702113 },
+ { "t": 4000, "d": 4000, "o": 703611, "l": 698402 }
+ ]
+ }
+ ],
+ "audio": [
+ { "id": "en", "language": "en", "label": "English", "channels": 2, "default": true }
+ ],
+ "subtitles": [
+ { "id": "en-cc", "language": "en", "kind": "captions", "format": "vtt", "file": "ed25519:c9f17d4eaf2b28122b46d111cef6697d2c3f708a19d4628bce5c0304481b355b" }
+ ],
+ "thumbnails": {
+ "file": "ed25519:4b74eb43677e4d03af5fb0856333f9aa21d9a5a3bbf944b13aa7eef379c7a342",
+ "vtt": "ed25519:ba8cd1a97b865a05cdc5840c63b086dd463624a831874c1ba0d7c4ce6f806f7a",
+ "intervalMs": 10000
+ },
+ "chapters": [
+ { "t": 0, "title": "Opening" },
+ { "t": 1830000, "title": "The question" }
+ ],
+ "royalties": [],
+ "createdAt": "2026-09-05T18:20:00.000Z",
+ "sigs": [
+ {
+ "alg": "ed25519",
+ "key": "ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e",
+ "sig": "RGRH7pDpXYcagULqj+8z05pLH99Lc6yLmfdbYgtGeRpjX5l0NcT3QSq9AWBtLw+5+JJiKoZ3s4ty74q/zBSE8w=="
+ }
+ ]
+}
+```
+
+(The `480p` segment list is truncated to two entries; a real one has one
+entry per segment.)
+
+| Field | Rule |
+| --- | --- |
+| `kind` | `movie`, `episode`, `clip`, `trailer`. `series`, `season`, `episode` are set for `episode`. |
+| `renditions[].container` | `cmaf` in this version: fragmented MP4 per ISO 23000-19, one segment per `moof`. |
+| `renditions[].codecs` | RFC 6381 string as MSE `isTypeSupported` wants it. |
+| `renditions[].file` | The `ipfile` manifest's file key. `size` MUST match. |
+| `renditions[].init` | Byte range of the init segment within the plaintext. |
+| `renditions[].segments` | Inline index, or `segmentsFile`: file key of a free `ipfile` (`keys.public` set) whose plaintext is the JSON array. Inline when under 64 KiB, file otherwise. |
+| `segments[].t`, `d` | Presentation start and duration in ms. `o`, `l`: byte offset and length in the plaintext. |
+| `audio` | Audio tracks muxed in the renditions. Alternate-language audio is a rendition with `codecs` of audio only and `video: false`. |
+| `subtitles[].format` | `vtt` in this version. Each is a free `ipfile` unless the publisher prices it. |
+| `thumbnails` | A sprite sheet `ipfile` and a WebVTT file mapping times to sprite regions, as hls.js and video.js expect. |
+| `royalties` | As `ipaudio` §5. Empty means the whole publisher share goes to `payTo`. |
+
+Every rendition MUST share the same `t` list. Every segment MUST start with
+a keyframe. Segment duration SHOULD be 4000 ms, matching c0mpute's
+live-stream DIP (`-hls_time 4`) so a recorded live stream and a VOD title
+use one ladder.
+
+### 3.2 Where records live
+
+`ipdb` under `title:` in the publisher's feed. Renditions, subtitles
+and thumbnails are `ipfile` manifests in the same feed with
+`meta.title = `.
+
+## 4. Playback
+
+### 4.1 Segment to pieces
+
+To play segment `s` of rendition `r`:
+
+```
+first piece = floor(s.o / pieceLength)
+last piece = floor((s.o + s.l - 1) / pieceLength)
+```
+
+Request those pieces; decrypt; verify against the plaintext piece layer;
+slice `[s.o, s.o + s.l)`; append to the MSE `SourceBuffer` after the init
+segment. With 1 MiB pieces and a 5 Mbps rendition a 4 s segment is about
+2.5 MiB, so two to three pieces.
+
+### 4.2 Adaptive bitrate
+
+Switch at segment boundaries only. The next segment is fetched from the
+rendition whose `bitrateKbps` is the highest below 0.8 times the measured
+piece throughput over the last 10 seconds, with a two-segment hysteresis
+before stepping up. Because `t` lists are shared, the MSE buffer stays
+contiguous across a switch; a new init segment is appended before the
+first segment of the new rendition, as in any CMAF player.
+
+### 4.3 Buffering and priority
+
+Hold 30 seconds ahead. Request the pieces for the next segment with
+sequential priority (what media-streamer's `torrent.select` does for range
+streaming today). Seeking to time `t`: find the segment with the largest
+`t <= target`, drop outstanding requests, start there. A player MUST keep
+sending vouchers for bytes received while seeking, including bytes it then
+discards; discarded bytes were still served.
+
+### 4.4 Browser
+
+A browser peer is a WebTorrent client with the `ipfile` extension, a
+WebCrypto AES-CTR decryptor, and MSE. This is bittorrented.com's player
+with three additions and no server in the path. Where MSE is unavailable
+(some iOS contexts), the gateway bridge in §5 applies.
+
+## 5. Gateway bridge
+
+A keeper gateway renders HLS from the title record:
+
+```
+GET /video//master.m3u8
+GET /video///index.m3u8
+GET /video///data (plaintext, Range)
+Authorization: Bearer
+```
+
+The media playlist uses `#EXT-X-MAP` for the init range and
+`#EXT-X-BYTERANGE` for every segment against the single `data` URL, so the
+gateway serves plaintext by range from decrypted pieces and never has to
+split files. Subtitles become `#EXT-X-MEDIA` entries pointing at
+`/file//data`. Credit and voucher headers are as `ippay` §7; a
+player that cannot send vouchers (a bare `` tag) is limited to the
+pass's first credit window unless the gateway is configured to trust the
+pass for the title's full size.
+
+HLS's own encryption (`EXT-X-KEY` with AES-128-CBC) is not used. The
+gateway is a keeper; it decrypts. A publisher who does not want any
+gateway holding a key does not delegate to one, and browser MSE is the
+only path.
+
+## 6. Transcode on c0mpute
+
+`ip video publish --ladder default` submits one c0mpute job per
+rendition. The existing `ffmpeg.transcode` workload takes
+`TranscodeSpec { codec, bitrate_bps, width, height, keyframe_interval,
+hardware_pref, extra_ffmpeg_args }`; `ipvideo` adds `extra_ffmpeg_args`
+that force CMAF output and a 4 s keyframe interval, and expects the result
+to include the segment index. The proposed `ipvideo.transcode` workload
+(`c0mpute.md` §3) is that: a wrapper whose `TranscodeResult` carries
+`segments`, `init`, `codecs` and gapless-irrelevant fields, and whose
+output is handed to `ip file add` on the worker so the rendition is seeded
+from where it was made.
+
+Default ladder:
+
+| id | codecs | size | bitrate |
+| --- | --- | --- | --- |
+| `1080p` | `avc1.640028,mp4a.40.2` | 1920x1080 | 5000 kbps |
+| `720p` | `avc1.64001f,mp4a.40.2` | 1280x720 | 2800 kbps |
+| `480p` | `avc1.64001e,mp4a.40.2` | 854x480 | 1400 kbps |
+| `360p` | `avc1.64001e,mp4a.40.2` | 640x360 | 800 kbps |
+| `1080p-av1` | `av01.0.08M.08,opus` | 1920x1080 | 3000 kbps, optional |
+
+## 7. Security and privacy
+
+As `ipfile`. A segment index reveals segment sizes, which for a
+constant-quality encode leak scene complexity over time; a publisher who
+cares uses constant bitrate. Subtitle files are small free swarms and
+reveal the dialogue to anyone; price them if that matters.
+
+## 8. Events
+
+| Type | Emitted by |
+| --- | --- |
+| `com.logicsrc.openswarm.ipvideo.title.published.v1` | publisher |
+| `com.logicsrc.openswarm.ipvideo.transcode.completed.v1` | c0mpute worker |
+
+## 9. Implementations
+
+| Piece | Exists | Where | What is new |
+| --- | --- | --- | --- |
+| Browser WebTorrent player, MSE-compatible fMP4 output, HLS fallback, range seeking | Yes | `media-streamer` (`media-player-modal.tsx`, `transcoding.ts`, `/api/stream/hls`) | Piece decryption before MSE; the title record instead of `vod_titles` |
+| Hardware ffmpeg transcode job with codec ladder | Yes | `c0mpute` (`c0mpute-transcode`, `TranscodeSpec`) | CMAF output flags and the segment index in the result |
+| VOD catalogue with per-title price and pass window | Yes (central) | `media-streamer` `vod_titles`, `vod_providers` | The title record and `ipfile` pricing replace both tables |
+| Thumbnail VTT, chapters, subtitle tracks | Partly | hls.js and video.js conventions | The record fields |
+| Title record, segment index file, HLS bridge from records | No | | All of it |
+
+## 10. Conformance
+
+A player fetches by segment per §4.1, switches at boundaries per §4.2,
+and vouchers for discarded bytes per §4.3. A publisher emits aligned
+ladders with keyframe-aligned segments. A gateway implementing §5 renders
+byte-range HLS and applies `ippay` §7.
+
+## 11. Version history
+
+| Version | Date | Change |
+| --- | --- | --- |
+| 0.1 | 2026-09-05 | Initial draft. |
diff --git a/docs/openswarm/security.md b/docs/openswarm/security.md
new file mode 100644
index 0000000..697c8cd
--- /dev/null
+++ b/docs/openswarm/security.md
@@ -0,0 +1,133 @@
+# OpenSwarm Security Model
+
+Version: **0.1** (draft)
+
+This document collects the security claims the member documents make and
+states plainly what the family does not protect.
+
+## 1. What is protected, by what
+
+| Asset | Protection | Where |
+| --- | --- | --- |
+| File contents | AES-256-CTR under a random content key; integrity from two SHA-256 merkle trees pinned by a signed manifest | ipfile §4 |
+| Content key in transit | X25519 sealed box to the recipient's box key | ipfile §3.2 |
+| Content key at rest for the owner | Sealed to the file box key; recoverable from seed plus manifest | core §4.3 |
+| Manifest authenticity | Ed25519 by the file key and the publisher key, with a domain prefix | core §3.4 |
+| Latest-manifest pointer | BEP 44 signature by the file key, `seq` monotonic | core §7.2 |
+| Catalogue integrity | Per-entry signature plus hash chain; head on the DHT | ipdb §3 |
+| Passes | Ed25519 by the hub; payer bound by a request signature | ippay §3 |
+| Vouchers | Ed25519 by the payer; cumulative; `usd` recomputed by the payee | ippay §4 |
+| Live segments | Same cipher; root in a signed head per unit; relays verify before forwarding | iplive §3 |
+| Name to key | Registry pin (SHA-256 of SPKI) or DNS TXT, plus a signed record | ipname §4 |
+| Peer identity on a connection | `hello` signature over infohash and both peer ids | ipfile §5.1 |
+| Link confidentiality (optional) | MTP/1: X25519 + ML-KEM-768, ML-DSA-65, ChaCha20-Poly1305 | core §6.5 |
+| Events | Standard Webhooks HMAC-SHA256 with timestamp tolerance | core §8 |
+
+## 2. Key material
+
+| Key | Type | Lives | Backs up as |
+| --- | --- | --- | --- |
+| Publisher seed | 32 bytes | Keystore or OpenCreds `key` item | The one thing to back up |
+| File seed (derived) | HKDF from publisher seed and `fileId` | Never stored | Recomputed |
+| File seed (standalone) | 32 random bytes | Keystore | Its own OpenCreds item |
+| Content key | 32 random bytes | Keystore, `keys.owner`, keepers, grantees | `keys.owner` |
+| Box keys | X25519 from the seeds | Derived | Recomputed |
+| Hub signing key | Ed25519 | Hub HSM | Hub's problem |
+| Payer EVM key | secp256k1 | `X402_PRIVATE_KEY`, wallet | Wallet |
+| Node identity | libp2p Ed25519 | `~/.config/c0mpute/identity.key` | c0mpute's problem |
+
+## 3. Threats and answers
+
+**A public tracker or DHT node wants to know what a swarm is.** It gets an
+infohash, a size, a piece length, a peer count, and a file named by a hex
+string. The manifest is not on the DHT; the pointer to it is signed and
+opaque. With `swarm.private` it gets nothing from the DHT at all.
+
+**A peer wants bytes without paying.** It gets one credit window per
+identity per session, then a choke. With `policy.vanilla: "ciphertext"` it
+can get all the ciphertext free, by design, and cannot read it.
+
+**A seeder wants to be paid for bytes it did not serve.** It cannot; the
+voucher is signed by the payer for bytes the payer verified. Its only lever
+is to stop serving after being paid for what it already sent.
+
+**A payer signs vouchers past its cap to many seeders.** Each seeder was
+exposed for one window. The hub pays in redemption order until the cap
+and refuses the rest; the payer's standing records it and future passes
+are refused.
+
+**A payer or seeder replays a voucher.** It is cumulative and per triple;
+replay pays zero.
+
+**A hub key is stolen.** Free passes until manifests drop the hub. The hub
+record SHOULD carry a successor key and publishers SHOULD list two hubs.
+
+**A hub does not pay out.** It is a trusted processor; the answer is to
+list a different hub. Receipts are signed, so non-payment is provable.
+
+**The publisher seed is stolen.** Every derived file key is compromised:
+the thief can revise manifests, change payout addresses and delegate
+keepers. Detection is a manifest revision the owner did not make, visible
+in the `ipdb` log. Remedy is publisher rotation (core §4.5) from a machine
+the thief does not control; `successor` links require the new key too, so
+the thief cannot forge the rotation.
+
+**The publisher seed is lost.** Core §4.7. Nothing is unreadable; nothing
+can be changed.
+
+**A grantee leaks the content key.** Anyone with the key and the ciphertext
+reads the file. The family prices access and does not prevent copying. A
+leaked key is answered by re-encryption, which protects only future
+readers. This is the same position every streaming service is in once the
+frame is on a screen, stated rather than hidden behind a DRM promise.
+
+**A relay forwards a bad segment.** Every downstream peer verifies the root
+against a signed head and drops the relay.
+
+**A Sybil of relays surrounds an origin.** They pull, earn nothing (nobody
+pays them), and cost the origin upload up to `maxDownstream`. Pins and
+hired relays are how an origin buys reach it controls.
+
+**A registry hands out a wrong pin.** The record fails the pin check;
+first-seen pinning in the resolver warns on change.
+
+**An EIP-3009 proof is presented for a different resource.** Known
+limitation of x402 v2: the struct cannot bind a resource. The hub records
+nonce to resource at verify time and refuses reuse (ippay §3.2).
+
+**A gateway is asked to serve plaintext to a bare `` tag.** It serves
+one credit window against the pass and then 402s; a gateway configured to
+trust the pass for the whole title is taking the whole title as its
+exposure, and the configuration says so.
+
+## 4. What is deliberately visible
+
+- File size, piece length, both infohashes, tracker list, peer count.
+- Manifest `name` and `meta`, to anyone who obtains the manifest (public
+ visibility) or any pass holder (pass visibility).
+- The existence and sequence length of a feed; the content of a public
+ feed.
+- Segment sizes and timing of a live stream, to relays.
+- Who paid whom how much, to the hub.
+- The publisher's payout address, in the signed manifest.
+
+## 5. Defaults an implementation MUST ship
+
+| Setting | Default | Reason |
+| --- | --- | --- |
+| `policy.creditBytes` | 4 MiB (`ipfile`), 8 MiB (`iplive`) | Bounded exposure, no choke at the live edge |
+| `policy.voucherBytes` | 1 MiB, 2 MiB | Four vouchers per window |
+| `policy.vanilla` | `ciphertext` | Wider swarm at zero confidentiality cost |
+| `visibility` | `public` | A catalogue that cannot be read is not a catalogue |
+| Pass lifetime | 30 days | Long enough to stream a release, short enough to bound a stolen key |
+| Key epoch (`iplive`) | 900 segments | 30 minutes at 2 s |
+| Resolver cache | 300 s positive, 5 s negative | Matches moshpit-proxy |
+| Keystore mode | 0600 | As every other Profullstack keystore |
+
+## 6. Cryptographic agility
+
+`cipher.alg`, `sigs[].alg`, `kdf` labels with `:v1` suffixes, and
+extension names are all versioned data. A new cipher is a new `alg` value
+and a new IV label; a new signature is a new `alg`; a breaking wire change
+is a new extension name. Nothing is edited in place, for the reason
+OpenCreds gives: peers that shipped the old value are still running.
diff --git a/docs/openswarm/spec.md b/docs/openswarm/spec.md
new file mode 100644
index 0000000..1a89cf7
--- /dev/null
+++ b/docs/openswarm/spec.md
@@ -0,0 +1,510 @@
+# The OpenSwarm Core Specification
+
+Version: **0.1** (draft)
+Status: draft. Nothing below is implemented yet. Wire formats are expected to
+change before 1.0, and this document says where.
+
+The key words MUST, MUST NOT, REQUIRED, SHOULD, SHOULD NOT and MAY are to be
+interpreted as described in RFC 2119.
+
+## 1. Scope
+
+The core defines what every member protocol shares:
+
+1. **Records** (§3): the JSON shape, canonicalisation, identifiers and
+ signatures of every object in the family.
+2. **Keys** (§4): publisher identities, derived per-file keys, box keys, and
+ the rules for reuse, rotation and loss.
+3. **Hashing and chunking** (§5): what a content identifier is and why it is
+ a BitTorrent v2 merkle root.
+4. **Transports** (§6): the BitTorrent wire, the extension protocol, browser
+ peers, webseeds, and the optional Moshpit tunnel.
+5. **Discovery** (§7): DHT, trackers, Moshpit names.
+6. **Events** (§8): the CloudEvents envelope and webhook signing.
+7. **Versioning and conformance** (§9, §10).
+
+Member protocols (`ipfile`, `ippay`, `ipdb`, `ipaudio`, `ipvideo`, `iplive`,
+`ipname`) add record types and extension messages. They MUST NOT redefine
+anything in this document.
+
+## 2. Terminology
+
+| Term | Meaning |
+| --- | --- |
+| **record** | A signed JSON object with an `openswarm` version and a `type`. |
+| **record id** | `sha256:` plus the hex SHA-256 of the record's canonical bytes without `sigs`. |
+| **publisher** | The Ed25519 identity that owns files and feeds. |
+| **file key** | A per-file Ed25519 pair derived from the publisher key. Its public half is the file's identity. |
+| **box key** | An X25519 pair used to receive sealed content keys. Every identity has one. |
+| **content key** | 32 random bytes that encrypt one file version's pieces. |
+| **swarm** | One BitTorrent swarm carrying one ciphertext payload. |
+| **piece** | A BEP 52 piece: a power-of-two length, at least 16 KiB. |
+| **block** | 16 KiB, the unit of a BitTorrent `request` and of a v2 merkle leaf. |
+| **peer** | Any participant in a swarm. A **seeder** has all pieces, a **leecher** wants some, a **relay** forwards live segments. |
+| **gateway** | An HTTP server that serves ciphertext by range (a BEP 19 webseed) and answers catalogue queries. |
+| **hub** | The settlement service that sells passes and redeems vouchers. See `ippay`. |
+| **pass** | A hub-signed record that lets a peer spend up to a cap on named swarms. |
+| **voucher** | A payer-signed cumulative IOU for bytes received from one payee. |
+| **grant** | A record carrying a content key sealed to one recipient's box key. |
+| **feed** | An `ipdb` log owned by one key. |
+
+## 3. Records
+
+### 3.1 Shape
+
+Every object in the family is a record:
+
+```json
+{
+ "openswarm": "0.1",
+ "type": "ipfile.manifest",
+ "createdAt": "2026-09-05T18:00:00.000Z",
+ "sigs": [
+ {
+ "alg": "ed25519",
+ "key": "ed25519:0d87e09c7fea3ad6ba6c2f3e027ea47f5b245452899910948470906704c5295d",
+ "sig": "RGRH7pDpXYcagULqj+8z05pLH99Lc6yLmfdbYgtGeRpjX5l0NcT3QSq9AWBtLw+5+JJiKoZ3s4ty74q/zBSE8w=="
+ }
+ ]
+}
+```
+
+| Field | Type | Rule |
+| --- | --- | --- |
+| `openswarm` | string | Family version the record was written under. `"0.1"`. |
+| `type` | string | `.`, lower case. Registered types are listed by each member document. |
+| `createdAt` | string | RFC 3339, UTC, millisecond precision. |
+| `sigs` | array | One or more signatures (§3.4). MAY be empty only for record types a member document marks as unsigned. |
+
+Every other field is defined by the member document for that `type`. An
+implementation reading an unknown field MUST preserve it on round trip.
+
+### 3.2 Canonical form
+
+The canonical bytes of a record are the RFC 8785 (JSON Canonicalization
+Scheme) serialisation of the record with the `sigs` member removed. JCS
+sorts keys, strips whitespace and fixes number formatting. Amounts and hashes
+are strings in this family precisely so that JCS never has to touch a float.
+
+### 3.3 Record id
+
+```
+id = "sha256:" + lowercase_hex(SHA-256(canonical bytes))
+```
+
+The id is never stored inside the record. A reader computes it. Two records
+with the same canonical bytes are the same record, which is what makes `ipdb`
+replication idempotent.
+
+### 3.4 Signatures
+
+A signature entry is `{ "alg", "key", "sig" }`. The signed message is:
+
+```
+"openswarm:sig:v1:" + type + "\n" + canonical bytes
+```
+
+The prefix is domain separation. A signature over an `ipfile.manifest` can
+not be replayed as a signature over an `ippay.voucher` with the same body.
+
+| `alg` | `key` prefix | Signature | Status |
+| --- | --- | --- | --- |
+| `ed25519` | `ed25519:` | 64 bytes, base64 | REQUIRED |
+| `mldsa65` | `mldsa65:` | 3309 bytes, base64 | OPTIONAL, for post-quantum ownership |
+
+A record MUST carry at least one `ed25519` signature by the key the member
+document names as its signer. It MAY carry an `mldsa65` signature by a key
+the same identity has bound (§4.6). A verifier that does not implement
+`mldsa65` MUST ignore those entries and MUST NOT treat their presence as an
+error.
+
+Key strings are `:`. Ed25519 and X25519 public keys are 32 bytes,
+so 64 hex characters. ML-DSA-65 public keys are 1952 bytes.
+
+### 3.5 Amounts and times
+
+Money is a decimal string with exactly six fractional digits, in USD:
+`"0.002500"`. Six digits is USDC's precision and is what CoinPay's x402
+dialect already carries. Integer arithmetic is specified where rounding
+matters (`ippay` §4). Times are RFC 3339 UTC. Byte counts are JSON integers
+and MUST stay below 2^53.
+
+## 4. Keys
+
+### 4.1 Publisher identity
+
+A publisher identity is a 32-byte seed. From it:
+
+```
+publisher signing key = Ed25519 keypair from seed
+publisher box key = X25519 keypair from HKDF-SHA256(seed, salt = "", info = "openswarm:box:v1")
+```
+
+The identity string is `ed25519:`. The box
+public key is published in the records that need it (§4.4).
+
+`ip init` creates one seed. It is reused for every file, feed and channel the
+publisher adds. The seed SHOULD be stored as an OpenCreds `key` item with
+`keyType: "openswarm-seed"`; it is the one thing in this family that cannot
+be regenerated.
+
+### 4.2 Per-file keys are derived, and reusable by default
+
+Adding a file mints a key pair for that file. By default the pair is derived,
+not random:
+
+```
+fileId = 16 random bytes, recorded in the manifest as hex
+fileSeed = HKDF-SHA256(ikm = publisher seed, salt = fileId, info = "openswarm:file:v1")
+file key = Ed25519 keypair from fileSeed
+file box = X25519 keypair from HKDF-SHA256(fileSeed, salt = "", info = "openswarm:box:v1")
+```
+
+Consequences, all intended:
+
+- Holding the publisher seed and any manifest (which carries `fileId`) is
+ enough to recover that file's private key. There is no key database to back
+ up beyond the seed.
+- Two files never share a key. Compromise of one file seed does not expose
+ another, because HKDF output is one-way from the seed.
+- The public half is stable across revisions of the file. A re-encode keeps
+ the same `fileId`, so it keeps the same identity and the same BEP 46
+ pointer (§7.2).
+
+`ip file add --standalone-key` mints a random `fileSeed` instead. The manifest
+records `"keyDerivation": "standalone"` so tooling knows the seed must be
+backed up on its own. Use it for a file whose ownership will be transferred:
+handing over a standalone seed transfers exactly one file.
+
+### 4.3 What each half does
+
+| Half | Used for |
+| --- | --- |
+| File public key | The file's identity in every reference (`"file": "ed25519:..."`), the BEP 44 key under which the latest manifest is announced, the key peers verify manifests against. |
+| File private key | Signs every manifest revision. Signs `ipfile.grant` records that delegate the content key to a keeper. Signs payout changes: the `pricing.payTo` a hub pays a publisher's share to is whatever the latest signed manifest says. |
+| File box key | Receives the owner's own sealed copy of the content key (`keys.owner` in the manifest), so the content key is recoverable from the manifest plus the seed. |
+| Content key | Encrypts the pieces. Random, 32 bytes, per file version. Never signs anything. |
+
+The file key is not a wallet. It authorises where money goes; the money goes
+to an EVM address the manifest names. A seeder's payout address is likewise
+its own, registered at the hub against the seeder's identity key (`ippay`
+§6). Nothing in this family derives a wallet from a signing key.
+
+### 4.4 Box keys and sealing
+
+Any content key that travels does so inside a sealed box:
+
+```
+sealed = X25519 anonymous sealed box (libsodium crypto_box_seal) to the recipient's box public key
+```
+
+This is the construction the OpenCreds `team` profile already uses to wrap a
+vault key to a member, and it is chosen for the same reason: a seeder can hand
+a key to a peer it has never met, given only that peer's box public key from
+the `hello` message. When the link is already an MTP/1 session (§6.5) the
+box is still applied; the transport protects the link, the box protects the
+key at rest in a `grant` record.
+
+### 4.5 Rotation
+
+**Publisher rotation.** Mint a new seed. For each file, publish a manifest
+revision signed by the old file key (derived from the old seed) that names
+the new `publisher`, and that carries a new `fileId` and a new file key
+derived from the new seed in `successor`. Readers that already hold the old
+file key follow `successor` once and re-pin. The old seed is kept only to
+derive old file keys during the transition.
+
+**File rotation.** Same mechanism for one file: a final revision under the
+old file key with `successor` set. A reader MUST NOT follow a `successor`
+chain more than 8 deep, and MUST refuse a successor whose own latest manifest
+does not name the predecessor in `predecessor`. Both links are required so a
+compromised old key cannot redirect a file to an attacker's swarm; the
+attacker would also need to sign as the new key.
+
+**Content key rotation.** Re-encrypt under a fresh content key and publish a
+new revision with a new `cipherRoot` and new swarm. Old grants stop working
+for the new swarm. Rotation is the only way to revoke a key that has been
+delivered; a delivered key is held by the peer, and the spec does not pretend
+otherwise.
+
+### 4.6 Post-quantum binding
+
+An identity MAY bind an ML-DSA-65 key by publishing an `openswarm.binding`
+record signed by both keys:
+
+```json
+{
+ "openswarm": "0.1",
+ "type": "openswarm.binding",
+ "subject": "ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e",
+ "pq": "mldsa65:0f3a51c0a3e6d1e2f4c99b3d3b6a2c5a9f1e8d7c6b5a4f3e2d1c0b9a8f7e6d5c",
+ "createdAt": "2026-09-05T18:00:00.000Z",
+ "sigs": [
+ {
+ "alg": "ed25519",
+ "key": "ed25519:5d292428e8a68946e5225136c8b10e8f33ab78a45e1663d0730996dd2b63d59e",
+ "sig": "RGRH7pDpXYcagULqj+8z05pLH99Lc6yLmfdbYgtGeRpjX5l0NcT3QSq9AWBtLw+5+JJiKoZ3s4ty74q/zBSE8w=="
+ }
+ ]
+}
+```
+
+(`pq` is truncated above for readability; a real value is 1952 bytes of
+hex, and the record carries a second entry with `"alg": "mldsa65"` whose
+3309-byte signature is omitted here for the same reason.)
+This matches the Moshpit registry's `mtp` pin kind, which is an ML-DSA-65
+identity. A verifier that trusts the binding accepts an `mldsa65` signature
+from `pq` as a signature by `subject`.
+
+### 4.7 Loss
+
+If the publisher seed is lost and no standalone seeds exist:
+
+- Every file's ciphertext stays servable. Seeders hold pieces, not keys.
+- Every grant already delivered keeps working.
+- No manifest can be revised: prices, payout address, trackers and
+ `successor` are frozen at the last signed revision.
+- Payouts continue to the frozen `pricing.payTo`.
+- No new keeper can be delegated. Existing keepers keep granting.
+
+That is the failure mode, stated so that it can be planned for. Store the
+seed in a vault.
+
+## 5. Hashing and chunking
+
+### 5.1 The content identifier
+
+The identifier of a file's bytes is its **BEP 52 pieces root**: the SHA-256
+merkle root over 16 KiB leaves, padded per BEP 52. It is written
+`sha256:<64 hex>`.
+
+This is chosen over a flat SHA-256 or an IPFS CID because a swarm peer can
+verify a single 16 KiB block against it with a merkle proof, which is what
+lets a leecher sign a voucher the moment a block arrives. A flat hash would
+make every payment wait for the whole file. A CID would be a second tree that
+no BitTorrent client checks.
+
+An `ipfile` manifest carries two roots: `plainRoot` over the plaintext and
+`cipherRoot` over the ciphertext. The swarm verifies against `cipherRoot`
+through the torrent's own hashes; the client verifies its decrypted output
+against `plainRoot`.
+
+c0mpute's chunk store addresses objects by blake3 (`c0mpute://blake3:`).
+That stays internal to the node. A c0mpute storage node maps piece index to
+blake3 chunk however it likes; what it puts on the wire is verified by SHA-256.
+
+### 5.2 Piece length
+
+A piece length MUST be a power of two and at least 16 KiB (BEP 52). Members
+recommend defaults: `ipfile` 1 MiB for files over 64 MiB and 256 KiB below;
+`ipaudio` 256 KiB; `ipvideo` 1 MiB; `iplive` one segment per piece.
+
+### 5.3 Hybrid torrents
+
+A swarm's info dictionary MUST be a hybrid v1/v2 torrent per BEP 52: it
+carries both `pieces` (SHA-1, v1) and `file tree` with `piece layers`
+(SHA-256, v2). Two infohashes result:
+
+- `infohashV1`: SHA-1 of the info dictionary, 20 bytes. This is what
+ WebTorrent in a browser uses, and what today's trackers and the bitmagnet
+ crawl behind bittorrented.com/dht record.
+- `infohashV2`: SHA-256 of the info dictionary, truncated to 20 bytes for
+ DHT and tracker use, full 32 bytes in records.
+
+The info dictionary MUST NOT contain plaintext file names. `name` is the
+ciphertext root in hex. A crawler that fetches metadata over BEP 9 learns a
+size and a hash.
+
+## 6. Transports
+
+### 6.1 The wire
+
+Peer connections are BitTorrent (BEP 3) over TCP, uTP, or WebRTC data
+channels (the WebTorrent dialect). Every standard message keeps its meaning.
+OpenSwarm never changes `choke`, `unchoke`, `have`, `request`, `piece` or the
+bitfield. That is what makes a vanilla client a valid, if unpaid, participant.
+
+### 6.2 Extension messages
+
+All family messages ride on the extension protocol (BEP 10). The extension
+handshake advertises:
+
+```
+{ "m": { "ipfile": 20, "ipdb": 21, "iplive": 22, "ut_metadata": 3, "ut_pex": 1 },
+ "v": "ip/0.1",
+ "openswarm": "0.1" }
+```
+
+The numbers are local ids chosen by the sender, as BEP 10 requires. The
+names are fixed: `ipfile`, `ipdb`, `iplive`. Message payloads are bencoded
+dictionaries with a string key `t` naming the message. Records embedded in a
+message are carried as their UTF-8 JSON bytes under key `r`, so the bencode
+layer never has to understand JSON.
+
+A peer that did not advertise a name MUST NOT be sent that extension's
+messages. A peer receiving a message for an extension it advertised but a `t`
+it does not know MUST ignore it.
+
+### 6.3 Browser peers
+
+WebTorrent supports BEP 10 extensions via `wire.use()`, and the four wss
+trackers bittorrented.com's player already announces to
+(`wss://tracker.webtorrent.dev`, `wss://tracker.openwebtorrent.com` are the
+two that handshake today) find WebRTC peers. A browser peer signs vouchers
+with WebCrypto Ed25519 and seals with X25519 from the same API. Nothing in
+this family requires a native binary in a browser.
+
+A browser can only reach WebRTC peers. A swarm that wants browser reach MUST
+have at least one hybrid peer (a native seeder that also announces to a wss
+tracker), which is what media-streamer's `StreamingService` already is.
+
+### 6.4 Webseeds and gateways
+
+A gateway is a BEP 19 webseed: it serves ciphertext by HTTP range under the
+URL the manifest lists. A gateway MAY require a pass (`Authorization: Bearer`
+with the pass's `token`) and MAY require vouchers in a request header
+(`ippay` §7). A gateway that requires neither is a free webseed and is how a
+publisher pays for its own distribution instead of charging for it.
+
+### 6.5 Moshpit tunnel
+
+Between two native peers that both hold Moshpit identities, the BitTorrent
+connection MAY be tunnelled through an MTP/1 session
+(`@profullstack/moshpit-transport`: X25519 + ML-KEM-768 key agreement,
+ML-DSA-65 server identity, ChaCha20-Poly1305 records, TCP). The `hello`
+message carries the peer's MTP pin (`base64(SHA-256(SPKI))`) so a peer can
+reconnect over the tunnel after meeting on the plain wire. MTP/1 has no
+client authentication; ownership on the tunnel is still proved by `hello`
+signatures (`ipfile` §5.1), not by the transport.
+
+MTP/1 is TCP only and 64 KiB per record. It is not available to browsers and
+it does not replace the swarm wire. It is the confidentiality option for
+node-to-node links, and it is the transport the hub API SHOULD be reachable
+over in addition to HTTPS.
+
+## 7. Discovery
+
+### 7.1 Peers
+
+A swarm is found the way any torrent is: DHT `get_peers` on `infohashV1` and
+on the truncated `infohashV2` (BEP 5), tracker announces (BEP 15, HTTP, and
+wss for browsers), PEX (BEP 11), LSD. A manifest lists the trackers it wants.
+A manifest MAY set `swarm.private` to true (BEP 27), which disables DHT and
+PEX and leaves only trackers and webseeds; that is the choice for a swarm
+whose existence should not be public.
+
+### 7.2 Latest manifest by file key
+
+The latest manifest for a file is announced as a BEP 44 mutable item:
+
+```
+k = file public key (32 bytes)
+salt = "ipfile"
+seq = manifest rev
+v = bencoded { "ih": , "ih2": , "m": }
+```
+
+`v` is 100 bytes, well inside the 1000 byte BEP 44 limit. The item is signed
+by the file key, which is why the file key is Ed25519 and why it is the file's
+identity. This is BEP 46 with an extra pointer: the magnet form is
+`magnet:?xs=urn:btpk:&s=ipfile`.
+
+The manifest itself is fetched from any peer in the swarm over the `ipfile`
+`manifest` message, from a gateway at `/swarm//manifest`, or from
+an `ipdb` replica.
+
+### 7.3 Feeds and names
+
+An `ipdb` feed head is a BEP 44 mutable item under the feed key with salt
+`ipdb:`. A Moshpit name resolves to a publisher key and a gateway
+through `ipname`. A client that starts from a name, a feed, a file key or an
+infohash reaches the same manifest.
+
+## 8. Events
+
+### 8.1 Envelope
+
+Every event is a CloudEvents 1.0 envelope, as `@profullstack/autoblog`
+already emits:
+
+```json
+{
+ "specversion": "1.0",
+ "id": "7f2b5a7e-2b0e-4c8f-9a4d-3d6c0b9c1e10",
+ "type": "com.logicsrc.openswarm.ipfile.voucher.redeemed.v1",
+ "source": "https://coinpayportal.com/api/openswarm",
+ "subject": "sha256:41d10f45e705e0526c9eeedf62b32dda3daaf552dd9b6a7b09e01776b05813bb",
+ "time": "2026-09-05T18:02:11.000Z",
+ "datacontenttype": "application/json",
+ "data": {
+ "record": { "openswarm": "0.1", "type": "ippay.voucher" }
+ }
+}
+```
+
+`type` is `com.logicsrc.openswarm....v1`. `subject` is
+the record id the event is about. `data.record` is the full record where the
+event carries one. Each member document registers its types.
+
+### 8.2 Delivery
+
+Webhooks are Standard Webhooks as autoblog signs them: `POST` with
+`content-type: application/cloudevents+json`, headers `webhook-id` (the
+event id), `webhook-timestamp` (unix seconds) and `webhook-signature`
+(`v1,` over `..`), a five minute
+tolerance window, retries at 0, 10 and 60 seconds on network error, 5xx, 408
+and 429. Receivers verify with a constant-time compare.
+
+## 9. Versioning
+
+- `openswarm` on a record is the family version. A 0.x reader MUST accept
+ any 0.x record and MUST preserve unknown fields.
+- Each member document has its own version history. Adding a record type,
+ a message `t` or a field is additive and does not bump the family version.
+- Wire changes that break an existing message get a new `t` name. A message
+ name is never redefined, for the same reason an OpenCreds label is never
+ edited: peers that shipped it are still running.
+- Extension names in the BEP 10 handshake (`ipfile`, `ipdb`, `iplive`) are
+ permanent. A breaking revision would ship as `ipfile2`.
+
+## 10. Conformance
+
+A conforming implementation of the core:
+
+1. Reads and writes records per §3, computes ids and verifies `ed25519`
+ signatures with the domain prefix.
+2. Derives file keys and box keys per §4 from a seed, and produces a manifest
+ that a second implementation given the same seed and `fileId` can sign
+ identically.
+3. Computes BEP 52 pieces roots and builds hybrid torrents per §5.
+4. Speaks BEP 10 and ignores unknown `t` values per §6.2.
+5. Publishes and reads the BEP 44 pointer per §7.2.
+6. Emits events per §8 where it emits events at all.
+
+Member conformance is defined per document; the checklist is collected in
+[conformance.md](./conformance.md).
+
+## 11. Registered record types
+
+| Type | Signer | Document |
+| --- | --- | --- |
+| `openswarm.binding` | subject and pq | this document §4.6 |
+| `ipfile.manifest` | file key (and publisher key) | ipfile |
+| `ipfile.grant` | grantor | ipfile |
+| `ippay.hub` | hub key | ippay |
+| `ippay.pass` | hub key | ippay |
+| `ippay.voucher` | payer key | ippay |
+| `ippay.receipt` | hub key | ippay |
+| `ipdb.entry` | feed key | ipdb |
+| `ipdb.head` | feed key | ipdb |
+| `ipdb.tombstone` | feed key | ipdb |
+| `ipaudio.release`, `ipaudio.track` | publisher key | ipaudio |
+| `ipvideo.title` | publisher key | ipvideo |
+| `iplive.channel`, `iplive.head` | channel key | iplive |
+| `ipname.pin` | publisher key | ipname |
+
+## 12. Version history
+
+| Version | Date | Change |
+| --- | --- | --- |
+| 0.1 | 2026-09-05 | Initial draft. Records, derived file keys, BEP 52 identifiers, BEP 10 extensions, BEP 44 pointers, CloudEvents. |
diff --git a/packages/logicsrc-mcp/src/standards.test.ts b/packages/logicsrc-mcp/src/standards.test.ts
index 50340e8..14ce0a8 100644
--- a/packages/logicsrc-mcp/src/standards.test.ts
+++ b/packages/logicsrc-mcp/src/standards.test.ts
@@ -203,7 +203,7 @@ describe("MCP: OpenPRD", () => {
it("reports the next free id and the allowed lifecycle moves", async () => {
const client = await connect();
// Asserted against the live prd/ directory, so this advances with every PRD added.
- expect(toolText(await client.callTool({ name: "prd_next_id", arguments: {} }))).toBe("0005");
+ expect(toolText(await client.callTool({ name: "prd_next_id", arguments: {} }))).toBe("0006");
const moves = await client.callTool({ name: "prd_next_statuses", arguments: { ref: "0001" } });
const payload = JSON.parse(toolText(moves)) as { status: string; allowedNext: string[] };
diff --git a/prd/0005-add-logicsrc-openswarm-spec.md b/prd/0005-add-logicsrc-openswarm-spec.md
new file mode 100644
index 0000000..4ca0a49
--- /dev/null
+++ b/prd/0005-add-logicsrc-openswarm-spec.md
@@ -0,0 +1,184 @@
+---
+openprd: "0.2"
+id: "0005"
+title: "Add the LogicSRC OpenSwarm specification family"
+status: Draft
+authors:
+ - anthony@profullstack.com
+created: 2026-09-05
+updated: 2026-09-05
+repo: profullstack/logicsrc
+discussion:
+implementation:
+tags:
+ - openswarm
+ - ipfile
+ - ipdb
+ - ipaudio
+ - ipvideo
+ - iplive
+ - bittorrent
+ - x402
+ - c0mpute
+supersedes:
+superseded-by:
+---
+
+## Problem
+
+BitTorrent distributes bytes better than any CDN and has never been a
+product, for two reasons. Nobody is paid to seed, so swarms die when the
+uploader loses interest. Nothing in it is private, so a publisher who wants
+to charge for access cannot use it at all.
+
+Every Profullstack media property has answered that by putting a central
+HTTP proxy in front of the bytes and bolting a pass system onto it:
+bittorrented.com's seedbox, IPTV and radio rails, tipoffwatch and
+genrewatch's shared playlists, p0dcasters' episodes. Each one re-implements
+pay-per-pass grants, session caps and manifest sealing, and each one pays
+for every byte it serves. The swarm the site is named after cannot carry
+the payment, so it is not used for the thing that costs money.
+
+c0mpute.com has the other half: nodes that already store, transcode,
+gateway and (by design) relay live segments, paid through a job auction.
+They have no way to earn from serving media once a job is done, and no
+protocol that tells them what to seed.
+
+What is missing is an open, versioned description of a paid, encrypted
+swarm: how a file is encrypted so the tracker learns nothing, how a peer
+that serves a verified piece is paid for it, how the key reaches a peer
+that paid, and how a catalogue of such swarms is replicated without a
+server. And the same primitives, reused, for audio, video and live.
+
+## Goals
+
+- A publisher adds a file once and it stays available as long as anyone is
+ paid to seed it, on c0mpute nodes or on strangers' machines.
+- A seeder is paid per verified byte served, with exposure bounded to a
+ small credit window, and a leecher never pays for a byte it did not
+ verify.
+- A public tracker, DHT node or crawler learns an infohash and a size, not
+ a title.
+- One key model: a publisher seed from which every file, feed and channel
+ key derives and can be recovered; a fresh unrelated key only on request.
+- Vanilla BitTorrent clients remain valid swarm members and browsers remain
+ first-class peers (WebTorrent, WebCrypto, MSE).
+- Payment reuses x402 and CoinPay exactly as `@profullstack/x402-gateway`
+ sells crawl passes today; events reuse CloudEvents and Standard Webhooks
+ as `@profullstack/autoblog` emits them; post-quantum links reuse
+ `moshpit-transport`; names reuse the Moshpit registry.
+- Every building block (chunking, manifests, keys, encryption, payment,
+ relay) is specified once and reused by `ipaudio`, `ipvideo`, `iplive`
+ and c0mpute; reuse is the default posture.
+- c0mpute nodes have concrete workload types to bid on and a per-byte
+ income path that needs no auction.
+
+## Non-Goals
+
+- Not a new transport. The wire is BEP 3, the extension is BEP 10, hashing
+ is BEP 52, discovery is BEP 5, 44 and 46.
+- Not a token or a chain. Passes are bought once in USDC; vouchers are
+ off-chain and redeemed at a hub; payouts are batched.
+- Not trustless settlement. A hub is trusted as a payment processor is.
+- Not DRM. A paying peer holds the key; the spec says so.
+- Not a player, a recommender, or a moderation system.
+- Not an implementation. This PRD adds specifications only.
+
+## Users
+
+- **A publisher** (label, studio, podcaster, vendor, company backing up)
+ who wants distribution paid for by the people who want the bytes.
+- **A seeder or relay operator**, including every c0mpute node, who wants
+ to be paid for bandwidth and disk.
+- **A listener or viewer** in a browser or an app, who wants to pay once
+ and stream without a server deciding whether they may.
+- **An agent** with an x402 wallet that buys a dataset unattended.
+- **An implementer** who wants to interoperate from the published
+ documents without reading Profullstack source.
+
+## Requirements
+
+- R1 [P0] Define the core: signed JSON records with JCS canonical form and
+ SHA-256 ids, Ed25519 signatures with domain prefixes, optional ML-DSA-65,
+ derived per-file, per-feed and per-channel keys from one seed, BEP 52
+ identifiers, BEP 10 extension names, BEP 44 pointers, CloudEvents.
+- R2 [P0] Define `ipfile`: the manifest, the grant, AES-256-CTR piece
+ encryption with two merkle roots, the `hello` and payment messages, the
+ credit window and voucher interval, key delivery by peer and by URL,
+ vanilla coexistence, publishing and retrieval procedures.
+- R3 [P0] Define `ippay`: passes bought over x402 with CoinPay's v2 offer
+ and proof, payer binding, cumulative vouchers with exact integer value
+ arithmetic, the hub record and API, receipts and split arithmetic, payee
+ registration, standing, payout, HTTP presentation headers.
+- R4 [P0] Define `ipdb`: signed hash-chained feeds, entries with put and
+ delete, heads on the DHT, replication by `ipfile` segments and by gossip,
+ fork detection, the query shape and result, playlists and ratings.
+- R5 [P0] Define `ipaudio`: track and release records, renditions as
+ `ipfile`s, seek index, aligned anchors, gapless trim, royalty split, the
+ gateway bridge and RSS.
+- R6 [P0] Define `ipvideo`: title record, CMAF renditions with segment
+ index, subtitles, thumbnails, chapters, MSE playback, byte-range HLS
+ bridge, transcode on c0mpute.
+- R7 [P0] Define `iplive`: channel and head records, key epochs, the
+ `iplive` extension with `window` backpressure, roles and relay
+ economics, low-latency parts, recording to `ipvideo`.
+- R8 [P1] Define `ipname`: the name record, the `openswarm` registry pin
+ kind, the DNS TXT form, clearnet-first, the `ip://` URL.
+- R9 [P1] Define the c0mpute integration: node identity and payee
+ registration, seven workload types with `JobOffer` shapes, at least ten
+ use cases with CLI flows, the list of proposed changes to the node.
+- R10 [P1] Propose the `ip` CLI as a contract: command tree, exit codes,
+ output fields, environment.
+- R11 [P1] Publish conformance profiles and checklists, a security model,
+ and an FAQ.
+- R12 [P1] Publish the family at `logicsrc.com/openswarm` with the same
+ site registration as OpenCreds (nav, docs registry, sitemap, landing).
+- R13 [P2] Ship JSON Schemas under `@logicsrc/schemas` and a reference
+ implementation with the fixture suite described in conformance.md.
+ Deferred to a later PRD; this one is documents only.
+
+## UX Notes
+
+Adding a file is one command and prints everything a person needs to hand
+out: the file key, both infohashes, a magnet link and an `ip://` URL. The
+seed is created once and reused; the CLI never prints it without
+`--reveal`.
+
+Fetching is one command that buys or reuses a pass, joins, pays, decrypts
+and verifies, and reports what it paid and who granted the key. A pass
+scoped to a publisher rather than a file is what a player wants, so a
+listener buys once per label, not per track.
+
+Failure modes are named: `no-pass`, `pass-cap`, `unpaid`, `not-keeper`,
+`voucher-stale`. A choke is explained by a `credit` message, not by
+silence.
+
+## Success Metrics
+
+- A swarm seeded by one implementation, paid for by a leecher from a
+ second, with vouchers redeemed at a third party's hub, decrypts to
+ `plainRoot`.
+- bittorrented.com's browser player streams an `ipaudio` track from peers
+ with no proxy in the path and the publisher's share arrives at the
+ address in the manifest.
+- A c0mpute node with no jobs earns voucher income from a swarm it chose
+ to pin.
+- Every spec document has zero em dashes and every JSON example is valid.
+
+## Risks & Open Questions
+
+- **Custodial hub.** CoinPay's x402 rail pays merchants directly and
+ collects no fee. A hub holds balances and pays out; that is a different
+ regulatory position and a product decision.
+- **EIP-3009 cannot bind a resource.** Mitigated at the hub by recording
+ nonce to resource; inherent to x402 v2.
+- **Key copying.** A grantee can leak the key. The family prices access
+ and does not prevent copying; re-encryption is the only remedy.
+- **The Moshpit registry has no `openswarm` pin kind.** One-line change on
+ the registry side; until then `ipname` works over DNS TXT only.
+- **Name.** The working name was IPMedia. OpenSwarm is proposed for the
+ reasons in the overview; the `ip*` protocol names stay either way.
+- **Seed loss** freezes every derived file. Mitigated by storing the seed
+ in OpenCreds and by standalone keys for files that change hands.
+- **Spec before code.** Every wire format here is unimplemented. The
+ fixture suite and the interop test are how this gets honest.
diff --git a/prd/README.md b/prd/README.md
index 057fda1..4ef71fd 100644
--- a/prd/README.md
+++ b/prd/README.md
@@ -15,3 +15,4 @@ Status lives in each file's front-matter and is the source of truth:
| [0002](./0002-hourly-hire-us-rate.md) | Move Hire Us pricing from a weekly retainer to an hourly rate | Accepted | pricing, site, billing |
| [0003](./0003-add-logicsrc-opencontext-spec.md) | Add the LogicSRC OpenContext specification | Draft | opencontext, context, agents, permissions, provenance, schemas |
| [0004](./0004-add-logicsrc-opencreds-spec.md) | Add the LogicSRC OpenCreds specification | Draft | opencreds, credentials, vault, encryption, portability, schemas |
+| [0005](./0005-add-logicsrc-openswarm-spec.md) | Add the LogicSRC OpenSwarm specification family | Draft | openswarm, ipfile, ipdb, ipaudio, ipvideo, iplive, bittorrent, x402, c0mpute |