Skip to content

symfonic.memory.scope_isolation

scope_isolation

Prefix-isolation primitives shared across all memory backends (v8.0 §5).

The isolation rule (design §5.d): a query at path P sees a memory at path Q iff Q is a prefix of P. This is enforced via exact-set-membership over the query's ancestor-prefix strings — NEVER a LIKE 'prefix%' scan, which would leak acme-corp into an acme query (the §5.b R2 CRITICAL class).

Filtering is ALWAYS-ON (it closes the Item D cross-session bleed bug) and is NOT gated behind scorer_on_hot_path. Weighted blending (§4) is the opt-in part; isolation is unconditional.

The materialised scope_path is stored in the zero-migration property bag (MemoryNode.properties['scope_path'] / vector metadata scope_path) so no schema change is required. Pre-v8.0 rows have no scope_path; they dual-read as a 1-level root path derived from tenant_id (design §6.b), keeping them tenant-global as before for v0 queries only. Native storage queries use canonical v1 keys and never infer identity from missing, NULL or v0 rows.

is_visible

is_visible(stored_path: str | None, query_prefixes: list[str]) -> bool

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

Exact set membership — the §5.d isolation invariant. query_prefixes is :meth:TenantScope.ancestor_prefix_paths. NEVER a startswith.

Source code in src/symfonic/memory/scope_isolation.py
def is_visible(
    stored_path: str | None,
    query_prefixes: list[str],
) -> bool:
    """Return True iff ``stored_path`` is one of the query's ancestor prefixes.

    Exact set membership — the §5.d isolation invariant.  ``query_prefixes`` is
    :meth:`TenantScope.ancestor_prefix_paths`.  NEVER a ``startswith``.
    """
    return stored_path in query_prefixes

materialise_scope_path

materialise_scope_path(scope: StorageScope) -> str

Return the materialised scope_path string for a write scope.

Source code in src/symfonic/memory/scope_isolation.py
def materialise_scope_path(scope: StorageScope) -> str:
    """Return the materialised ``scope_path`` string for a write scope."""
    return scope.scope_path

metadata_scope_path

metadata_scope_path(metadata: dict[str, object] | None, tenant_id: str, *, scope: StorageScope | None = None) -> str | None

Vector-backend variant of :func:stored_scope_path (metadata bag).

Source code in src/symfonic/memory/scope_isolation.py
def metadata_scope_path(
    metadata: dict[str, object] | None,
    tenant_id: str,
    *,
    scope: StorageScope | None = None,
) -> str | None:
    """Vector-backend variant of :func:`stored_scope_path` (metadata bag)."""
    return stored_scope_path(metadata, tenant_id, scope=scope)

mongo_prefix_filter

mongo_prefix_filter(scope: StorageScope, field: str = SCOPE_PATH_KEY) -> dict

Build the Mongo prefix-isolation filter for a scope_path field.

Returns a query fragment matching docs whose field is one of the query's ancestor prefixes (exact $in — NEVER a $regex prefix, which would re-introduce the false-prefix-leak class). Includes the §6.b dual-read for pre-v8.0 docs missing the field: they behave as a 1-level root path. Compose into a larger find filter with $or-free AND by merging into the top-level dict, or under $and when other keys overlap. Native predicates use $expr: always compose under $and if another expression is present, rather than overwriting either isolation predicate.

Source code in src/symfonic/memory/scope_isolation.py
def mongo_prefix_filter(scope: StorageScope, field: str = SCOPE_PATH_KEY) -> dict:
    """Build the Mongo prefix-isolation filter for a scope_path field.

    Returns a query fragment matching docs whose ``field`` is one of the
    query's ancestor prefixes (exact ``$in`` — NEVER a ``$regex`` prefix,
    which would re-introduce the false-prefix-leak class).  Includes the §6.b
    dual-read for pre-v8.0 docs missing the field: they behave as a 1-level
    root path.  Compose into a larger ``find`` filter with ``$or``-free AND by
    merging into the top-level dict, or under ``$and`` when other keys overlap.
    Native predicates use ``$expr``: always compose under ``$and`` if another
    expression is present, rather than overwriting either isolation predicate.
    """
    prefixes = scope.ancestor_prefix_paths()
    cond: dict = {field: {"$in": prefixes}}
    if _is_native_scope(scope):
        # Query-field $in also matches array elements; native owners must be
        # explicit scalar canonical keys, not malformed multi-owner arrays.
        return {"$expr": {"$and": [
            {"$eq": [{"$type": f"${field}"}, "string"]},
            {"$in": [f"${field}", {"$literal": prefixes}]},
        ]}}
    legacy = legacy_scope_path(scope.tenant_id)
    if legacy in prefixes:
        # Legacy (missing/absent) docs read as the root path; include them only
        # when the root path is on the query's ancestor chain.
        return {"$or": [cond, {field: {"$exists": False}}, {field: None}]}
    return cond

sql_prefix_condition

sql_prefix_condition(scope: StorageScope, column: str, next_param_index: int) -> tuple[str, list[object]]

Build the SQL prefix-isolation predicate for a materialised column.

Returns (sql_fragment, params) where the fragment is an exact-IN over the query's ancestor prefixes (NEVER LIKE — the §5.b R2 guard). Explicit C collation keeps equality byte-exact even when a deployment gives the stored column a nondeterministic ICU collation:

(col COLLATE "C" = ANY($N::text[]) OR
 (col IS NULL AND $M::text COLLATE "C" = ANY($N::text[])))

The first branch matches v8.0 rows; the second is the §6.b dual-read for pre-v8.0 rows whose scope_path is NULL — they behave as a 1-level root path tenant\x1f<tenant_id>, visible to any descendant query under that tenant. next_param_index is the 1-based positional placeholder to use first (asyncpg $N style). V0 consumes two placeholders, unchanged. Native v1 consumes one array placeholder and emits no NULL branch. Callers enabling v1 must advance indices by len(params), not a fixed count.

Source code in src/symfonic/memory/scope_isolation.py
def sql_prefix_condition(
    scope: StorageScope,
    column: str,
    next_param_index: int,
) -> tuple[str, list[object]]:
    """Build the SQL prefix-isolation predicate for a materialised column.

    Returns ``(sql_fragment, params)`` where the fragment is an exact-IN over
    the query's ancestor prefixes (NEVER ``LIKE`` — the §5.b R2 guard).
    Explicit C collation keeps equality byte-exact even when a deployment
    gives the stored column a nondeterministic ICU collation:

        (col COLLATE "C" = ANY($N::text[]) OR
         (col IS NULL AND $M::text COLLATE "C" = ANY($N::text[])))

    The first branch matches v8.0 rows; the second is the §6.b dual-read for
    pre-v8.0 rows whose ``scope_path`` is NULL — they behave as a 1-level root
    path ``tenant\\x1f<tenant_id>``, visible to any descendant query under that
    tenant.  ``next_param_index`` is the 1-based positional placeholder to use
    first (asyncpg ``$N`` style). V0 consumes two placeholders, unchanged.
    Native v1 consumes one array placeholder and emits no NULL branch. Callers
    enabling v1 must advance indices by len(params), not a fixed count.
    """
    prefixes = scope.ancestor_prefix_paths()
    if _is_native_scope(scope):
        return (
            f'({column} COLLATE "C" = ANY(${next_param_index}::text[]))',
            [prefixes],
        )
    legacy = legacy_scope_path(scope.tenant_id)
    arr_idx = next_param_index
    legacy_idx = next_param_index + 1
    fragment = (
        f"({column} COLLATE \"C\" = ANY(${arr_idx}::text[]) "
        f"OR ({column} IS NULL AND ${legacy_idx}::text COLLATE \"C\" "
        f"= ANY(${arr_idx}::text[])))"
    )
    return fragment, [prefixes, legacy]

stored_scope_path

stored_scope_path(properties: dict[str, object] | None, tenant_id: str, *, scope: StorageScope | None = None) -> str | None

Resolve a stored row's effective scope_path (with dual-read).

Reads properties['scope_path'] when present. Pre-v8.0 rows (no key) fall back to the 1-level root path tenant\x1f<tenant_id> (design §6.b) — visible to every descendant query under that tenant, the correct conservative default for v0 callers (including callers omitting scope). Explicit native scopes require canonical v1 keys; absent, NULL, malformed and v0 paths return None so they cannot enter native ancestor membership.

Source code in src/symfonic/memory/scope_isolation.py
def stored_scope_path(
    properties: dict[str, object] | None,
    tenant_id: str,
    *,
    scope: StorageScope | None = None,
) -> str | None:
    """Resolve a stored row's effective ``scope_path`` (with dual-read).

    Reads ``properties['scope_path']`` when present.  Pre-v8.0 rows (no key)
    fall back to the 1-level root path ``tenant\\x1f<tenant_id>`` (design §6.b)
    — visible to every descendant query under that tenant, the correct
    conservative default for v0 callers (including callers omitting scope).
    Explicit native scopes require canonical v1 keys; absent, NULL, malformed
    and v0 paths return None so they cannot enter native ancestor membership.
    """
    raw = (properties or {}).get(SCOPE_PATH_KEY)
    if _is_native_scope(scope):
        from symfonic.capabilities.memory.errors import MemoryContractError
        from symfonic.capabilities.memory.storage_key import StorageScopeKey

        if not isinstance(raw, str) or not raw:
            return None
        try:
            return StorageScopeKey.parse(raw).key
        except MemoryContractError:
            return None
    if isinstance(raw, str) and raw:
        return raw
    return legacy_scope_path(tenant_id)