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
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
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 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)
|