Hierarchical N-level TenantScope (v8.0)¶
Status: implemented on the v8.0 line (BREAKING). Supersedes the former
v8-roadmap.md(now a thin redirect here). Prefix isolation is the security boundary — read §3 carefully.
v8.0 turns TenantScope from a fixed (tenant_id, sub_tenant_id, namespace)
shape into an arbitrary-depth, root-first path of ScopeLevel(kind, id)
values. An org → brand → conversation hierarchy is now first-class,
and retrieval can blend ancestor-level memories into a descendant query,
down-weighted by hierarchy distance.
1. The data model¶
from symfonic.core.scope import TenantScope, ScopeLevel
scope = (
TenantScope.root("org", "acme")
.child("brand", "myhalos")
.child("conversation", "c-123")
)
scope.path # (org:acme, brand:myhalos, conversation:c-123) — root-first
scope.tenant_id # "acme" (computed: path[0].id — backward-compatible)
scope.sub_tenant_id # "myhalos" (computed: path[1].id)
scope.scope_depth # 3
path[0]is the broadest level (org);path[-1]is the most specific (conversation).kindis adopter-defined (org/brand/conversation, ororg/team/project/session, …) — not a fixed enum.
Backward compatibility (load-bearing)¶
The legacy flat fields are retained as real fields. A legacy construction maps to a 1-/2-level path with no source change:
TenantScope(tenant_id="acme", sub_tenant_id="myhalos")
# path == (tenant:acme, sub_tenant:myhalos); tenant_id/sub_tenant_id unchanged
Every existing scope.tenant_id read site, to_state_dict()/equality, and
the FrameworkTenantScope subclass keep working byte-identically. A legacy
2-level scope round-trips through to_state_dict with NO path key.
Factories (the blessed construction path)¶
from_path([...]), root(kind, id), .child(kind, id), from_request(...),
from_state_dict(state), and narrow(...). Bare TenantScope(tenant_id=...)
still works but emits a DeprecationWarning (v8.0 hardens the v7.21
soft-deprecation; full removal is reserved for v8.0 GA). The v7.22
inherits_from field is subsumed by the path (see §5) and deprecated.
2. Materialised scope_path + isolation¶
Each level is serialised into a US-delimited (\x1f), root-first string:
This is stored on every memory row (a dedicated column + index on Postgres/
Mongo; the property/metadata bag everywhere). The delimiter is what makes the
isolation safe: ids like acme and acme-corp produce distinct, non-overlapping
strings.
3. The isolation rule (SECURITY boundary, ALWAYS-ON)¶
A query at path P sees a memory at path Q iff Q is a prefix of P.
org=acme/brand=myhalos/conv=c-123sees:acme(org),acme/myhalos(brand),acme/myhalos/c-123(conv).- It NEVER sees a sibling conversation
c-999, a sibling brand, or another org. This closes the cross-session-bleed bug. - A brand-level query sees org+brand, NOT a specific child conversation.
Enforced by exact set membership over the query's ancestor-prefix strings
(WHERE scope_path = ANY(...) / Mongo $in), never LIKE 'prefix%' /
$regex — a prefix scan would leak acme-corp into an acme query. This
filter is always on (it is the security boundary) and is independent of the
retrieval-weighting opt-in below.
Migrating pre-v8.0 rows¶
Rows written before v8.0 have no scope_path; they dual-read as a 1-level
root path tenant\x1f<tenant_id> — visible to any descendant query under that
tenant (their pre-v8.0 tenant-global behaviour, preserved). Postgres ships an
online-safe ALTER ... ADD COLUMN IF NOT EXISTS + an optional eager
backfill_scope_paths().
4. Hierarchical retrieval weighting (opt-in blend)¶
Set FrameworkConfig.scope_blend_mode = "blend" and
scorer_on_hot_path = True to blend ancestor memories into a descendant query,
down-weighted by hierarchy distance:
scope_distance = (L-1) - level_index(0 at the tip, +1 per step toward root).- The composite score is multiplied by an exponential decay
exp(-ln2 / half_life * distance), lifted off an optional floor. - Defaults (
scope_half_life_levels=1.0,scope_specificity_floor=0.0): tip ×1.0, parent ×0.5, grandparent ×0.25.
It is a multiplicative gate, not a 5th additive weight, so
ScoringWeights still sums to 1.0 and existing weight tunings are untouched.
A tip memory outranks an equal-quality ancestor; a much stronger ancestor can
still surface. scope_blend_mode="off" (default) keeps the composite
byte-stable with pre-v8.0.
Per-layer: Semantic + Episodic honour the blend; Procedural keeps shadow-override (re-expressed on the path); Working + Prospective stay tip-only.
5. Procedural shadow-override on the path¶
ProceduralLayer.query_skills fans out over the scope's ancestor prefixes
(most-specific first) and merges with first-wins shadow by identifier/label
— a brand skill shadows an org skill of the same identifier; distinct skills
blend in. This replaces the v7.22 inherits_from chain (still supported for
legacy callers, deprecated).
6. Durable posture + promotion¶
What v8.0.0 actually ships (explicit-write posture)¶
- Durable levels: org → brand. Conversation is an ephemeral working tip
(
transientdurability). The path model stays N-level-capable, so a durable conversation level can be added later additively with zero re-migration. - Durable brand/org memory in v8.0.0 is reached via EXPLICIT WRITES at the
target scope level only — an adopter (or a tool) writing a
MemoryEntry/MemoryNodeat a brand or orgscope_path. Reads honour the prefix-isolation rule, so a conversation-scoped query sees those explicit brand/org writes via the ancestor-prefix fan-out.
Deferred to v8.1 — automatic promotion + provenance/erasure cascade¶
Automatic conversation→brand/org promotion is NOT wired in v8.0.0. The promotion + GDPR-provenance primitives ship as tested pure functions in
symfonic.core.learning.promotion(is_promotion_eligible,build_provenance/merge_provenance,apply_erasure), but no live pipeline calls them — theSleepConsolidatorhot path is unchanged. Live wiring lands in v8.1.
When promotion IS wired (v8.1), the intended design is:
- Brand/org memory becomes read-mostly from the conversation agent, with
facts moving up via promotion out of band in sleep consolidation: a
conversation fact promotes to brand when it is
durableAND extraction confidence ≥ 0.7. Org promotion is never automatic. - Promoted nodes carry provenance (
source_conversation_idsarray,source_scope_paths,promoted_at,promoted_by,extraction_confidence). GDPR erasure becomes reference-counted: removing a conversation prunes it from the provenance; an empty array deletes the node (no orphaned PII), a surviving corroborating conversation keeps the fact.
In v8.0.0 GDPR erasure is direct scope-level deletion (delete a
conversation's scope_path rows). The reference-counted cascade rides with
promotion in v8.1 — no provenance is auto-produced until promotion is wired, so
in v8.0.0 there is nothing for the cascade to cascade.
7. Performance caveat — Postgres scope_path = ANY(...) at high scale¶
The isolation filter is exact-IN-correct on every backend regardless of query
plan. However, on Postgres the ANN hot path combines the vector index with the
scope_path = ANY(ancestor_prefixes) predicate, and that combined plan is
unbenchmarked on a live DB at high-tenant scale. The scope_path B-tree
index ships, but adopters running very large tenants should benchmark the
ANY(...) filter against the vector index before high-scale rollout — worst
case is a latency regression, never an isolation defect. Validating (and, if
needed, tuning the index/plan) is a v8.1 perf item.