symfonic.memory.backends¶
backends ¶
Backend implementations for graph and vector storage.
The Postgres backends import asyncpg (and pgvector) at module level
behind a try/except ImportError guard. Exporting them eagerly here made
that guard run for every import of anything under symfonic.memory --
including import symfonic.memory itself -- so a deployment with no
Postgres still asked the interpreter for asyncpg on every process start.
That is the "disabled integrations are not imported or probed" clause of
story s4-2, and rule IA-5 of the integration-adapter standard (T4.2.1).
They are therefore resolved through a module __getattr__ (PEP 562):
from symfonic.memory.backends import PostgresGraphBackend still works and
still returns the same class, but the import cost is paid by the caller that
asks for it. The in-memory backends stay eager -- they cost nothing.
EraseStep
dataclass
¶
What one eraser transaction left: complete, or a job still erasing.
EraserAttestation
dataclass
¶
What attest proved, once, on the eraser's own login (CORE-194).
InMemoryGraphBackend ¶
GraphBackend over dicts keyed by (tenant_id, node_id/edge_id).
Source code in src/symfonic/memory/backends/in_memory.py
add_edge
async
¶
Add an edge; native endpoints must already be live and visible.
Source code in src/symfonic/memory/backends/in_memory.py
add_node
async
¶
Stamp ownership; native writes cannot replace another owner.
Source code in src/symfonic/memory/backends/in_memory.py
delete_edge
async
¶
Delete an edge visible on this scope's exact ancestor chain.
Source code in src/symfonic/memory/backends/in_memory.py
delete_node
async
¶
Delete a node, optionally cascading to connected edges.
Source code in src/symfonic/memory/backends/in_memory.py
delete_subtree
async
¶
Erase owned subtree, edges and cursors in one non-yielding operation.
Source code in src/symfonic/memory/backends/in_memory.py
get_neighbors
async
¶
get_neighbors(scope: StorageScope, node_id: NodeId, relationship: str | None = None) -> list[MemoryNode]
Get nodes connected to the given node by edges.
Source code in src/symfonic/memory/backends/in_memory.py
get_node
async
¶
Get a node by ID on its visible ancestor chain.
Source code in src/symfonic/memory/backends/in_memory.py
patch_owned_node
async
¶
patch_owned_node(scope: StorageScope, node_id: NodeId, expected: OwnedNodeVersion, properties: dict[str, Any]) -> MemoryNode | None
Merge properties only if exact owner, incarnation and revision match.
Source code in src/symfonic/memory/backends/in_memory.py
query_edges
async
¶
query_edges(scope: StorageScope, filters: dict[str, Any] | None = None, limit: int | None = 50, offset: int = 0) -> list[MemoryEdge]
Query tenant edges, optionally restricted to induced endpoints.
Source code in src/symfonic/memory/backends/in_memory.py
query_nodes
async
¶
query_nodes(scope: StorageScope, filters: dict[str, Any], limit: int | None = 50) -> list[MemoryNode]
Filter visible nodes; None is unlimited and zero returns no rows.
Source code in src/symfonic/memory/backends/in_memory.py
query_subtree
async
¶
query_subtree(scope: StorageScope, filters: dict[str, Any], limit: int | None = None) -> list[MemoryNode]
Filter owned nodes at or below scope.
Source code in src/symfonic/memory/backends/in_memory.py
read_owned_node
async
¶
Return a detached exact-owner row with its immutable stored guard.
Source code in src/symfonic/memory/backends/in_memory.py
transaction ¶
All of a batch, or none of it -- see :mod:.in_memory_transaction.
traverse
async
¶
BFS traversal from start node up to max_depth.
Source code in src/symfonic/memory/backends/in_memory.py
update_node
async
¶
Partial update of node properties.
Source code in src/symfonic/memory/backends/in_memory.py
upsert_edge
async
¶
Insert or increment an edge matching scope, endpoints, and relation.
Source code in src/symfonic/memory/backends/in_memory.py
InMemoryVectorBackend ¶
In-memory implementation of the VectorBackend protocol.
Stores embeddings in a list and uses brute-force cosine similarity for search. Suitable for testing only.
Source code in src/symfonic/memory/backends/in_memory_vector.py
add
async
¶
add(scope: StorageScope, ids: list[str], embeddings: list[list[float]], metadatas: list[dict[str, Any]], documents: list[str]) -> None
Add vectors with metadata to the store.
v8.0: stamps the materialised scope_path into the metadata bag so
the prefix-isolation filter (§5.d) can run on every search.
Source code in src/symfonic/memory/backends/in_memory_vector.py
add_owned_batch
async
¶
Validate every native owner before changing vectors; never awaits.
Source code in src/symfonic/memory/backends/in_memory_vector.py
count
async
¶
Return the total number of stored vectors for the tenant.
Source code in src/symfonic/memory/backends/in_memory_vector.py
delete
async
¶
Delete vectors by their IDs.
Source code in src/symfonic/memory/backends/in_memory_vector.py
search
async
¶
search(scope: StorageScope, query_embedding: list[float], top_k: int = 5, *, allowed_scope_paths: tuple[str, ...] | None = None) -> list[dict[str, Any]]
Brute-force cosine similarity search.
v8.0: ALWAYS-ON prefix-isolation (§5.d) — only entries whose stored
scope_path is a prefix of the query path are scored. Dual-reads
pre-v8.0 metadata (no scope_path) as a 1-level root path.
Source code in src/symfonic/memory/backends/in_memory_vector.py
LegacyEdgeBootstrapRefused ¶
Bases: RuntimeError
A default host cannot restore the tenant-wide index after cutover.
PostgresGraphBackend ¶
PostgresGraphBackend(pool: PostgresPoolManager, *, edge_upsert_mode: str = 'tenant_guarded', eraser: Any = None)
Bases: PostgresReviewCas
GraphBackend backed by PostgreSQL with JSONB storage.
All operations enforce tenant isolation via WHERE tenant_id = $N.
Source code in src/symfonic/memory/backends/postgres_graph.py
eraser_attestation
property
¶
The eraser's standing proof for this runtime, or None (CORE-194).
Read-only: present only after :meth:attest_eraser succeeded and no
later attestation or eraser transaction has seen it contradicted.
pool
property
¶
The pool this backend speaks through, and so its transaction domain.
Public because the consolidation commit has to prove that the graph, the lease and the staged records share one domain before it will call a cycle atomic. A proof that read a private attribute would be a proof about this class's spelling.
attest_eraser
async
¶
Prove the eraser holds D6, erases this runtime's very memory_nodes
and logs in as a different session_user; read-only, else refuse.
Source code in src/symfonic/memory/backends/postgres_graph.py
backfill_scope_paths
async
¶
Eagerly backfill NULL scope_path rows (design §6.b migration).
Pre-v8.0 rows have scope_path IS NULL; they read as a 1-level root
path tenant\x1f<tenant_id> via the query-time dual-read, so this
eager backfill is OPTIONAL (online-safe, idempotent) — it just
materialises the same value into the column so the B-tree index and
the scorer's dual-read agree. Returns the number of rows updated.
Uses a parameterised expression so adopter tenant_ids cannot inject.
Source code in src/symfonic/memory/backends/postgres_graph.py
delete_native_subtree_ids
async
¶
Return precisely the native nodes erased by the locked sweep.
Source code in src/symfonic/memory/backends/postgres_graph.py
delete_subtree
async
¶
Erase scope and every descendant scope.
Native v1 validates persisted owners under a transaction and table locks.
For v0, a data-modifying CTE, not two round trips and not a client-side scan: one statement is one implicit transaction, so a write that lands in a descendant scope while this runs is either wholly before the sweep's snapshot (and erased) or wholly after it. The enumerate-then-delete shape has a window between those two facts, and the operation that opens it is a tenant deletion running while other work drains.
The predicate is the delimiter-terminated descendant sweep from
:mod:symfonic.memory.subtree — the one place a LIKE on a scope
path is correct, and only because the delimiter is appended. Without
it, forgetting acme would take acmecorp with it.
Edges go if either endpoint went, or if the edge itself sits in the subtree; the same two placeholders serve both halves.
Source code in src/symfonic/memory/backends/postgres_graph.py
ensure_schema
async
¶
Create graph tables (nodes + edges) and their indexes.
Does NOT create the memory_vectors table — that is the
responsibility of :class:PostgresVectorBackend.ensure_schema.
Source code in src/symfonic/memory/backends/postgres_graph.py
query_edges
async
¶
query_edges(scope: TenantScope, filters: dict[str, Any] | None = None, limit: int | None = 50, offset: int = 0) -> list[MemoryEdge]
Query edges directly from memory_edges table with pagination.
Supports relationship, induced/incident endpoints, and opt-in edge owner visibility for v0. Native edge/endpoint admission is mandatory before pagination. Other keys are ignored; all values are bound.
Source code in src/symfonic/memory/backends/postgres_graph.py
query_subtree
async
¶
query_subtree(scope: TenantScope, filters: dict[str, Any], limit: int | None = None) -> list[MemoryNode]
Descendant read; native phases share a transaction and time budget.
Source code in src/symfonic/memory/backends/postgres_graph.py
query_subtree_page
async
¶
query_subtree_page(scope: TenantScope, filters: dict[str, Any], *, limit: int, offset: int) -> list[MemoryNode]
One native descendant page without reading every prior page.
GraphAdminService accepts offsets for its browser contract. Its
compatibility fallback must fetch through offset + limit because
the historical backend protocol has no offset; Postgres does, and at
volume that fallback turns page fifty into a ten-thousand-row process
allocation. Keep the optional seam on the concrete adapter until the
backend protocol can add it without breaking adopters.
Source code in src/symfonic/memory/backends/postgres_graph.py
traverse
async
¶
BFS graph traversal up to max_depth hops from start node.
Source code in src/symfonic/memory/backends/postgres_graph.py
upsert_edge
async
¶
Insert edge or increment weight by 1 if the same edge already exists.
Uniqueness is determined by (tenant_id, source, target, relationship).
The unique index idx_memory_edges_upsert must exist for ON CONFLICT
to resolve correctly (created by ensure_schema). Until the index
includes owner scope, a sibling collision is refused rather than
incrementing another owner's edge. Legacy NULL paths belong to the
tenant root.
Source code in src/symfonic/memory/backends/postgres_graph.py
PostgresGraphRuntime ¶
PostgresGraphRuntime(pool: Any, *, owner_login: str, edge_upsert_mode: str = 'tenant_guarded', eraser: Any = None)
Bases: PostgresGraphBackend
Explicit nonowner graph adapter; ensure_schema performs read-only checks.
Source code in src/symfonic/memory/backends/postgres_graph_runtime.py
attest_eraser
async
¶
The runtime role contract, then the eraser's proof for this runtime.
One runtime connection proves the role contract and resolves the
session_user and memory_nodes the eraser must match
(CORE-194). Read-only. Any refusal withdraws the standing attestation.
Source code in src/symfonic/memory/backends/postgres_graph_runtime.py
ensure_schema
async
¶
Read-only startup; with an eraser composed, also attest_eraser.
Source code in src/symfonic/memory/backends/postgres_graph_runtime.py
PostgresLeases ¶
A :class:~symfonic.capabilities.memory.leases.LeasePort over Postgres.
Source code in src/symfonic/memory/backends/postgres_leases.py
pool
property
¶
The pool this port speaks through, and so its transaction domain.
Public because the commit path has to prove that the lease, the graph and the staged records all live in one domain before it will call a cycle atomic. Reading that off a private attribute would make the proof depend on an implementation detail of this class.
ensure_schema
async
¶
Create table and native index under a schema-scoped DDL lock.
IF NOT EXISTS is idempotent across time and not across
concurrency: two sessions running it at the same instant both pass the
existence check and both insert into pg_type, and one gets a
duplicate-key error. Ordinarily an obscure wrinkle -- here it is the
expected case, because the workers this table exists to coordinate are
exactly the ones that start together.
So a failure is retried once against a table that exists by then, and only then re-raised. Caught by the first run of the cross-process test and by nothing else: every later run found the table already there.
Source code in src/symfonic/memory/backends/postgres_leases.py
hold_for_update
async
¶
Whether lease is ours, and keep it ours until this transaction ends.
The commit-time fence. :meth:holds answers a question about the past
tense the moment it returns; this one takes the row lock, so a rival
acquisition queues behind the caller's transaction instead of landing
between the answer and the write it authorised.
Only meaningful inside a transaction -- outside one the lock is released
with the statement and this is :meth:holds with extra cost. The commit
path always calls it inside one; see
:func:symfonic.capabilities.memory.commit.commit_cycle, which refuses
to run without a transaction domain rather than calling this and
pretending.
Source code in src/symfonic/memory/backends/postgres_leases.py
PostgresNativeEraser ¶
PostgresNativeEraser(pool: Any, *, schema: str, receipt_key: bytes, erase_anchor: Any = None, receipt_key_id: str | None = None, receipt_verification_keys: Any = None, anchor_key: bytes | None = None)
Job creation, chunking and completion on the eraser's own login.
Source code in src/symfonic/memory/backends/postgres_native_eraser.py
attest
async
¶
D6 on this login, binding's very graph, and not runtime_login.
Read-only. Refuses with EraserNotReady and withdraws every earlier
attestation, so a failed re-attestation cannot leave a stale proof.
Source code in src/symfonic/memory/backends/postgres_native_eraser.py
attestation_for ¶
The standing attestation for that runtime identity, or None.
guarding ¶
Guard the runtime's pool with this eraser's anchor proof (CORE-171).
Source code in src/symfonic/memory/backends/postgres_native_eraser.py
require_ready
async
¶
resume
async
¶
Erase one chunk of job_id in the configured schema; the operator's entry.
Source code in src/symfonic/memory/backends/postgres_native_eraser.py
retire_tenant
async
¶
Explicit permanent tenant retirement; ordinary start stays reusable.
Source code in src/symfonic/memory/backends/postgres_native_eraser.py
start
async
¶
Erase content and create the job, or resume the identical one.
Source code in src/symfonic/memory/backends/postgres_native_eraser.py
PostgresPoolManager ¶
Async connection pool manager wrapping asyncpg.Pool.
Usage::
pool = PostgresPoolManager(dsn="postgresql://user:pw@host/db")
await pool.open()
async with pool as conn:
await conn.execute("SELECT 1")
await pool.close()
Or as a context manager for the pool itself::
async with PostgresPoolManager(dsn=...) as pool:
conn = await pool.acquire()
try:
await conn.execute("SELECT 1")
finally:
await pool.release(conn)
Source code in src/symfonic/memory/backends/pool.py
dsn
property
¶
Return the configured connection string (read-only).
Used by the v7.1.1 PostgresCheckpointerFactory to build a
separate psycopg pool against the same database. The two pools
intentionally do not share connections (asyncpg vs. psycopg
protocols are incompatible inside langgraph-checkpoint-postgres).
acquire
async
¶
Acquire a connection from the pool.
Caller is responsible for releasing via :meth:release.
Inside :meth:transaction this hands back that transaction's own
connection, so every backend on this pool joins it without knowing it
exists (:mod:.pool_transaction).
Source code in src/symfonic/memory/backends/pool.py
anchor_graph ¶
CORE-171: guard this pool with eraser's anchor proof, or refuse native reads.
Source code in src/symfonic/memory/backends/pool.py
close
async
¶
guard_connections ¶
Also run guard on every connection acquire hands out, before the caller.
Guards only add: a connection is handed out once every guard admitted it.
A placeholder guard (refuse: nothing proves this pool) yields to,
and is never added beside, a guard that proves every checkout.
Source code in src/symfonic/memory/backends/pool.py
open
async
¶
Create the underlying asyncpg connection pool.
Passes init=_register_pgvector_on_connection so every new
physical connection registers the pgvector binary codec before
the pool hands it out. See module docstring for the rationale.
Source code in src/symfonic/memory/backends/pool.py
release
async
¶
Return a connection to the pool.
A no-op for a connection :meth:transaction holds: releasing it
mid-way would hand a half-written batch to somebody else.
Source code in src/symfonic/memory/backends/pool.py
transaction ¶
Run a block so every operation on this pool commits or fails together.
Ambient rather than passed: :mod:.pool_transaction has the why.
PostgresVectorBackend ¶
PostgresVectorBackend(pool: PostgresPoolManager, embedding_dim: int = 1536, *, search_strategy: Literal['exact', 'approximate'] = 'exact')
VectorBackend backed by PostgreSQL + pgvector.
Uses cosine distance (<=> operator) for similarity search.
All operations enforce tenant isolation via WHERE tenant_id = $N.
search_strategy='exact' ranks the authorized subset: bounded returned
rows, but O(visible vectors) database distance work. 'approximate' opts
into HNSW, which can underfill or miss neighbours after scope filtering.
Source code in src/symfonic/memory/backends/postgres_vector.py
pool
property
¶
The pool this backend speaks through, and so its transaction domain.
count
async
¶
Count tenant vectors subject to permanent retirement visibility.
Source code in src/symfonic/memory/backends/postgres_vector.py
ensure_schema
async
¶
Create the memory_vectors table and indexes if not present.
Source code in src/symfonic/memory/backends/postgres_vector.py
RuntimeRoleNotReady ¶
Bases: RuntimeError
A login violates the migrator/runtime split contract.
grant_runtime
async
¶
grant_runtime(connection: Any, runtime_login: str, *, schema: str = 'public', ledger: str = MIGRATION_LEDGER) -> tuple[str, ...]
Owner side: reset the runtime to exact DML on every owned table, atomically.
Every table in schema gets SELECT/INSERT/UPDATE/DELETE and nothing else,
column grants included; the CORE-199 erase job table gets SELECT only,
its receipts nothing, its memory erase generations SELECT/INSERT/UPDATE and its
erase log SELECT/INSERT; the
migration ledger, views, materialized views and foreign tables get nothing;
sequences get USAGE only. No default privileges are installed, so each
migration must be followed by this call. Returns the tables granted DML.
Source code in src/symfonic/memory/backends/postgres_runtime_roles.py
prove_terminal
async
¶
prove_terminal(eraser: Any, tenant_id: str, job_id: str, *, runtime_login: str, binding: Any) -> TerminalRetirementEvidence
Recheck one exact, versioned, empty tenant-root retirement under its fence.
Do not call retire_tenant here: a proof request must never initiate an
irreversible retirement. This is a read-side prerequisite, not an issuer.
Source code in src/symfonic/memory/backends/postgres_retirement_evidence.py
require_legacy_bootstrap_safe
async
¶
Refuse default index DDL once the scoped index has replaced it.
The resolved table is the same unqualified table used by ensure_schema. A fresh database has neither index and keeps legacy bootstrap behavior.
Source code in src/symfonic/memory/backends/postgres_edge_index_preflight.py
require_scoped_edge_index
async
¶
Check the exact table resolved by the writer on this connection.
Source code in src/symfonic/memory/backends/postgres_edge_scoped_writer.py
require_split_runtime
async
¶
require_split_runtime(connection: Any, *, owner_login: str, tables: tuple[str, ...], schema: str = 'public', ledger: str = MIGRATION_LEDGER) -> None
Runtime side, read-only: refuse startup unless this login is DML-only.
The connected login must be a direct, unprivileged, membership-free LOGIN
owning nothing (large objects included), able to create nothing, holding no
large object grant and unable to EXECUTE any SECURITY DEFINER function
outside pg_catalog and information_schema. schema and every
relation in it must belong to owner_login; each table carries exactly
the table-level DML grant and no column grant beyond it (the ledger none,
the erase job table SELECT only, its receipts none, the memory erase
generations SELECT/INSERT/UPDATE, the erase log SELECT/INSERT), every tables entry
and memory_nodes resolve on this login's search_path to schema,
views, materialized views and foreign tables carry none, every tables
entry exists, and sequences are USAGE without UPDATE.
Source code in src/symfonic/memory/backends/postgres_runtime_roles.py
seal ¶
seal(evidence: TerminalRetirementEvidence, chat: ChatRetirementIntent, *, witness_key_id: str, witness_key: bytes, receipt_key: bytes, anchor_key: bytes) -> SignedRetirementWitness
Seal already-verified evidence; do not use from the API/worker process.