Named memory with bounded open engines
MemoryWorkspace keeps named projects in separate local packs or PostgreSQL
schemas and limits how many engines stay open. The same owner and entity name
can appear in multiple projects without combining their sources, relationships, maintenance or receipts.
from prme import MemoryWorkspace, PRMEConfig, Scope
config = PRMEConfig(database_url=None, duckdb_threads=1)
async with MemoryWorkspace.open("./memories", config=config, max_open=4) as workspace:
async with workspace.namespace("client-aurora") as memory:
await memory.store(
"Retention is seven days", user_id="alice", scope=Scope.PROJECT,
)
result = await memory.retrieve("retention?", user_id="alice")
print(memory.namespace.id, result.bundle.rendered_context)
async with workspace.namespace("client-borealis") as memory:
# This project has its own event log, graph, indexes, jobs and receipts.
await memory.store(
"Retention is thirty days", user_id="alice", scope=Scope.PROJECT,
)Names are exact, case-sensitive keys of 1–512 UTF-8 bytes. Surrounding whitespace
and control characters are rejected. A slash is an ordinary name character,
not a directory separator or a grant hierarchy. namespace(name) creates a
registry entry when missing; use namespace(name, create=False) to require an
existing project. await workspace.list_namespaces() returns sorted immutable
NamespaceInfo records with names and stable UUIDs.
Ownership and capacity
Each namespace context returns a NamespaceMemory with the public async
MemoryEngine API and signatures. The workspace owns engine lifecycle: direct
close, open, create, lock and unlock are unavailable on the lease.
Do not access private engine internals. A released lease and saved methods from
that lease reject further operations, even if another lease uses the same engine.
Concurrent contexts for the same project share one capacity slot. Each context
has its own lifetime. On exit, new calls are rejected and operations already
started through that lease finish before its reference is released. Explicitly
close partially consumed async iterators with aclose() or contextlib.aclosing
before exiting their lease; an unclosed iterator is still an active operation.
When the cache is full, the least recently released engine with no leases is
closed before another engine opens. If every engine is leased, another task
waits for capacity and can cancel that wait. A nested request raises
WorkspaceError when its task holds a lease on every engine and would otherwise
wait on itself. Closing a workspace from inside one of that task’s leases also
raises instead of deadlocking. Release the contexts first.
Cancellation during native startup or shutdown waits for ownership to settle; it does not mean the operation was rolled back. Workspace shutdown rejects new leases and waits for existing leases and their calls to finish. Cleanup failures propagate and prevent reuse of a failed engine. All workspace and lease calls must run on the workspace’s owning event loop.
max_open defaults to four; this is a bounded convenience default, not a
benchmark-proven optimum. One embedding provider is shared across engines.
Supply embedding_provider= to use a caller-owned provider, which the workspace
does not close. DuckDB worker control can separately limit
workers per pack. Neither setting is a hard process-memory or total-thread cap.
Ingestion and maintenance
For completed extraction within a lease, call
await memory.ingest(..., wait_for_extraction=True). The default False accepts
the source and starts background work. Normal engine shutdown on eviction or
workspace close can cancel that background extraction; its source and durable
work remain available. Inspect extraction_status and explicitly run
process_extractions after reopening. A lease ending does not claim extraction
is complete. Pending raw indexing can similarly be inspected and resumed through
processing_status and process_pending; retrieval can materialize raw sources.
Unscoped maintenance applies to users inside the selected project only. Owner and scope rules within each pack still apply. Scoped feedback receipts and processing IDs from another project behave as absent; project selection never turns an owner ID into authorization.
Durable identity and copying
The workspace stores a transactional workspace.sqlite3 registry and puts packs
under packs/<namespace UUID>/. Each pack also stores its namespace UUID inside
DuckDB. That identity is checked before normal schema migration, backfill, index
opening or recovery. A swapped or unbound database fails explicitly. An
initialized project whose database disappears is not recreated as empty memory.
Existing legacy packs require an explicit import procedure, which is not yet
provided; do not copy them into a generated project directory.
With MemoryWorkspace(..., single_file_packs=True) (also accepted by open()),
each namespace pack is one file, packs/<namespace UUID>/memory.prme, instead
of the directory layout (issue #247). The registry does not record which layout
a workspace uses, so pass the flag the same way every time the workspace is
opened. In directory mode the workspace clears an inherited pack_path, so a
PRME_PACK_PATH in the environment cannot point every namespace at one file.
Close the workspace before copying the entire directory, including its registry and packs. Reopening that copy preserves namespace IDs, sources and histories. Copying one pack into a differently registered project is rejected. Registry names and UUIDs are plaintext metadata, even when pack encryption is configured; the source contents use the existing encrypted-pack behavior. Supply the same encryption credentials and compatible embedding provider when reopening.
Only one workspace instance may own a directory at a time, including across processes. Share that instance inside the application. A native file lock is released on clean shutdown or process exit. This is local storage ownership, not a distributed lock for shared network filesystems. Symbolic links within workspace storage are rejected, but the filesystem and application remain trusted; this is not a boundary against malicious local file mutation.
PostgreSQL workspaces
Install prme[postgres] and supply a database connection through configuration:
# PRME_DATABASE_URL is read from the environment or .env; keep credentials there.
config = PRMEConfig()
async with MemoryWorkspace.open_postgres(
config, name="assistant-app", max_open=4, max_connections=10,
) as workspace:
async with workspace.namespace("client-aurora") as memory:
await memory.store("Retention is seven days", user_id="alice")
result = await memory.retrieve("retention?", user_id="alice")name identifies a workspace in this database; project names are scoped by it.
Names remain exact case-sensitive keys. Independent application instances using
that same database/workspace/project resolve the same stable namespace UUID.
They can run concurrently. Each workspace instance has one asyncpg pool with
min_connections=1 and max_connections=10 by default; these are per-instance
bounds. The engine cache and lease rules above apply to both backends. Evicting
one engine drains its namespace checkouts without closing the shared root pool.
An application running many workspace instances must budget their pools together.
The prme_workspace schema contains the transactional registry. Each project
uses prme_ns_<UUID hex> for its event log, graph, vectors, lexical search, jobs,
profiles and receipts. Registry initialization and the first complete project
schema commit atomically; failed startup can retry the same identity. Initialized
missing tables, sequences, schemas or mismatched identity fail explicitly before
normal migration can recreate them. Loss of the registry while namespace schemas
remain also fails. Existing unbound schemas require explicit import, not adoption.
Every checkout verifies identity, clears temporary relations left on reused
connections, and selects only the project’s schema. Missing tables cannot fall
back to public. pgvector types, functions, operators and index operator classes
are qualified using its installed extension schema; that schema is not added to
the table search path. Existing extensions keep their location. The initializer
installs missing vector and pgcrypto extensions in public, requiring the
appropriate database privileges or administrator provisioning. These choices
follow PostgreSQL’s schema resolution rules
and asyncpg’s pool reset behavior .
Do not move extensions while engines are open.
Use PostgreSQL-native backup/restore for these schemas and their registry, including extension dependencies. They are not local copyable pack directories; local pack encryption is rejected by this factory. Configure database transport, at-rest encryption, roles and backups through PostgreSQL deployment controls. Neither schema routing nor a workspace/project name grants database access. Privileged SQL and trusted application code can still access multiple schemas.
Current boundary
This API implements named local and PostgreSQL physical partitions. It does not
implement a synchronous workspace client, hosted credential-to-project grants,
cross-project queries, hierarchy, rename/delete/import, or full RFC-0004
conformance. open() remains local; use open_postgres() for PostgreSQL. Applications
must authorize the requested project before selecting it. HTTP/MCP servers do not
gain project routing merely by installing this API. Direct unbound MemoryEngine
and CLI access remain trusted operator tools, not tenant access controls.
The resource study motivated bounded ownership. Correctness checks use authored histories and controlled extraction output; they do not establish memory-QA superiority.
Source: docs/WORKSPACES.md in PRME at 58caddb1.