Skip to content

zettelkasten.sharing.grants_edit

zettelkasten.sharing.grants_edit

Grant-editing core for Zettelkasten revocation (angelo zettelkasten revoke).

This is the edit half of encryption-first Zettelkasten sharing, the sibling of :mod:memory.sharing.grants_edit. Where :mod:zettelkasten.sharing.config reads the owner-authored sharing/grants policy, this module rewrites it to drop a recipient id — either everywhere (a firm-wide eviction) or within a single project's closure (--project P). Re-publishing then rebuilds the bundle units/ from scratch (see :func:zettelkasten.sharing.build_bundle), so the new ciphertext simply excludes the revoked id. No crypto happens here.

Two layers, mirroring the memory sibling:

  • :func:revoke_from_config — a PURE transform: dict -> (dict, change). It deep-copies its input (never mutates it) and records exactly what it touched in a :class:RevocationChange so the CLI can print an honest report.
  • :func:revoke_in_store — a thin read/write wrapper that loads .zettelkasten/config.yaml via :func:zettelkasten.config.read_config, resolves the firm roster from the shared identity registry, computes a project closure when scoped, calls the pure transform, and writes the result back via :func:zettelkasten.config.write_config. It performs NO git operations.

The Zettelkasten grant vocabulary is PROJECT-CENTRIC (see :mod:zettelkasten.sharing.config): grant/scope keys are a project name, a source-box name, a per-note <box>/<note_id> (cross/<id> is an alias for _cross/<id>), or a derived citation:<id> / review:<name> / org:<id>.

Revocation rules:

  • A recipient listed in a grants[key] recipient list is dropped from it; a key whose list becomes empty is removed.
  • A firm-scoped key shares with every firm member, so the revoked id cannot simply be "removed" from firm. Instead the scope is DOWNGRADED to bilateral with an explicit audience of sorted(firm_members) - {id} (a firm scope is only touched when the revoked id is actually a firm member — a non-member is already excluded, so nothing changes).
  • A bilateral scope simply drops the id from its audience; an audience that becomes empty removes the scope key (equivalent to private).
  • With project set, only keys within that project's CLOSURE are touched (see :func:revoke_in_store for how the closure is resolved from the store); without it, every grants list and bilateral audience is edited.

RevocationError

Bases: RuntimeError

A revoke could not be applied safely and must not silently succeed.

Raised (rather than reporting a misleading no-op / success) when the operation cannot be carried out correctly — e.g. firm-scoped keys are in scope but the identity registry is empty/unavailable, so the firm audience cannot be rebuilt and the id would be re-granted on the next publish.

Source code in zettelkasten/sharing/grants_edit.py
class RevocationError(RuntimeError):
    """A revoke could not be applied safely and must not silently succeed.

    Raised (rather than reporting a misleading no-op / success) when the
    operation cannot be carried out correctly — e.g. firm-scoped keys are in
    scope but the identity registry is empty/unavailable, so the firm audience
    cannot be rebuilt and the id would be re-granted on the next publish.
    """

RevocationChange dataclass

An honest record of what a revocation edited (for the CLI report).

  • grants_removed_from — grant keys the id was dropped from.
  • firm_converted — scope keys converted firm -> bilateral (the id was a firm member and some members remain).
  • audiences_dropped_frombilateral scope keys the id was dropped from the audience of.
  • keys_removed — keys removed because their recipient list / audience became empty (spans both grants keys and emptied sharing.scopes keys; a key emptied by a drop appears in both its "…_from" list and here).
  • id_present — whether the id was found anywhere in the edit scope.
  • firm_scopes_in_scope — every firm scope key within the edit scope, regardless of conversion. Used by :func:revoke_in_store to detect an empty/unavailable registry (which would leave a firm key as firm and silently re-grant the id on the next publish).
  • cross_project_sharedcitation:/org: keys in the project closure that were intentionally LEFT untouched because they are also referenced/owned OUTSIDE the target project; editing them would revoke the id from another project's grant. The id therefore retains access via these keys until revoked globally or from the sharing project.
  • residual — for a scoped (--project) revoke, grant/scope keys OUTSIDE the revoked project's closure that STILL grant the id (an explicit grants list, a firm scope the id is a member of, or a bilateral audience listing it). Because the rebuilt bundle unions ALL of a recipient's grants, such a key keeps the id an EFFECTIVE bundle recipient even after the in-project edit — so a scoped revoke that leaves residual non-empty did NOT fully cut the id off. Distinct from cross_project_shared (which is the in-closure shared keys deliberately skipped); the two are additive. Analogous to memory's residual_inherited.
  • residual_undetermined — for a scoped revoke, firm-scoped keys OUTSIDE the closure whose residual status CANNOT be decided because the identity registry is empty/unavailable (firm membership is unknown). Such a key would re-grant the id on the next publish if the id IS a firm member, so a clean cut-off must NOT be claimed while any remain. Kept separate from residual (which lists keys that DEFINITELY still grant the id) so the CLI can phrase it as "undetermined (registry unavailable)".
  • closure_incomplete — for a scoped (--project) revoke, True when the project closure could not be fully computed because a note was unparseable or its citations were unreadable. Those notes' keys are omitted from the edit, so the id may still be granted on them — a clean cut-off must NOT be claimed while this is set (the id may be UNDER-revoked).
  • changed — whether any edit was made (id_present with no possible edit — e.g. a non-member id against a bare firm scope — leaves this False).
Source code in zettelkasten/sharing/grants_edit.py
@dataclass
class RevocationChange:
    """An honest record of what a revocation edited (for the CLI report).

    * ``grants_removed_from`` — grant keys the id was dropped from.
    * ``firm_converted`` — scope keys converted ``firm`` -> ``bilateral`` (the id
      was a firm member and some members remain).
    * ``audiences_dropped_from`` — ``bilateral`` scope keys the id was dropped
      from the ``audience`` of.
    * ``keys_removed`` — keys removed because their recipient list / audience
      became empty (spans both ``grants`` keys and emptied ``sharing.scopes``
      keys; a key emptied by a drop appears in both its "…_from" list and here).
    * ``id_present`` — whether the id was found anywhere in the edit scope.
    * ``firm_scopes_in_scope`` — every ``firm`` scope key within the edit scope,
      regardless of conversion. Used by :func:`revoke_in_store` to detect an
      empty/unavailable registry (which would leave a firm key as ``firm`` and
      silently re-grant the id on the next publish).
    * ``cross_project_shared`` — ``citation:``/``org:`` keys in the project
      closure that were intentionally LEFT untouched because they are also
      referenced/owned OUTSIDE the target project; editing them would revoke the
      id from another project's grant. The id therefore retains access via these
      keys until revoked globally or from the sharing project.
    * ``residual`` — for a scoped (``--project``) revoke, grant/scope keys
      OUTSIDE the revoked project's closure that STILL grant the id (an explicit
      ``grants`` list, a ``firm`` scope the id is a member of, or a ``bilateral``
      audience listing it). Because the rebuilt bundle unions ALL of a
      recipient's grants, such a key keeps the id an EFFECTIVE bundle recipient
      even after the in-project edit — so a scoped revoke that leaves ``residual``
      non-empty did NOT fully cut the id off. Distinct from
      ``cross_project_shared`` (which is the in-closure shared keys deliberately
      skipped); the two are additive. Analogous to memory's
      ``residual_inherited``.
    * ``residual_undetermined`` — for a scoped revoke, ``firm``-scoped keys
      OUTSIDE the closure whose residual status CANNOT be decided because the
      identity registry is empty/unavailable (firm membership is unknown). Such a
      key would re-grant the id on the next publish if the id IS a firm member,
      so a clean cut-off must NOT be claimed while any remain. Kept separate from
      ``residual`` (which lists keys that DEFINITELY still grant the id) so the
      CLI can phrase it as "undetermined (registry unavailable)".
    * ``closure_incomplete`` — for a scoped (``--project``) revoke, ``True`` when
      the project closure could not be fully computed because a note was
      unparseable or its citations were unreadable. Those notes' keys are omitted
      from the edit, so the id may still be granted on them — a clean cut-off must
      NOT be claimed while this is set (the id may be UNDER-revoked).
    * ``changed`` — whether any edit was made (``id_present`` with no possible
      edit — e.g. a non-member id against a bare ``firm`` scope — leaves this
      ``False``).
    """

    revoked_id: str
    project: str | None = None
    id_present: bool = False
    changed: bool = False
    grants_removed_from: tuple[str, ...] = ()
    firm_converted: tuple[str, ...] = ()
    audiences_dropped_from: tuple[str, ...] = ()
    keys_removed: tuple[str, ...] = ()
    firm_scopes_in_scope: tuple[str, ...] = ()
    cross_project_shared: tuple[str, ...] = ()
    residual: tuple[str, ...] = ()
    residual_undetermined: tuple[str, ...] = ()
    closure_incomplete: bool = False

revoke_from_config

revoke_from_config(config: Mapping[str, Any] | None, revoked_id: str, *, firm_members: Iterable[str], project: str | None = None, project_keys: Iterable[str] | None = None) -> tuple[dict[str, Any], RevocationChange]

Drop revoked_id from a loaded config dict; return (new_config, change).

Pure: config is deep-copied and never mutated. firm_members is the registered firm roster (used to expand a firm scope on downgrade).

When project is None the revoke is GLOBAL — every grants list and every bilateral audience is edited, and every firm scope the id belongs to is downgraded. When project is set the revoke is CONFINED to that project's closure: only keys equal to project or present in project_keys (both normalized with :func:~zettelkasten.sharing.config.normalize_note_key) are touched. The caller (:func:revoke_in_store) resolves project_keys from the store; when omitted, only the bare project-name key is in scope.

Source code in zettelkasten/sharing/grants_edit.py
def revoke_from_config(
    config: Mapping[str, Any] | None,
    revoked_id: str,
    *,
    firm_members: Iterable[str],
    project: str | None = None,
    project_keys: Iterable[str] | None = None,
) -> tuple[dict[str, Any], RevocationChange]:
    """Drop ``revoked_id`` from a loaded config dict; return ``(new_config, change)``.

    Pure: ``config`` is deep-copied and never mutated. ``firm_members`` is the
    registered firm roster (used to expand a ``firm`` scope on downgrade).

    When ``project`` is ``None`` the revoke is GLOBAL — every ``grants`` list and
    every ``bilateral`` audience is edited, and every ``firm`` scope the id
    belongs to is downgraded. When ``project`` is set the revoke is CONFINED to
    that project's closure: only keys equal to ``project`` or present in
    ``project_keys`` (both normalized with
    :func:`~zettelkasten.sharing.config.normalize_note_key`) are touched. The
    caller (:func:`revoke_in_store`) resolves ``project_keys`` from the store;
    when omitted, only the bare project-name key is in scope.
    """
    revoked_id = str(revoked_id).strip()
    firm = sorted({str(m).strip() for m in firm_members if str(m).strip()})
    new_config: dict[str, Any] = copy.deepcopy(dict(config)) if isinstance(config, Mapping) else {}

    scoped = project is not None
    project_name = str(project).strip() if scoped else None
    change = RevocationChange(revoked_id=revoked_id, project=project_name)
    if not revoked_id:
        return new_config, change

    closure: set[str] = set()
    if scoped:
        if project_name:
            closure.add(project_name)
        for k in (project_keys or ()):
            ks = str(k).strip()
            if ks:
                closure.add(normalize_note_key(ks))

    def in_scope(key: str) -> bool:
        if not scoped:
            return True
        return normalize_note_key(key) in closure

    id_present = False
    grants_removed_from: list[str] = []
    firm_converted: list[str] = []
    audiences_dropped_from: list[str] = []
    keys_removed: list[str] = []
    firm_scopes_in_scope: list[str] = []

    # ---- grants: drop the id from each in-scope recipient list ----
    grants = new_config.get("grants")
    if isinstance(grants, dict):
        for key in list(grants.keys()):
            key_s = str(key)
            if not in_scope(key_s):
                continue
            ids = _as_id_list(grants[key])
            if revoked_id not in ids:
                continue
            id_present = True
            grants_removed_from.append(key_s)
            remaining = [i for i in ids if i != revoked_id]
            if remaining:
                grants[key] = remaining
            else:
                del grants[key]
                keys_removed.append(key_s)

    # ---- sharing.scopes: downgrade firm, drop from bilateral audiences ----
    sharing_block = new_config.get("sharing")
    if isinstance(sharing_block, dict):
        scopes = sharing_block.get("scopes")
        if isinstance(scopes, dict):
            for key in list(scopes.keys()):
                key_s = str(key)
                if not in_scope(key_s):
                    continue
                kind, audience, extra = _scope_parts(scopes[key])
                if kind == SCOPE_FIRM:
                    # Record every in-scope firm key so the store wrapper can
                    # detect an empty/unavailable registry (which would leave it
                    # as firm and silently re-grant the id on the next publish).
                    firm_scopes_in_scope.append(key_s)
                    # A firm scope only touches the id if the id is a firm member;
                    # a non-member is already outside a firm scope.
                    if revoked_id not in firm:
                        continue
                    id_present = True
                    remaining = [m for m in firm if m != revoked_id]
                    if remaining:
                        new_val: dict[str, Any] = {"scope": SCOPE_BILATERAL, "audience": remaining}
                        new_val.update(extra)
                        scopes[key] = new_val
                        firm_converted.append(key_s)
                    else:
                        del scopes[key]
                        keys_removed.append(key_s)
                elif kind == SCOPE_BILATERAL:
                    if revoked_id not in audience:
                        continue
                    id_present = True
                    audiences_dropped_from.append(key_s)
                    remaining = [a for a in audience if a != revoked_id]
                    if remaining:
                        new_val = {"scope": SCOPE_BILATERAL, "audience": remaining}
                        new_val.update(extra)
                        scopes[key] = new_val
                    else:
                        del scopes[key]
                        keys_removed.append(key_s)

    change.id_present = id_present
    change.grants_removed_from = tuple(grants_removed_from)
    change.firm_converted = tuple(firm_converted)
    change.audiences_dropped_from = tuple(audiences_dropped_from)
    change.keys_removed = tuple(keys_removed)
    change.firm_scopes_in_scope = tuple(firm_scopes_in_scope)
    change.changed = bool(
        grants_removed_from or firm_converted or audiences_dropped_from or keys_removed
    )
    return new_config, change

revoke_in_store

revoke_in_store(revoked_id: str, *, project: str | None = None, registry: Any = None, store_root: Path | str | None = None) -> RevocationChange

Revoke revoked_id from the local .zettelkasten/config.yaml in place.

Reads the config via :func:zettelkasten.config.read_config, resolves the firm roster from registry (the shared identity registry — loaded via memory.sharing.load_registry() when omitted), computes the project closure when project is given, applies :func:revoke_from_config, and writes the result back via :func:zettelkasten.config.write_config (only when something actually changed). Performs NO git operations.

store_root overrides the store location (the directory holding config.yaml — i.e. the .zettelkasten dir); when omitted the default :data:zettelkasten.graph.GRAPHS_DIR is used. Returns the :class:RevocationChange for the caller to report.

Source code in zettelkasten/sharing/grants_edit.py
def revoke_in_store(
    revoked_id: str,
    *,
    project: str | None = None,
    registry: Any = None,
    store_root: Path | str | None = None,
) -> RevocationChange:
    """Revoke ``revoked_id`` from the local ``.zettelkasten/config.yaml`` in place.

    Reads the config via :func:`zettelkasten.config.read_config`, resolves the
    firm roster from ``registry`` (the shared identity registry — loaded via
    ``memory.sharing.load_registry()`` when omitted), computes the project
    closure when ``project`` is given, applies :func:`revoke_from_config`, and
    writes the result back via :func:`zettelkasten.config.write_config` (only
    when something actually changed). Performs NO git operations.

    ``store_root`` overrides the store location (the directory holding
    ``config.yaml`` — i.e. the ``.zettelkasten`` dir); when omitted the default
    :data:`zettelkasten.graph.GRAPHS_DIR` is used. Returns the
    :class:`RevocationChange` for the caller to report.
    """
    from zettelkasten import config as zk_config
    from zettelkasten import graph as zk_graph

    if registry is None:
        from memory import sharing as mem_sharing

        registry = mem_sharing.load_registry()
    firm_members = registry.member_ids()

    project_name = str(project).strip() if project is not None else None

    previous_dir = zk_graph.GRAPHS_DIR
    if store_root is not None:
        zk_graph.GRAPHS_DIR = Path(store_root)
    try:
        current = zk_graph.GRAPHS_DIR
        cfg = zk_config.read_config()
        project_keys = None
        full_closure: set[str] = set()
        cross_shared: set[str] = set()
        closure_incomplete = False
        if project_name:
            full_closure, closure_incomplete = _project_closure_keys(current, project_name)
            # Do NOT edit citation:/org:/box/review keys shared with other
            # projects — dropping the id there would revoke another project's
            # grant too.
            cross_shared = _cross_project_shared_keys(current, project_name)
            project_keys = {k for k in full_closure if k not in cross_shared}
        new_cfg, change = revoke_from_config(
            cfg,
            revoked_id,
            firm_members=firm_members,
            project=project_name,
            project_keys=project_keys,
        )

        # Report the shared keys the id is STILL granted on (skipped above).
        # Residual access can be preserved TWO ways on a shared key we left
        # untouched: an explicit ``grants[key]`` recipient list, OR a
        # ``sharing.scopes[key]`` audience (a ``firm`` scope the id is a member
        # of, or a ``bilateral`` scope listing the id). Inspecting only ``grants``
        # would miss the scopes case and let the CLI falsely claim a clean
        # cut-off while the id still decrypts. Check both.
        if cross_shared:
            rid = str(revoked_id).strip()
            firm_set = {str(m).strip() for m in firm_members if str(m).strip()}
            raw_grants = cfg.get("grants") if isinstance(cfg, Mapping) else None
            raw_sharing = cfg.get("sharing") if isinstance(cfg, Mapping) else None
            raw_scopes = (
                raw_sharing.get("scopes") if isinstance(raw_sharing, Mapping) else None
            )
            still: list[str] = []
            for k in cross_shared:
                retains = isinstance(raw_grants, Mapping) and rid in _as_id_list(
                    raw_grants.get(k)
                )
                if not retains and isinstance(raw_scopes, Mapping) and k in raw_scopes:
                    kind, audience, _extra = _scope_parts(raw_scopes.get(k))
                    if kind == SCOPE_FIRM:
                        retains = rid in firm_set
                    elif kind == SCOPE_BILATERAL:
                        retains = rid in audience
                if retains:
                    still.append(k)
            change.cross_project_shared = tuple(sorted(still))

        # A scoped revoke only edits the target project's closure, so the id can
        # remain an EFFECTIVE bundle recipient via grants/scopes OUTSIDE that
        # closure (e.g. still granted on another project's keys). Detect that on
        # the POST-edit config so the CLI does not falsely claim a clean cut-off.
        # Exclude the in-closure shared keys already reported via
        # ``cross_project_shared`` (they live inside the full closure anyway).
        if project_name:
            residual, residual_undetermined = _residual_keys_outside_closure(
                new_cfg if change.changed else cfg,
                revoked_id,
                full_closure,
                firm_members,
            )
            change.residual = tuple(residual)
            change.residual_undetermined = tuple(residual_undetermined)
            # An incomplete closure (unparseable note / unreadable citations)
            # omitted keys from the edit, so the id may still be granted there —
            # surface it so the CLI does not claim a clean cut-off.
            change.closure_incomplete = closure_incomplete

        # An empty/unavailable registry cannot rebuild a firm audience, so a firm
        # key would be left as ``firm`` and silently re-grant the id on the next
        # publish. Refuse loudly rather than reporting a misleading success/no-op.
        if not firm_members and change.firm_scopes_in_scope:
            raise RevocationError(
                "cannot apply firm revocation: "
                f"{len(change.firm_scopes_in_scope)} firm-scoped key(s) "
                f"({', '.join(change.firm_scopes_in_scope)}) are in scope but the "
                "identity registry is empty or unavailable, so the firm audience "
                "cannot be rebuilt without the revoked id. Fix the registry "
                "(.memory/identities.yaml) and retry — no config change was made."
            )

        if change.changed:
            zk_config.write_config(new_cfg)
    finally:
        zk_graph.GRAPHS_DIR = previous_dir

    return change