docs: FoF-gating spec skeleton (hand-off to Opus)

Drafts the Friend-of-Friend post-gating spec with crypto specifics
marked TBD — OPUS for Opus to fill in. Six-layer implementation plan;
each layer independently shippable.

Includes README overview + six layer files:
- Layer 1: V_me vouch primitive (keys, keyring, VouchGrant wire format)
- Layer 2: Mode 2 — public post + FoF-gated comments
- Layer 3: Mode 1 — FoFClosed (encrypted body via wrap_slots + prefilter)
- Layer 4: per-post keypair rotation
- Layer 5: unlock cache + prefilter optimization (perf-critical)
- Layer 6: revocation (stub; likely deferred post-v1)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scott Reimers 2026-04-23 23:20:56 -04:00
parent d118daee28
commit 1fdf9a94cc
8 changed files with 867 additions and 0 deletions

94
docs/fof-spec/README.md Normal file
View file

@ -0,0 +1,94 @@
# Friend-of-Friend (FoF) Gating — Implementation Spec
**Status**: Skeleton drafted 2026-04-23 by Scott + Lead Claude. Crypto-level byte layouts and algorithm specifics are intentionally left as `TBD — OPUS` markers for Opus to fill in. See each layer file for scope.
---
## What this spec covers
FoF-gating is a new post-visibility mechanism that gates readership and commenting by **social reachability** rather than explicit membership lists. It complements, rather than replaces, the existing primitives (`Public`, per-recipient `Encrypted`, per-circle `GroupEncrypted`).
The central primitive is the **per-person vouch key** `V_me` — a symmetric key each persona owns, handed to everyone they vouch for. Cryptographic reachability for an author's FoF post emerges from the union of every `V_x` that author holds (own + received-from-vouchers), and every reader whose keyring intersects that set.
### User-facing model
Authors pick a post visibility from four levels:
| Level | Wrap slots include | Cryptographic reach |
|---|---|---|
| **Public** | (none) | All readers |
| **Friends-only** | `V_me` only | My vouchees |
| **Friends-of-Friends** | `V_me` + every `V_x` I hold | My vouchees + every vouchee of anyone who vouched for me (emergent FoF) |
| **Custom** | Author-selected subset of held `V_x` | Union of reach through each included key |
No centrally-computed membership list. Reach is a function of the wrap-slot set and which keys each reader holds.
### Key design properties
- **Unilateral**: vouching is a one-way act (Alice hands out `V_alice`). No bilateral handshake required for the FoF graph to form.
- **Graph-private**: wrap slots carry no recipient IDs. Observers cannot enumerate "who can read this post."
- **Anonymous prefilter**: each wrap slot carries a 2-byte `HMAC(V_x, post_id)[:2B]` tag. Readers precompute tags for keys in their ring and skip non-matching slots, cutting trial-decrypt cost ~65000× per post.
- **Slot count padding**: author pads wrap slots to power-of-2 buckets to blunt vouch-count leak.
- **Per-post keypair**: each FoF post generates a fresh `(priv_post, pub_post)`. Leak of one post's `priv_post` bounds blast radius to that post's comments; doesn't expose other posts or the author's identity key.
### Two modes
- **Mode 1 — `FOF_CLOSED`**: post body encrypted, comments encrypted. FoF-only readership + writership.
- **Mode 2 — `PUBLIC_FOF_COMMENT`**: post body public (indexable, cacheable, shardable), comments can be public OR FoF-encrypted per-comment.
---
## Layering / implementation order
Build and ship bottom-up. Each layer is independently shippable and exercised before moving to the next.
1. **[Layer 1](layer-1-vouch-primitive.md) — Vouch primitive.** `V_x` keys, per-persona keyring storage, epoch tag, distribution/exchange mechanism, minimal UI. No posts yet.
2. **[Layer 2](layer-2-mode2-fof-comments.md) — Mode 2: public posts with FoF-gated comments.** Easier implementation path; reuses existing public-post CDN path; extends `CommentPolicy` with a new `GroupMembersOfFoF` variant.
3. **[Layer 3](layer-3-mode1-fof-closed.md) — Mode 1: `FOF_CLOSED` posts.** New `PostVisibility::FoFClosed` variant. Wrap slots, anonymous prefilter tag. Receive-path integration.
4. **[Layer 4](layer-4-keypair-rotation.md) — Per-post keypair rotation.** Graceful `(priv_post', pub_post')` rotation record re-wrapped to current FoF set. Old comments still verifiable under old `pub_post`; new comments require new.
5. **[Layer 5](layer-5-prefilter-and-cache.md) — Unlock cache + prefilter optimization.** Author-direct fast path, winning-V_x-per-author cache, unreadable-posts retry table, re-try-on-new-V_x trigger. Performance-critical at realistic keyring sizes (400500 keys × 400500 slots).
6. **[Layer 6](layer-6-revocation.md) — Revocation & rotation cascades.** Deferred; may not be in v1. Drafted as a stub for design review.
---
## Intentionally out of scope (v1)
- **First-contact DM gating.** Deferred; Scott has a separate approach to sketch later.
- **Spam / abuse mitigation beyond vouch_mac.** Rate limits, PoW, reputation, reporting — tracked as separate work.
- **Intra-circle anonymity.** Ring signatures over the vouch set so even other FoF members cannot tell which voucher's chain a comment arrived through. v2.
- **Zero-knowledge audience gating.** v2.
- **PQ migration** of `priv_post` / identity sigs. The spec shape above is algorithm-agnostic; PQ (ML-DSA ~1.3KB sigs) swaps in without structural change. Tracked separately.
---
## Glossary
- **Persona**: a user's posting identity. A device may hold multiple. Each persona has its own `V_me` and its own received-vouches keyring.
- **Vouch**: a unilateral act by persona P1 declaring trust in persona P2. P1 gives P2 a copy of `V_P1`.
- **Vouch key (`V_x`)**: a symmetric key owned by persona `x`, distributed by `x` to everyone `x` vouches for. `V_me` refers to the current persona's own vouch key.
- **Keyring**: for a given persona, the set of vouch keys held = `{V_me}` `{V_x : x vouched for me}`.
- **Wrap slot**: an anonymous ciphertext in a post header, carrying the post's private material encrypted under some `V_x`. Readers trial-decrypt slots whose 2-byte prefilter tag matches an owned key.
- **pub_post / priv_post**: per-post ephemeral ed25519 keypair. `priv_post` is wrapped in the post's wrap slots; `pub_post` is in the header plaintext and used for group signature verification on comments.
- **Identity key**: persona's long-term ed25519 key, used to sign content as that persona. Distinct from `priv_post`. (In current ItsGoin terms: this is the persona's posting key. Worth naming-alignment review when specifying.)
- **Vouch MAC**: `HMAC(V_x, post_id || comment_hash)` — 16B truncated. Identifies which `V_x` a commenter holds. Inside encrypted payload (Mode 1) or alongside plaintext (Mode 2). Used by author strict-mode to verify the commenter is reachable via a known vouch chain.
---
## Integration with existing ItsGoin primitives
- **`Circle` + `GroupEncrypted`** remain as-is for named explicit-membership groups. FoF is a separate visibility class, not a replacement.
- **`PostVisibility`** gains one new variant (`FoFClosed`, from Layer 3). Mode 2 reuses `PostVisibility::Public` with extended `CommentPolicy`.
- **`CommentPolicy`** gains one new variant for Layer 2 (Mode 2 comment gating).
- **`InlineComment`** gets optional `group_sig` + `vouch_mac` fields (back-compat via `#[serde(default)]`, same pattern as Phase 2e `ref_post_id`).
- **`control::receive_post`** gets new verify-gate branches for `FoFClosed` posts (author_sig + wrap_slots well-formedness) and FoF comments (group_sig verifies against `pub_post` from the referenced parent post).
- **Multi-persona**: keyrings are per-persona. Unlock attempts iterate personas; the persona that successfully unlocks is recorded and drives comment-authorship defaults. See Layer 3 for detail.
---
## How to read this spec
- Every `TBD — OPUS` marker identifies a crypto specifier / byte layout / test vector that Opus will fill in.
- `Lead decisions` blocks capture commitments that came out of the Scott + Lead Claude conversation; these are not open to further design iteration.
- `Open questions` blocks flag things the Lead wants Opus's input on before proceeding.
Once Opus fills in the crypto layers, the Lead re-reviews + we move to implementation on a per-layer branch schedule.

View file

@ -0,0 +1,130 @@
# Layer 1 — Vouch Primitive
**Scope**: Introduce `V_x` vouch keys, per-persona keyring storage, and a distribution/exchange mechanism. No post-gating yet — this layer ships first so the primitive is in place and UI-exercised before any post encryption depends on it.
---
## Goal
After Layer 1 ships:
- Each persona owns a current `V_me` symmetric key. New personas auto-generate one at creation.
- Each persona has a keyring of received vouch keys: `{V_x : x has vouched for me}`.
- Users can view who they've vouched for, who has vouched for them, and revoke/rotate `V_me`.
- Wire protocol can transfer `V_me` from voucher to vouchee inside an existing encrypted channel.
- No post encryption or comment gating depends on Layer 1 yet — that arrives in Layer 2/3.
---
## Lead decisions
- **`V_me` is symmetric, not asymmetric.** Every vouchee holds the same `V_me`. This is the property that makes FoF reach emergent (wrap under `V_x`, anyone `x` vouched for decrypts).
- **Distribution is unilateral.** Alice vouching for Bob = Alice gives Bob a copy of `V_alice`. No request/accept handshake. Bob can immediately read FoF posts that include `V_alice` as a wrap slot.
- **Revocation = rotate `V_me`.** There is no per-vouchee revocation. To un-vouch someone, the persona generates a new `V_me'` and re-distributes to every remaining vouchee. Layer 6 stub.
- **Epoch tag is part of the key.** Every `V_x` has an associated `(owner_id, epoch)` so receivers can tell fresh from stale when multiple copies arrive (e.g., if voucher rotated).
- **Keyring is per-persona, not per-device.** Multi-persona users have independent keyrings. Layer 3 reader logic iterates personas when trial-decrypting.
---
## Data model
### `vouch_keys_own` table
Per-persona, stores the persona's own `V_me` history (current + recent-past for graceful rotation).
TBD — OPUS: exact column types, key size in bytes, how many past epochs to retain.
```
vouch_keys_own(
persona_id BLOB,
epoch INTEGER,
key_material BLOB, -- TBD — OPUS: symmetric key bytes (32B for a 256-bit key?)
created_at_ms INTEGER,
is_current INTEGER, -- 1 for the active V_me, 0 for retained past epochs
PRIMARY KEY (persona_id, epoch)
)
```
### `vouch_keys_received` table
Per-persona, stores vouch keys received from other personas (one row per `(owner_id, epoch)` currently held).
```
vouch_keys_received(
holder_persona_id BLOB, -- whose keyring this entry belongs to
owner_id BLOB, -- the persona who owns (issued) this V_x
epoch INTEGER,
key_material BLOB,
received_at_ms INTEGER,
PRIMARY KEY (holder_persona_id, owner_id, epoch)
)
```
Readers compute their keyring as `SELECT key_material FROM vouch_keys_received WHERE holder_persona_id = ? UNION SELECT key_material FROM vouch_keys_own WHERE persona_id = ? AND is_current = 1`.
---
## Key generation
TBD — OPUS: specify the primitive. Candidates:
- Random 32B from CSPRNG (simplest; `V_me` is pure symmetric secret)
- HKDF-derived from persona identity key + epoch counter (allows deterministic re-derivation; couples key rotation to identity key exposure)
Lead leaning: **random 32B CSPRNG, stored encrypted at rest alongside identity key material**. Identity-key coupling offers no defensive benefit given the keyring is already indexed per-persona in the same DB.
---
## Wire format for vouch distribution
A `VouchGrant` message carries a copy of the voucher's current `V_me` from voucher to vouchee.
TBD — OPUS: exact byte layout. Target shape:
```
VouchGrant {
voucher_persona_id: NodeId, -- sender's persona
epoch: u32,
key_material: [u8; 32], -- V_voucher at this epoch
issued_at_ms: u64,
sig: [u8; 64], -- voucher's identity-key signature over the above
}
```
Delivery: wrap `VouchGrant` inside the existing `Direct` (encrypted-to-one-recipient) post primitive. Do not introduce a new top-level control message. Content-type byte distinguishes vouch grants from regular DMs. Recipient's receive-path decodes, verifies signature, inserts into `vouch_keys_received`.
TBD — OPUS: decide whether vouch grants should use a reserved visibility intent (`VisibilityIntent::VouchGrant`) for filtering out of the Messages tab, or whether they ride under `Direct` and are suppressed client-side by content-type.
---
## UI / UX
Minimum viable surface for Layer 1 ship:
- **Persona screen**: "Vouch for someone" button. Picker of contacts. Hands them a `V_me`.
- **Persona screen**: "Who has vouched for me" list (reads `vouch_keys_received` grouped by `owner_id`).
- **Persona screen**: "People I've vouched for" list (local-only; TBD — OPUS on whether to track this explicitly or derive from DM-sent history — see open question below).
- **Settings**: "Rotate my vouch key" button → generates new epoch, queues re-distribution to tracked vouchees.
Layer 1 ships without any post/comment behavior change. Vouches are visible in UI but don't gate content yet.
---
## Open questions
- **Do we track outbound vouches explicitly?** Option A: local `vouches_issued(recipient_persona_id, epoch_at_grant)` table. Option B: derive from DM-sent history searching for `VouchGrant` payloads. A is simpler + survives DM history loss; B avoids a redundant record. Lead leaning: A.
- **Re-distribution on rotation.** When persona rotates `V_me`, do we auto-queue `VouchGrant` DMs to every tracked vouchee, or require user-initiated re-vouch? Lead leaning: auto-queue with a confirmation summary ("rotate will re-send vouch to N people").
- **Key size.** 256-bit symmetric key is standard and matches our ChaCha20-Poly1305 usage. Confirm with Opus there's no reason to prefer 192 or 128 bits.
- **Epoch granularity.** Monotonic counter per persona, or wall-clock-based? Counter is simpler; wall-clock aids debugging. Lead leaning: counter.
- **Should `V_me` ever be exported in the persona backup bundle?** Losing `V_me` means every FoF post gated under it becomes permanently unreadable for every vouchee. Lead leaning: yes, include in identity export.
---
## Ship criteria for Layer 1
- All personas auto-generate `V_me` at creation.
- Users can vouch and receive vouches end-to-end via DM-wrapped `VouchGrant`.
- UI shows received and issued vouches per persona.
- `V_me` rotation works; re-distribution to tracked vouchees is demonstrated.
- No change to post visibility / comment behavior.
- Integration test: two personas on two devices, Alice vouches for Bob, Bob's `vouch_keys_received` contains `V_alice` with correct signature.

View file

@ -0,0 +1,127 @@
# Layer 2 — Mode 2: Public Posts with FoF-Gated Comments
**Scope**: Extend `CommentPolicy` with a `FriendsOfFriends` variant. Post body is public (indexable, cacheable, shardable via existing CDN). Comments on the post must prove FoF-reachability to the author before being accepted.
This layer ships before Mode 1 because it reuses the existing public-post path and only adds a verification gate on comments. No new `PostVisibility` variant needed.
---
## Goal
- An author creates a public post with `comment_policy = FriendsOfFriends`.
- Body is encrypted to no one — plaintext in the CDN, same as a normal public post today.
- Comments on the post include a proof artifact that lets readers (and the author in strict mode) verify the commenter is reachable through the author's FoF graph.
- Non-FoF readers can still READ the post. They cannot post an accepted comment.
---
## Lead decisions
- **Mode 2 does not gate readership.** The post body is genuinely public. Only comments are gated. This is intentional — it preserves CDN shardability for the body and gives authors a lightweight "comments to my circle, body to the world" mode.
- **`pub_post` is included in the public post header.** Every FoF-eligible post (both modes) has a per-post ephemeral ed25519 keypair. `pub_post` in the header, `priv_post` in wrap slots (Mode 1) or directly in a control record (Mode 2 — TBD below).
- **Comments carry a `group_sig` signed with `priv_post`.** Any device that can unwrap `priv_post` (i.e., holds a matching `V_x`) can sign. `group_sig` verification against `pub_post` is the cryptographic proof of FoF-reachability. Verifiable by any observer holding the public post.
- **Optional `vouch_mac` for strict mode.** `HMAC(V_x, post_id || comment_hash)` identifies *which* voucher's chain a commenter holds. Author can run in strict mode and reject comments whose `vouch_mac` doesn't match a `V_x` the author has a record of distributing. Non-strict mode accepts any valid `group_sig`.
- **Comment still signed by commenter's identity key as well.** `group_sig` proves FoF-membership; identity sig binds the comment to a specific persona for display / abuse reporting / author-side blocklist.
- **Non-FoF devices can still render the post.** Read path is unchanged — comments missing `group_sig` (or failing verification) are filtered out in the feed renderer rather than hard-rejected in storage. This leaves forensic traces and is cheaper than re-verifying in the renderer vs at ingest. TBD open question below.
---
## Data model
### Extend `CommentPolicy`
```rust
pub enum CommentPolicy {
Public,
FollowersOnly,
None,
FriendsOfFriends, // NEW — Layer 2
}
```
### Post header additions (for posts with `comment_policy = FriendsOfFriends`)
```rust
struct PostHeader {
// ... existing fields ...
// NEW for Mode 2 comment gating:
pub_post: Option<[u8; 32]>, // ed25519 public key of per-post ephemeral keypair
wrap_slots: Vec<WrapSlot>, // wraps priv_post under each V_x in author's keyring
// TBD — OPUS: WrapSlot byte layout (same type as Layer 3 uses)
}
```
**Note**: Mode 2 posts carry `wrap_slots` even though the body is public, because commenters need `priv_post` to sign. The wrap slot set IS the FoF membership definition.
### Extend `InlineComment`
```rust
struct InlineComment {
// ... existing fields ...
#[serde(default, skip_serializing_if = "Option::is_none")]
group_sig: Option<[u8; 64]>, // ed25519 signature over comment_hash, verifies against parent post's pub_post
#[serde(default, skip_serializing_if = "Option::is_none")]
vouch_mac: Option<[u8; 16]>, // HMAC(V_x, post_id || comment_hash), truncated
}
```
Back-compat: old comments without these fields are treated as "not FoF-signed" — accepted on non-FoF posts, rejected on FoF posts.
---
## Comment creation (author of the comment)
1. Commenter fetches parent post. Reads `pub_post` from header.
2. Commenter iterates their keyring. For each `V_x` held, computes `HMAC(V_x, post_id)[:2B]`. Compares against each `WrapSlot.prefilter_tag`. On match, trial-decrypts the slot to get `priv_post`.
3. If any slot unwraps successfully: commenter now holds `priv_post`. Signs `comment_hash` to produce `group_sig`. Computes `vouch_mac = HMAC(V_x_winner, post_id || comment_hash)`. Attaches both to comment.
4. Publishes comment through normal comment-propagation path.
If NO slot unwraps: commenter is not in the author's FoF set. UI reports "You can't comment on this post." Pure client-side enforcement — no wire attempt.
---
## Comment verification (reader side)
At feed render time, for every comment on a `FriendsOfFriends`-policy post:
1. If `group_sig` is missing → filter out (strict) or show with "unverified" badge (permissive). Lead leaning: **filter out**.
2. Verify `group_sig` against parent post's `pub_post` over `comment_hash`. If fail → filter out.
3. Verify identity sig of comment as normal. If fail → filter out.
At author side (strict mode, optional):
4. Recompute expected `vouch_mac` candidates from the set of `V_x` the author has distributed. If `comment.vouch_mac` doesn't match any → discard at ingest (don't store, don't propagate).
---
## Propagation
No changes to comment propagation path. Comments still flow via existing `BlobHeaderDiff` / engagement-diff mechanism. Verification is done at display time (reader) and optionally at ingest (author-strict).
---
## Open questions
- **Filter-out vs reject-at-storage for bad `group_sig`.** Filter-out preserves forensic trail and avoids double-verify cost. Reject-at-storage saves disk and sync bandwidth. Lead leaning: filter-out at render, reject-at-storage only on author-side strict mode.
- **`priv_post` distribution for Mode 2.** In Mode 1, `priv_post` is inside `wrap_slots`. In Mode 2, body is plaintext, but commenters still need `priv_post`. Options: (A) `wrap_slots` still present in Mode 2 headers, carrying only `priv_post`; (B) separate `comment_priv_key` control record distributed out of band. Lead leaning: A (uniform structure with Mode 1).
- **Wrap slot prefilter tag.** 2B = 65536 buckets, false-positive 1/65536. For Mode 2 this also defines the commenter-eligibility filter. TBD — OPUS: confirm 2B is enough for realistic keyring sizes.
- **Author's own comments.** Author has `priv_post` directly (generated it). Do they self-vouch-mac against `V_me`? Leaning: yes, so strict mode uniformly verifies all comments.
- **Displaying vouch path to the author.** Mode 2 strict mode knows which `V_x` a commenter arrived through. Should the author's UI show "comment arrived via your vouch to Alice" or keep it opaque? Lead leaning: opaque by default; optional power-user setting.
- **Rate-limiting / spam.** A malicious FoF member can flood comments. `vouch_mac` identifies the chain, so author can block a chain. Out-of-scope for Layer 2 ship (tracked separately).
---
## Ship criteria for Layer 2
- `CommentPolicy::FriendsOfFriends` variant exists end-to-end (storage, protocol, UI picker).
- Authors can create public posts with FoF-gated comments.
- `pub_post` / `priv_post` / `wrap_slots` generated on post creation, wrapped under author's full keyring.
- Commenters: client-side check of FoF eligibility before offering comment box; `group_sig` + `vouch_mac` attached on send.
- Readers: filter-out comments failing `group_sig` verification.
- Author strict-mode: optional ingress rejection on unknown `vouch_mac`.
- Back-compat: old clients see FoF posts as readable (body is public) but can't comment (missing `priv_post`); old comments on new posts are filtered at render.
- Integration test: 3-node FoF chain (A→B→C). A posts Mode 2. B comments (reachable). C comments (reachable via B). D (unrelated) cannot.

View file

@ -0,0 +1,139 @@
# Layer 3 — Mode 1: `FOF_CLOSED` Posts
**Scope**: New `PostVisibility::FoFClosed` variant. Both post body AND comments are gated to the FoF graph. Body is encrypted; readership emerges from keyring intersection with `wrap_slots`.
Builds on Layer 2's `pub_post` / `priv_post` / `wrap_slot` primitives — same structures, just that the CEK encrypting the body is *also* in the wrap slots (alongside `priv_post`).
---
## Goal
- Author creates a post with `visibility = FoFClosed`.
- Body ciphertext in the CDN; only FoF-reachable readers can decrypt.
- `wrap_slots` carry both the post CEK and `priv_post`, wrapped under each `V_x` the author holds.
- Readers trial-decrypt slots whose 2-byte prefilter tag matches a held key.
- Comments use Layer 2's `group_sig` / `vouch_mac` mechanism — unchanged.
- Observers cannot enumerate recipients from the header; slot count padded to power-of-2 buckets.
---
## Lead decisions
- **New variant, not extended Encrypted.** `PostVisibility::FoFClosed` is its own variant. Existing `Encrypted{recipients}` wraps per-recipient NodeIds — visible on the wire. FoF wraps anonymously under symmetric keys — no NodeIds.
- **One wrap slot per `V_x` in the author's keyring.** For a Friends-only post, one slot under `V_me`. For FoF, N+1 slots (one per `V_x` the author holds + own `V_me`). For Custom, subset chosen by author.
- **Slot count padded to power-of-2.** Prevents observers from counting vouchers the author has. TBD — OPUS: confirm padding up to next power of 2 with random dummy slots (non-decryptable ciphertext indistinguishable from real slots).
- **Each slot carries both CEK and priv_post.** Wrapped together as a single plaintext. One successful unwrap gives reader everything they need to read body + sign comments.
- **Prefilter tag is `HMAC(V_x, post_id)[:2B]`.** Readers precompute a 2-byte tag for each key in their keyring and skip slots that don't match. Cuts trial-decrypt cost by ~2^16 on average.
- **Order of slots is randomized.** No positional leak about which slot corresponds to which voucher.
---
## Data model
### Extend `PostVisibility`
```rust
pub enum PostVisibility {
Public,
Encrypted { recipients: Vec<NodeId> },
GroupEncrypted { group_id: GroupId, epoch: u32, wrapped_cek: Vec<u8> },
FoFClosed { // NEW
pub_post: [u8; 32],
wrap_slots: Vec<WrapSlot>,
},
}
```
### `WrapSlot` byte layout
TBD — OPUS. Target shape:
```
WrapSlot {
prefilter_tag: [u8; 2], // HMAC(V_x, post_id)[:2B]
nonce: [u8; 12], // AEAD nonce (unique per slot)
ciphertext: [u8; N], // AEAD(V_x, nonce, plaintext) — plaintext is CEK || priv_post
tag: [u8; 16], // AEAD auth tag
}
```
Plaintext inside the slot:
```
SlotPlaintext {
cek: [u8; 32], // ChaCha20-Poly1305 key for body
priv_post: [u8; 32], // ed25519 seed for per-post keypair
}
```
TBD — OPUS:
- AEAD choice (ChaCha20-Poly1305 matches existing usage — confirm).
- Whether to include `post_id` as AAD to bind slot to post and prevent slot-reuse across posts.
- Padding scheme for dummy slots (same size + random bytes that fail AEAD on any key).
---
## Encryption (author, post creation)
1. Generate per-post ephemeral ed25519 keypair `(priv_post, pub_post)`.
2. Generate random 32B CEK.
3. Encrypt body: `body_ct = ChaCha20-Poly1305(CEK, nonce, body || aad=post_id)`.
4. For each `V_x` in selected set (own `V_me` + held vouches for FoF, or custom subset):
- `prefilter_tag = HMAC(V_x, post_id)[:2B]`
- `slot_ct = AEAD(V_x, slot_nonce, CEK || priv_post, aad=post_id)`
- Append `WrapSlot { prefilter_tag, slot_nonce, slot_ct, tag }` to `wrap_slots`
5. Pad `wrap_slots` to next power of 2 with dummy slots.
6. Randomize slot order.
7. Sign post header with author identity key as normal.
---
## Decryption (reader side)
1. Receive post. Parse header → `pub_post`, `wrap_slots`.
2. For each `V_x` in reader's keyring (all personas):
- Compute `candidate_tag = HMAC(V_x, post_id)[:2B]`.
- For each slot with matching `prefilter_tag`, attempt `AEAD-open(V_x, slot.nonce, slot.ct)`.
- On success: extract `CEK`, `priv_post`. Decrypt body with CEK. Record which `(persona, V_x)` won (Layer 5 cache).
3. If no slot unwraps across all personas: mark post as unreadable (Layer 5 retry table).
Expected cost without prefilter: `keyring_size × slot_count` AEAD attempts. With 2B prefilter: `keyring_size × slot_count / 65536` AEAD attempts on average. For 500×500 = 250K attempts → ~4 attempts average. Prefilter is load-bearing for feasibility; see Layer 5.
---
## Receive-path integration
Extend `control::receive_post` with a new gate branch for `FoFClosed`:
- Verify author identity sig over header (as existing).
- Verify `wrap_slots` well-formedness: each slot has correct field sizes; slot count is power of 2.
- Accept without being able to decrypt. Non-FoF nodes still store and propagate the ciphertext as part of normal CDN replication. Decryption is a READ-side concern.
---
## Ciphertext propagation
Ciphertext `FoFClosed` posts ride the same CDN propagation as other encrypted posts. No change to `BlobHeaderDiff` / `file_holders`. Any node can hold and forward the ciphertext; only FoF-graph nodes can decrypt.
---
## Open questions
- **Slot size uniformity.** Real slots and dummy padding slots must be byte-identical-sized. Confirmed. TBD — OPUS: should we also pad the body length to a bucket to avoid length-based classification?
- **Prefilter false-positive cost.** 1/65536 false positive per slot. With 500 slots × reader iterating 500 keys, expected ~3.8 false-positive AEAD attempts per post. Acceptable.
- **Prefilter collision on legitimate hits.** Two different `V_x` could produce the same `prefilter_tag` for the same `post_id`. Reader just tries both. No correctness issue.
- **Slot-reuse across posts.** If the same `V_x` is used across many posts, an attacker can observe prefilter tags recur. Since `post_id` is in the HMAC input, tags differ per post. No leak.
- **Custom mode slot selection.** Does the author UI let them pick specific vouchers, specific groups of vouchers, or only "all held + own" vs "own only"? Lead leaning: initial UI = only the three preset levels (Friends-only / FoF / Public); custom ships as power-user option later.
- **Deduplication of `V_x` across personas.** If multiple personas hold the same `V_x`, do we include one slot or one-per-persona? Lead leaning: dedup at the `V_x` bytes level; one slot per unique key.
---
## Ship criteria for Layer 3
- `PostVisibility::FoFClosed` exists end-to-end.
- Author creation path generates ephemeral keypair, wraps CEK+priv_post under each eligible `V_x`, pads to power-of-2.
- Reader decryption path iterates personas × keyring with prefilter tag.
- `receive_post` accepts FoFClosed ciphertext without decrypting.
- UI surface: post composer has Public / Friends-only / FoF / Custom picker.
- Integration test: A posts FoFClosed. B (direct vouchee) reads. C (FoF via B) reads. D (unrelated) gets ciphertext, cannot decrypt.
- Performance: decryption completes within budget at 500-key keyring × 500-slot posts (see Layer 5 for the optimization work that makes this budget feasible).

View file

@ -0,0 +1,122 @@
# Layer 4 — Per-Post Keypair Rotation
**Scope**: Graceful rotation of `(priv_post, pub_post)` when the author's FoF set changes (new vouches granted, `V_me` rotated, or a vouchee removed). Old comments remain verifiable under the old `pub_post`; new comments require the new `pub_post`.
---
## Goal
- Author can update the FoF set of an existing post without deleting / recreating it.
- A `PostKeyRotation` record, signed by author identity key, carries a new `(priv_post', pub_post')` wrapped under the current keyring.
- Existing comments under old `pub_post` stay cryptographically valid.
- New comments must sign under `priv_post'`.
- Readers who were admitted under the OLD set but not the NEW one retain read access to the body (CEK didn't change) but can no longer produce accepted comments.
---
## Lead decisions
- **Body CEK is NOT rotated.** Only the signing keypair rotates. Rotation's purpose is to narrow (or widen) the *commenter* set going forward, not revoke read access to the body. Read access of already-distributed content is non-recoverable by design — if an author needs that, they delete the post.
- **Rotation is append-only.** Rotation records accumulate; `pub_post_n` is derived from the latest rotation record. Old `pub_post` values are retained for verifying already-posted comments.
- **Rotation is optional.** Simple case is a post with one immutable `pub_post`. Layer 4 adds the escape hatch; most posts never rotate.
- **Author-signed.** Only the post author (identity key) can rotate. Prevents an admitted commenter from rotating others out.
---
## Data model
### `PostKeyRotation` record
```rust
struct PostKeyRotation {
post_id: PostId,
rotation_index: u32, // monotonic; 0 = original keypair (implicit), 1 = first rotation, etc.
new_pub_post: [u8; 32],
new_wrap_slots: Vec<WrapSlot>, // wraps new priv_post under current keyring (same as post creation)
superseded_at: u64, // ms; rotation timestamp
sig: [u8; 64], // author identity-key signature over the above
}
```
Persisted as a sidecar to the post. TBD — OPUS: whether this lives in its own table (`post_key_rotations`) or as a serialized column on the post.
### Comment verification after rotation
Each comment's `group_sig` is verified against a specific `pub_post`. Determination rule:
- If `comment.created_at < rotation_1.superseded_at` → verify against `pub_post_0` (original).
- If `rotation_n.superseded_at ≤ comment.created_at < rotation_{n+1}.superseded_at` → verify against `rotation_n.new_pub_post`.
- If `comment.created_at ≥ latest_rotation.superseded_at` → verify against latest `new_pub_post`.
TBD — OPUS: time-based bucketing requires trusting `comment.created_at`. Alternative: comment carries an explicit `pub_post_index` field pointing at which keypair generation it's signed under. Lead leaning: **explicit index field in comment**, avoids clock-skew ambiguity.
### Extend `InlineComment` (from Layer 2)
```rust
struct InlineComment {
// ... existing fields ...
#[serde(default)]
pub_post_index: u32, // 0 for original keypair, n for rotation n
group_sig: ...,
vouch_mac: ...,
}
```
---
## Rotation flow (author side)
1. Author changes FoF-relevant state (new vouch granted, someone un-vouched, `V_me` rotated).
2. Author decides to re-gate a specific post's comments: UI action "rotate comment keys for this post."
3. Generate new `(priv_post', pub_post')`.
4. Re-wrap `priv_post'` under the author's CURRENT keyring (the same algorithm as initial post creation, Layer 3).
5. Build `PostKeyRotation` record, sign, publish.
6. Rotation record propagates via normal CDN (it's a diff on the post, same mechanism as engagement diffs).
---
## Reader/commenter side
- On receiving a `PostKeyRotation` record, readers store it keyed by `(post_id, rotation_index)`.
- At comment-creation time: look up the **latest** rotation record for the parent post; trial-decrypt the new slots; if success, use `priv_post'` to sign, set `pub_post_index` to latest index.
- At comment-verification time: look up the rotation referenced by `comment.pub_post_index`; verify against that `pub_post`.
If a reader can unwrap `rotation_n.wrap_slots` but NOT `rotation_{n+1}.wrap_slots`, they've been rotated out between n and n+1. They can still READ the body (CEK unchanged) and verify historical comments; they cannot author new comments after rotation n+1.
---
## Propagation
Rotation records are signed post-deltas. They reuse the existing `BlobHeaderDiff` propagation mechanism — the post header gains the rotation's `pub_post` and `wrap_slots`; readers pull updated posts through the normal CDN.
TBD — OPUS: whether rotation records are part of the post's BlobHeader diff (natural fit) or a separate post-referencing record (cleaner separation but more protocol surface).
---
## Edge cases
- **Multiple rotations in short succession.** Monotonic index + explicit `pub_post_index` on comments disambiguates. No clock dependency.
- **Comment authored against stale rotation.** If a commenter creates a comment under rotation n, but by the time it propagates, rotation n+1 exists — the comment is still valid against rotation n. Readers verify against the rotation the comment declares.
- **Attacker forges rotation.** Rotation is signed by author identity key. Forgery == identity key compromise, which is outside FoF scope.
- **Reader never sees rotation record but sees new comments.** Until the rotation record arrives, new comments appear "unverified." Filter-out at render leaves them as pending until rotation arrives. Standard eventually-consistent behavior.
---
## Open questions
- **Rotation on `V_me` rotation.** When a persona rotates their own `V_me`, do we auto-rotate every one of their open FoFClosed posts? Cost is O(posts × eligible_keys). Lead leaning: **no auto-rotation**; user opts in per-post. Posts without rotation continue to accept comments from the old keyring (which still holds the old `V_me`).
- **Garbage-collecting old rotation records.** They're needed to verify historical comments. Never GC'd? Or keep only most recent N? Lead leaning: keep all; historical comments don't re-verify often and the data is cheap.
- **UI for rotation.** "Update who can comment" button on post. Simple. No scheduling / batch rotation UI in v1.
- **Rotation without keyring change.** Could be used to kick out a specific commenter by rotating and manually excluding their winning `V_x`. But winning `V_x` isn't known to the author (wrap slots are anonymous). Practical effect: authors can widen or narrow the whole set, not surgically exclude one person, without additional support.
---
## Ship criteria for Layer 4
- `PostKeyRotation` record type exists end-to-end.
- Author UI action: "Update who can comment on this post."
- Rotation records propagate via CDN.
- Comment signing uses latest rotation's `priv_post`; `pub_post_index` attached.
- Comment verification routes to the correct `pub_post` via index.
- Back-compat: posts without rotation records are handled as `pub_post_index = 0` uniformly.
- Integration test: A posts FoFClosed. B comments (admitted). A vouches for C, rotates. C comments (admitted). Old B-comment still verifies; new B-comment still verifies (B still in keyring); new D-comment (never admitted) rejected.

View file

@ -0,0 +1,135 @@
# Layer 5 — Unlock Cache & Prefilter Optimization
**Scope**: Performance layer. Makes FoF post decryption feasible at realistic keyring sizes (target: 400500 keys × 400500 wrap slots per post). Three mechanisms: author-direct fast path, winning-`V_x`-per-author cache, and unreadable-posts retry table.
This layer is load-bearing, not optional. Without it, a modest keyring × slot matrix makes per-post unlock cost user-visible in feed scroll.
---
## Goal
- Trial-decryption cost on any single FoF post approaches O(1) once the reader has successfully read any prior post from that author.
- Author-direct posts (author = one of the reader's personas) skip wrap-slot iteration entirely.
- Posts that couldn't be decrypted when received get re-attempted automatically when the reader's keyring changes (new `V_x` received).
---
## Lead decisions
- **Cache the winning `(persona, V_x)` per author.** First time persona `P` decrypts an FoFClosed post from author `A` using `V_x`, remember the tuple. Next post from `A`: try that `(P, V_x)` first. Author almost always re-wraps under the same set.
- **Track unreadable posts.** If no key currently held unwraps a post, insert into `vouch_unreadable_posts` for later retry. Clearing this set is cheap and necessary — a newly-received `V_x` potentially unlocks an arbitrary number of old posts.
- **Author-direct fast path.** If `post.author` is one of the reader's persona IDs, the reader is the author and holds `priv_post` implicitly (author-local cache of per-post keypairs). No wrap-slot iteration needed.
- **Prefilter tag precompute per-post, not per-feed-fetch.** At ingest time (once per post received), compute the reader's full set of `HMAC(V_x, post_id)[:2B]` tags and note which slots have matching prefilter values. Cache that index. Avoids recomputing HMACs on every re-render.
---
## Data model
### `vouch_unlock_cache` table
```
vouch_unlock_cache(
reader_persona_id BLOB,
author_id BLOB,
winning_v_x_owner BLOB, -- who issued the V_x that unlocked
winning_v_x_epoch INTEGER,
last_hit_ms INTEGER,
hit_count INTEGER,
PRIMARY KEY (reader_persona_id, author_id)
)
```
One row per `(reader_persona, author)` pair. Updated on every successful unlock (bump hit_count, touch last_hit_ms). On miss-then-hit with a different `V_x`, overwrite the tuple.
### `vouch_unreadable_posts` table
```
vouch_unreadable_posts(
post_id BLOB PRIMARY KEY,
author_id BLOB,
first_seen_ms INTEGER,
last_attempt_ms INTEGER,
keyring_sig_at_attempt BLOB -- hash of reader's keyring state at last attempt; skip re-attempt if unchanged
)
```
When a post can't be decrypted, insert here. On keyring change (new `V_x` arrives), sweep this table and re-attempt with the new key only.
### `post_slot_prefilter_index` table (optional, perf-heavy workloads only)
```
post_slot_prefilter_index(
post_id BLOB,
slot_index INTEGER,
prefilter_tag BLOB(2),
PRIMARY KEY (post_id, slot_index)
)
```
Cached per-slot tags. On first ingest, parse and insert. Query by tag when trying to decrypt.
TBD — OPUS: whether this table is worth it. Alternative: keep `wrap_slots` as a parsed struct in memory on read; iterate linearly. At 500 slots × 20B overhead = 10KB per post header, in-memory iteration is probably faster than SQLite index lookup. Lead leaning: **skip the table; iterate in-memory after parsing**.
---
## Fast path algorithm
On incoming FoFClosed post from author `A`:
1. **Author-direct check**: Is `A` in reader's list of personas? If yes → reader authored it; pull `priv_post` from local author cache. Done.
2. **Cache lookup**: Query `vouch_unlock_cache` for `(any_persona, A)`. For each cached winning `V_x`:
- Compute `prefilter_tag = HMAC(V_x, post_id)[:2B]`.
- Find matching slot(s) in post's `wrap_slots`; attempt AEAD-open.
- On success → done. Update `last_hit_ms`, increment `hit_count`.
3. **Full scan fallback**: If cache missed, iterate reader's full keyring × wrap_slots with prefilter. On success → insert/update `vouch_unlock_cache`. Done.
4. **Unreadable**: If full scan fails → insert into `vouch_unreadable_posts`.
Step 2 expected cost: 1 HMAC + 1 AEAD attempt (hot path, recurring author).
Step 3 cost: `keyring_size × slot_count / 65536` AEAD attempts on average.
Step 1 cost: single table lookup.
---
## Keyring-change trigger
When a new `V_x` is inserted into `vouch_keys_received` (Layer 1 distribution):
1. Compute the prefilter tag for the new `V_x` against each unreadable post's ID.
2. For every matching slot in `vouch_unreadable_posts` entries → attempt AEAD-open with the new `V_x`.
3. On success → move row out of `vouch_unreadable_posts`, insert into `vouch_unlock_cache`, render post in feed.
4. Emit UI signal: "N new posts from your friends-of-friends are now readable."
Cost: proportional to size of `vouch_unreadable_posts`, but only fires when the user receives a new vouch (rare event).
---
## Feed-rendering budget
Target: feed scroll renders within existing budget (16ms/frame on desktop, 33ms on mobile).
- Post already cached (99% hit rate in steady state): ~microseconds per post.
- Cache miss (first post from a new author): tens of microseconds per post.
- Cold full-scan: milliseconds; ideally done once per post during ingest, not during render.
**Ingest-time decrypt vs render-time decrypt.** Lead leaning: **decrypt at ingest**. Store cleartext body keyed by post_id in a local `post_body_cache` (encrypted at rest by persona key). Feed rendering reads from cache; no per-frame crypto. TBD — OPUS: confirm ingest-time decrypt is acceptable for the privacy model (cleartext at rest is already standard for posts the user can read).
---
## Open questions
- **Cache invalidation on persona switch.** If user switches default persona, cache queries filter by reader_persona_id, so this is automatic. Check.
- **Multi-persona unlock priority.** If two personas could both decrypt the same post, which wins? Probably doesn't matter — whichever tries first. Consequence: comments on FoF posts default to the persona that decrypted (Layer 2 UX). TBD — OPUS: confirm deterministic iteration order of personas.
- **Cache size bounds.** `vouch_unlock_cache` is O(personas × authors_ever_read). Authors-ever-read can grow unbounded. GC old entries past some threshold? Lead leaning: no GC; memory cost is trivial at realistic scales.
- **Unreadable retry storm on large keyring arrival.** Receiving an entire bundle of new vouches at once (e.g., account import) could trigger a storm of retries. Throttle / batch. Lead leaning: background task, non-blocking on UI.
- **Negative-cache on bad `V_x`.** If a reader holds a `V_x` that has NO overlap with any post they've seen, every unreadable post retry pays the full prefilter scan. Store a "tried and failed" marker per `(post_id, V_x)` to skip. TBD — OPUS: is this worth the storage overhead? Realistically, new `V_x` arrivals are rare enough that the scan cost per arrival is acceptable.
---
## Ship criteria for Layer 5
- `vouch_unlock_cache` and `vouch_unreadable_posts` tables exist.
- Author-direct fast path: reader's own posts skip wrap-slot iteration.
- `(reader_persona, author)` cache hit → single AEAD attempt on known post from recurring author.
- New `V_x` arrival triggers retry sweep on `vouch_unreadable_posts`.
- Feed-scroll performance: no user-visible stutter at 500-key keyring × 500-slot posts.
- Integration benchmark: 100-post feed from 20 authors, 400-key keyring, 400-slot posts → total decrypt < 100ms cold, < 10ms warm.

View file

@ -0,0 +1,94 @@
# Layer 6 — Revocation & Rotation Cascades
**Status**: Stub. May not be in v1. Drafted for design review only.
**Scope**: Mechanism for a persona to un-vouch a specific vouchee without rotating `V_me` (which affects everyone), and for cascading rotations when a down-chain vouchee is un-vouched.
---
## The problem
In Layers 15, the only way to revoke a vouch is to rotate `V_me` and re-distribute to every remaining vouchee. This is:
- **Coarse**: rotates everyone to remove one.
- **Expensive at scale**: O(remaining_vouchees) DM sends.
- **Traceable**: observers can't see who, but a flurry of DMs hints at a change.
- **Cascading**: if Alice rotates her `V_me`, every FoFClosed post Alice authored that was open to "friends" was open under `V_alice_old`; new readers on the updated `V_alice_new` can't decrypt old posts. Post-level Layer 4 rotation fixes this only if the author re-wraps every post under the new set.
Layer 6 is where we explore whether any of these frictions is worth addressing, and at what cost.
---
## Out of scope for v1
Without Layer 6, here's what breaks vs. what holds:
- **Holds**: FoF gating works. Revocation works (via coarse `V_me` rotation). Privacy properties intact.
- **Breaks**: surgical un-vouch. If Alice wants to remove Bob but keep Charlie, she must rotate `V_alice`, re-give to Charlie. If she has 50 vouchees, that's 49 re-grants. Probably tolerable for a social-graph change that's inherently rare. Sharp corner only at large vouchee fanout.
Lead leaning: **v1 ships without Layer 6**. Revocation is via coarse `V_me` rotation. Revisit after usage data shows whether surgical revocation is load-bearing.
---
## Candidate designs (if we do Layer 6 later)
### Candidate A: Revocation List signed by voucher
Voucher publishes `(revoked_vouchee_id, since_epoch)`. Readers exclude posts whose `vouch_mac` identifies a chain through a revoked vouchee.
Drawbacks:
- Requires `vouch_mac` to be public (not just for author strict mode).
- Publishes the social graph — the thing we worked to keep private.
- Non-starter without Zero-Knowledge proofs.
### Candidate B: Per-vouchee key derivation
Instead of one `V_me`, derive a per-vouchee key `V_me_bob = HKDF(V_me_master, bob_id)`. To revoke Bob, rotate `V_me_master` (affects everyone again — same problem).
Alternative: author re-wraps posts under `V_me_current \ {V_me_bob}`. This is per-post rotation (Layer 4) with deliberate exclusion. Feasible but requires the author to know which slot was Bob's — which breaks anonymous wrap slots.
### Candidate C: Forward-secrecy ratchet
`V_me` ratchets forward on a cadence. Old vouchees retain access to content encrypted before revocation; lose access to new. Avoids explicit revocation. Close to Signal's group ratchet.
Complexity is significant. Would require a per-persona state machine, out-of-band sync of current epoch between persona's devices, etc.
### Candidate D: Accept the coarse rotation
Acknowledge `V_me` rotation IS the revocation primitive. Smooth the UX:
- UI: "Remove Bob from your Friends" → warns "This will re-distribute your Friends key to your other 49 Friends."
- Background rotation task handles the N DMs.
- Existing FoFClosed posts don't auto-re-wrap (remain under old `V_alice`). Author can opt to re-wrap specific posts via Layer 4.
Lead leaning: **Candidate D**. The "problem" is mostly UX friction, addressable with good affordances.
---
## Open design questions (deferred)
- Is there user demand for surgical revocation that doesn't rotate everyone?
- What's the actual fanout distribution? If p95 vouchee count is <20, coarse rotation is a ~20-DM operation tolerable.
- Does Candidate D's UX feel heavy enough that users avoid revoking at all? (Anti-pattern: graph accumulates stale vouches.)
- For Mode 2 (public posts with FoF comments), revocation has different urgency — author cares about who can still COMMENT, not about access to past content. Is surgical comment-only revocation simpler? (Hint: Layer 4 rotation on a specific post already does this coarsely — rotate `priv_post`, re-wrap under the narrowed set.)
---
## What Layer 6 should deliver IF we build it
Not decided. Placeholder:
- A defined revocation primitive (candidate selected).
- Cascading / not-cascading behavior specified.
- UI surface consistent with Layers 15.
- Does NOT introduce per-vouchee public identifiers in any wire format.
---
## Decision point
**Revisit after Layers 15 ship and 30 days of production usage.** Signals that would move Layer 6 into scope:
- p95 vouchee count > 50 (coarse rotation cost meaningfully high).
- User reports of "I want to remove X but not everyone."
- Privacy audit finding that the DM-flurry of coarse rotation leaks social-graph change timing.
Absent those, Layer 6 stays deferred.

View file

@ -6,6 +6,32 @@ See `CONTRIBUTING.md` for the protocol. See `AGENTS.md` for the Claude-specific
---
## 2026-04-23 — primary Claude (Lead) — `docs/fof-spec-skeleton`
**Started**: late April 23 UTC
**Instance**: Scott's primary Claude (Lead role)
**Issue**: none (spec-drafting work; hand-off to Opus for crypto fill-in)
**Branch**: `docs/fof-spec-skeleton`
**Scope**: Skeleton spec for Friend-of-Friend (FoF) post gating. Lays out the per-person vouch-key (`V_me`) primitive, four visibility levels (Public / Friends-only / FoF / Custom), Mode 1 (`FOF_CLOSED`) and Mode 2 (public post + FoF comments), and a six-layer implementation plan. Crypto byte layouts and algorithm specifics are marked `TBD — OPUS` for Opus to fill in.
**Completed in this session**:
- `docs/fof-spec/README.md` — top-level overview, user-facing model, design properties, layering plan, out-of-scope, glossary, integration with existing primitives.
- `docs/fof-spec/layer-1-vouch-primitive.md``V_x` keys, per-persona keyring, `VouchGrant` wire format (DM-wrapped).
- `docs/fof-spec/layer-2-mode2-fof-comments.md``CommentPolicy::FriendsOfFriends`, `pub_post` / `priv_post` / wrap-slot primitives, `group_sig` + `vouch_mac` on comments.
- `docs/fof-spec/layer-3-mode1-fof-closed.md``PostVisibility::FoFClosed`, wrap-slot byte layout, anonymous 2B prefilter, power-of-2 slot padding.
- `docs/fof-spec/layer-4-keypair-rotation.md``PostKeyRotation` record, explicit `pub_post_index` on comments, per-post re-gating.
- `docs/fof-spec/layer-5-prefilter-and-cache.md``vouch_unlock_cache`, `vouch_unreadable_posts`, author-direct fast path, keyring-change retry sweep.
- `docs/fof-spec/layer-6-revocation.md` — stub; candidate designs AD; Lead leaning is coarse-rotation with UX polish (Candidate D); revisit after 30 days of production data.
**Pending after this PR merges**:
- Opus review pass: fill in `TBD — OPUS` markers (AEAD specifier, key sizes, WrapSlot byte layout, prefilter tag algorithm confirmation, epoch granularity, etc.).
- Lead re-review after Opus fills in crypto.
- Per-layer branch schedule for implementation (Layer 1 ships first, independently exercised).
**Stopping point**: session ending after Lead self-merges `docs/fof-spec-skeleton` to master. Branch to be deleted locally + remote.
---
## 2026-04-23 — primary Claude (Lead) — `chore/workflow-adoption`
**Started**: late April 23 UTC