Skip to content

symfonic.memory.subtree

subtree

Descendant-sweep predicates: the erase direction of the scope hierarchy.

Deliberately a separate module from :mod:symfonic.memory.scope_isolation, because it answers the opposite question and is allowed the one construction that module forbids.

  • Isolation (read). A query at path P sees a memory at path Q iff Q is a prefix of P — the query's own ancestors. Enforced by exact set membership over ancestor_prefix_paths(). A LIKE 'P%' there would let a query at acme read acmecorp's rows, which is the §5.b R2 CRITICAL class.
  • Erasure (write). MemoryLifecyclePort.forget(scope) must reach a scope and its descendants, which no read on GraphBackend can enumerate. The set is unbounded — a tenant may hold any number of sessions nobody listed — so it can only be expressed as a pattern.

The pattern is safe here, and it is safe for exactly one reason: the delimiter is appended before the wildcard. org\x1facme sweeps org\x1facme itself and everything under org\x1facme\x1f…; org\x1facmecorp does not begin with org\x1facme\x1f, so the false-prefix pair the isolation filter exists to refuse is refused here too. Drop the delimiter and this becomes the same leak with a delete on the end of it.

Two hazards the naive spelling misses, and why the helpers exist rather than each backend writing its own predicate:

  • Wildcard metacharacters in an id. LIKE reads _ as "any one character" and % as "any run", and an adopter id may legitimately contain both — a tenant acme_corp would otherwise sweep acmeXcorp. The SQL pattern is therefore escaped and the Mongo one goes through re.escape. ScopeLevel rejects the delimiter in an id, so the boundary cannot be forged; nothing rejects an underscore.
  • Pre-v8.0 rows. A row written before scope_path existed dual-reads as the 1-level tenant root (design §6.b). Such a row is in the subtree of the tenant root and of nothing narrower, so the NULL branch is included only when the sweep is rooted at the tenant. A privacy deletion that skipped those rows would report success having erased nothing a legacy write produced.

is_in_subtree

is_in_subtree(stored_path: str, root_path: str) -> bool

Whether stored_path is root_path or lies beneath it.

The delimiter-terminated comparison, stated once. startswith(root_path) without it would call acmecorp a descendant of acme.

Source code in src/symfonic/memory/subtree.py
def is_in_subtree(stored_path: str, root_path: str) -> bool:
    """Whether ``stored_path`` is ``root_path`` or lies beneath it.

    The delimiter-terminated comparison, stated once. ``startswith(root_path)``
    without it would call ``acmecorp`` a descendant of ``acme``.
    """
    return stored_path == root_path or stored_path.startswith(
        root_path + SCOPE_PATH_DELIMITER
    )

mongo_subtree_filter

mongo_subtree_filter(scope: TenantScope, field: str = SCOPE_PATH_KEY) -> dict

Build the Mongo descendant-sweep filter for a scope_path field.

An anchored $regex over a re.escape-d literal, which is the Mongo spelling of the same delimiter-terminated prefix. Returned as a bare $or so a caller can merge it under $and alongside other clauses.

Source code in src/symfonic/memory/subtree.py
def mongo_subtree_filter(scope: TenantScope, field: str = SCOPE_PATH_KEY) -> dict:
    """Build the Mongo descendant-sweep filter for a scope_path field.

    An anchored ``$regex`` over a ``re.escape``-d literal, which is the Mongo
    spelling of the same delimiter-terminated prefix. Returned as a bare
    ``$or`` so a caller can merge it under ``$and`` alongside other clauses.
    """
    root = scope.scope_path
    branches: list[dict] = [
        {field: root},
        {field: {"$regex": "^" + re.escape(root + SCOPE_PATH_DELIMITER)}},
    ]
    if sweeps_legacy_rows(scope):
        branches += [{field: {"$exists": False}}, {field: None}]
    return {"$or": branches}

sql_subtree_condition

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

Build the SQL descendant-sweep predicate for a materialised column.

Returns (sql_fragment, params). Two placeholders are consumed from next_param_index (asyncpg $N style): the exact root path, and the escaped root || delimiter || '%' pattern.

The IS NULL branch is appended, without a placeholder, only when :func:sweeps_legacy_rows holds — see the module docstring.

Source code in src/symfonic/memory/subtree.py
def sql_subtree_condition(
    scope: TenantScope,
    column: str,
    next_param_index: int,
) -> tuple[str, list[object]]:
    """Build the SQL descendant-sweep predicate for a materialised column.

    Returns ``(sql_fragment, params)``. Two placeholders are consumed from
    ``next_param_index`` (asyncpg ``$N`` style): the exact root path, and the
    escaped ``root || delimiter || '%'`` pattern.

    The ``IS NULL`` branch is appended, without a placeholder, only when
    :func:`sweeps_legacy_rows` holds — see the module docstring.
    """
    root = scope.scope_path
    root_idx = next_param_index
    pattern_idx = next_param_index + 1
    pattern = _escape_like(root) + SCOPE_PATH_DELIMITER + "%"
    fragment = (
        f"({column} = ${root_idx} "
        f"OR {column} LIKE ${pattern_idx} ESCAPE {_LIKE_ESCAPE_SQL}"
    )
    if sweeps_legacy_rows(scope):
        fragment += f" OR {column} IS NULL"
    return fragment + ")", [root, pattern]

sweeps_legacy_rows

sweeps_legacy_rows(scope: TenantScope) -> bool

Whether a sweep rooted at scope must also take pre-v8.0 rows.

True only at the 1-level tenant root, because that is the path a row with no stored scope_path dual-reads as. Every narrower sweep must leave them alone: they were tenant-global, and a session-scoped forget that deleted them would erase memories the session never owned.

Source code in src/symfonic/memory/subtree.py
def sweeps_legacy_rows(scope: TenantScope) -> bool:
    """Whether a sweep rooted at ``scope`` must also take pre-v8.0 rows.

    True only at the 1-level tenant root, because that is the path a row with
    no stored ``scope_path`` dual-reads as. Every narrower sweep must leave
    them alone: they were tenant-global, and a session-scoped forget that
    deleted them would erase memories the session never owned.
    """
    return scope.scope_path == legacy_scope_path(scope.tenant_id)