Versioned memory scope values with exact root-first visibility.
Every port signature in this capability is keyed by a scope, and the reason is
SEC-TEN-5: a backend must be able to enforce tenant isolation without importing
the platform that derived the tenant. A scope value that carries its own
hierarchy is what makes that possible — the backend answers "is this memory
visible here?" by comparing complete levels, not by asking anyone.
Positional v0 scopes retain their one-to-three-level slash path. Native v1
scopes keep every kind/ID pair at arbitrary depth in a disjoint canonical
envelope. The versions never share visibility, even where kinds and IDs match.
Visibility runs one way only. A memory written at acme is visible inside
acme/alice/s1, because a tenant-wide fact is true of every session in the
tenant. A memory written at acme/alice/s1 is not visible at acme:
promoting session content upward is a consolidation decision (T3.3.3), and a
retrieval that did it implicitly would leak one user's session into another's.
For v0, the charset is not cosmetic. / separates levels, so a segment containing one
forges a level — MemoryScope("acme/evil") would otherwise claim to be a
tenant while comparing equal to a descendant of acme.
MemoryScope
dataclass
MemoryScope(tenant: str, principal: str = '', session: str = '', *, _levels: tuple[tuple[str, str], ...] = ())
Where a memory lives, preserving positional or native identity.
Positional levels are gapless. A session without a principal is a
hole in the hierarchy — it would compare as a child of the tenant while
naming something the tenant cannot enumerate — so it is refused at
construction rather than normalised into something plausible. Native
levels retain explicit kinds and can have arbitrary depth.
identity_version
property
Zero is positional; one retains every native kind.
levels
property
levels: tuple[tuple[str, str], ...]
Complete root-first identity including kinds.
path
property
Canonical capability path, preserving native kinds on roundtrip.
segments
property
segments: tuple[str, ...]
The populated levels, outermost first.
ancestor
ancestor(depth: int) -> MemoryScope
Select a populated ancestor without losing kinds or version.
Source code in src/symfonic/capabilities/memory/scope.py
| def ancestor(self, depth: int) -> MemoryScope:
"""Select a populated ancestor without losing kinds or version."""
if type(depth) is not int or not 1 <= depth <= len(self.segments):
raise MemoryContractError("scope ancestor depth must select a populated level")
if self._levels:
return MemoryScope.from_levels(self._levels[:depth])
return MemoryScope(*self.segments[:depth])
|
covers
covers(other: MemoryScope) -> bool
Whether a memory written at self is visible at other.
Complete-level, never string-prefix: acme does not cover acmecorp,
and a rule written with :meth:str.startswith would say it does.
Source code in src/symfonic/capabilities/memory/scope.py
| def covers(self, other: MemoryScope) -> bool:
"""Whether a memory written at ``self`` is visible at ``other``.
Complete-level, never string-prefix: ``acme`` does not cover ``acmecorp``,
and a rule written with :meth:`str.startswith` would say it does.
"""
mine = self.levels
theirs = other.levels
return (
self.identity_version == other.identity_version
and len(mine) <= len(theirs)
and theirs[: len(mine)] == mine
)
|
distance
distance(other: MemoryScope) -> int
Levels from self down to other, or -1 when not covered.
-1 rather than an exception: distance is asked once per candidate
during ranking, and "not visible from here" is an ordinary answer there.
Source code in src/symfonic/capabilities/memory/scope.py
| def distance(self, other: MemoryScope) -> int:
"""Levels from ``self`` down to ``other``, or ``-1`` when not covered.
``-1`` rather than an exception: distance is asked once per candidate
during ranking, and "not visible from here" is an ordinary answer there.
"""
if not self.covers(other):
return -1
return len(other.segments) - len(self.segments)
|
from_levels
classmethod
from_levels(levels: Iterable[tuple[str, str]]) -> MemoryScope
Snapshot explicit kind/ID pairs without inferring missing kinds.
Source code in src/symfonic/capabilities/memory/scope.py
| @classmethod
def from_levels(cls, levels: Iterable[tuple[str, str]]) -> MemoryScope:
"""Snapshot explicit kind/ID pairs without inferring missing kinds."""
try:
values = tuple(levels)
except TypeError as error:
raise MemoryContractError("native scope levels must be iterable") from error
if not values:
raise MemoryContractError("a native memory scope must name a level")
_validate_levels(values)
return cls(values[0][1], _levels=values)
|
validate
Refuse every scope this capability cannot compare.
Source code in src/symfonic/capabilities/memory/scope.py
| def validate(self) -> None:
"""Refuse every scope this capability cannot compare."""
if type(self._levels) is not tuple:
raise MemoryContractError("scope levels must be an immutable tuple")
if self._levels:
_validate_levels(self._levels)
if self.tenant != self._levels[0][1] or self.principal or self.session:
raise MemoryContractError("native scope flat fields disagree with its levels")
return
if not self.tenant:
raise MemoryContractError(
"a memory scope must name a tenant; an unscoped memory is a memory no "
"backend can isolate (SEC-TEN-5)."
)
if self.session and not self.principal:
raise MemoryContractError(
f"scope declares session {self.session!r} with no principal. The levels are "
"positional: a session under an anonymous principal would compare as a direct "
"child of the tenant, making it visible to every other principal in it."
)
for level, value in (
("tenant", self.tenant),
("principal", self.principal),
("session", self.session),
):
if value and not _SEGMENT_CHARSET.match(value):
raise MemoryContractError(
f"{level} segment {value!r} is outside the permitted charset "
f"[A-Za-z0-9_.:@-]. {SCOPE_SEPARATOR!r} separates levels, so a segment "
"containing one forges a level of the hierarchy."
)
|
scope_from_path
scope_from_path(path: str) -> MemoryScope
Parse the canonical path form back into a scope.
Source code in src/symfonic/capabilities/memory/scope.py
| def scope_from_path(path: str) -> MemoryScope:
"""Parse the canonical path form back into a scope."""
if type(path) is not str:
raise MemoryContractError("scope path must be a string")
if path.startswith("["):
try:
value = json.loads(path)
if (
type(value) is not list
or len(value) != 3
or value[0] != _NATIVE_FORMAT
or type(value[1]) is not int
or value[1] != 1
or type(value[2]) is not list
or any(type(level) is not list for level in value[2])
):
raise ValueError("invalid native envelope")
scope = MemoryScope.from_levels(tuple(tuple(level) for level in value[2]))
if scope.path != path:
raise ValueError("noncanonical native envelope")
return scope
except (ValueError, TypeError) as error:
raise MemoryContractError("invalid native scope identity envelope") from error
segments = path.split(SCOPE_SEPARATOR) if path else []
if not 1 <= len(segments) <= 3:
raise MemoryContractError(
f"scope path {path!r} has {len(segments)} levels; a memory scope is "
"tenant[/principal[/session]] — between one and three."
)
return MemoryScope(*segments)
|