Skip to content

memory.sharing.grants_edit

memory.sharing.grants_edit

Revoke a recipient id from the owner-authored sharing policy.

This is the edit half of the Iceberg sharing layer: given the parsed .memory/config.yaml policy (sharing.scopes / sharing.skills / grants — see :mod:memory.sharing.config), drop a recipient id from a named scope (a subtree) or from everywhere. It performs the manifest edit only; the forward secrecy comes for free because build_bundle rebuilds units/ from scratch, so republishing after a grant change already excludes the revoked id.

The core is a PURE transform (:func:revoke_from_config) that takes a loaded config dict and returns a NEW dict plus a :class:RevocationChange record for an honest revocation report. A thin wrapper (:func:revoke_in_store) reads/writes .memory/config.yaml around it.

Design notes:

  • firm -> bilateral conversion. firm means "every registered member", so a firm-scoped node cannot be narrowed by editing a recipient list. Revoking a firm member instead converts that node from firm to bilateral with audience = sorted(firm_members) - revoked_id (recorded in the change). A revoked id that is NOT a firm member leaves firm nodes untouched (nothing to remove).
  • Scope semantics. With scope=<entry_id> only keys for that entry AND its descendants are touched — a key K is in scope iff scope sits on K's parent_id ancestor chain (mirrors :func:memory.sharing.config._ancestor_ids). Grants on an ANCESTOR of scope are intentionally left intact (editing them would affect out-of-subtree siblings). Without a scope, everything is revoked: every grants list, every bilateral audience, and the sharing.skills default. When entries_by_id is None a scoped revoke can only match the exact scope key (no descendant resolution).
  • Cleanup. Grant keys whose list becomes empty are dropped (kept tidy). A bilateral (or firm-downgraded) scope whose audience becomes empty is REMOVED entirely so it falls back to the private default — this matches the Zettelkasten sibling (:mod:zettelkasten.sharing.grants_edit), so the two stores agree on the empty-audience outcome. All unrelated keys/blocks are preserved verbatim.
  • Scoped inheritance is honest. A scoped revoke only edits keys at/below the scope node, but :func:memory.sharing.config.entry_recipients UNIONS ancestor grants and applies the nearest-ancestor scope (incl. firm on a parent). So an id may retain access to the subtree purely by inheritance from an ANCESTOR grant/scope this edit intentionally never touches. That residual access is DETECTED and recorded (residual_inherited) so the report can say the id is not actually cut off from the subtree and name the ancestor keys to revoke at instead.

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 nodes 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 memory/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 nodes 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

What a revoke did to the config, for an honest revocation report.

  • grants_removed_fromgrants keys the id was removed from (includes keys that then became empty).
  • emptied_grant_keys — the subset of the above whose list became empty and was therefore dropped from grants.
  • firm_convertedsharing.scopes node ids converted firm -> bilateral(firm_members - id) (plus "sharing.skills" sentinel handled via skills_default_touched).
  • bilateral_dropped_fromsharing.scopes node ids whose bilateral audience the id was dropped from.
  • skills_default_touched — whether the global sharing.skills default was edited (firm->bilateral conversion or bilateral drop). Only a global revoke touches it.
  • id_present — whether the revoked id was EXPLICITLY listed anywhere in the config (any grants list or any bilateral audience, scope-independent). Firm membership is implicit and does NOT count here.
  • emptied_scope_keyssharing.scopes node ids (plus the "sharing.skills" sentinel) whose bilateral audience became empty and were therefore REMOVED (falling back to the private default).
  • firm_scopes_in_scope — every firm-scoped node id within the edit scope (plus "sharing.skills" when the global default is firm), regardless of whether it was converted. Used by :func:revoke_in_store to detect the empty-registry hazard (a firm node left as firm because the registry was unavailable would silently re-grant the id on the next publish).
  • residual_inherited — for a scoped revoke, the ANCESTOR grant/scope keys (above the scope node, so intentionally untouched) that STILL cover the revoked id within the subtree. Non-empty means the id is NOT actually cut off from the subtree — the owner must revoke at those ancestor nodes (or globally) to fully cut access.
  • changed — whether this operation actually modified the config. Revoking an absent id is a clean no-op (changed == False).
Source code in memory/sharing/grants_edit.py
@dataclass
class RevocationChange:
    """What a revoke did to the config, for an honest revocation report.

    * ``grants_removed_from`` — ``grants`` keys the id was removed from (includes
      keys that then became empty).
    * ``emptied_grant_keys`` — the subset of the above whose list became empty and
      was therefore dropped from ``grants``.
    * ``firm_converted`` — ``sharing.scopes`` node ids converted ``firm`` ->
      ``bilateral(firm_members - id)`` (plus ``"sharing.skills"`` sentinel handled
      via ``skills_default_touched``).
    * ``bilateral_dropped_from`` — ``sharing.scopes`` node ids whose ``bilateral``
      audience the id was dropped from.
    * ``skills_default_touched`` — whether the global ``sharing.skills`` default
      was edited (firm->bilateral conversion or bilateral drop). Only a global
      revoke touches it.
    * ``id_present`` — whether the revoked id was EXPLICITLY listed anywhere in the
      config (any ``grants`` list or any ``bilateral`` audience, scope-independent).
      Firm membership is implicit and does NOT count here.
    * ``emptied_scope_keys`` — ``sharing.scopes`` node ids (plus the
      ``"sharing.skills"`` sentinel) whose ``bilateral`` audience became empty and
      were therefore REMOVED (falling back to the ``private`` default).
    * ``firm_scopes_in_scope`` — every ``firm``-scoped node id within the edit
      scope (plus ``"sharing.skills"`` when the global default is firm),
      regardless of whether it was converted. Used by :func:`revoke_in_store` to
      detect the empty-registry hazard (a firm node left as ``firm`` because the
      registry was unavailable would silently re-grant the id on the next publish).
    * ``residual_inherited`` — for a scoped revoke, the ANCESTOR grant/scope keys
      (above the scope node, so intentionally untouched) that STILL cover the
      revoked id within the subtree. Non-empty means the id is NOT actually cut
      off from the subtree — the owner must revoke at those ancestor nodes (or
      globally) to fully cut access.
    * ``changed`` — whether this operation actually modified the config. Revoking
      an absent id is a clean no-op (``changed == False``).
    """

    revoked_id: str
    changed: bool = False
    id_present: bool = False
    grants_removed_from: list[str] = field(default_factory=list)
    emptied_grant_keys: list[str] = field(default_factory=list)
    firm_converted: list[str] = field(default_factory=list)
    bilateral_dropped_from: list[str] = field(default_factory=list)
    emptied_scope_keys: list[str] = field(default_factory=list)
    firm_scopes_in_scope: list[str] = field(default_factory=list)
    residual_inherited: list[str] = field(default_factory=list)
    skills_default_touched: bool = False

revoke_from_config

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

Drop revoked_id from the sharing policy, returning a NEW config + report.

The input config is never mutated (it is deep-copied first). scope limits the edit to a subtree (see module docstring); firm_members is the full registered-member set used to rebuild a firm node's audience when it is converted to bilateral. entries_by_id maps entry id -> a mapping carrying parent_id, used only for subtree resolution on a scoped revoke.

Source code in memory/sharing/grants_edit.py
def revoke_from_config(
    config: Mapping[str, Any],
    revoked_id: str,
    *,
    firm_members: Iterable[str],
    scope: str | None = None,
    entries_by_id: Mapping[str, Any] | None = None,
) -> tuple[dict, RevocationChange]:
    """Drop ``revoked_id`` from the sharing policy, returning a NEW config + report.

    The input ``config`` is never mutated (it is deep-copied first). ``scope``
    limits the edit to a subtree (see module docstring); ``firm_members`` is the
    full registered-member set used to rebuild a ``firm`` node's audience when it
    is converted to ``bilateral``. ``entries_by_id`` maps entry id -> a mapping
    carrying ``parent_id``, used only for subtree resolution on a scoped revoke.
    """
    new_config: dict[str, Any] = copy.deepcopy(dict(config))
    change = RevocationChange(revoked_id=revoked_id)
    firm_set = set(firm_members)

    # --- id_present: explicit, scope-independent presence in the config ---
    raw_grants = config.get("grants") if isinstance(config, Mapping) else None
    if isinstance(raw_grants, Mapping):
        for value in raw_grants.values():
            if revoked_id in _parse_recipient_list(value):
                change.id_present = True
                break
    if not change.id_present:
        raw_sharing = config.get("sharing") if isinstance(config, Mapping) else None
        if isinstance(raw_sharing, Mapping):
            raw_scopes = raw_sharing.get("scopes")
            candidates: list[Any] = []
            if isinstance(raw_scopes, Mapping):
                candidates.extend(raw_scopes.values())
            if "skills" in raw_sharing:
                candidates.append(raw_sharing.get("skills"))
            for raw in candidates:
                sc = _parse_scope(raw)
                if sc.kind == SCOPE_BILATERAL and revoked_id in sc.audience:
                    change.id_present = True
                    break

    # --- 1. grants: drop the id from each in-scope list; drop emptied keys ---
    grants = new_config.get("grants")
    if isinstance(grants, dict):
        for key in list(grants.keys()):
            if not _key_in_scope(key, scope, entries_by_id):
                continue
            recips = _parse_recipient_list(grants[key])
            if revoked_id not in recips:
                continue
            remaining = [r for r in recips if r != revoked_id]
            change.grants_removed_from.append(key)
            if remaining:
                grants[key] = remaining
            else:
                del grants[key]
                change.emptied_grant_keys.append(key)

    # --- 2. sharing.scopes + sharing.skills default ---
    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()):
                if not _key_in_scope(key, scope, entries_by_id):
                    continue
                sc = _parse_scope(scopes[key])
                if sc.kind == SCOPE_FIRM:
                    # Record every in-scope firm node 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).
                    change.firm_scopes_in_scope.append(key)
                    if revoked_id in firm_set:
                        remaining = sorted(firm_set - {revoked_id})
                        if remaining:
                            scopes[key] = _bilateral_mapping(remaining)
                            change.firm_converted.append(key)
                        else:
                            del scopes[key]
                            change.emptied_scope_keys.append(key)
                elif sc.kind == SCOPE_BILATERAL and revoked_id in sc.audience:
                    change.bilateral_dropped_from.append(key)
                    remaining = [a for a in sc.audience if a != revoked_id]
                    if remaining:
                        scopes[key] = _bilateral_mapping(remaining)
                    else:
                        del scopes[key]
                        change.emptied_scope_keys.append(key)

        # The skills default is global (skills live outside the entry tree), so
        # only a global (unscoped) revoke touches it.
        if scope is None and "skills" in sharing_block:
            sc = _parse_scope(sharing_block.get("skills"))
            if sc.kind == SCOPE_FIRM:
                change.firm_scopes_in_scope.append("sharing.skills")
                if revoked_id in firm_set:
                    remaining = sorted(firm_set - {revoked_id})
                    if remaining:
                        sharing_block["skills"] = _bilateral_mapping(remaining)
                    else:
                        del sharing_block["skills"]
                        change.emptied_scope_keys.append("sharing.skills")
                    change.skills_default_touched = True
            elif sc.kind == SCOPE_BILATERAL and revoked_id in sc.audience:
                remaining = [a for a in sc.audience if a != revoked_id]
                if remaining:
                    sharing_block["skills"] = _bilateral_mapping(remaining)
                else:
                    del sharing_block["skills"]
                    change.emptied_scope_keys.append("sharing.skills")
                change.skills_default_touched = True

    change.changed = bool(
        change.grants_removed_from
        or change.firm_converted
        or change.bilateral_dropped_from
        or change.emptied_scope_keys
        or change.skills_default_touched
    )

    # A scoped revoke leaves ancestor grants/scopes untouched by design, so the
    # id may still reach the subtree by inheritance. Detect + record it so the
    # report can be honest rather than falsely claiming a clean cut-off.
    if scope is not None and entries_by_id:
        change.residual_inherited = _residual_inherited_keys(
            new_config, revoked_id, scope, entries_by_id, firm_set
        )

    return new_config, change

revoke_in_store

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

Read .memory/config.yaml, revoke revoked_id, and write it back.

Thin IO wrapper around :func:revoke_from_config. registry (an :class:~memory.sharing.identity.IdentityRegistry) supplies the firm-member set for firm->bilateral conversion; it defaults to :func:memory.sharing.load_registry. memory_dir overrides the store location (defaults to the live .memory). The config is written back only when something actually changed.

Source code in memory/sharing/grants_edit.py
def revoke_in_store(
    revoked_id: str,
    *,
    scope: str | None = None,
    registry: Any | None = None,
    memory_dir: Path | str | None = None,
) -> RevocationChange:
    """Read ``.memory/config.yaml``, revoke ``revoked_id``, and write it back.

    Thin IO wrapper around :func:`revoke_from_config`. ``registry`` (an
    :class:`~memory.sharing.identity.IdentityRegistry`) supplies the firm-member
    set for firm->bilateral conversion; it defaults to
    :func:`memory.sharing.load_registry`. ``memory_dir`` overrides the store
    location (defaults to the live ``.memory``). The config is written back only
    when something actually changed.
    """
    from memory import storage
    from memory.sharing.identity import load_registry

    if registry is None:
        registry = load_registry()
    firm_members = registry.member_ids()

    if memory_dir is None:
        config = storage.read_config()
        entries_by_id = _load_entries_by_id(storage.MEMORY_DIR)
    else:
        import yaml

        mem = Path(memory_dir)
        cfg_path = mem / "config.yaml"
        if cfg_path.exists():
            config = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or {}
        else:
            config = {}
        entries_by_id = _load_entries_by_id(mem)

    new_config, change = revoke_from_config(
        config,
        revoked_id,
        firm_members=firm_members,
        scope=scope,
        entries_by_id=entries_by_id,
    )

    # An empty/unavailable registry means a firm scope cannot be rebuilt, so it
    # 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 node(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:
        if memory_dir is None:
            storage.write_config(new_config)
        else:
            import yaml

            (Path(memory_dir) / "config.yaml").write_text(
                yaml.dump(
                    new_config,
                    default_flow_style=False,
                    allow_unicode=True,
                    sort_keys=False,
                ),
                encoding="utf-8",
            )

    return change