Skip to content

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).
  • kind is adopter-defined (org/brand/conversation, or org/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:

org\x1facme\x1fbrand\x1fmyhalos\x1fconversation\x1fc-123

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-123 sees: 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 (transient durability). 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 / MemoryNode at a brand or org scope_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 — the SleepConsolidator hot 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 durable AND extraction confidence ≥ 0.7. Org promotion is never automatic.
  • Promoted nodes carry provenance (source_conversation_ids array, 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.