# PRD: zodia-circles — private-circle sharing

**Status:** shipped end-to-end, backend through GTK UI — `zodia-sync` (one unified `LogSync` engine, `ZodiaExtensions`), `zodia-pipeline` (shared decode/materialize path), `zodia-sdk` (`create_circle`/`open_circle`/`invite_to_circle`/`revoke_from_circle`/`share_interp_to_circle`/`circle_members`), `sdk/tests/features/circle_sharing.feature` (BDD-proven over the real network: an invited member reads a share, a never-invited peer doesn't), and a relm4/libadwaita UI (a Circles sidebar section with per-circle pages, and a share-to-circle picker on feed cards that creates a circle on the fly). Known gap: all of an author's circles currently share one log (see "Wiring into zodia-sync" below).
**Branch:** `main`
**Foundation required:** `docs/prd/p2panda-0.7-migration.md` (ships `p2panda-spaces`, `p2panda-auth`, `p2panda-encryption`, and p2panda-stream's `spaces` processor — all load-bearing here)

## Problem Statement

User stories 9 and 10 from `docs/prd/operations-and-streams-rearchitecture.md`:

> 9. As a Zodia user who shared an interpretation in a private friend circle, I want only members of that circle to be able to read it, so that I can share intimate readings without publishing to everyone.
>
> 10. As a Zodia user, I want to revoke a friend's access to my private circle and rotate the circle's key, so that I retain control of who reads my private contributions over time.

Today sharing is all-or-nothing public. The parent PRD's own sketch for this ("a new `zodia-circles` crate... wraps p2panda 0.6's group-encryption primitives") turned out to rest on a crate that doesn't exist at 0.6.x. Having done the 0.7 upgrade, the real primitives are `p2panda-auth` (group membership + per-member access levels), `p2panda-encryption` (the actual message/key-agreement crypto, with post-compromise security and optional forward secrecy), and `p2panda-spaces` (the crate that composes both into exactly the "space" concept a circle needs — its own description is literally "data encryption for multi-device groups").

## Solution

`zodia-circles` is a thin wrapper around `p2panda_spaces::{Manager, Space}`, not a reimplementation of group crypto. Confirmed by reading the actual 0.7.0 source (not assumed from crate names):

- **A circle IS a `Space`.** `Space::add`/`Space::remove` manage membership; `Space::members()` lists `(MemberId, Access<C>)`; `Space::publish(...)` encrypts an application message to the current group secret, all peers with at least `Read` access can decrypt.
- **Access levels come from `p2panda-auth`** (`Pull`/`Read`/`Write`/`Manage`), not something we invent. A circle owner has `Manage`; an invited friend gets `Read` (or `Write` if we want circles to support replies later — v1 only needs `Read`).
- **Key rotation on revocation is automatic**, not something `zodia-circles` implements. `Space::remove` triggers `p2panda-encryption`'s own group-secret rotation internally (confirmed via `p2panda-spaces`'s `repair_spaces`/`key_bundle_expired` machinery) — the parent PRD's assumption that we'd need to hand-roll "revoke + rotate" was wrong; it's a property of the library.
- **Storage needs nothing new.** `p2panda-store` 0.7.0 ships `groups_v1`, `key_registry_v1`, `key_secrets_v1`, and `spaces_v1` tables and implements `GroupsStore`/`KeyRegistryStore`/`KeySecretsStore`/`SpacesStore` directly on `SqliteStore` — the same `SqliteStore` instance `zodia-sync` already owns. No second database file, no schema we maintain ourselves.
- **Identity needs one new secret, not a new identity.** `p2panda_spaces::Credentials::from_keys(signing_key, identity_secret)` accepts our existing Zodia `SigningKey` directly; only `identity_secret` (an x25519 `SecretKey` used for key agreement) is new, generated once per device and persisted alongside the existing identity material in `zodia-crypto`.
- **The Pipeline gets a new stage, not a new pipeline.** `p2panda-stream` 0.7.0 ships a `spaces::Spaces` processor — a working `Processor<T>` impl for exactly this. It slots into `zodia-pipeline`'s existing processor chain for circle-topic traffic, decrypting `SpacesMessage`s before whatever comes next, the same way `DecodeProcessor` sits at the head of the existing InterpOp/DocOp chain.
- **Circle traffic is its own operation stream**, confirmed by `p2panda-spaces`'s own `Forge` pattern: a circle/space operation's extensions slot holds `SpacesArgs<C>` (`Operation<SpacesArgs<C>>`), not `zodia_ops::OpExtensions` — it cannot share a log with regular `InterpOp`/`DocOp` traffic (different `Operation<E>` at the type level, not just a routing choice). Matches the parent PRD's `Topic::from(blake3("circle:" || circle_id))` sketch — one log/topic per circle, separate from per-key and global topics.
- **`C` (access conditions) starts as `()`.** `p2panda-spaces`'s own `TestConditions = ()` is the minimal instantiation. Per-path/per-content access partitioning (the parent PRD's throwaway example: restricting `Read` to a path) is real but not needed for "share this reading with these five friends" — deferred, not designed around speculatively.
- **Concurrency resolution uses the provided `StrongRemoveResolver<C>`**, not a custom `Resolver` impl. p2panda-auth's own docs describe this as the "cautious" default (a member removed concurrently with new operations they authored has those operations invalidated) — the right default for a friends-and-family circle, and not something to second-guess without a concrete case that needs different semantics.

## User Stories

Reusing 9 and 10 verbatim from the parent PRD (Problem Statement above), plus:

3. As a Zodia developer, I want circle membership and key rotation implemented by calling into `p2panda-spaces` directly, not by hand-rolling group cryptography, so that Zodia's security properties are only as strong as its own code where that code is unavoidable, and inherited from a maintained library everywhere else.

## Implementation Decisions (draft — to be refined during BDD)

### `zodia-circles` crate shape

A new crate owning:

- `CircleManager` — thin wrapper over `p2panda_spaces::Manager<SqliteSpacesStore<CircleExtensions>, ZodiaForge, (), StrongRemoveResolver<()>>`, constructed once per `ZodiaClient` alongside the existing `ZodiaSyncNode`, sharing its `SqliteStore`.
- `ZodiaForge` — our `Forge<()>` impl, mirroring `p2panda-spaces`'s own `test_utils::TestForge`: signs a `Header<ZodiaExtensions>` (extensions wrapped as `ZodiaExtensions::Circle(args)` — see "Wiring into zodia-sync" below for why the extensions type isn't bare `SpacesArgs<()>`) with the local `SigningKey`, persists via `OperationStore::insert_operation` against `CIRCLE_LOG_ID`, same shape `zodia-sync`'s `store_and_associate` already uses for regular ops.
- Circle-facing API: `create_circle(name) -> CircleId`, `invite(circle_id, peer) `, `revoke(circle_id, peer)`, `share_to_circle(circle_id, InterpOp::Author{..})`, `members(circle_id)`. Exact shape TBD during BDD — this PRD fixes the *primitives underneath*, not the final Rust API, matching how `zodia-sdk.md` was drafted before its command surface was finalised.

### Wiring into `zodia-sync`: one `LogSync` engine for everything, not two

The original plan here was wrong, and shipped-then-reverted: `LogSync<S, L, E>` is monomorphic in its extensions type `E`, so the first instinct was "circle traffic is `Operation<CircleExtensions>`, a different `E` than `OpExtensions`, so give it its own `LogSync` instance." That compiled, its own tests passed, and it went into a real commit — but running two `LogSync` engines on the *same* iroh `Endpoint` silently breaks one of them. `p2panda-net::LogSync` registers a single **fixed** protocol id (`b"p2panda/log_sync/v1"`, independent of `E`) on the `Endpoint`; registering it twice doesn't multiplex, it overwrites — confirmed by reading `ToIrohEndpoint::RegisterProtocol`'s handler (a plain `HashMap::insert` keyed on `hash(alpn, network_id)`, no dependence on `E`), then verified empirically: `zodia-sdk`'s real two-client round-trip tests started failing the moment the second engine was merely *constructed*, before any circle topic was ever opened. Not a circles-only bug — every device running both engines loses its regular InterpOp/DocOp sync.

The fix: **one** `LogSync<SqliteStore, u64, ZodiaExtensions>` engine, where `ZodiaExtensions` (`zodia-ops`) is `enum { Interp(OpExtensions), Circle(SpacesArgs<()>) }` — the single wire-level extensions type for *everything*, interp/doc traffic and circle traffic alike, so there's only ever one protocol registration. `zodia-ops` depends on `p2panda-spaces` for the `SpacesArgs<()>` payload type (not on `zodia-circles`, which instead depends on `zodia-ops` — no cycle). `zodia-circles`'s `CircleExtensions` is now a type alias for `zodia_ops::ZodiaExtensions`; `CircleOperation`'s `Borrow<SpacesArgs<()>>` impl and a matching impl on `ZodiaExtensions` itself (for `SqliteSpacesStore`'s own bound) project out the `Circle` variant, `unreachable!()`ing on `Interp` (sound because both types are only ever constructed with the matching variant). `DecodeProcessor`/`MaterializationProcessor` needed no logic changes at all — they only ever touch `.body` (untouched by the extensions type) and `.header.extension::<Timestamp>()` (still works, `ZodiaExtensions`'s own `Extension<Timestamp>` impl delegates to the `Interp` variant, returns `None` for `Circle`) — just a mechanical type-parameter swap throughout `zodia-pipeline`.

`zodia-sync`'s single `open_topic`/forwarder now serves every topic kind (global, per-key, the circle directory, per-circle) — the forwarder branches on each received operation's own `ZodiaExtensions` variant to decide interp/doc handling (unchanged) vs. circle handling (persist + `CircleManager::process_and_persist` + surface `Event::Application` as `SyncEvent::CircleContentReceived`).

**Known limitation, deliberately not fixed in this slice**: every circle operation an author publishes — across *all* their circles — lives in one shared per-author log (`zodia_circles::CIRCLE_LOG_ID`, a fixed constant), the same convention `p2panda-spaces`'s own `test_utils::TestForge` uses (`DEFAULT_LOG_ID`). Because `TopicStore::associate` maps a topic to a *whole log*, a peer catching up on Circle A's topic will replicate that entire log — including (still-encrypted) operations belonging to Circle B, if the same author is in both. Content stays unreadable to a non-member, but existence/timing/volume of unrelated-circle activity leaks, and a receiving peer's own `process()` will error (harmlessly, just noisily) on ops referencing a space it doesn't know about. The correct fix — a log id derived per circle, mirroring `zodia_ops::log_id_for_key`'s already-established per-interp-key pattern — is real but non-trivial: `Forge::forge`'s signature is fixed by `p2panda-spaces` to take only `SpacesArgs<C>`, and a space's very first `Auth` (create) message carries a freshly-random `group_id`, not the caller-chosen `space_id`, so picking a log id from `args` alone doesn't cleanly work at every call site. Deferred as a named, tracked gap rather than solved partially — a candidate next slice, not blocking this one.

- **Outgoing**: `zodia-circles`'s own `create_circle`/`invite_to_circle`/`revoke_from_circle`/`share_to_circle` (see below) sign and persist via `ZodiaForge`. What they *don't* do is broadcast — same division of labour `publish_bytes` has today (persist, then hand to `SyncHandle::publish`). `broadcast_circle_op` is the missing step, using the same shared `handles` map every other topic uses.
- **Incoming**: same shape as the existing forwarder — `zodia_circles::persist_received_and_associate` on `TopicLogSyncEvent::OperationReceived`, then feed the operation to `zodia_circles::process_and_persist`. That call returns `p2panda_spaces::Event`s (`Application { data, .. }`, membership changes) — `Application`'s `data: Vec<u8>` is exactly the bytes an app-layer `Space::publish` call handed it, plaintext, post-decrypt.

### Wiring into `zodia-pipeline`: reuse the decode logic, not `p2panda-stream`'s `spaces::Spaces` processor

The original sketch here (composing p2panda-stream's `spaces::Spaces` processor into the same chain as `DecodeProcessor`) doesn't typecheck: that processor is `Processor<T> where T: Borrow<SpacesProcessorArgs<C>>`, and `ZodiaPipeline` is built as `Pipeline<...>` over `Operation<OpExtensions>` specifically — a different `T`. More fundamentally, decryption already happened by the time `zodia-sync` hands us an `Event::Application` — there's no separate "decrypt" pipeline stage left to add; `CircleManager::process` *is* that stage, just living in `zodia-circles`/`zodia-sync` rather than `zodia-pipeline`.

What a circle-shared interpretation needs downstream is the *same* decode-then-materialize logic `DecodeProcessor`/`MaterializationProcessor` already have for `InterpOp`/`DocOp` bytes — it just needs to run on `Event::Application`'s plaintext `data: Vec<u8>` instead of `Operation<OpExtensions>.body`. The fix is a refactor, not a new processor type: pull the `InterpOp`/`DocOp` match arms in `MaterializationProcessor::process` out into a free function taking `(op_id: Hash, author: VerifyingKey, timestamp: Timestamp, plaintext: &[u8]) -> StateEvent`, callable from both the existing `Operation<OpExtensions>` path and a new circle-content path. `op_id`/`author` for circle content come from the *circle* operation that carried the ciphertext (`CircleOperation`'s own hash/verifying_key, both already exposed via its `Digest`/`Provenance` impls) — the plaintext `InterpOp` itself carries neither. `timestamp` has no equivalent on a `CircleOperation` (its extensions slot is `SpacesArgs<C>`, not `OpExtensions`) — local receipt time (`Timestamp::now()` when the `Application` event arrives) is the practical stand-in, same reasoning as any other "when did I learn about this" display value.

### "Share with..." UI — shipped, and reshaped mid-build by real-time direction

Per the parent PRD: a picker on each contribution choosing public network (current behaviour) vs. a named circle. Shipped, but not as originally sketched — two explicit steers changed the shape while building it:

- **No bundled "Circles" management tab.** The first pass built exactly that (one static sidebar entry, one page listing every circle with create/invite/members all together) — reworked into a dedicated **Circles sidebar section**, one row per circle, each opening its own dynamically-added page (mirrors how connected-peer pages already work — a plain widget-builder function, `circle_page.rs`, not a relm4 sub-`Component`, since it only needs to fire `AppMsg`s on click). Ranks directly below Chart/Sky/Network and above the "Others" (direct-peer) section.
- **No standalone "create circle" form.** Circle creation is folded into the share flow itself: the new share button on a feed card's own contributions (visible under the same rule as the existing revoke button — your own `InterpOp::Author` only) opens a picker listing existing circles plus an entry to type a new name — picking a name creates the circle and shares to it in one step, "born out of" whatever's being shared rather than an abstract management action.

Local-only circle names live in `circles.tsv` (`circle_id_hex → name`), same flat-TSV pattern `nicknames.tsv` already established for "this identifier is a hash with no name."

### Small follow-ups: accent colors + access level at invite

Two low-risk additions on top of the shipped UI:

- **Deterministic per-circle accent color** (`circle_page::circle_accent_hex`), Signal/Matrix-style — derived from the id hash, needs no storage, rendered as a small Pango-markup dot (no `CssProvider` needed for one-off color) in both the sidebar row and the circle page header.
- **Read vs. read+write at invite time** — two buttons instead of one. `Write` access turns a one-way share into a small collaborative circle where the invitee can post their own `InterpOp`s back (identical plumbing to the owner's own share path, just a different `Access` level). **No in-place promotion**: confirmed by reading `p2panda-auth`'s `group::crdt::state::add` that it errors `AlreadyAdded` on an already-active member — changing someone's level today means revoke, then re-invite at the new level (which also rotates the group key, so it's a real round trip, not a cheap toggle). Real UX gap, left as such rather than worked around.

### Two things the real API taught us that no amount of reading source would have (found via the failing tests, not guessed)

- **`insert_operation` needs an open store transaction**, exactly like `zodia-sync`'s own pattern (`store_and_associate`) — `ZodiaForge::forge` wraps it in `store.begin()`/`store.commit()`. Confirmed by a real `TransactionMissing` failure, not assumed from reading `p2panda-spaces`'s own `TestForge` (which uses a `tx!` macro that hides the same requirement).
- **DCGKA key agreement needs each peer's prekey bundle registered on *both* sides**, not just the space creator's. Registering only Bob's bundle with Alice (enough for Alice to encrypt to Bob) produced `MissingPreKeys` when Bob tried to *decrypt* Alice's space-membership message — Bob's own manager needed Alice's bundle registered too, symmetric to how any Diffie-Hellman-style key agreement needs both sides to know the other's public material. `p2panda-spaces`'s own `tests.rs` does this correctly (`send_and_receive` registers both directions) — worth having read after finding the bug, but the bug was caught by an initially-too-narrow test, not by reading their test first.
- **Two `LogSync` engines on one `Endpoint` silently break one of them** — the biggest finding of this slice, its own subsection above ("Wiring into zodia-sync: one LogSync engine for everything, not two"), caught by a real regression in `zodia-sdk`'s existing round-trip tests, not by reading source first.
- **`ZodiaForge::forge` never calls `TopicStore::associate`** — it can't; `Forge::forge`'s signature (`fn forge(&self, args: SpacesArgs<C>)`) carries no topic. Every self-published circle op (including the key-bundle broadcast every device makes at startup) was invisible to a third peer's catch-up query until `broadcast_circle_op` (which *does* know the topic, since the caller supplies it) also associates. Caught by the circle-sharing cucumber scenario's "invites" step failing even after a full 25-second retry window — long enough to rule out mere timing.
- **Inviting someone only updates the inviter's side.** `invite_to_circle` broadcasts a membership change on the circle's topic — which reaches nobody if the invitee was never subscribed to it, and the invitee has no way to *learn* the topic except from messages sent on that same topic (circular). Added `ZodiaSyncNode::open_circle`/`ZodiaClient::open_circle` as an explicit "start listening" step the invitee calls once they've learned the circle's id some other way (a real invite notification/link is out of scope for this slice) — caught by the cucumber scenario's "Bob" never observing the share despite a successful invite.
- **The `_persisted` convenience methods (`create_space_persisted`, `add_persisted`, `remove_persisted`, `publish_persisted`, `process_persisted`, and the `set_groups_state`/`set_space_state` they call) are `#[cfg(any(test, feature = "test_utils"))]`-gated in the vendored 0.7.0 source** — invisible to a normal production dependency, only usable with `test_utils` enabled outside `cfg(test)`, which would also loosen `p2panda-auth`/`p2panda-encryption`'s own test-only escape hatches (e.g. `x25519::SecretKey::from_bytes` becomes constructible from arbitrary bytes) — not something to flip on for production code. The plain `create_space`/`add`/`remove`/`publish`/`process` are *not* gated, and return the state deltas the gated wrappers would have persisted, uncommitted. `zodia-circles` now has its own `create_circle`/`invite_to_circle`/`revoke_from_circle`/`share_to_circle`/`process_and_persist`, reading the gated wrappers' own source (still readable regardless of the cfg gate) to reproduce the exact same persist-after-mutate sequence against the ungated `p2panda_store::{GroupsStore, SpacesStore}` trait methods directly — including reproducing their internal fixed storage key (`Hash::digest(b"global-groups-context")`, a hardcoded string, not something generated) so reads/writes land in the same slot the library's own internal (also ungated) state-reading code expects. Proven equivalent by a real test (`production_path_without_persisted_sugar_still_shares_correctly`) exercising the full share/receive/decrypt round trip through only these production-safe entry points.

## Testing Decisions

Same model as `granular-topic-subscription.md`/`zodia-sdk.md`: unit tests for `zodia-circles` against `SqliteStore::temporary()` with no live network (`p2panda-spaces`'s own `test_utils::TestPeer` pattern is the template), then a real-network cucumber scenario in `sdk/tests/features/` proving the actual user-visible guarantee — a circle member reads a shared interpretation, a non-member does not, and a revoked member can't read anything published after revocation. No mocking of the crypto layer; if `p2panda-encryption`'s primitives have rough edges (the parent PRD's own risk callout), a real round-trip test is what surfaces them.

## Out of Scope

- Final `zodia-circles` public API surface (deferred to implementation, per above).
- Per-path/content access conditions (`C` beyond `()`).
- Multi-device identity sharing — per the parent PRD's own exclusion, each device keeps its own identity + its own new `identity_secret`; a device joining a circle a friend already invited "you" on a different device to is a separate, unaddressed problem.
- `zodia-channels` (the other untouched Phase D piece) — unrelated, no shared code.
- UI ("Share with..." picker) — follows once the underlying crate is real.

## Further Notes

**Why this PRD is thinner than `pruning.md` or `granular-topic-subscription.md`.** Those PRDs were written with the implementation essentially fully designed, because the primitives involved (SQL DELETE, per-key topic derivation) were already well understood from existing code. `p2panda-spaces` is new to this codebase and larger than either of those features — Manager/Space/Forge/Credentials/Config/Resolver is a wider API than a first read fully resolves. This PRD is deliberately scoped to *confirm the primitives are real and fit* (they are, closely) rather than pre-design every method signature; the BDD cycle that follows is where the remaining design questions (pipeline composition, the final circle-facing API) get resolved against a failing test, the same way every other feature this cycle has been built.
