Skip to content

zettelkasten.sharing.config

zettelkasten.sharing.config

Grants / sharing-scope config parsing and shareability resolution.

This is the policy half of encryption-first Zettelkasten sharing, mirroring :mod:memory.sharing.config. It reads the owner-authored sharing/grants blocks from .zettelkasten/config.yaml (a plain dict) and turns them into typed accessors plus per-unit recipient resolvers. It performs no crypto and no I/O beyond parsing an already-loaded dict.

The scope model is PROJECT-CENTRIC (a project is the unit of sharing), which is the natural grain for the Zettelkasten store: a project spans some source boxes and some _cross synthesis notes, and its closure is what a grantee sees.

Two independent, additive mechanisms combine to decide who may open a unit:

  • sharing.scopes — a visibility scope keyed on a PROJECT name (and, optionally, a source-BOX name). Vocabulary:

  • private (the default) — shared with nobody.

  • firm — shared with every registered firm member.
  • bilateral— shared with an explicit audience list of recipient ids.

  • grants — an owner-authored key -> [recipient_ids] map that grants specific recipients access. Keys may be a project name, a source-box name, a per-note key box/note_id (cross/note_id is accepted as an alias for _cross/note_id), or citation:<id>.

A project's CLOSURE is: every note in each of its sources[] boxes, those boxes' _meta.yaml (source_meta units), its cross[] synthesis notes, and every citation referenced (via a links[].graph == "_citations" edge) by any note in the closure. A unit's recipient set is the UNION over every granting scope/closure that includes it (so a _cross note claimed by several projects gets the union of their recipients).

Fully additive / opt-in: when both keys are absent every resolver returns an empty map — nothing is shared, and no existing config (rerank/federated_repos) is affected.

Scope dataclass

A visibility scope attached to a project or source box.

audience is only meaningful when kind == 'bilateral' and holds the recipient ids that may access the subtree.

Source code in zettelkasten/sharing/config.py
@dataclass(frozen=True)
class Scope:
    """A visibility scope attached to a project or source box.

    ``audience`` is only meaningful when ``kind == 'bilateral'`` and holds the
    recipient ids that may access the subtree.
    """

    kind: str = SCOPE_PRIVATE
    audience: tuple[str, ...] = ()

SharingConfig dataclass

Parsed, typed view of the sharing + grants config blocks.

  • scopes maps a project name (or a source-box name) to its :class:Scope.
  • grants maps a key (project name, box name, box/note_id, cross/note_id alias, or citation:<id>) to the tuple of recipient ids granted access to that unit (and, for a project/box key, its closure).
Source code in zettelkasten/sharing/config.py
@dataclass(frozen=True)
class SharingConfig:
    """Parsed, typed view of the ``sharing`` + ``grants`` config blocks.

    * ``scopes`` maps a project name (or a source-box name) to its :class:`Scope`.
    * ``grants`` maps a key (project name, box name, ``box/note_id``,
      ``cross/note_id`` alias, or ``citation:<id>``) to the tuple of recipient
      ids granted access to that unit (and, for a project/box key, its closure).
    """

    scopes: Mapping[str, Scope] = field(default_factory=dict)
    grants: Mapping[str, tuple[str, ...]] = field(default_factory=dict)
    # Per-unit transport tier keyed exactly like ``scopes``/``grants`` (a project
    # name, box name, or per-note key). A note inherits (per-note > box >
    # project-closure) and ultimately defaults to :data:`TRANSPORT_OFFLINE`
    # (deny-by-default). Values are validated to ``offline``/``hosted`` at parse.
    transports: Mapping[str, str] = field(default_factory=dict)

    def is_empty(self) -> bool:
        """True when no sharing policy is configured (today's behaviour)."""
        return not self.scopes and not self.grants and not self.transports

is_empty

is_empty() -> bool

True when no sharing policy is configured (today's behaviour).

Source code in zettelkasten/sharing/config.py
def is_empty(self) -> bool:
    """True when no sharing policy is configured (today's behaviour)."""
    return not self.scopes and not self.grants and not self.transports

parse_sharing_config

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

Parse the sharing and grants blocks of a loaded config dict.

config is the plain dict read from .zettelkasten/config.yaml. Missing/empty/malformed blocks yield an empty :class:SharingConfig (nothing shared) — this never raises on a partially-formed config; unusable items are skipped. The existing rerank/federated_repos blocks are ignored here (left untouched for their own readers).

Source code in zettelkasten/sharing/config.py
def parse_sharing_config(config: Mapping[str, Any] | None) -> SharingConfig:
    """Parse the ``sharing`` and ``grants`` blocks of a loaded config dict.

    ``config`` is the plain dict read from ``.zettelkasten/config.yaml``.
    Missing/empty/malformed blocks yield an empty :class:`SharingConfig`
    (nothing shared) — this never raises on a partially-formed config; unusable
    items are skipped. The existing ``rerank``/``federated_repos`` blocks are
    ignored here (left untouched for their own readers).
    """
    if not isinstance(config, Mapping):
        return SharingConfig()

    scopes: dict[str, Scope] = {}
    transports: dict[str, str] = {}
    sharing_block = config.get("sharing")
    if isinstance(sharing_block, Mapping):
        raw_scopes = sharing_block.get("scopes")
        if isinstance(raw_scopes, Mapping):
            for node_id, raw in raw_scopes.items():
                nid = str(node_id).strip()
                if nid:
                    scopes[nid] = _parse_scope(raw)
        # Per-unit transport tier (``sharing.transports``), keyed like scopes.
        # Unlike scopes (fail-closed to ``private`` on a typo), an unrecognised
        # transport is REJECTED with a clear error — the network tier is
        # deny-by-default, so a mistyped value must never be silently swallowed.
        raw_transports = sharing_block.get("transports")
        if isinstance(raw_transports, Mapping):
            for node_id, raw in raw_transports.items():
                nid = str(node_id).strip()
                if not nid:
                    continue
                transports[nid] = _parse_transport(raw, nid)

    grants: dict[str, tuple[str, ...]] = {}
    raw_grants = config.get("grants")
    if isinstance(raw_grants, Mapping):
        for node_id, raw in raw_grants.items():
            nid = str(node_id).strip()
            recips = _parse_recipient_list(raw)
            if nid and recips:
                grants[nid] = recips

    return SharingConfig(scopes=scopes, grants=grants, transports=transports)

normalize_note_key

normalize_note_key(key: str) -> str

Normalize a per-note grant key to the canonical <box>/<note_id> form.

A leading cross/ segment is rewritten to the on-disk _cross/ folder so a config author can write the friendlier cross/<id>. Any other key is returned unchanged.

Source code in zettelkasten/sharing/config.py
def normalize_note_key(key: str) -> str:
    """Normalize a per-note grant key to the canonical ``<box>/<note_id>`` form.

    A leading ``cross/`` segment is rewritten to the on-disk ``_cross/`` folder
    so a config author can write the friendlier ``cross/<id>``. Any other key is
    returned unchanged.
    """
    if key.startswith("cross/"):
        return CROSS_BOX + "/" + key[len("cross/"):]
    return key

project_recipients

project_recipients(sharing: SharingConfig, project_names: Iterable[str], firm_members: Iterable[str] = ()) -> dict[str, set[str]]

Map each project name to the concrete set of recipient ids.

A project's recipients are its scopes[name] (firm expanded to firm_members) UNIONED with any grants[name]. Projects resolving to no recipients are omitted (the caller uses this to drop them from the bundle).

Source code in zettelkasten/sharing/config.py
def project_recipients(
    sharing: SharingConfig,
    project_names: Iterable[str],
    firm_members: Iterable[str] = (),
) -> dict[str, set[str]]:
    """Map each project name to the concrete set of recipient ids.

    A project's recipients are its ``scopes[name]`` (firm expanded to
    ``firm_members``) UNIONED with any ``grants[name]``. Projects resolving to no
    recipients are omitted (the caller uses this to drop them from the bundle).
    """
    firm = set(firm_members)
    out: dict[str, set[str]] = {}
    for name in project_names:
        recips = set(sharing.grants.get(name, ()))
        recips |= _scope_recipients(sharing.scopes.get(name), firm)
        if recips:
            out[name] = recips
    return out

note_recipients

note_recipients(sharing: SharingConfig, projects: Iterable[Mapping[str, Any]], notes_by_box: Mapping[str, Iterable[str]], firm_members: Iterable[str] = ()) -> dict[str, set[str]]

Map each shareable note (<box>/<note_id>) to its recipient set.

The recipients of a note are the UNION of:

  • every project closure that includes it — a note is in project P's closure when it lives in one of P's sources[] boxes, or when its id is listed in P's cross[] (a _cross synthesis note); such notes get P's recipients;
  • a direct source-box scope/grant on the note's box; and
  • a direct per-note grant keyed on <box>/<note_id> (cross/<id> alias accepted).

Notes resolving to no recipients are omitted.

Source code in zettelkasten/sharing/config.py
def note_recipients(
    sharing: SharingConfig,
    projects: Iterable[Mapping[str, Any]],
    notes_by_box: Mapping[str, Iterable[str]],
    firm_members: Iterable[str] = (),
) -> dict[str, set[str]]:
    """Map each shareable note (``<box>/<note_id>``) to its recipient set.

    The recipients of a note are the UNION of:

    * every project closure that includes it — a note is in project P's closure
      when it lives in one of P's ``sources[]`` boxes, or when its id is listed
      in P's ``cross[]`` (a ``_cross`` synthesis note); such notes get P's
      recipients;
    * a direct source-box scope/grant on the note's box; and
    * a direct per-note grant keyed on ``<box>/<note_id>`` (``cross/<id>`` alias
      accepted).

    Notes resolving to no recipients are omitted.
    """
    firm = set(firm_members)
    out: dict[str, set[str]] = {}

    def add(key: str, recips: set[str]) -> None:
        if recips:
            out.setdefault(key, set()).update(recips)

    proj_recip = project_recipients(sharing, [n for n, _, _ in _iter_projects(projects)], firm)

    # Project closures: every note in each source box + each claimed cross note.
    for name, sources, cross in _iter_projects(projects):
        recips = proj_recip.get(name)
        if not recips:
            continue
        for box in sources:
            for nid in notes_by_box.get(box, ()):  # type: ignore[union-attr]
                add(f"{box}/{nid}", recips)
        for cid in cross:
            add(f"{CROSS_BOX}/{cid}", recips)

    # Direct source-box scope/grant applies to every note in the box.
    for box, nids in notes_by_box.items():
        box_recips = set(sharing.grants.get(box, ()))
        box_recips |= _scope_recipients(sharing.scopes.get(box), firm)
        if box_recips:
            for nid in nids:
                add(f"{box}/{nid}", box_recips)

    # Direct per-note grants (keys containing '/', not a citation key).
    for key, recips in sharing.grants.items():
        if "/" not in key or key.startswith("citation:"):
            continue
        add(normalize_note_key(key), set(recips))

    return out

note_transports

note_transports(sharing: SharingConfig, projects: Iterable[Mapping[str, Any]], notes_by_box: Mapping[str, Iterable[str]]) -> dict[str, str]

Map each note (<box>/<note_id>) to its effective transport tier.

Resolves the transport by SPECIFICITY, honouring inheritance the way scopes flow through the project-centric model, most-specific winning:

  1. a per-note transport keyed <box>/<note_id> (cross/<id> alias accepted) — the tightest override;
  2. a direct source-box transport keyed on the note's box;
  3. a project-closure transport — the note lives in a sources[] box of, or is a cross[] note of, a project whose name is keyed.

A note with no transport anywhere on this chain defaults to :data:TRANSPORT_OFFLINE (deny-by-default). This mirrors :func:note_recipients' closure walk, but a single value is resolved with precedence (rather than a union) so an offline note override under a hosted project/box is genuinely offline — and thus excluded from the broker projection.

Source code in zettelkasten/sharing/config.py
def note_transports(
    sharing: SharingConfig,
    projects: Iterable[Mapping[str, Any]],
    notes_by_box: Mapping[str, Iterable[str]],
) -> dict[str, str]:
    """Map each note (``<box>/<note_id>``) to its effective transport tier.

    Resolves the transport by SPECIFICITY, honouring inheritance the way scopes
    flow through the project-centric model, most-specific winning:

    1. a per-note transport keyed ``<box>/<note_id>`` (``cross/<id>`` alias
       accepted) — the tightest override;
    2. a direct source-box transport keyed on the note's box;
    3. a project-closure transport — the note lives in a ``sources[]`` box of, or
       is a ``cross[]`` note of, a project whose name is keyed.

    A note with no transport anywhere on this chain defaults to
    :data:`TRANSPORT_OFFLINE` (deny-by-default). This mirrors
    :func:`note_recipients`' closure walk, but a single value is resolved with
    precedence (rather than a union) so an ``offline`` note override under a
    ``hosted`` project/box is genuinely ``offline`` — and thus excluded from the
    broker projection.
    """
    out: dict[str, str] = {}

    # Coarsest first: project closures. ``setdefault`` so the first project that
    # claims a note wins at this level (finer levels below overwrite it anyway).
    for name, sources, cross in _iter_projects(projects):
        transport = sharing.transports.get(name)
        if transport is None:
            continue
        for box in sources:
            for nid in notes_by_box.get(box, ()):  # type: ignore[union-attr]
                out.setdefault(f"{box}/{nid}", transport)
        for cid in cross:
            out.setdefault(f"{CROSS_BOX}/{cid}", transport)

    # Box-level overrides the project closure.
    for box, nids in notes_by_box.items():
        transport = sharing.transports.get(box)
        if transport is None:
            continue
        for nid in nids:
            out[f"{box}/{nid}"] = transport

    # Per-note key (``<box>/<note_id>``) overrides everything.
    for key, transport in sharing.transports.items():
        if "/" not in key or key.startswith("citation:"):
            continue
        out[normalize_note_key(key)] = transport

    return out

source_recipients

source_recipients(sharing: SharingConfig, projects: Iterable[Mapping[str, Any]], boxes: Iterable[str], firm_members: Iterable[str] = ()) -> dict[str, set[str]]

Map each shareable source box to its _meta.yaml recipient set.

A box's meta is shared to the UNION of every project that lists the box in its sources[] (getting that project's recipients) plus any direct box-level scope/grant. Boxes resolving to no recipients are omitted.

Source code in zettelkasten/sharing/config.py
def source_recipients(
    sharing: SharingConfig,
    projects: Iterable[Mapping[str, Any]],
    boxes: Iterable[str],
    firm_members: Iterable[str] = (),
) -> dict[str, set[str]]:
    """Map each shareable source box to its ``_meta.yaml`` recipient set.

    A box's meta is shared to the UNION of every project that lists the box in
    its ``sources[]`` (getting that project's recipients) plus any direct
    box-level scope/grant. Boxes resolving to no recipients are omitted.
    """
    firm = set(firm_members)
    box_set = set(boxes)
    out: dict[str, set[str]] = {}

    def add(box: str, recips: set[str]) -> None:
        if recips and box in box_set:
            out.setdefault(box, set()).update(recips)

    proj_recip = project_recipients(sharing, [n for n, _, _ in _iter_projects(projects)], firm)
    for name, sources, _cross in _iter_projects(projects):
        recips = proj_recip.get(name)
        if not recips:
            continue
        for box in sources:
            add(box, recips)

    for box in box_set:
        box_recips = set(sharing.grants.get(box, ()))
        box_recips |= _scope_recipients(sharing.scopes.get(box), firm)
        add(box, box_recips)

    return out

citation_recipients

citation_recipients(sharing: SharingConfig, note_recipients_map: Mapping[str, set[str]], note_citation_refs: Mapping[str, Iterable[str]]) -> dict[str, set[str]]

Map each shareable citation (citation:<id>) to its recipient set.

A citation is a leaf bibliographic entity referenced BY notes (via a links[].graph == "_citations" edge). Its recipients are the UNION over every SHARED note that references it of that note's recipient set — so every recipient of a note can also open the citation the note points at (the superset property that keeps the citation link from ever dangling). Any direct grants['citation:<id>'] recipients are added on top. Citations resolving to no recipients are omitted.

Source code in zettelkasten/sharing/config.py
def citation_recipients(
    sharing: SharingConfig,
    note_recipients_map: Mapping[str, set[str]],
    note_citation_refs: Mapping[str, Iterable[str]],
) -> dict[str, set[str]]:
    """Map each shareable citation (``citation:<id>``) to its recipient set.

    A citation is a leaf bibliographic entity referenced BY notes (via a
    ``links[].graph == "_citations"`` edge). Its recipients are the UNION over
    every SHARED note that references it of that note's recipient set — so every
    recipient of a note can also open the citation the note points at (the
    superset property that keeps the citation link from ever dangling). Any
    direct ``grants['citation:<id>']`` recipients are added on top. Citations
    resolving to no recipients are omitted.
    """
    out: dict[str, set[str]] = {}
    for note_key, cids in note_citation_refs.items():
        note_recips = note_recipients_map.get(note_key)
        if not note_recips:
            continue
        for cid in cids:
            out.setdefault(f"citation:{cid}", set()).update(note_recips)

    for key, recips in sharing.grants.items():
        if key.startswith("citation:"):
            out.setdefault(key, set()).update(recips)

    return out

review_recipients

review_recipients(sharing: SharingConfig, reviews: Iterable[Mapping[str, Any]], note_recipients_map: Mapping[str, set[str]], source_recipients_map: Mapping[str, set[str]], project_recipients_map: Mapping[str, set[str]]) -> dict[str, set[str]]

Map each review name to its recipient set (also used for its tables/outlines).

A review is shared to the recipients who can see its SCOPE, plus any direct grants['review:<name>']:

  • graph set -> box-scoped: everyone who can see box G (see :func:_box_recipients);
  • else project set -> project-scoped: the project's recipients;
  • empty/empty -> full-corpus: everyone who can see any shared unit.

A direct grants['review:<name>'] widens the audience. Reviews resolving to no recipients are omitted. The returned map is reused verbatim for a review's KIND_TABLE grid file and KIND_OUTLINE sidecar (both keyed by the review name), so a table/outline can never reach a recipient the review itself could not.

Source code in zettelkasten/sharing/config.py
def review_recipients(
    sharing: SharingConfig,
    reviews: Iterable[Mapping[str, Any]],
    note_recipients_map: Mapping[str, set[str]],
    source_recipients_map: Mapping[str, set[str]],
    project_recipients_map: Mapping[str, set[str]],
) -> dict[str, set[str]]:
    """Map each review ``name`` to its recipient set (also used for its tables/outlines).

    A review is shared to the recipients who can see its SCOPE, plus any direct
    ``grants['review:<name>']``:

    * ``graph`` set  -> box-scoped: everyone who can see box ``G`` (see
      :func:`_box_recipients`);
    * else ``project`` set -> project-scoped: the project's recipients;
    * empty/empty    -> full-corpus: everyone who can see any shared unit.

    A direct ``grants['review:<name>']`` widens the audience. Reviews resolving
    to no recipients are omitted. The returned map is reused verbatim for a
    review's ``KIND_TABLE`` grid file and ``KIND_OUTLINE`` sidecar (both keyed by
    the review name), so a table/outline can never reach a recipient the review
    itself could not.
    """
    out: dict[str, set[str]] = {}
    for manifest in reviews:
        if not isinstance(manifest, Mapping):
            continue
        name = str(manifest.get("name") or "").strip()
        if not name:
            continue
        graph = str(manifest.get("graph") or "").strip()
        project = str(manifest.get("project") or "").strip()
        if graph:
            recips = _box_recipients(note_recipients_map, source_recipients_map, graph)
        elif project:
            recips = set(project_recipients_map.get(project, set()))
        else:
            recips = _full_corpus_recipients(
                note_recipients_map, source_recipients_map, project_recipients_map
            )
        recips = set(recips)
        recips |= set(sharing.grants.get(f"review:{name}", ()))
        if recips:
            out[name] = recips
    return out

org_recipients

org_recipients(sharing: SharingConfig, organizations: Iterable[Mapping[str, Any]], note_recipients_map: Mapping[str, set[str]], source_recipients_map: Mapping[str, set[str]], project_recipients_map: Mapping[str, set[str]]) -> dict[str, set[str]]

Map each organization id to its recipient set.

An organization is owned by exactly one project OR one graph (see :data:zettelkasten.organizations.OWNER_TYPES). Its recipients are the owner's recipients — the project's recipients, or (for a graph owner) everyone who can see that box — plus any direct grants['org:<id>']. Organizations resolving to no recipients are omitted.

Source code in zettelkasten/sharing/config.py
def org_recipients(
    sharing: SharingConfig,
    organizations: Iterable[Mapping[str, Any]],
    note_recipients_map: Mapping[str, set[str]],
    source_recipients_map: Mapping[str, set[str]],
    project_recipients_map: Mapping[str, set[str]],
) -> dict[str, set[str]]:
    """Map each organization ``id`` to its recipient set.

    An organization is owned by exactly one project OR one graph (see
    :data:`zettelkasten.organizations.OWNER_TYPES`). Its recipients are the
    owner's recipients — the project's recipients, or (for a ``graph`` owner)
    everyone who can see that box — plus any direct ``grants['org:<id>']``.
    Organizations resolving to no recipients are omitted.
    """
    out: dict[str, set[str]] = {}
    for org in organizations:
        if not isinstance(org, Mapping):
            continue
        oid = str(org.get("id") or "").strip()
        if not oid:
            continue
        owner = org.get("owner") if isinstance(org.get("owner"), Mapping) else {}
        owner_type = str(owner.get("type") or "").strip()
        owner_name = str(owner.get("name") or "").strip()
        if owner_type == "project" and owner_name:
            recips = set(project_recipients_map.get(owner_name, set()))
        elif owner_type == "graph" and owner_name:
            recips = _box_recipients(note_recipients_map, source_recipients_map, owner_name)
        else:
            recips = set()
        recips |= set(sharing.grants.get(f"org:{oid}", ()))
        if recips:
            out[oid] = recips
    return out