The storage ports (see ports.md) are the entire persistence
contract. The five core ports — CompanyStore, EventLog, MemoryStore,
ContextStore, SecretStore — plus the six console-surface stores added in
WS3 — TaskStore, WorkspaceStore, FactStore, UsageMeter,
SkillStateStore, InboxStore — are all that a backend must implement. The
kernel never names an engine; a backend is anything that implements those
traits and passes the conformance suite (src/store/conformance.rs). This file
documents the shipped backends and how one is selected at boot.
OPENCOMPANY_STORAGE picks the backend once per process; serve and
platform provisioning inject the same opened handles into every company's
RuntimeBuilder (src/store/select.rs). A selected-but-unavailable backend
aborts boot — there is never a silent fallback to the filesystem.
| Value | Backend | Feature flag | Notes |
|---|---|---|---|
fs (default) |
Per-company bundle directories | — | Human-inspectable; no external service |
sqlite |
One SQLite file under the data dir | sqlite |
Single-file, offline |
mongodb |
A MongoDB database on a shared cluster | mongodb |
The multi-tenant platform backend |
Each backend implements all fourteen ports. The fs backend keeps the core
records as inspectable TOML/JSONL bundles and the WS3 console-surface stores
under a sibling ops/ layout (src/store/fs_ops.rs); sqlite and mongodb add
one collection/table per store.
A fifteenth, JournalStore, joined them in issue #726. The runtime journal was
built on the filesystem unconditionally until then, so on a mongodb tenant the
at-most-once effect set and the parked-approval queue lived on /data —
ephemeral scratch, discarded on every container replacement. It is now selected
from the same handles as every other store, with a one-time receipt-gated import
off the old file: journal.md.
Three of those fourteen — UserStore, SessionStore, LoginCodeStore — back
human user authentication. Sessions and login codes are credential
material: they hold hashes only, and they must never be added to the
export path below.
MongoDB settings:
OPENCOMPANY_MONGODB_URI— connection string (required formongodb).OPENCOMPANY_MONGODB_DB— database name (defaultopencompany).OPENCOMPANY_TENANT_ID— tenant identity for shared-single-DB mode (default unset). See Shared single database.
fs writes one plaintext file per secret under
<data-dir>/companies/<slug>/secrets/; sqlite puts the same bytes in a
database file on the same disk. Both are readable by the uid the server runs
as — which is the uid an agent's shell tool runs as, in the same container.
There is no boundary in between; see
../security/agent-isolation.md. mongodb is
the only backend that keeps secrets out of the container, in the tenant
database.
Repository credentials therefore require OPENCOMPANY_STORAGE=mongodb.
This is enforced, not advised, in three places:
| Where | What happens |
|---|---|
POST …/repos (bind) |
409 Conflict with the refusal message; nothing is stored |
| Company boot / rebuild | The company does not come up — OpenCompanyError::Config — when its effective roster grants repo, including an agent naming repo under a wildcard company allow-list |
| Agent build | Repo tools are withheld (fail-closed, with a warning), which covers a teammate added through the console on a live runtime |
The refusal names both remedies: set OPENCOMPANY_STORAGE=mongodb with
OPENCOMPANY_MONGODB_URI, or drop the repo grant.
This is a breaking change for fs and sqlite deployments that have already
bound a repository. That is deliberate. Those are precisely the deployments
carrying a plaintext repository token on a disk their agents can read, so a
warning would leave the exposure in place and call it handled. Migration is one
of the two remedies above; a bound credential on an fs host should also be
revoked at the forge, since it has been readable for as long as it has been
installed.
Every other secret on an fs or sqlite host is still plaintext next to it.
This gate closes one credential on one path; it does not make the filesystem
safe.
How the data root is resolved, why only one process may write it, and what
instance-id is: data-root.md. This file describes the layout
inside the root.
Moved to workspace-layout.md — this file was over the repository's 500-line limit. See that page for the full detail.
Moved to memory-engine.md — this file was over the repository's 500-line limit. See that page for the full detail.
OPENCOMPANY_MEMORY selects store (default), remote, or null. A hosted engine additionally needs
OPENCOMPANY_MEMORY_DRIVER, OPENCOMPANY_MEMORY_URL and
OPENCOMPANY_MEMORY_API_KEY; each refuses at boot when missing, naming the
knob, and never falls back to the base store's memory. The in-pod
embedded/tinycortex engine and its namespace provider-store mode were
removed in #1568 and refuse at boot if still selected. The credential and the
endpoint never appear in logs, /healthz, /spec, status output, or an export
— /spec reports the engine's driver_id and negotiated capabilities only.
One MongoStore wraps a single database and implements all five ports.
Payloads are stored as the same JSON strings the fs/sqlite backends persist,
so records round-trip byte-identically across backends and export/import
migrate between any two backends unchanged. Monotonic 0-based sequences come
from a counters collection via atomic findOneAndUpdate {$inc}.
Collections (all uniquely indexed on company_id + their key):
companies, ledger, events, memory_traces, memory_tasks,
context_chunks, secrets, journal and journal_imports, plus counters
and owners; and the WS3 console-surface collections tasks, workspace,
facts, usage, skills, and inboxes. The usage collection is trimmed to the 90-day retention window
on each record (see UsageMeter).
Path-uniqueness indexes on workspace_nodes. This backend has neither a
per-company lock nor a transaction it can require of its deployment, so "one node
per path" cannot be a read followed by a write; it is the database's own
invariant. Two partial unique indexes carry it: (company_id, file_path_key) for files (issue #697) and (company_id, folder_path_key) for
the folders adopt_or_create_folder claims (issue #759). Both keys encode
{parent_id}\0{name} — NUL, because no node name may contain one — and both are
separate fields on purpose, so a folder and a note may still share a name exactly
as they always could. This is a race fix, not a new tree rule.
Partial is what makes them safe to add to a live tenant: a plain unique index
is built over every existing document, so a company that already lost one of
these races would fail index creation, and that failure happens during
ensure_indexes — taking the tenant's startup down. Restricted to documents that
have the field, each index covers everything written from now on and ignores
history it cannot repair; legacy duplicates keep being refused one layer up.
Only the primitives stamp the keys. Plain create does not stamp
folder_path_key, so console-made folders are unguarded (and unaffected). A
folder that rename_move touches drops its key: the claim exists to decide
contention on the publish walk, and a key that travelled with a hand-moved folder
would keep guarding the path it left — refusing that path forever, which is the
outage the primitive exists to prevent.
- Database per tenant (recommended). The hosting layer (the
opencompany-manager control plane) runs one shared MongoDB but creates one
logical database per tenant (
oc-<slug>) and one database-level user whose only role isreadWriteon that database. The credentials are injected asOPENCOMPANY_MONGODB_URI/OPENCOMPANY_MONGODB_DBwhen the tenant workload is created. A compromised tenant container cannot address any other tenant's data — isolation is MongoDB auth, not an application filter. - Company scoping inside a database. Mirroring the sqlite backend, every
document carries
company_idand every query filters on it, so one database can also host multiple companies (platform mode). Theownerscollection makes the company → tenant map durable:servehydrates the in-memoryAppStateownership map from it at boot, and provisioning updates it — closing the previous restart-loses-ownership stub.
An operator may run every tenant workload against one logical MongoDB
database instead of one database per tenant (e.g. to stay under a managed
cluster's database/namespace limits). In this mode the manager injects
OPENCOMPANY_TENANT_ID=<tenant-slug> (alongside OPENCOMPANY_MONGODB_DB
pointing all tenants at the shared database name) so the workload can keep its
records apart:
- Id namespacing. Company ids are prefixed with
<tenant>--before they reach the store (AppConfig::namespaced_company_id). Both the boot path and the API provisioning path prefix with the workload's ownOPENCOMPANY_TENANT_ID— config, not the request's acting tenant, is authoritative for this workload's data scope. So even a full-platform token provisioning on behalf of another tenant yields a workload-local id rather than one prefixed with a foreign tenant. This keeps the same boot template (OPENCOMPANY_COMPANY=agentic_software_companyfor every tenant) from colliding on thecompaniescollection's uniquecompany_idindex. The prefix is idempotent — an already-prefixed id passes through unchanged. - Ownership. A provisioned or boot company's
company_id -> tenant_idmapping is written to theownerscollection (best-effort) with the workload's ownOPENCOMPANY_TENANT_ID, so a shared-DB manager can enumerate and purge a tenant's companies later. Recording the same value the id is namespaced with is what lets owners hydration reload it: hydration at boot filters to rows whosetenant_idequals this workload'sOPENCOMPANY_TENANT_ID, so the in-memory ownership map never carries other tenants' companies and no API-provisioned company is orphaned across a restart.
Everything is backwards compatible: with OPENCOMPANY_TENANT_ID unset, id
derivation, ownership recording, and owners hydration behave exactly as before
(the db-per-tenant and single-tenant paths are unchanged).
In shared-single-DB mode all tenant workloads hold credentials to the same
logical database. Isolation is application-layer only — the <tenant>--
id namespace, the company_id filter on every query, and the registry serving
only locally-loaded companies. A compromised or malicious tenant container that
reaches the database directly can read and write every tenant's documents;
nothing at the MongoDB auth layer stops it. Database-per-tenant (layer 1 below)
remains the security-recommended mode and stays the manager default; enable
shared-single-DB mode only where the operational constraint outweighs this
weaker isolation.
Implement the five traits in a new src/store/<engine>.rs behind a feature
flag, key everything on company_id, run the conformance suite against it,
and add a StorageKind arm in src/store/select.rs. No business logic
changes.
src/store/conformance.rs is the backend-agnostic suite every backend runs.
Beyond the core assertions (per-company isolation, append-only event/ledger,
monotonic event sequence, export totality) it exercises each WS3 store —
assert_task_store, assert_workspace_store, assert_fact_store,
assert_skill_state_store, assert_inbox_store, assert_usage_meter,
assert_secret_store — plus a dedicated assert_usage_retention that verifies
samples older than the 90-day window are evicted on write. A new backend passes
only when all of these hold.
assert_secret_store (issue #1505) covers the port holding a tenant's inference
credential, MCP OAuth tokens, Composio account tokens and SMTP password:
read-back, absence, per-key independence, overwrite, "cleared is an empty value
and not absence", and — the property with security consequences — that a secret
written for company A is unreadable as company B, in both directions. The port
has no delete; callers clear by writing an empty value, which is why the
empty-value case stands in for a deletion case.
It also asserts that two distinct keys stay distinct — issue #1510. The
filesystem backend encodes each key into an injective filename (percent-encoded
with a % prefix the legacy slug layout can never produce, and truncated with a
digest suffix for long keys), and the old slugged file is kept readable as a
migration fallback. Upper-case letters are percent-encoded rather than passed
through (so filenames stay distinct on case-insensitive volumes — the macOS and
Windows default), and a trailing . is encoded as %2E (Windows strips
trailing periods), so distinct keys map to distinct files on every supported
filesystem. set keeps the legacy file for non-empty rotations, because one
slug can name several distinct keys and it may still hold a colliding alias's
value that an un-migrated alias reads through the fallback. Clears are
different: writing an empty value is a revocation, so the shared legacy file is
removed rather than allowing an un-migrated alias to resurrect the revoked
credential. get prefers the canonical file, so a rotated key is shadowed while
a cleared ambiguous legacy value is unavailable to every alias. The suite covers
both the space-vs-underscore keys the old slug conflated, two keys differing only
in letter case, a key ending in a period, and a key shaped like a legacy filename
(key-foo) reading or deleting a different key's value.
Fixtures in this suite are non-empty on purpose. An empty vec, map or None
survives every possible bug, including a backend that never persisted the field
at all, so seeding one certifies the gap it was meant to close. Issue #1504 was
exactly that: CompanyRecord::overlay_agents was seeded as Vec::new() and
never read back, so a backend that dropped every console-created teammate passed
the whole suite. The fixture now seeds overlay_agents, overlay_desks and
overlay_desk_members with their optional fields populated, and both
assert_isolation_by_company and assert_export_totality assert them.
assert_workspace_folder_claims (issue #759) additionally drives eight
concurrent callers at one (parent, name): all must succeed, all must come away
holding the same folder, and exactly one may report having created it. That case
is the point of the function — a naive read-then-create passes every sequential
assertion beside it and fails only there, which is the defect's exact shape.
cargo test --features mongodb,sqlite runs everything; the MongoDB
conformance tests are env-gated and skip unless
OPENCOMPANY_TEST_MONGODB_URI points at a live server:
OPENCOMPANY_TEST_MONGODB_URI=mongodb://127.0.0.1:27017 \
cargo test --features mongodbEach test creates (and drops) a uniquely named throwaway database.
Each backend gains one store for the unredacted companion of a run's steps —
fs deep-trace.jsonl, sqlite run_step_details, one mongodb collection of the
same name — all keyed (company_id, run_id, step_seq) and held to
assert_deep_trace_store in the shared conformance suite. See
deep-trace.md for what it holds, its caps, and why it is a
sibling of the run store rather than part of it.
runs also gains a workflow_run_id mirror for the workflow-node join
(see ports-runs.md). On sqlite the column is added by
add_column_if_missing and its index created after the #983 table rebuild
— that rebuild recreates runs from a fixed column list, so an index declared in
MIGRATIONS would fail outright on exactly the legacy databases the additive
step exists for.