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 v0 set is unbounded — a tenant may hold any number of sessions nobody listed — so the backend expresses it 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. Native v1 wire keys
already end at a complete level with "/". In-memory native sweeps validate
each stored key before comparing that boundary. SQL and Mongo native sweeps
fail closed until those backends can validate canonical stored keys; a prefix
or grammar regex cannot reject every malformed UTF-8 or forbidden NUL field.
Native reads remain exact ancestor membership and exclude legacy rows.
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. Native v1
roots already terminate a complete level; malformed native roots refuse.
Stored paths must be canonical storage keys; None is outside every subtree.
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 for v0. Native sweeps
refuse explicitly until stored keys can be validated by the backend.
Source code in src/symfonic/memory/subtree.py
sql_subtree_condition ¶
sql_subtree_condition(scope: StorageScope, column: str, next_param_index: int) -> tuple[str, list[object]]
Build the SQL descendant-sweep predicate for a materialised column.
Returns (sql_fragment, params). V0 consumes two placeholders from
next_param_index (asyncpg $N style): the exact root path, and
the escaped complete-level subtree prefix followed by '%'. Explicit C
collation keeps equality and LIKE byte-exact under nondeterministic column
collations. Native sweeps refuse explicitly: SQL cannot prove stored-key
canonicality from this prefix alone, and a no-op could look like erasure.
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
subtree_prefix ¶
Choose a complete boundary by wire grammar, never by an ID prefix.
Every valid v0 core path contains US, even at its root. A legacy kind may spell the native header, so testing that header alone would misclassify it. Otherwise require a canonical v1 key; malformed native roots fail closed. Callers must validate stored native keys before using this prefix.
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.