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