Grants / sharing-scope config parsing and shareability resolution.
This module reads the owner-authored sharing policy from .memory/config.yaml
(loaded via :func:memory.storage.read_config, a plain dict) and turns it into
typed accessors. It is the policy half of firm-wide sharing: it decides which
memory entries are shareable to which recipient id. It performs no crypto and no
I/O beyond parsing an already-loaded dict.
Two independent, additive mechanisms combine to decide shareability:
-
sharing.scopes — a visibility scope attached to a subtree node (a
subproject/phase node id). The scope is inherited by every descendant entry
and may be overridden on a deeper node (or the entry itself). 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 node_id -> [recipient_ids] map that grants
specific recipients access to a node and its descendants, regardless of scope.
An entry is shareable to a recipient if EITHER the effective scope admits them
(firm for anyone, bilateral if listed) OR an ancestor grant lists them.
Fully additive / opt-in: when both keys are absent the resolver returns the
empty set — i.e. today's behaviour (nothing is shared). No existing config is
affected.
Scope
dataclass
A visibility scope attached to a subtree node.
audience is only meaningful when kind == 'bilateral' and holds the
recipient ids that may access the subtree.
Source code in memory/sharing/config.py
| @dataclass(frozen=True)
class Scope:
"""A visibility scope attached to a subtree node.
``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 node id (subproject/phase/entry) to its :class:Scope.
grants maps a node id to the tuple of recipient ids granted access to
that node and its descendants. Grants also carry the out-of-tree kinds:
grants['skill:<name>'] grants a specific skill and
grants['doc:<id>'] (reserved) a specific document.
skills_default is the global default :class:Scope for skills (which
live OUTSIDE the entry tree, so they cannot inherit a subtree scope). It
defaults to private — skills are shared with nobody unless the owner
sets sharing.skills or a skill:<name> grant.
Source code in memory/sharing/config.py
| @dataclass(frozen=True)
class SharingConfig:
"""Parsed, typed view of the ``sharing`` + ``grants`` config blocks.
* ``scopes`` maps a node id (subproject/phase/entry) to its :class:`Scope`.
* ``grants`` maps a node id to the tuple of recipient ids granted access to
that node and its descendants. Grants also carry the out-of-tree kinds:
``grants['skill:<name>']`` grants a specific skill and
``grants['doc:<id>']`` (reserved) a specific document.
* ``skills_default`` is the global default :class:`Scope` for skills (which
live OUTSIDE the entry tree, so they cannot inherit a subtree scope). It
defaults to ``private`` — skills are shared with nobody unless the owner
sets ``sharing.skills`` or a ``skill:<name>`` grant.
"""
scopes: Mapping[str, Scope] = field(default_factory=dict)
grants: Mapping[str, tuple[str, ...]] = field(default_factory=dict)
skills_default: Scope = field(default_factory=lambda: Scope(SCOPE_PRIVATE))
# Per-node transport tier keyed exactly like ``scopes`` (by node/entry id).
# A node without an entry here inherits its nearest ancestor's transport and
# ultimately defaults to :data:`TRANSPORT_OFFLINE` (deny-by-default for the
# network tier). 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
and self.skills_default.kind == SCOPE_PRIVATE
)
|
is_empty
True when no sharing policy is configured (today's behaviour).
Source code in memory/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
and self.skills_default.kind == SCOPE_PRIVATE
)
|
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 returned by
:func:memory.storage.read_config. Missing/empty/malformed blocks yield an
empty :class:SharingConfig (nothing shared) — this function never raises on
a partially-formed config; unusable items are skipped.
Source code in memory/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 returned by
:func:`memory.storage.read_config`. Missing/empty/malformed blocks yield an
empty :class:`SharingConfig` (nothing shared) — this function never raises on
a partially-formed config; unusable items are skipped.
"""
if not isinstance(config, Mapping):
return SharingConfig()
scopes: dict[str, Scope] = {}
transports: dict[str, str] = {}
skills_default = Scope(SCOPE_PRIVATE)
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-node transport tier (``sharing.transports``), keyed exactly like
# ``sharing.scopes``. Unlike scopes (which fail-closed to ``private`` on a
# typo), an unrecognised transport is REJECTED with a clear error: the
# network tier is deny-by-default, and a silent fallback could either hide
# a hosting typo or (worse) be read as widening — so we surface it loudly.
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)
# Skills live OUTSIDE the entry tree, so they take a single global
# default scope from ``sharing.skills`` (string shorthand or a
# ``{scope: bilateral, audience: [...]}`` mapping). Absent -> private.
if "skills" in sharing_block:
skills_default = _parse_scope(sharing_block.get("skills"))
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,
skills_default=skills_default,
transports=transports,
)
|
effective_scope
effective_scope(entry: Mapping[str, Any], entries_by_id: Mapping[str, Mapping[str, Any]], sharing: SharingConfig) -> Scope
Resolve the scope that applies to entry after subtree inheritance.
Source code in memory/sharing/config.py
| def effective_scope(
entry: Mapping[str, Any],
entries_by_id: Mapping[str, Mapping[str, Any]],
sharing: SharingConfig,
) -> Scope:
"""Resolve the scope that applies to ``entry`` after subtree inheritance."""
entry_id = str(entry.get("id") or "")
if not entry_id:
return Scope(SCOPE_PRIVATE)
return _scope_for_chain(_ancestor_ids(entry_id, entries_by_id), sharing)
|
effective_grants
effective_grants(entry: Mapping[str, Any], entries_by_id: Mapping[str, Mapping[str, Any]], sharing: SharingConfig) -> set[str]
Recipient ids explicitly granted to entry via any ancestor grant.
Source code in memory/sharing/config.py
| def effective_grants(
entry: Mapping[str, Any],
entries_by_id: Mapping[str, Mapping[str, Any]],
sharing: SharingConfig,
) -> set[str]:
"""Recipient ids explicitly granted to ``entry`` via any ancestor grant."""
entry_id = str(entry.get("id") or "")
if not entry_id:
return set()
return _grants_for_chain(_ancestor_ids(entry_id, entries_by_id), sharing)
|
effective_transport
effective_transport(entry: Mapping[str, Any], entries_by_id: Mapping[str, Mapping[str, Any]], sharing: SharingConfig) -> str
Resolve the transport tier for entry after subtree inheritance.
Returns offline or hosted. Parallels :func:effective_scope: it
walks the same ancestor chain and honours inheritance identically, so a child
under a hosted ancestor is hosted unless it (or a nearer ancestor)
overrides back to offline. Defaults to :data:TRANSPORT_OFFLINE for an
entry with no transport anywhere on its chain (deny-by-default).
Source code in memory/sharing/config.py
| def effective_transport(
entry: Mapping[str, Any],
entries_by_id: Mapping[str, Mapping[str, Any]],
sharing: SharingConfig,
) -> str:
"""Resolve the transport tier for ``entry`` after subtree inheritance.
Returns ``offline`` or ``hosted``. Parallels :func:`effective_scope`: it
walks the same ancestor chain and honours inheritance identically, so a child
under a ``hosted`` ancestor is ``hosted`` unless it (or a nearer ancestor)
overrides back to ``offline``. Defaults to :data:`TRANSPORT_OFFLINE` for an
entry with no transport anywhere on its chain (deny-by-default).
"""
entry_id = str(entry.get("id") or "")
if not entry_id:
return TRANSPORT_OFFLINE
return _transport_for_chain(_ancestor_ids(entry_id, entries_by_id), sharing)
|
is_shareable_to
is_shareable_to(entry: Mapping[str, Any], entries_by_id: Mapping[str, Mapping[str, Any]], sharing: SharingConfig, recipient_id: str) -> bool
Whether entry is shareable to recipient_id.
Note: a firm-scoped entry is shareable to ANY recipient id here — firm
membership is enforced by the registry at bundle-build time (see
:func:entry_recipients), not by this logical query.
Source code in memory/sharing/config.py
| def is_shareable_to(
entry: Mapping[str, Any],
entries_by_id: Mapping[str, Mapping[str, Any]],
sharing: SharingConfig,
recipient_id: str,
) -> bool:
"""Whether ``entry`` is shareable to ``recipient_id``.
Note: a ``firm``-scoped entry is shareable to ANY recipient id here — firm
membership is enforced by the registry at bundle-build time (see
:func:`entry_recipients`), not by this logical query.
"""
entry_id = str(entry.get("id") or "")
if not entry_id:
return False
chain = _ancestor_ids(entry_id, entries_by_id)
if recipient_id in _grants_for_chain(chain, sharing):
return True
scope = _scope_for_chain(chain, sharing)
if scope.kind == SCOPE_FIRM:
return True
if scope.kind == SCOPE_BILATERAL and recipient_id in scope.audience:
return True
return False
|
shareable_entries
shareable_entries(sharing: SharingConfig, entries: Iterable[Mapping[str, Any]], recipient_id: str) -> list[Mapping[str, Any]]
Return the subset of entries shareable to recipient_id.
entries is the list of entry dicts (as produced by
:func:memory.storage.read_all; each must carry id and parent_id).
With an empty :class:SharingConfig this returns [] — nothing shared.
Source code in memory/sharing/config.py
| def shareable_entries(
sharing: SharingConfig,
entries: Iterable[Mapping[str, Any]],
recipient_id: str,
) -> list[Mapping[str, Any]]:
"""Return the subset of ``entries`` shareable to ``recipient_id``.
``entries`` is the list of entry dicts (as produced by
:func:`memory.storage.read_all`; each must carry ``id`` and ``parent_id``).
With an empty :class:`SharingConfig` this returns ``[]`` — nothing shared.
"""
entries = list(entries)
by_id = {str(e.get("id")): e for e in entries if e.get("id")}
return [
e
for e in entries
if e.get("id") and is_shareable_to(e, by_id, sharing, recipient_id)
]
|
entry_recipients
entry_recipients(sharing: SharingConfig, entries: Iterable[Mapping[str, Any]], firm_members: Iterable[str] = (), *, index_entries: Iterable[Mapping[str, Any]] | None = None) -> dict[str, set[str]]
Map each shareable entry id to the concrete set of recipient ids.
Unlike :func:is_shareable_to, this EXPANDS a firm scope to the actual
firm_members (the registered identities) so the bundle builder knows the
exact public keys to encrypt to. Entries that resolve to no recipients
(private and ungranted) are omitted from the result — the caller uses
this to physically drop them from the bundle before any encryption.
entries are the entries actually considered for bundling (typically the
ACTIVE entries). index_entries is the pool used to build the
ancestor-resolution index — pass ALL entries (including discarded/any
status) here so scope/grant inheritance can traverse a discarded
intermediate plan node. When index_entries is None the index is
built from entries (backwards-compatible behaviour). Only ids in
entries are resolved and returned, so a discarded ancestor never gets
bundled itself — it only keeps the chain from its active descendants intact.
Source code in memory/sharing/config.py
| def entry_recipients(
sharing: SharingConfig,
entries: Iterable[Mapping[str, Any]],
firm_members: Iterable[str] = (),
*,
index_entries: Iterable[Mapping[str, Any]] | None = None,
) -> dict[str, set[str]]:
"""Map each shareable entry id to the concrete set of recipient ids.
Unlike :func:`is_shareable_to`, this EXPANDS a ``firm`` scope to the actual
``firm_members`` (the registered identities) so the bundle builder knows the
exact public keys to encrypt to. Entries that resolve to no recipients
(``private`` and ungranted) are omitted from the result — the caller uses
this to physically drop them from the bundle before any encryption.
``entries`` are the entries actually considered for bundling (typically the
ACTIVE entries). ``index_entries`` is the pool used to build the
ancestor-resolution index — pass ALL entries (including discarded/any
status) here so scope/grant inheritance can traverse a discarded
intermediate plan node. When ``index_entries`` is ``None`` the index is
built from ``entries`` (backwards-compatible behaviour). Only ids in
``entries`` are resolved and returned, so a discarded ancestor never gets
bundled itself — it only keeps the chain from its active descendants intact.
"""
entries = list(entries)
index_source = list(index_entries) if index_entries is not None else entries
by_id = {str(e.get("id")): e for e in index_source if e.get("id")}
firm = set(firm_members)
result: dict[str, set[str]] = {}
for e in entries:
entry_id = str(e.get("id") or "")
if not entry_id:
continue
chain = _ancestor_ids(entry_id, by_id)
recips = set(_grants_for_chain(chain, sharing))
scope = _scope_for_chain(chain, sharing)
if scope.kind == SCOPE_FIRM:
recips.update(firm)
elif scope.kind == SCOPE_BILATERAL:
recips.update(scope.audience)
if recips:
result[entry_id] = recips
return result
|
document_recipients
document_recipients(sharing: SharingConfig, documents: Iterable[Mapping[str, Any]], entry_recipients_map: Mapping[str, set[str]]) -> dict[str, set[str]]
Map each shareable document id to its recipient set.
A document rides along with the entry it documents: it is shareable to a
recipient R iff EVERY entry named in its links_to is bundled (present in
entry_recipients_map) AND R can see all of them. The recipient set is
therefore the INTERSECTION of the links_to targets' recipient sets — this
guarantees that any retained links_to target is visible to every
recipient of the document (so the id never leaks; see the per-unit scrub in
:mod:~memory.sharing.bundle).
A document is OMITTED (returns no entry) when it has no links_to (orphan)
or any target is itself omitted/private. Only status == "active"
documents are considered. Per-document grants['doc:<id>'] overrides are
intentionally NOT honoured here (see the module notes): widening a document's
audience beyond its link target's would force the links_to to be scrubbed
for the extra recipients, defeating the document's purpose. Deferred.
Source code in memory/sharing/config.py
| def document_recipients(
sharing: SharingConfig,
documents: Iterable[Mapping[str, Any]],
entry_recipients_map: Mapping[str, set[str]],
) -> dict[str, set[str]]:
"""Map each shareable document id to its recipient set.
A document rides along with the entry it documents: it is shareable to a
recipient R iff EVERY entry named in its ``links_to`` is bundled (present in
``entry_recipients_map``) AND R can see all of them. The recipient set is
therefore the INTERSECTION of the ``links_to`` targets' recipient sets — this
guarantees that any retained ``links_to`` target is visible to every
recipient of the document (so the id never leaks; see the per-unit scrub in
:mod:`~memory.sharing.bundle`).
A document is OMITTED (returns no entry) when it has no ``links_to`` (orphan)
or any target is itself omitted/private. Only ``status == "active"``
documents are considered. Per-document ``grants['doc:<id>']`` overrides are
intentionally NOT honoured here (see the module notes): widening a document's
audience beyond its link target's would force the ``links_to`` to be scrubbed
for the extra recipients, defeating the document's purpose. Deferred.
"""
result: dict[str, set[str]] = {}
for doc in documents:
if str(doc.get("status") or "active") != "active":
continue
doc_id = str(doc.get("id") or "").strip()
if not doc_id:
continue
targets = _link_targets(doc.get("links_to"))
if not targets:
continue
recips: set[str] | None = None
for target in targets:
target_recips = entry_recipients_map.get(target)
if not target_recips:
recips = set()
break
recips = set(target_recips) if recips is None else (recips & set(target_recips))
if recips:
result[doc_id] = recips
return result
|
session_recipients
session_recipients(sharing: SharingConfig, sessions: Iterable[Mapping[str, Any]], entry_recipients_map: Mapping[str, set[str]]) -> dict[str, set[str]]
Map each shareable session id to its recipient set.
A session is shareable to a recipient R iff at least ONE of its referenced
entries (the union of entries_created/entries_accessed/
entries_modified) is visible to R. The session unit's recipient set is
therefore the UNION over its referenced entries of their recipient sets.
This is the same per-unit model as entries: the unit is encrypted to that
union U, and the per-unit scrub then keeps in the three ref arrays only the
entry ids whose recipient set is a superset of U (so every recipient of the
session can open every id it retains). A session whose arrays scrub to empty
is omitted entirely by the builder. Sessions have no status field, so all
present sessions are considered.
Source code in memory/sharing/config.py
| def session_recipients(
sharing: SharingConfig,
sessions: Iterable[Mapping[str, Any]],
entry_recipients_map: Mapping[str, set[str]],
) -> dict[str, set[str]]:
"""Map each shareable session id to its recipient set.
A session is shareable to a recipient R iff at least ONE of its referenced
entries (the union of ``entries_created``/``entries_accessed``/
``entries_modified``) is visible to R. The session unit's recipient set is
therefore the UNION over its referenced entries of their recipient sets.
This is the same per-unit model as entries: the unit is encrypted to that
union U, and the per-unit scrub then keeps in the three ref arrays only the
entry ids whose recipient set is a superset of U (so every recipient of the
session can open every id it retains). A session whose arrays scrub to empty
is omitted entirely by the builder. Sessions have no ``status`` field, so all
present sessions are considered.
"""
result: dict[str, set[str]] = {}
for session in sessions:
sid = str(session.get("id") or "").strip()
if not sid:
continue
recips: set[str] = set()
for eid in _session_referenced_ids(session):
recips |= entry_recipients_map.get(eid, set())
if recips:
result[sid] = recips
return result
|
skill_recipients
skill_recipients(sharing: SharingConfig, skills: Iterable[Mapping[str, Any]], firm_members: Iterable[str] = ()) -> dict[str, set[str]]
Map each shareable skill (by name) to its recipient set.
Skills live OUTSIDE the entry tree, so they cannot inherit a subtree scope.
Their audience is the global sharing.skills default
(:attr:SharingConfig.skills_default) UNIONED with any per-skill
grants['skill:<name>'] override:
private (default) — nobody, unless a skill:<name> grant lists them.
firm — every registered firm_members id.
bilateral — the scope's audience ids.
Only status == "active" skills are considered; a skill resolving to no
recipients is omitted from the result (physically dropped from the bundle).
Source code in memory/sharing/config.py
| def skill_recipients(
sharing: SharingConfig,
skills: Iterable[Mapping[str, Any]],
firm_members: Iterable[str] = (),
) -> dict[str, set[str]]:
"""Map each shareable skill (by name) to its recipient set.
Skills live OUTSIDE the entry tree, so they cannot inherit a subtree scope.
Their audience is the global ``sharing.skills`` default
(:attr:`SharingConfig.skills_default`) UNIONED with any per-skill
``grants['skill:<name>']`` override:
* ``private`` (default) — nobody, unless a ``skill:<name>`` grant lists them.
* ``firm`` — every registered ``firm_members`` id.
* ``bilateral`` — the scope's ``audience`` ids.
Only ``status == "active"`` skills are considered; a skill resolving to no
recipients is omitted from the result (physically dropped from the bundle).
"""
firm = set(firm_members)
scope = sharing.skills_default
result: dict[str, set[str]] = {}
for skill in skills:
if str(skill.get("status") or "active") != "active":
continue
name = str(skill.get("name") or skill.get("id") or "").strip()
if not name:
continue
recips: set[str] = set(sharing.grants.get(f"skill:{name}", ()))
if scope.kind == SCOPE_FIRM:
recips |= firm
elif scope.kind == SCOPE_BILATERAL:
recips |= set(scope.audience)
if recips:
result[name] = recips
return result
|