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(). ALIKE 'P%'there would let a query atacmereadacmecorp'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 onGraphBackendcan 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.
LIKEreads_as "any one character" and%as "any run", and an adopter id may legitimately contain both — a tenantacme_corpwould otherwise sweepacmeXcorp. The SQL pattern is therefore escaped and the Mongo one goes throughre.escape.ScopeLevelrejects the delimiter in an id, so the boundary cannot be forged; nothing rejects an underscore. - Pre-v8.0 rows. A row written before
scope_pathexisted 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 ¶
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
mongo_subtree_filter ¶
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
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
sweeps_legacy_rows ¶
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.