Skip to content

symfonic.core.scope

scope

TenantScope — immutable hierarchical N-level multi-tenant request scope.

v8.0 introduces a root-first ordered path of :class:ScopeLevel values as the canonical hierarchical representation (org → brand → conversation, arbitrary depth). The legacy flat fields (tenant_id / sub_tenant_id / namespace / inherits_from) are RETAINED as real fields so that every existing read site (224 in src/, 462 test constructions) and the byte-identical to_state_dict / equality / frozen contracts keep working unchanged.

The path is computed: - When no explicit path is supplied, it is DERIVED from the flat fields (a 1- or 2-level path) — backward-compatible. - When an explicit N-level path is supplied (via :meth:from_path / :meth:root / :meth:child), it is stored and the flat tenant_id / sub_tenant_id are computed back off it for the legacy read sites.

The materialised scope_path string (US-delimited, root-first) and the :meth:ancestor_prefix_paths IN-list are the backend isolation primitives (design §5.b) — exact-IN over ancestor prefixes, NEVER LIKE 'prefix%'.

ScopeLevel

Bases: BaseModel

One level in a hierarchical :class:TenantScope path.

kind is an adopter-defined label ("org" / "brand" / "conversation" / "tenant" …) — deliberately NOT an enum so that arbitrary hierarchies (org/team/project/session) are expressible (design §3). id is the identifier at this level. Both must be non-empty and MUST NOT contain the path delimiter (a delimiter inside an id would forge a false level boundary — the §5.b false-prefix class).

TenantScope

Bases: BaseModel

Immutable hierarchical N-level multi-tenant request scope.

Canonical representation is the root-first path (path[0] broadest, path[-1] most-specific). The legacy tenant_id / sub_tenant_id fields are RETAINED for backward compatibility and, when no explicit path override is supplied, ARE the 1-/2-level path. When an explicit N-level path is supplied via the factories, tenant_id / sub_tenant_id read back off the path.

Isolation (design §5.d): a query at path P sees a memory at path Q iff Q is a prefix of P. Enforced via :meth:ancestor_prefix_paths exact-IN at the backend, NEVER a string prefix scan.

path property

path: tuple[ScopeLevel, ...]

Root-first ordered path (canonical hierarchical representation).

Derived from the flat fields when no explicit override is set
  • tenant_id only → 1-level (tenant:tenant_id,)
    • sub_tenant_id → 2-level (tenant:tenant_id, sub_tenant:sub)

scope_depth property

scope_depth: int

Number of levels in the path (1-based; root-only path is depth 1).

scope_path property

scope_path: str

Materialised US-delimited, root-first path string for the backend.

org\x1facme\x1fbrand\x1fmyhalos\x1fconversation\x1fc-123. This is the stored column/field value (design §5.b).

ancestor_prefix_paths

ancestor_prefix_paths() -> list[str]

The exact-IN list for the isolation filter (design §5.b/§5.d).

Returns the materialised scope_path string of EVERY prefix of this scope's path (root → … → self), inclusive. A query at path P uses this list as WHERE scope_path = ANY(list) so it matches a stored memory at path Q iff Q is a prefix of P — exact set membership, never a LIKE 'prefix%' scan (which would leak acme-corp to acme).

Source code in src/symfonic/core/scope.py
def ancestor_prefix_paths(self) -> list[str]:
    """The exact-IN list for the isolation filter (design §5.b/§5.d).

    Returns the materialised ``scope_path`` string of EVERY prefix of this
    scope's path (root → … → self), inclusive.  A query at path P uses
    this list as ``WHERE scope_path = ANY(list)`` so it matches a stored
    memory at path Q iff Q is a prefix of P — exact set membership, never
    a ``LIKE 'prefix%'`` scan (which would leak ``acme-corp`` to ``acme``).
    """
    levels = self.path
    return [
        scope_path_for(levels[: i + 1]) for i in range(len(levels))
    ]

child

child(kind: str, id: str) -> TenantScope

Return a new scope with one more (deeper) level appended.

org_scope.child("brand", "myhalos").child("conversation", "c-123") builds the full hierarchy. Namespace and inherits_from are preserved.

The result preserves the receiver's concrete type (type(self)) so chaining .child() off a subclass such as :class:~symfonic.agent.types.FrameworkTenantScope keeps the subclass (and its converter methods) instead of silently downcasting to the base TenantScope (P1 — subclass-drop on chaining).

Source code in src/symfonic/core/scope.py
def child(self, kind: str, id: str) -> TenantScope:
    """Return a new scope with one more (deeper) level appended.

    ``org_scope.child("brand", "myhalos").child("conversation", "c-123")``
    builds the full hierarchy.  Namespace and inherits_from are preserved.

    The result preserves the receiver's concrete type (``type(self)``) so
    chaining ``.child()`` off a subclass such as
    :class:`~symfonic.agent.types.FrameworkTenantScope` keeps the subclass
    (and its converter methods) instead of silently downcasting to the
    base ``TenantScope`` (P1 — subclass-drop on chaining).
    """
    new_path = (*self.path, ScopeLevel(kind=kind, id=id))
    return type(self)(
        tenant_id=new_path[0].id,
        sub_tenant_id=new_path[1].id if len(new_path) > 1 else None,
        namespace=self.namespace,
        inherits_from=self.inherits_from,
        path_override=new_path,
    )

from_path classmethod

from_path(
    path: list[ScopeLevel] | tuple[ScopeLevel, ...],
    *,
    namespace: str | None = None,
) -> TenantScope

Construct an N-level scope from an explicit root-first path.

Source code in src/symfonic/core/scope.py
@classmethod
def from_path(
    cls,
    path: list[ScopeLevel] | tuple[ScopeLevel, ...],
    *,
    namespace: str | None = None,
) -> TenantScope:
    """Construct an N-level scope from an explicit root-first path."""
    levels = tuple(path)
    if not levels:
        raise ValueError("TenantScope.from_path requires a non-empty path")
    tenant_id = levels[0].id
    sub_tenant_id = levels[1].id if len(levels) > 1 else None
    return cls(
        tenant_id=tenant_id,
        sub_tenant_id=sub_tenant_id,
        namespace=namespace,
        path_override=levels,
    )

from_request classmethod

from_request(
    tenant_id: str,
    *,
    sub_tenant_id: str | None = None,
    namespace: str | None = None,
    path: list[ScopeLevel]
    | tuple[ScopeLevel, ...]
    | None = None,
) -> TenantScope

Construct a scope from request-edge fields (blessed adopter entry).

Supply path for N-level hierarchies, or the flat fields for the legacy 1-/2-level shape.

Source code in src/symfonic/core/scope.py
@classmethod
def from_request(
    cls,
    tenant_id: str,
    *,
    sub_tenant_id: str | None = None,
    namespace: str | None = None,
    path: list[ScopeLevel] | tuple[ScopeLevel, ...] | None = None,
) -> TenantScope:
    """Construct a scope from request-edge fields (blessed adopter entry).

    Supply ``path`` for N-level hierarchies, or the flat fields for the
    legacy 1-/2-level shape.
    """
    if path is not None:
        return cls.from_path(path, namespace=namespace)
    return cls(
        tenant_id=tenant_id,
        sub_tenant_id=sub_tenant_id,
        namespace=namespace,
    )

from_state_dict classmethod

from_state_dict(state: Mapping[str, Any]) -> TenantScope

Reconstruct a :class:TenantScope from a state-dict mapping.

Accepts UPPER_SNAKE and lower_snake casings (UPPER wins). Reads an explicit path / PATH key (list of {kind, id} dicts) when present — the N-level form; otherwise derives from the legacy flat fields. inherits_from is read recursively (v7.22 compat).

Source code in src/symfonic/core/scope.py
@classmethod
def from_state_dict(cls, state: Mapping[str, Any]) -> TenantScope:
    """Reconstruct a :class:`TenantScope` from a state-dict mapping.

    Accepts UPPER_SNAKE and lower_snake casings (UPPER wins).  Reads an
    explicit ``path`` / ``PATH`` key (list of ``{kind, id}`` dicts) when
    present — the N-level form; otherwise derives from the legacy flat
    fields.  ``inherits_from`` is read recursively (v7.22 compat).
    """
    def _read(*keys: str) -> Any:
        for k in keys:
            if k in state and state[k] is not None:
                return state[k]
        return None

    raw_path = _read("PATH", "path")
    if isinstance(raw_path, (list, tuple)) and raw_path:
        # Fail loud on malformed levels rather than silently dropping
        # them (silent scope data loss). Round-2 fix (triage 2026-07-02).
        from symfonic.agent.types import ScopeValidationError

        built: list[ScopeLevel] = []
        for lvl in raw_path:
            if not (isinstance(lvl, Mapping) and "kind" in lvl and "id" in lvl):
                raise ScopeValidationError(
                    "Malformed scope path level in from_state_dict: "
                    f"{lvl!r} (each level requires 'kind' and 'id').",
                )
            built.append(ScopeLevel(kind=str(lvl["kind"]), id=str(lvl["id"])))
        levels = tuple(built)
        if levels:
            namespace = _read("NAMESPACE", "namespace")
            return cls.from_path(levels, namespace=namespace)

    tenant_id = _read("TENANT_ID", "tenant_id") or ""
    sub_tenant_id = _read("SUB_TENANT_ID", "sub_tenant_id")
    namespace = _read("NAMESPACE", "namespace")

    raw_inherits = _read("INHERITS_FROM", "inherits_from")
    inherits_from: tuple[TenantScope, ...] = ()
    if isinstance(raw_inherits, (list, tuple)) and raw_inherits:
        inherits_from = tuple(
            cls.from_state_dict(ancestor)
            for ancestor in raw_inherits
            if isinstance(ancestor, Mapping)
        )

    kwargs: dict[str, Any] = {"tenant_id": tenant_id}
    if sub_tenant_id is not None:
        kwargs["sub_tenant_id"] = sub_tenant_id
    if namespace is not None:
        kwargs["namespace"] = namespace
    if inherits_from:
        kwargs["inherits_from"] = inherits_from
    return cls(**kwargs)

narrow

narrow(sub_tenant_id: str) -> TenantScope

Create a new scope narrowed to a sub-tenant (legacy 2-level shim).

Retained byte-identically from v7.22: sets both sub_tenant_id and namespace to the supplied value and preserves inherits_from. For N-level hierarchies prefer :meth:child.

Source code in src/symfonic/core/scope.py
def narrow(self, sub_tenant_id: str) -> TenantScope:
    """Create a new scope narrowed to a sub-tenant (legacy 2-level shim).

    Retained byte-identically from v7.22: sets both ``sub_tenant_id`` and
    ``namespace`` to the supplied value and preserves ``inherits_from``.
    For N-level hierarchies prefer :meth:`child`.
    """
    return TenantScope(
        tenant_id=self.tenant_id,
        sub_tenant_id=sub_tenant_id,
        namespace=sub_tenant_id,
        inherits_from=self.inherits_from,
    )

root classmethod

root(
    kind: str, id: str, *, namespace: str | None = None
) -> TenantScope

Construct a 1-level root scope (kind:id,).

Source code in src/symfonic/core/scope.py
@classmethod
def root(cls, kind: str, id: str, *, namespace: str | None = None) -> TenantScope:
    """Construct a 1-level root scope ``(kind:id,)``."""
    return cls.from_path([ScopeLevel(kind=kind, id=id)], namespace=namespace)

to_state_dict

to_state_dict() -> dict[str, Any]

Convert to state-dict fields for BaseAgentState injection.

Backward-compatible: a legacy 1-/2-level scope (no explicit path override) emits EXACTLY the pre-v8.0 keys (tenant_id + sub_tenant_id [+ namespace] [+ inherits_from]) — byte-identical. An explicit N-level path additionally emits a path key (list of {kind, id} dicts) so the hierarchy round-trips.

Source code in src/symfonic/core/scope.py
def to_state_dict(self) -> dict[str, Any]:
    """Convert to state-dict fields for BaseAgentState injection.

    Backward-compatible: a legacy 1-/2-level scope (no explicit path
    override) emits EXACTLY the pre-v8.0 keys
    (``tenant_id`` + ``sub_tenant_id`` [+ ``namespace``] [+
    ``inherits_from``]) — byte-identical.  An explicit N-level path
    additionally emits a ``path`` key (list of ``{kind, id}`` dicts) so
    the hierarchy round-trips.
    """
    d: dict[str, Any] = {
        "tenant_id": self.tenant_id,
        "sub_tenant_id": self.sub_tenant_id,
    }
    if self.namespace is not None:
        d["namespace"] = self.namespace
    if self.inherits_from:
        d["inherits_from"] = [
            ancestor.to_state_dict() for ancestor in self.inherits_from
        ]
    if self.path_override is not None:
        d["path"] = [
            {"kind": lvl.kind, "id": lvl.id} for lvl in self.path_override
        ]
    return d

is_prefix_path

is_prefix_path(
    stored: str, query_prefixes: list[str]
) -> bool

Return True iff stored is one of the query's ancestor prefixes.

The exact-set-membership isolation primitive (design §5.d). stored is a node's materialised scope_path; query_prefixes is the query's :meth:TenantScope.ancestor_prefix_paths. Exact membership — NEVER a startswith (which leaks acme-corp into an acme query).

Source code in src/symfonic/core/scope.py
def is_prefix_path(stored: str, query_prefixes: list[str]) -> bool:
    """Return True iff ``stored`` is one of the query's ancestor prefixes.

    The exact-set-membership isolation primitive (design §5.d).  ``stored`` is
    a node's materialised ``scope_path``; ``query_prefixes`` is the query's
    :meth:`TenantScope.ancestor_prefix_paths`.  Exact membership — NEVER a
    ``startswith`` (which leaks ``acme-corp`` into an ``acme`` query).
    """
    return stored in query_prefixes

legacy_scope_path

legacy_scope_path(tenant_id: str) -> str

Backfill string for a pre-v8.0 tenant_id-only row (design §6.b).

Maps to a 1-level root path tenant\x1f<tenant_id> so old rows behave as tenant-global memories visible to every descendant query under that tenant — the correct conservative default (they were tenant-global before).

Source code in src/symfonic/core/scope.py
def legacy_scope_path(tenant_id: str) -> str:
    """Backfill string for a pre-v8.0 ``tenant_id``-only row (design §6.b).

    Maps to a 1-level root path ``tenant\\x1f<tenant_id>`` so old rows behave
    as tenant-global memories visible to every descendant query under that
    tenant — the correct conservative default (they were tenant-global before).
    """
    return scope_path_for((ScopeLevel(kind=_LEGACY_TENANT_KIND, id=tenant_id),))

scope_path_for

scope_path_for(
    levels: tuple[ScopeLevel, ...] | list[ScopeLevel],
) -> str

Materialise a root-first path into the US-delimited storage string.

(org:acme, brand:myhalos)org\x1facme\x1fbrand\x1fmyhalos.

Source code in src/symfonic/core/scope.py
def scope_path_for(levels: tuple[ScopeLevel, ...] | list[ScopeLevel]) -> str:
    """Materialise a root-first path into the US-delimited storage string.

    ``(org:acme, brand:myhalos)`` → ``org\\x1facme\\x1fbrand\\x1fmyhalos``.
    """
    parts: list[str] = []
    for lvl in levels:
        parts.append(lvl.kind)
        parts.append(lvl.id)
    return SCOPE_PATH_DELIMITER.join(parts)