Skip to content

zettelkasten.synapse.sharing.config

zettelkasten.synapse.sharing.config

Sharing config parsing + edge-recipient resolution for the synapse store.

This is the policy half of encryption-first synapse-edge sharing. Unlike the memory and zettelkasten sharing configs — which each own a scopes/grants vocabulary keyed on their own units — a synapse EDGE has NO independent sharing policy of its own. An edge is a bipartite record naming BOTH a memory entry and a ZK note, so who may see it is fully DERIVED from the two underlying stores: an edge ships to a recipient iff that recipient can already open BOTH endpoints.

The only synapse-local config is the plumbing needed to publish/subscribe a bundle, parsed here from .synapse/config.yaml alongside the existing intersects: block (see :mod:zettelkasten.synapse.config, left untouched):

sharing:
  bundle_repo: ../my-synapse-bundle    # producer: where to write the bundle
  producer_id: alpha                    # optional attribution stamp
  recipient_index_max_age_hours: 36    # opt-in fail-closed staleness TTL
bundle_subscriptions:                   # consumer: bundles to fetch+decrypt
  - id: remote
    path: ../peer-synapse-bundle        # or: remote: git@...

edge_recipients is THE id-secrecy control: it intersects the two stores' per-unit recipient maps, so an edge naming memory id M and ZK note Z reaches ONLY recipients granted BOTH M and Z. For a FEDERATED <repo>:<graph> endpoint (zk_source containing ":") the ZK end lives in a PEER repo, so the note recipients come not from the local map but from an optional peer-policy map supplied by the caller (a decrypted recipient-index export; see :mod:zettelkasten.synapse.sharing.recipient_index). When no such peer policy is supplied (or it lacks the note) the function FAILS CLOSED — returning the empty set exactly as it always has — so the default behaviour is unchanged.

SynapseSharingConfig dataclass

Parsed synapse sharing plumbing (no per-edge scopes — those are derived).

  • bundle_repo — producer-owned repo path the publisher writes into.
  • producer_id — optional attribution id stamped in the bundle manifest.
  • bundle_subscriptions — consumer subscription dicts (each an id plus a local path or a git remote), mirroring the memory/zk shape.
  • recipient_index_max_age_hours — OPT-IN fail-closed staleness TTL (default None = disabled). When set to a positive number, synapse publish REFUSES to publish if any cross-producer edge relies on a peer recipient-index policy that is missing or older than this many hours (a "publish-requires-recent-sync" gate). Left unset, publish keeps its default forward-only behaviour and only emits a non-fatal staleness warning.
Source code in zettelkasten/synapse/sharing/config.py
@dataclass(frozen=True)
class SynapseSharingConfig:
    """Parsed synapse sharing plumbing (no per-edge scopes — those are derived).

    * ``bundle_repo`` — producer-owned repo path the publisher writes into.
    * ``producer_id`` — optional attribution id stamped in the bundle manifest.
    * ``bundle_subscriptions`` — consumer subscription dicts (each an ``id`` plus
      a local ``path`` or a git ``remote``), mirroring the memory/zk shape.
    * ``recipient_index_max_age_hours`` — OPT-IN fail-closed staleness TTL (default
      ``None`` = disabled). When set to a positive number, ``synapse publish``
      REFUSES to publish if any cross-producer edge relies on a peer
      recipient-index policy that is missing or older than this many hours (a
      "publish-requires-recent-sync" gate). Left unset, publish keeps its default
      forward-only behaviour and only emits a non-fatal staleness *warning*.
    """

    bundle_repo: str | None = None
    producer_id: str | None = None
    bundle_subscriptions: tuple[Mapping[str, Any], ...] = field(default_factory=tuple)
    recipient_index_max_age_hours: float | None = None

config_path

config_path() -> Path

Absolute path to .synapse/config.yaml.

Source code in zettelkasten/synapse/config.py
def config_path() -> Path:
    """Absolute path to ``.synapse/config.yaml``."""
    return synapse_dir() / "config.yaml"

read_config

read_config() -> dict[str, Any]

Read the synapse config, returning {} when missing or malformed.

Source code in zettelkasten/synapse/config.py
def read_config() -> dict[str, Any]:
    """Read the synapse config, returning ``{}`` when missing or malformed."""
    path = config_path()
    if not path.exists():
        return {}
    try:
        data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
    except (OSError, yaml.YAMLError) as exc:
        logger.warning("Could not read synapse config %s: %s", path, exc)
        return {}
    return data if isinstance(data, dict) else {}

synapse_dir

synapse_dir() -> Path

Absolute path to the committed .synapse/ directory.

Source code in zettelkasten/synapse/config.py
def synapse_dir() -> Path:
    """Absolute path to the committed ``.synapse/`` directory."""
    return _anchor(Path(os.environ.get("SYNAPSE_DIR", ".synapse")))

parse_sharing_config

parse_sharing_config(config: Mapping[str, Any] | None) -> SynapseSharingConfig

Parse the sharing block + bundle_subscriptions of a config dict.

config is the plain dict read from .synapse/config.yaml (via :func:read_config). Missing/malformed blocks yield an empty config (nothing to publish or subscribe) — never raises on a partial config. The existing intersects: block is ignored here (owned by :mod:zettelkasten.synapse.config).

Source code in zettelkasten/synapse/sharing/config.py
def parse_sharing_config(config: Mapping[str, Any] | None) -> SynapseSharingConfig:
    """Parse the ``sharing`` block + ``bundle_subscriptions`` of a config dict.

    ``config`` is the plain dict read from ``.synapse/config.yaml`` (via
    :func:`read_config`). Missing/malformed blocks yield an empty config
    (nothing to publish or subscribe) — never raises on a partial config. The
    existing ``intersects:`` block is ignored here (owned by
    :mod:`zettelkasten.synapse.config`).
    """
    if not isinstance(config, Mapping):
        return SynapseSharingConfig()

    block = config.get(SHARING_KEY)
    block = block if isinstance(block, Mapping) else {}
    bundle_repo = str(block.get("bundle_repo") or "").strip() or None
    producer_id = str(block.get("producer_id") or "").strip() or None

    # Opt-in fail-closed TTL: only a POSITIVE finite number enables it; anything
    # else (missing, non-numeric, bool, <= 0) leaves it disabled (None) so a
    # malformed value silently degrades to today's warn-only behaviour rather than
    # accidentally blocking every publish.
    max_age = None
    raw_ttl = block.get("recipient_index_max_age_hours")
    if raw_ttl is not None and not isinstance(raw_ttl, bool):
        try:
            val = float(raw_ttl)
        except (TypeError, ValueError):
            val = None
        if val is not None and val > 0:
            max_age = val

    raw_subs = config.get(BUNDLE_SUBSCRIPTIONS_KEY)
    subs: tuple[Mapping[str, Any], ...] = ()
    if isinstance(raw_subs, list):
        subs = tuple(s for s in raw_subs if isinstance(s, Mapping))

    return SynapseSharingConfig(
        bundle_repo=bundle_repo,
        producer_id=producer_id,
        bundle_subscriptions=subs,
        recipient_index_max_age_hours=max_age,
    )

edge_recipients

edge_recipients(edge: Mapping[str, Any], entry_recipients_map: Mapping[str, set[str]], note_recipients_map: Mapping[str, set[str]], peer_note_recipients_map: Mapping[str, set[str]] | None = None) -> set[str]

Recipients who may see a synapse edge: the INTERSECTION of both endpoints.

An edge names a memory entry (memory_id) and a ZK note (zk_source/zk_id). It reaches a recipient R iff R can open BOTH — so the recipient set is entry_recipients_map[memory_id] ∩ (that ZK note's recipients). This intersection is the whole id-secrecy property: an edge names ids from two stores, so shipping it to anyone who lacks EITHER end would leak an id they were never granted.

The ZK note's recipients come from one of two maps, keyed by "<zk_source>/<zk_id>":

  • SAME-PRODUCER edge (zk_source has no ":") — the local note_recipients_map, exactly as before. peer_note_recipients_map is ignored, so same-producer behaviour is UNCHANGED.
  • CROSS-PRODUCER / FEDERATED edge (zk_source contains ":", e.g. "remote:alpha") — the ZK end lives in a peer repo, so its recipients are looked up in the supplied peer_note_recipients_map (a decrypted recipient-index export; see :mod:zettelkasten.synapse.sharing.recipient_index).

Returns the EMPTY set (→ the caller omits the edge) when:

  • memory_id, zk_id or zk_source is missing/blank;
  • the edge is federated and peer_note_recipients_map is absent/empty or does not contain the note — FAIL CLOSED (the default when no peer policy is supplied, preserving today's "omit federated" behaviour);
  • either endpoint is unresolvable (absent from its recipient map, i.e. private/ungranted); or
  • the intersection itself is empty (no recipient sees both ends).
Source code in zettelkasten/synapse/sharing/config.py
def edge_recipients(
    edge: Mapping[str, Any],
    entry_recipients_map: Mapping[str, set[str]],
    note_recipients_map: Mapping[str, set[str]],
    peer_note_recipients_map: Mapping[str, set[str]] | None = None,
) -> set[str]:
    """Recipients who may see a synapse edge: the INTERSECTION of both endpoints.

    An edge names a memory entry (``memory_id``) and a ZK note
    (``zk_source``/``zk_id``). It reaches a recipient R iff R can open BOTH — so
    the recipient set is ``entry_recipients_map[memory_id]`` ∩ (that ZK note's
    recipients). This intersection is the whole id-secrecy property: an edge names
    ids from two stores, so shipping it to anyone who lacks EITHER end would leak
    an id they were never granted.

    The ZK note's recipients come from one of two maps, keyed by
    ``"<zk_source>/<zk_id>"``:

    * SAME-PRODUCER edge (``zk_source`` has no ``":"``) — the local
      ``note_recipients_map``, exactly as before. ``peer_note_recipients_map`` is
      ignored, so same-producer behaviour is UNCHANGED.
    * CROSS-PRODUCER / FEDERATED edge (``zk_source`` contains ``":"``, e.g.
      ``"remote:alpha"``) — the ZK end lives in a peer repo, so its recipients are
      looked up in the supplied ``peer_note_recipients_map`` (a decrypted
      recipient-index export; see
      :mod:`zettelkasten.synapse.sharing.recipient_index`).

    Returns the EMPTY set (→ the caller omits the edge) when:

    * ``memory_id``, ``zk_id`` or ``zk_source`` is missing/blank;
    * the edge is federated and ``peer_note_recipients_map`` is absent/empty or
      does not contain the note — FAIL CLOSED (the default when no peer policy is
      supplied, preserving today's "omit federated" behaviour);
    * either endpoint is unresolvable (absent from its recipient map,
      i.e. private/ungranted); or
    * the intersection itself is empty (no recipient sees both ends).
    """
    if not isinstance(edge, Mapping):
        return set()
    memory_id = str(edge.get("memory_id") or "").strip()
    zk_id = str(edge.get("zk_id") or "").strip()
    zk_source = str(edge.get("zk_source") or "").strip()
    if not (memory_id and zk_id and zk_source):
        return set()
    note_key = f"{zk_source}/{zk_id}"
    if ":" in zk_source:
        # A federated endpoint (``<repo_id>:<graph>``) is not locally resolvable:
        # its recipients come from the peer-policy map, and absent that map (or
        # the note within it) the edge is omitted — fail-closed.
        if not peer_note_recipients_map:
            return set()
        note_recips = peer_note_recipients_map.get(note_key)
    else:
        note_recips = note_recipients_map.get(note_key)
    mem_recips = entry_recipients_map.get(memory_id)
    if not mem_recips or not note_recips:
        return set()
    return set(mem_recips) & set(note_recips)