Skip to content

zettelkasten.synapse.sharing.recipient_index

zettelkasten.synapse.sharing.recipient_index

Firm-scoped recipient-index policy export — the enabler for CROSS-PRODUCER synapse edges.

A cross-producer synapse edge is one whose ZK endpoint lives in a DIFFERENT producer's repo: a FEDERATED endpoint whose zk_source contains ":" (e.g. "remote:alpha"). Such an edge cannot be shared by the same-producer synapse path because the edge author (repo A) cannot compute the peer note's recipient set — A's federation cache only carries content peer B already filtered FOR A, never B's grant graph. The fix is this recipient-index export: a small policy artifact B publishes ALONGSIDE its ZK bundle that names, for exactly the notes B ships, which firm recipients may open each one. A then intersects that peer note policy with its own memory-entry recipients to decide who a cross-producer edge may reach (see :func:zettelkasten.synapse.sharing.config.edge_recipients and :func:zettelkasten.synapse.sharing.bundle.build_bundle).

Wire format (ONE place — this module)

The export is a sibling of the ZK bundle's manifest.json inside the SAME producer-owned bundle repo::

<bundle_root>/
    manifest.json               # existing ZK bundle manifest (untouched)
    units/<digest>.age          # existing ZK bundle units (untouched)
    recipient-index.json        # cleartext structural manifest (NO ids)
    recipient-index/<digest>.age  # ONE firm-encrypted ciphertext

The cleartext recipient-index.json carries only structural fields (format/format_version/created_at/optional producer_id and the opaque unit path) — never a note id or a recipient id. The mapping itself ({"<box>/<note_id>": [sorted recipient_ids], ...}) lives INSIDE the single age ciphertext, mirroring the privacy-first manifest discipline of the memory/zk/synapse bundles. The unit filename is a fixed sha256 digest so it is stable across republishes and opaque to anyone who does not already hold the bundle.

Scope + encryption

The export covers EXACTLY the notes B ships in its shared ZK bundle (the tightest scope B can compute for itself — "notes B already shares"), reusing the per-note recipient sets already resolved by :func:zettelkasten.sharing.bundle.build_bundle. The whole mapping is encrypted firm-wide — to the public keys of ALL registered firm members — so any firm member's angelo federation sync can decrypt it. This is a DELIBERATE, ACCEPTED membership leak: a firm member who can open the export learns which recipients are granted a note even if that member cannot open the note itself. The leak is bounded to the firm (a non-member / unregistered key cannot decrypt the export at all).

Forward-only revocation

Both sides rebuild from scratch each run. The producer removes and rewrites the recipient-index artifacts on every publish, so a note dropped from B's shared set simply stops appearing in the next export. The consumer rewrites (or, when the peer stops exporting / the key can no longer open it, REMOVES) the decrypted policy each sync. As with the parent bundles this is FORWARD-ONLY: a bundle repo's git history still retains old ciphertexts, so a leaked past export cannot be un-published — only superseded.

Revocation is bounded by the republish -> resync latency

A synapse build_bundle reads the DECRYPTED policy from the federation cache; it does NOT itself re-fetch B's export. So for cross-producer revocation to take effect, synapse publish MUST be preceded by angelo federation sync — the sync is what re-decrypts B's latest export (or removes a stranded one) into the cache. The window in which A may still ship a federated edge to a recipient B has revoked is therefore bounded to the combined producer republish -> consumer resync latency (i.e. A's sync cadence), never unbounded. Two mechanisms keep it bounded in practice (both driven by angelo federation sync):

  • the ZK bundle_subscriptions sync loop re-decrypts each subscribed peer's recipient-index every run (revocation for a still-subscribed peer propagates on the next sync); and
  • the sync then REMOVES a stranded _policy/note-recipients.json for any peer NOT in the ZK bundle_subscriptions set — i.e. the keep-set is exactly the REFRESHED set, the peers the ZK loop above re-decrypts. A peer's policy is refreshed by, and only by, that ZK loop, so retaining a policy for a peer the loop never touches would ship a stale (revoked) grant unboundedly. See :func:remove_recipient_index and the angelo federation sync CLI path.

Required precondition: a federated intersects peer MUST also be a ZK subscription

Because the ZK bundle_subscriptions loop is the SOLE path that refreshes (or removes) a peer's recipient-index, a synapse intersects peer that is NOT also a ZK bundle_subscription is never refreshed. Its cross-producer edges therefore FAIL CLOSED (its stale policy is cleaned by the cleanup step above, so :func:load_recipient_index returns None and the edge is omitted). This is intended, not a bug: it is the fail-closed direction for an unrefreshable peer. To SHIP a cross-producer edge to a peer, that peer must appear in BOTH the synapse intersects scope AND the ZK bundle_subscriptions. angelo federation sync emits an operator WARNING (never a failure) when a peer is in intersects but not in the ZK subscriptions, so the operator learns why its edges are silently omitted.

Id-agreement precondition (ZK sub id == federated_repos id == zk_source prefix)

The decrypted policy is WRITTEN under the ZK subscription id (the bundle_subscriptions entry's id, used as the federation-cache out_root dir), but LOADED under peer_id = split_namespace(zk_source)[0] — the federated_repos / intersects repo id derived from an edge's zk_source. For the lookup to hit, these three ids must be BYTE-IDENTICAL: the ZK subscription id, the federated_repos repo id, and the zk_source namespace prefix. If they diverge (e.g. a peer subscribed as alpha but intersected as alpha-notes) the load silently misses and the edge fails closed. This is an operational precondition the module cannot enforce; the intersects-not-subscribed WARNING above is the cheap detectable case (an intersects repo id with no matching ZK subscription id).

The stamp (:data:POLICY_STAMP_FILENAME) records the producer generation and the consumer decrypt time so this staleness is observable. Behaviour stays FAIL-CLOSED throughout: a missing/unresolvable policy omits the edge.

Accepted trust-model boundaries

Two properties are DELIBERATE and NOT defended cryptographically:

  1. The recipient-index is a TRUSTED assertion by the peer producer. Because age hides recipient identities, consumer A cannot independently verify B's recipient claims from the ciphertext. A malicious or compromised producer could OVERSTATE a note's grants and cause A to ship a federated edge to a firm member who cannot actually open the peer note — a both-ends id-secrecy break. This is bounded: the peer note id is already firm-visible via the firm-wide export itself, and a producer can always leak its own content/relationships anyway. There is no cryptographic binding to add here — with age's anonymous recipients it is infeasible — so the export is treated as trusted producer input, exactly like the accepted body-leak class of the parent bundles.
  2. Registry-consistency precondition. Cross-producer edges assume a single, byte-consistent firm .memory/identities.yaml across repos, so that firm expansion and id -> pubkey mapping agree on both sides. If the registries diverge (e.g. a member added in one repo but not another), an edge can be mis-delivered or omitted. Keeping the firm registry consistent across repos is an operational precondition, not something this module can enforce.

RecipientIndexSignatureError

Bases: ValueError

Raised by :func:decrypt_recipient_index when signature enforcement fails.

Under the fail-closed verification matrix (a registry is provided AND the producer's signing public key is registered), this is raised when either the attached signature does not verify against the decrypted canonical mapping bytes (TAMPER) or the manifest carries NO signature at all (DOWNGRADE). It is a :class:ValueError subclass so existing except ValueError callers keep catching it.

Source code in zettelkasten/synapse/sharing/recipient_index.py
class RecipientIndexSignatureError(ValueError):
    """Raised by :func:`decrypt_recipient_index` when signature enforcement fails.

    Under the fail-closed verification matrix (a ``registry`` is provided AND the
    producer's signing public key is registered), this is raised when either the
    attached signature does not verify against the decrypted canonical mapping
    bytes (TAMPER) or the manifest carries NO signature at all (DOWNGRADE). It is
    a :class:`ValueError` subclass so existing ``except ValueError`` callers keep
    catching it.
    """

RecipientIndexBuildResult dataclass

Summary of a :func:build_recipient_index run.

Source code in zettelkasten/synapse/sharing/recipient_index.py
@dataclass(frozen=True)
class RecipientIndexBuildResult:
    """Summary of a :func:`build_recipient_index` run."""

    output_dir: Path
    note_count: int
    recipient_ids: tuple[str, ...]
    skipped_unregistered_ids: tuple[str, ...]
    written: bool
    # True iff the export was published WITH an Ed25519 signature over the
    # canonical mapping bytes; False for an unsigned (no-signing-key) publish.
    signed: bool = False

RecipientIndexDecryptResult dataclass

Summary of a :func:decrypt_recipient_index run.

Source code in zettelkasten/synapse/sharing/recipient_index.py
@dataclass(frozen=True)
class RecipientIndexDecryptResult:
    """Summary of a :func:`decrypt_recipient_index` run."""

    output_dir: Path
    note_count: int

build_recipient_index

build_recipient_index(output_dir: Path | str, note_recipients: Mapping[str, set[str]], registry: IdentityRegistry, *, producer_id: str | None = None, signing_private_key: str | None = None) -> RecipientIndexBuildResult

Write the firm-encrypted recipient-index export as a ZK-bundle sibling.

note_recipients maps a "<box>/<note_id>" key to the recipient id set for EXACTLY the notes shipped in the ZK bundle (pass the already-resolved per-note recipients — do NOT invent a new scope). The mapping is encrypted once to ALL registered firm members (registry.member_ids()), so any firm member can decrypt it (the accepted, firm-bounded membership leak).

Owns output_dir/recipient-index.json and output_dir/recipient-index/: both are removed first and rewritten from scratch, so a revoked note never lingers. With an empty scope (no shared notes) or no registered members, the artifacts are simply left removed and written=False is returned.

Signing (tamper-evident, non-repudiable binding to the producer): when signing_private_key is given, the EXACT canonical plaintext bytes that get encrypted (json.dumps(mapping, sort_keys=True).encode()) are Ed25519-signed and the base64 signature is attached to the cleartext manifest alongside producer_id (the verifier resolves the producer's signing pubkey by producer_id). With no signing key the export is built UNSIGNED — a warning is logged, but the publish never fails (backward-compat).

Source code in zettelkasten/synapse/sharing/recipient_index.py
def build_recipient_index(
    output_dir: Path | str,
    note_recipients: Mapping[str, set[str]],
    registry: IdentityRegistry,
    *,
    producer_id: str | None = None,
    signing_private_key: str | None = None,
) -> RecipientIndexBuildResult:
    """Write the firm-encrypted recipient-index export as a ZK-bundle sibling.

    ``note_recipients`` maps a ``"<box>/<note_id>"`` key to the recipient id set
    for EXACTLY the notes shipped in the ZK bundle (pass the already-resolved
    per-note recipients — do NOT invent a new scope). The mapping is encrypted
    once to ALL registered firm members (``registry.member_ids()``), so any firm
    member can decrypt it (the accepted, firm-bounded membership leak).

    Owns ``output_dir/recipient-index.json`` and ``output_dir/recipient-index/``:
    both are removed first and rewritten from scratch, so a revoked note never
    lingers. With an empty scope (no shared notes) or no registered members, the
    artifacts are simply left removed and ``written=False`` is returned.

    Signing (tamper-evident, non-repudiable binding to the producer):
    when ``signing_private_key`` is given, the EXACT canonical ``plaintext`` bytes
    that get encrypted (``json.dumps(mapping, sort_keys=True).encode()``) are
    Ed25519-signed and the base64 ``signature`` is attached to the cleartext
    manifest alongside ``producer_id`` (the verifier resolves the producer's
    signing pubkey by ``producer_id``). With no signing key the export is built
    UNSIGNED — a warning is logged, but the publish never fails (backward-compat).
    """
    output_dir = Path(output_dir)
    units_dir = output_dir / UNITS_DIRNAME
    manifest_path = output_dir / MANIFEST_NAME

    # Rebuild-from-scratch: drop any prior export so revoked entries never linger.
    if units_dir.exists():
        shutil.rmtree(units_dir)
    _unlink_quietly(manifest_path)

    mapping: dict[str, list[str]] = {}
    for key, recips in note_recipients.items():
        k = str(key).strip()
        if not k or "/" not in k:
            continue
        ids = sorted({str(r).strip() for r in recips if str(r).strip()})
        if ids:
            mapping[k] = ids

    if not mapping:
        return RecipientIndexBuildResult(
            output_dir=output_dir, note_count=0, recipient_ids=(),
            skipped_unregistered_ids=(), written=False,
        )

    member_ids = sorted(registry.member_ids())
    pubkeys: list[str] = []
    skipped: set[str] = set()
    for rid in member_ids:
        pk = registry.public_key_for(rid)
        if pk:
            pubkeys.append(pk)
        else:
            skipped.add(rid)
    if not pubkeys:
        logger.warning(
            "recipient-index export: no registered firm members to encrypt to; "
            "omitting export"
        )
        return RecipientIndexBuildResult(
            output_dir=output_dir, note_count=0, recipient_ids=(),
            skipped_unregistered_ids=tuple(sorted(skipped)), written=False,
        )

    # The signed bytes MUST be EXACTLY the canonical mapping bytes that get
    # encrypted below — the verifier signs/verifies the same ``plaintext``.
    plaintext = json.dumps(mapping, sort_keys=True).encode("utf-8")

    signature: str | None = None
    if signing_private_key:
        try:
            signature = signing.sign(plaintext, signing_private_key)
        except ValueError as exc:
            # A malformed/empty signing key -> publish UNSIGNED rather than fail.
            logger.warning(
                "recipient-index export: signing key unusable (%s); publishing "
                "UNSIGNED", exc
            )
            signature = None
    else:
        logger.warning(
            "recipient-index export: no signing key available; publishing UNSIGNED "
            "(peers with a registered signing key for this producer will REJECT it)"
        )

    ciphertext = crypto.encrypt(plaintext, pubkeys)
    units_dir.mkdir(parents=True, exist_ok=True)
    filename = _unit_filename()
    atomic_write(units_dir / filename, ciphertext)

    manifest: dict[str, Any] = {
        "format": EXPORT_FORMAT,
        "format_version": EXPORT_FORMAT_VERSION,
        "created_at": datetime.now(timezone.utc).isoformat(),
        "unit": f"{UNITS_DIRNAME}/{filename}",
    }
    if producer_id:
        manifest["producer_id"] = str(producer_id)
    if signature:
        manifest["signature"] = signature
    atomic_write(manifest_path, json.dumps(manifest, indent=2))

    return RecipientIndexBuildResult(
        output_dir=output_dir,
        note_count=len(mapping),
        recipient_ids=tuple(rid for rid in member_ids if rid not in skipped),
        skipped_unregistered_ids=tuple(sorted(skipped)),
        written=True,
        signed=signature is not None,
    )

decrypt_recipient_index

decrypt_recipient_index(bundle_dir: Path | str, out_root: Path | str, *, identity: LocalIdentity | Any | None = None, private_key: str | None = None, registry: IdentityRegistry | None = None, expected_producer_id: str | None = None) -> RecipientIndexDecryptResult

Decrypt a peer's recipient-index export into its federation cache.

Reads <bundle_dir>/recipient-index.json + its ciphertext, decrypts with the local firm key, defensively validates the (untrusted) mapping, and writes <out_root>/.zettelkasten/_policy/note-recipients.json.

Signature verification (fail-closed, opt-in via registry): when registry is provided the Ed25519 signature over the decrypted canonical plaintext bytes is enforced against the producer's registered signing public key. expected_producer_id (the subscription/peer id the consumer believes it is syncing) binds the manifest to that peer:

  • expected_producer_id given (the CLI federation sync path): the manifest's producer_id MUST equal expected_producer_id (a missing or relabelled producer_id is rejected as TAMPER), and the signing key is resolved by expected_producer_id — never the manifest's self-asserted value. Then: registered key + signature -> must verify (else TAMPER); registered key + NO signature -> :class:RecipientIndexSignatureError (DOWNGRADE); NO registered key -> accept UNSIGNED (weaker mode, logged).
  • expected_producer_id None (API back-compat): the signing key is resolved by the manifest's own producer_id (a no-op when the manifest carries none), preserving the pre-P1 behaviour for existing registry= callers.
  • registry is None -> verification is SKIPPED entirely (pure backward compat; existing callers are unaffected).

A verification failure (TAMPER/DOWNGRADE/relabel) is a HARD failure: the :class:RecipientIndexSignatureError propagates AND any stale policy for this peer is REMOVED first, so a rejected index never leaves a stale/partial policy file behind (the edge then fails closed).

signing.verify() itself never raises (returns False); this function raises on a False result so a tampered/downgraded export fails closed.

Fail-safe / forward-only revocation:

  • MISSING manifest (peer no longer exports) or a mapping this key cannot open / that validates empty -> the stale policy file (and its stamp) is REMOVED (revocation).
  • a corrupt/unsafe manifest, a bad unit path, or a missing unit -> stale policy removed (nothing valid to trust).
  • a TRANSIENT I/O fault reading the manifest/unit -> the existing policy is PRESERVED untouched and retried next sync.

On success the policy is (over)written AND a freshness stamp sidecar (:data:POLICY_STAMP_FILENAME) is written carrying the producer's manifest created_at/producer_id and this decrypt's timestamp, so a downstream caller can observe how old the policy it trusts is.

Source code in zettelkasten/synapse/sharing/recipient_index.py
def decrypt_recipient_index(
    bundle_dir: Path | str,
    out_root: Path | str,
    *,
    identity: LocalIdentity | Any | None = None,
    private_key: str | None = None,
    registry: IdentityRegistry | None = None,
    expected_producer_id: str | None = None,
) -> RecipientIndexDecryptResult:
    """Decrypt a peer's recipient-index export into its federation cache.

    Reads ``<bundle_dir>/recipient-index.json`` + its ciphertext, decrypts with
    the local firm key, defensively validates the (untrusted) mapping, and writes
    ``<out_root>/.zettelkasten/_policy/note-recipients.json``.

    Signature verification (fail-closed, opt-in via ``registry``):
    when ``registry`` is provided the Ed25519 signature over the decrypted
    canonical ``plaintext`` bytes is enforced against the producer's registered
    signing public key. ``expected_producer_id`` (the subscription/peer id the
    consumer believes it is syncing) binds the manifest to that peer:

    * ``expected_producer_id`` given (the CLI ``federation sync`` path): the
      manifest's ``producer_id`` MUST equal ``expected_producer_id`` (a missing or
      relabelled producer_id is rejected as TAMPER), and the signing key is
      resolved by ``expected_producer_id`` — never the manifest's self-asserted
      value. Then: registered key + signature -> must verify (else TAMPER);
      registered key + NO signature -> :class:`RecipientIndexSignatureError`
      (DOWNGRADE); NO registered key -> accept UNSIGNED (weaker mode, logged).
    * ``expected_producer_id`` None (API back-compat): the signing key is resolved
      by the manifest's own ``producer_id`` (a no-op when the manifest carries
      none), preserving the pre-P1 behaviour for existing ``registry=`` callers.
    * ``registry is None`` -> verification is SKIPPED entirely (pure backward
      compat; existing callers are unaffected).

    A verification failure (TAMPER/DOWNGRADE/relabel) is a HARD failure: the
    :class:`RecipientIndexSignatureError` propagates AND any stale policy for this
    peer is REMOVED first, so a rejected index never leaves a stale/partial policy
    file behind (the edge then fails closed).

    ``signing.verify()`` itself never raises (returns ``False``); this function
    raises on a ``False`` result so a tampered/downgraded export fails closed.

    Fail-safe / forward-only revocation:

    * MISSING manifest (peer no longer exports) or a mapping this key cannot open
      / that validates empty -> the stale policy file (and its stamp) is REMOVED
      (revocation).
    * a corrupt/unsafe manifest, a bad unit path, or a missing unit -> stale
      policy removed (nothing valid to trust).
    * a TRANSIENT I/O fault reading the manifest/unit -> the existing policy is
      PRESERVED untouched and retried next sync.

    On success the policy is (over)written AND a freshness stamp sidecar
    (:data:`POLICY_STAMP_FILENAME`) is written carrying the producer's manifest
    ``created_at``/``producer_id`` and this decrypt's timestamp, so a downstream
    caller can observe how old the policy it trusts is.
    """
    bundle_dir = Path(bundle_dir)
    policy_path = _policy_path(out_root)
    manifest_path = bundle_dir / MANIFEST_NAME

    try:
        raw = manifest_path.read_text(encoding="utf-8")
    except FileNotFoundError:
        _remove_policy(policy_path)
        return RecipientIndexDecryptResult(policy_path.parent, 0)
    except OSError as exc:
        logger.warning(
            "recipient-index manifest unreadable (transient I/O): %s; preserving "
            "existing policy", exc
        )
        return RecipientIndexDecryptResult(policy_path.parent, 0)

    try:
        manifest = json.loads(raw)
    except (json.JSONDecodeError, ValueError):
        logger.warning("recipient-index manifest corrupt; removing stale policy")
        _remove_policy(policy_path)
        return RecipientIndexDecryptResult(policy_path.parent, 0)
    if not isinstance(manifest, dict):
        _remove_policy(policy_path)
        return RecipientIndexDecryptResult(policy_path.parent, 0)

    unit_path = _resolve_unit_path(bundle_dir, str(manifest.get("unit") or ""))
    if unit_path is None:
        _remove_policy(policy_path)
        return RecipientIndexDecryptResult(policy_path.parent, 0)
    if not unit_path.exists():
        logger.warning("recipient-index unit missing on disk: %s", unit_path)
        _remove_policy(policy_path)
        return RecipientIndexDecryptResult(policy_path.parent, 0)
    try:
        ciphertext = unit_path.read_bytes()
    except OSError as exc:
        logger.warning(
            "recipient-index unit unreadable (transient I/O): %s; preserving "
            "existing policy", exc
        )
        return RecipientIndexDecryptResult(policy_path.parent, 0)

    key = _resolve_private_key(identity, private_key)
    try:
        plaintext = crypto.decrypt(ciphertext, key)
    except crypto.DecryptError:
        # Not a firm member (cannot open the firm-wide export): no valid policy.
        _remove_policy(policy_path)
        return RecipientIndexDecryptResult(policy_path.parent, 0)
    except Exception as exc:  # pragma: no cover - defensive
        logger.warning("recipient-index decrypt failed: %s", exc)
        _remove_policy(policy_path)
        return RecipientIndexDecryptResult(policy_path.parent, 0)

    # Fail-closed signature verification (opt-in via ``registry``). Enforced over
    # the EXACT decrypted ``plaintext`` bytes — the same canonical mapping bytes
    # the producer signed. This raises (fail-closed) BEFORE the mapping is trusted
    # / persisted, so a tampered or downgraded export never reaches
    # _validate_mapping. On rejection the stale policy is REMOVED before the error
    # propagates, so a rejected index leaves NO stale/partial policy behind.
    try:
        _verify_signature(
            manifest, plaintext, registry,
            expected_producer_id=expected_producer_id,
        )
    except RecipientIndexSignatureError:
        _remove_policy(policy_path)
        raise

    mapping = _validate_mapping(plaintext)
    if not mapping:
        _remove_policy(policy_path)
        return RecipientIndexDecryptResult(policy_path.parent, 0)

    atomic_write(
        policy_path,
        json.dumps(mapping, sort_keys=True, indent=2).encode("utf-8"),
    )
    # Freshness stamp (best-effort): a plain sidecar so the policy file's shape is
    # untouched. Carries the producer's generation (manifest ``created_at`` /
    # ``producer_id``) plus this decrypt's timestamp and note count.
    stamp: dict[str, Any] = {
        "format": EXPORT_FORMAT,
        "format_version": EXPORT_FORMAT_VERSION,
        "decrypted_at": datetime.now(timezone.utc).isoformat(),
        "note_count": len(mapping),
    }
    created_at = manifest.get("created_at")
    if isinstance(created_at, str) and created_at.strip():
        stamp["created_at"] = created_at
    producer_id = manifest.get("producer_id")
    if isinstance(producer_id, str) and producer_id.strip():
        stamp["producer_id"] = producer_id
    try:
        atomic_write(
            _stamp_path(policy_path),
            json.dumps(stamp, sort_keys=True, indent=2).encode("utf-8"),
        )
    except OSError as exc:  # pragma: no cover - defensive
        logger.warning("could not write recipient-index freshness stamp: %s", exc)
    return RecipientIndexDecryptResult(policy_path.parent, len(mapping))

load_recipient_index

load_recipient_index(cache_root: Path | str, peer_id: str) -> dict[str, set[str]] | None

Load a peer's DECRYPTED recipient-index policy from the federation cache.

Reads <cache_root>/<peer_id>/.zettelkasten/_policy/note-recipients.json, re-validates it defensively (belt-and-suspenders over the decrypt-time validation), and returns a {"<box>/<note_id>": {recipient_ids}} map — or None when absent/unreadable/empty/invalid (the caller then FAILS CLOSED, omitting the cross-producer edge).

peer_id is derived from an edge's zk_source and is ID_RE-validated before it is turned into a path (defence-in-depth over :func:safe_join); a malformed peer id fails closed with None.

Source code in zettelkasten/synapse/sharing/recipient_index.py
def load_recipient_index(
    cache_root: Path | str, peer_id: str
) -> dict[str, set[str]] | None:
    """Load a peer's DECRYPTED recipient-index policy from the federation cache.

    Reads ``<cache_root>/<peer_id>/.zettelkasten/_policy/note-recipients.json``,
    re-validates it defensively (belt-and-suspenders over the decrypt-time
    validation), and returns a ``{"<box>/<note_id>": {recipient_ids}}`` map — or
    ``None`` when absent/unreadable/empty/invalid (the caller then FAILS CLOSED,
    omitting the cross-producer edge).

    ``peer_id`` is derived from an edge's ``zk_source`` and is ID_RE-validated
    before it is turned into a path (defence-in-depth over :func:`safe_join`); a
    malformed peer id fails closed with ``None``.
    """
    pid = _valid_peer_id(peer_id)
    if pid is None:
        return None
    try:
        policy_path = safe_join(
            Path(cache_root), pid, ".zettelkasten", POLICY_DIRNAME, POLICY_FILENAME
        )
    except ValueError:
        return None
    try:
        raw = policy_path.read_bytes()
    except (FileNotFoundError, OSError):
        return None
    mapping = _validate_mapping(raw)
    if not mapping:
        return None
    return {key: set(ids) for key, ids in mapping.items()}

load_recipient_index_stamp

load_recipient_index_stamp(cache_root: Path | str, peer_id: str) -> dict[str, Any] | None

Load a peer's recipient-index freshness stamp, or None if absent/bad.

Returns the parsed :data:POLICY_STAMP_FILENAME sidecar (a dict carrying the producer generation created_at/producer_id and the consumer decrypted_at) so a caller can observe how stale the policy it trusts is. peer_id is ID_RE-validated exactly as in :func:load_recipient_index.

Source code in zettelkasten/synapse/sharing/recipient_index.py
def load_recipient_index_stamp(
    cache_root: Path | str, peer_id: str
) -> dict[str, Any] | None:
    """Load a peer's recipient-index freshness stamp, or ``None`` if absent/bad.

    Returns the parsed :data:`POLICY_STAMP_FILENAME` sidecar (a dict carrying the
    producer generation ``created_at``/``producer_id`` and the consumer
    ``decrypted_at``) so a caller can observe how stale the policy it trusts is.
    ``peer_id`` is ID_RE-validated exactly as in :func:`load_recipient_index`.
    """
    pid = _valid_peer_id(peer_id)
    if pid is None:
        return None
    try:
        stamp_path = safe_join(
            Path(cache_root), pid, ".zettelkasten", POLICY_DIRNAME,
            POLICY_STAMP_FILENAME,
        )
    except ValueError:
        return None
    try:
        raw = stamp_path.read_bytes()
    except (FileNotFoundError, OSError):
        return None
    try:
        data = json.loads(raw.decode("utf-8"))
    except (UnicodeDecodeError, json.JSONDecodeError):
        return None
    return data if isinstance(data, dict) else None

remove_recipient_index

remove_recipient_index(cache_root: Path | str, peer_id: str) -> bool

Remove a peer's decrypted recipient-index policy (+ stamp). Returns True if present.

Cleanup-on-unsubscribe: the recipient-index is refreshed ONLY for peers in the ZK bundle_subscriptions set (its sole refresh path). When a peer leaves that set it is no longer refreshed, so its cached policy would otherwise linger and keep shipping a revoked federated edge unboundedly. Removing it bounds cross-producer revocation to the config change (fail-closed thereafter). A peer that is only in the synapse intersects scope but NOT ZK-subscribed is likewise never refreshed and so is cleaned here (its edges then fail closed).

ONLY the _policy/note-recipients.json file and its stamp sidecar are touched — never the peer's .memory/.zettelkasten/.synapse caches or anything else in the federation cache. peer_id is ID_RE-validated first.

Source code in zettelkasten/synapse/sharing/recipient_index.py
def remove_recipient_index(cache_root: Path | str, peer_id: str) -> bool:
    """Remove a peer's decrypted recipient-index policy (+ stamp). Returns True if present.

    Cleanup-on-unsubscribe: the recipient-index is refreshed ONLY for peers in the
    ZK ``bundle_subscriptions`` set (its sole refresh path). When a peer leaves that
    set it is no longer refreshed, so its cached policy would otherwise linger and
    keep shipping a revoked federated edge unboundedly. Removing it bounds
    cross-producer revocation to the config change (fail-closed thereafter). A peer
    that is only in the synapse ``intersects`` scope but NOT ZK-subscribed is
    likewise never refreshed and so is cleaned here (its edges then fail closed).

    ONLY the ``_policy/note-recipients.json`` file and its stamp sidecar are
    touched — never the peer's ``.memory``/``.zettelkasten``/``.synapse`` caches or
    anything else in the federation cache. ``peer_id`` is ID_RE-validated first.
    """
    pid = _valid_peer_id(peer_id)
    if pid is None:
        return False
    try:
        policy_path = safe_join(
            Path(cache_root), pid, ".zettelkasten", POLICY_DIRNAME, POLICY_FILENAME
        )
    except ValueError:
        return False
    existed = policy_path.exists() or _stamp_path(policy_path).exists()
    _remove_policy(policy_path)
    return existed