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
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:
- a per-note transport keyed
<box>/<note_id> (cross/<id> alias
accepted) — the tightest override;
- a direct source-box transport keyed on the note's box;
- 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
|