NewRead the Claudium Whitepaper — the science behind the brain
Blog
·7 min read·by Claudium team

Per-org learning without ever crossing org boundaries

The intelligence layer learns from your team's activity and nobody else's. Here's the schema, the RLS, and the embedding pipeline that makes that promise enforceable at the SQL layer — not just trusted in the application code.

engineeringsecurityintelligence

The hardest sentence to keep honest in a multi-tenant product is this one:

Your data only ever helps your team.

It's the sentence behind every "is my code being used to train someone else's model" question. It's the sentence behind every procurement review. And it's the sentence that, in most products, is true only because application code happens to write the right WHERE clause every time. One bug in one query and the promise breaks.

This week we shipped the per-org learning loop in Claudium — pattern detection that surfaces "your team frequently does X before Y" inside an org, never across orgs. We wanted that promise to be enforceable below the application code, not trusted in it. This post is how we built that, why we made the choices we made, and where the next version is going.

The threat model

Claudium captures tool-use events from every contributor on every team. With a learning loop, every org's events go through three stages:

  1. Capture — raw events land in events.
  2. Stitch — consecutive events get grouped into turns (the natural unit of work between two user messages) with a short summary string.
  3. Embed — each summary becomes a 1536-dim vector in turn_embeddings, queryable via pgvector for nearest-neighbour patterns.

The threat we care about isn't external attackers. It's the next bug we write. We work fast. We refactor query helpers. We sometimes hand a query to a model and ship what it returns. Any of those changes can introduce a missing WHERE org_id = ? and silently leak one customer's working patterns into another's recommendations.

The mitigation has to be boring and unavoidable: the database itself refuses to return foreign rows.

The schema, with RLS as the second floor

Every tenant table — senders, memberships, orgs, and now turns, events, turn_embeddings, subscriptions — carries an org_id column and the same Row Level Security policy:

ALTER TABLE turn_embeddings ENABLE ROW LEVEL SECURITY;

CREATE POLICY turn_embeddings_tenant ON turn_embeddings
  FOR ALL
  USING (
    current_setting('app.bypass_rls', true) = 'on'
    OR org_id = NULLIF(current_setting('app.org_id', true), '')::bigint
  )
  WITH CHECK (
    current_setting('app.bypass_rls', true) = 'on'
    OR org_id = NULLIF(current_setting('app.org_id', true), '')::bigint
  );

Two Postgres session GUCs control visibility:

  • app.org_id — set at the start of every per-org transaction. Rows are visible only when their org_id matches.
  • app.bypass_rls — set to 'on' exclusively for legitimate cross-org work: looking up a sender token by hash (we don't know the org yet), running the embedding worker's "which orgs have unembedded turns?" discovery query, the rare admin tool.

Both are wrapped in two small helpers — withOrgClient(orgId, fn) and withServiceClient(fn) — that take the pg client out of the pool, set the right GUC inside the transaction, run the caller's function, and commit. Every query in the app goes through one or the other. The application-level rule is dead simple: if you wrote a client.query(...) without one of these wrappers, it doesn't run.

The wrappers exist to make the right thing easy. The RLS policy exists to make the wrong thing impossible. Both layers can fail; they can't both fail at the same time.

Denormalising org_id onto turn_embeddings

The first design we drafted had turn_embeddings(turn_id, embedding, embedding_model, embedded_at) and inherited org_id from the turns table via the foreign key. RLS would have been a JOIN: "a turn_embeddings row is visible iff the corresponding turn is visible."

We rejected that for two reasons:

RLS efficiency. A policy that has to JOIN to check visibility is slower and more fragile than a policy that compares a local column. Adding org_id directly onto turn_embeddings means the policy is a single integer comparison; Postgres can drop foreign rows before any join, any index lookup, any vector math.

Query planning. Every nearest-neighbour vector query we want to run is "find turns similar to X, scoped to my org." With org_id on the same row, the planner pre-filters by org first, then runs the HNSW similarity search on a much smaller candidate set. With the JOIN approach, the planner often runs the vector search globally first, then filters — orders of magnitude slower as the table grows.

So org_id is duplicated. It's kept in sync at insert time (the embedding worker writes both columns; turns.org_id is the source of truth, and ON DELETE CASCADE from orgs cleans both rows together).

The embedding pipeline

The actual flow is short:

sender ws event
    ↓
hub validate + broadcast (live)
    ↓
event-store INSERT (per-org RLS, gated by monthly quota)
    ↓
turn-stitcher (every 30s) groups events into turns + writes summaries
    ↓
embedding-worker (every 60s) embeds summaries via OpenAI → pgvector

Two background workers, both unref'd timers so they don't pin the process up. Both follow the same pattern: discover work across orgs under withServiceClient, then dispatch per-org under withOrgClient. Bounded per tick (25 orgs × 25 turns for the embedding worker) so one chatty org can never starve another.

The embedding worker has a tier-aware daily cost cap. text-embedding-3-small is cheap — $0.02 per million tokens — but cheap isn't free, and a runaway sender could burn the budget. So we cap globally at $5/day by default, then sub-cap per org: $0.05/day for Free (≈ 25,000 turns), $0.30/day for Team (≈ 150,000 turns). Both numbers are env-overridable. Past the cap, the worker leaves the turn unembedded and tries again tomorrow.

What we deliberately didn't do

A few decisions that we made and want on the record:

No shared model fine-tuning across orgs. Even with strong RLS, sharing a fine-tuned model would mean some signal from one team's data influences another team's experience. We never fine-tune. The intelligence is structural — clustering and sequence mining — and runs per-org against per-org data.

No cross-org recommendations even with explicit consent. It's tempting to offer "would you like to compare your team's patterns against industry averages?" The answer right now is no. The product has to be obviously safe; an opt-in cross-org channel changes the trust model and we'd rather earn that bit of trust before spending it.

No persistence of file contents or prompts. Only metadata leaves the contributor's machine — tool name, region, token count, file path. The PII/secret filter on the hub drops anything that looks like a key, token, or password before the broadcast layer can see it. The embedding input is the summary string the stitcher builds ("Read auth.ts; Edited auth.ts; ran tests"), not the contents.

What's next

The schema and the pipeline are live; pattern detection sits on top of them.

The first surface will be a Patterns tab in /admin — read-only, analytics-grade. Show the team their most frequent tool sequences, the embedding clusters their work falls into, the times of day their attention shifts between regions. We want to see what real patterns look like in real orgs before we decide how aggressively to push them — a recommendation that fires in-app needs to be useful 95% of the time or it's worse than silence.

We'll write about that when we get there.

A note on RLS as a practice

A thing we've learned the hard way: RLS is not a magic safety net. It's a tool that turns one kind of mistake (a missing WHERE) into a different kind of behaviour (zero rows returned, which is often an obvious bug in development), and that's all. If your application code doesn't go through helpers that set the right GUCs every time, RLS doesn't save you — it just fails open until someone tells it which org they're acting as.

The discipline that makes RLS load-bearing is the discipline of routing every query through a wrapper. We learned that the hard way too: an early version of our codebase had three different ways to get a Postgres client, and the RLS policies were silently bypassed by two of them because we hadn't set app.org_id. The fix wasn't to add more RLS; it was to delete two of the three client paths.

If you're building a multi-tenant product on Postgres and you want this property: pick one wrapper, put it everywhere, and make a code-review rule that a raw pool.connect() is a blocker.

That's the boring foundation underneath every fancy feature.