Skip to content

symfonic.capabilities.memory.scope

scope

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

identity_version: int

Zero is positional; one retains every native kind.

levels property

levels: tuple[tuple[str, str], ...]

Complete root-first identity including kinds.

path property

path: str

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

validate() -> None

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)