Two-layer data model — shared vs per-user
How useCollection and the agent's data tools resolve a collection to one of two namespaces — a physically isolated per-user database or a single world-readable shared pool — via the layer option.
useCollection (and the agent's data tools) resolve a collection to one of two namespaces via the layer option:
- `layer: 'user'` (per-user) — each end-user gets their own physically isolated MongoDB database (u_{sha256(user_id)[:16]}), not just a filtered view. The backend always keys this DB off the requesting user, so one user literally cannot read or write another user's partition through any path. Reads are already scoped — do not add a user_id/key filter. Use for anything truly private: results, drafts, private history (e.g. matches).
- `layer: 'shared'` — one builder database (b_{...}, one per app) that every signed-in user reads (e.g. a directory, catalog, leaderboard). Writes are restricted: the app cannot insert() directly; a collection marked `agent_writable` in the data plan lets the app's own agent append (e.g. a registrar writing member profiles), while blocking arbitrary end-user writes.
- `layer: 'auto'` (default) — resolves from the collection's configured data model.
Security — `shared` is not access-controlled per user. Rows in a shared collection carry a _flowstack.user_id field, but that is a filterable field, not an enforced read ACL — any signed-in user can read every row. Treat a shared collection as a public bulletin board at the storage layer; never store data one user must not see from another there. Only the 'user' layer is private.
Platform gap — no cross-user delivery (today). Because the per-user DB is always keyed to the requester, you cannot write into another user's private partition. Patterns that deliver data privately to a specific recipient — direct messages, per-user notifications — are not achievable: the only private store ('user') is unreachable cross-user, and the only cross-user store ('shared') is world-readable. The correct design (deliver into the recipient's per-user partition) needs a cross-user write primitive the platform does not yet expose.
Reads of a shared collection via useCollection({ layer: 'shared' }) always hit the shared pool. Agent-side reads of shared collections are also routed correctly as of P0-132, but the recommended pattern remains "read in the app, pass into the prompt" — it's the most reliable and keeps PII out of the agent.
File Upload → MongoDB: parse a file in the app and insert() the rows directly via useCollection; all useCollection instances for that collection auto-refresh.
Examples
const { insert } = useCollection('transactions');
const handleCSVUpload = async (file: File) => {
const text = await file.text();
const rows = Papa.parse(text, { header: true }).data;
await insert(rows.map(row => ({
...row,
amount: Number(row.amount),
imported_at: new Date().toISOString(),
})));
// All useCollection('transactions') instances auto-refresh
};layer: 'user' reads are already scoped to the current user — do not add a user_id/key filter. The partition is a separate physical MongoDB database (u_{hash}), always keyed to the requesting user._flowstack.user_id field on shared rows is a filter, not an access-control boundary — any signed-in user can read every row. Never store private data (messages, PII, secrets) in a shared collection, even filtered by user. Only layer: 'user' is private.insert() directly into a shared collection. Only an agent acting on a collection marked agent_writable in the data plan may append to the shared pool; arbitrary end-user writes stay blocked.See also
useCollection · reason-write-read · useAgent
Source: README.md#two-layer-data-model--shared-vs-per-user · Also available in llms-full.txt and registry.json.