Security model
Isolation in Ledgenter is not a middleware habit — it is the shape of the database. This chapter walks the walls, the doors, the keys, the things the building refuses to remember, and the proofs that run on every release.
How Ledgenter keeps each customer's data walled off from everyone else's.
Every workspace is sealed. One company's projects, tasks, and notes are completely invisible to any other — and that separation is enforced deep in the database itself, on every single request, not as an afterthought a bug could skip.
Agents never hold the keys to the kingdom: each one gets a narrow, per-agent credential that's swapped for a short-lived pass, while the powerful master credentials never leave the secure core. Sensitive records, like the decision log, can't be quietly edited or deleted.
Inside a single workspace, your own agents and teammates are treated as cooperative colleagues — the product guards against honest mistakes there, and against outsiders at the walls. Every one of these promises is backed by automated tests that run continuously.
The tenant is the wall
A tenant is one organisation's entire workspace, and the boundary around it is built from three reinforcing layers — each enforced by Postgres itself, so no application bug can route around them.
create table tasks ( tenant_id uuid not null, -- always the first column id uuid not null, -- ... primary key (tenant_id, id), -- composite: identity includes the tenant foreign key (tenant_id, project_id) references projects (tenant_id, id) -- composite: can only point inside the wall ); alter table tasks force row level security; -- no exemption, not even the table owner
The three layers, in order of how they fail:
- Forced row-level security. Every public table carries
FORCE ROW LEVEL SECURITYand at least one tenant policy that scopes rows to the caller's JWT claims. Forced matters: ordinary RLS exempts the table owner; forced RLS exempts nobody. - Tenant-leading composite keys. Primary keys are
(tenant_id, id), and every foreign key carriestenant_idtoo. This is defence in depth with teeth: a task in tenant A structurally cannot reference a tenant-B actor, project, or run — the row pair does not exist on A's side of the wall, so the FK fails before any policy is even consulted. A cross-tenant leak would require breaking the policy and the key structure at once. - The CI lint.
pnpm db:lintwalks all 27 public tables and fails the build if any lacks forced RLS or a tenant policy. Service-only tables that agents never touch directly (api_keys,idempotency_keys,tool_call_log) are policy-exempt via an explicit allowlist — but still carry forced RLS. A new migration cannot quietly add an unguarded table.
Writes are a different door
Reads and writes do not share an entrance, and the asymmetry is the system's central data rule (chapter 3):
┌─ READS ──► PostgREST select ──► RLS filters every row to the caller's tenant
JWT claims ──┤
└─ WRITES ─► rpc() ──► SECURITY DEFINER function, one transaction
require_scope('write') → validate → idempotency → write → record → emit
direct table DML: revoked (SQLSTATE 42501)
Direct INSERT/UPDATE/DELETE privileges are
revoked from the agent-facing role entirely — an agent that tries raw DML dies at the
privilege gate with SQLSTATE 42501, a fact the test suite asserts
explicitly. Every mutation instead enters through a SECURITY DEFINER
Postgres function that begins with require_scope('write') — the scope read
from JWT claims, never from a parameter — and then performs the full sequence in
one transaction: validate the state machine and preconditions, register the
idempotency key, apply the write, record the activity row, emit notifications. A write
either fully happens with its paper trail, or does not happen at all. There is no state
where a task changed but the logbook missed it.
Why definer functions are safe here.
SECURITY DEFINER means the function runs with elevated rights — which is
exactly why every one derives its tenant and actor from the caller's JWT claims
inside the function body, and why the pgTAP suite proves each RPC is tenant-scoped.
The elevation buys atomicity; the claims keep it honest.
Identity and keys
Every actor — agent, human, or service — holds exactly one credential: a Ledgenter API
key, hashed at rest. The agent machine never sees a database role, a connection string,
or a service-role key. Core exchanges the API key at the auth-exchange
edge function for a short-lived (~15 minute) JWT carrying three claims —
tenant_id, actor_id, scopes — cached and
refreshed about two minutes before expiry. Those claims drive everything downstream:
RLS policies filter on the tenant claim, RPCs attribute writes to the actor claim, and
require_scope checks the scopes claim. Nothing trusts a client-supplied
identity parameter, ever.
The consequences are deliberate:
- Revocation is fast and loud. A revoked key fails its next exchange; the residual blast radius is the remaining JWT lifetime, at most 15 minutes.
- The service role is quarantined. It exists only server-side, in a registered set of entry points (migrations, edge functions, cron). No agent process, harness, or gate runs with it — even the verification gates audit with an ordinary actor key RLS-scoped to the tenant they inspect.
- Read-only is real. A key minted with only the
readscope canselectfreely inside its tenant but every mutation —task_create,skill_upsert, all of them — raises42501at therequire_scope('write')gate. This is proven by pgTAP, not promised by convention.
What is never stored
An agent works on a real machine, in a real checkout, with real credentials in its environment. Ledgenter's posture is that the server should know which work happened, never the private texture of the machine it happened on. Four classes of data are structurally excluded:
| Never stored | What happens instead | How it's held |
|---|---|---|
| Local filesystem paths | Runs record the worktree basename only (ledgenter, never
C:\dev\ledgenter). The repo-map local_path overlay exists so
the agent on this machine knows where its checkout lives — it is served
in tool results, then structurally dropped from telemetry by key, not by
pattern-matching values, so the guarantee cannot rot as path formats change. |
Gate probe X13 asserts the path reaches the agent and is absent — key and value — from the transcript. |
| Credentials in remote URLs | normalizeRemoteUrl strips user:token@ userinfo
client-side before the URL leaves the machine; the server's repo-resolve RPC
re-strips it before fingerprinting or storing. Two independent layers — a
bypassed client still cannot persist a token. |
Probe X9 registers a tokenful remote and asserts the stored row is credential-free. |
| Provider tokens & JWTs in transcripts | The telemetry sink scrubs every persisted payload: values under secret-bearing
keys (api_key, token, authorization,
jwt, …) and token-shaped strings become [REDACTED]
before any write. |
Probe X11 pushes a real token through a logged argument and demands
positive evidence — the transcript must contain
[REDACTED], not merely lack the token. |
| File contents (CLAUDE.md, code, configs) | Ledgenter never stores or serves repo conventions — the agent's host loads
CLAUDE.md and local skills from the checkout natively. The server
holds git metadata only: remote URL, slug, branch, HEAD. |
By construction — no tool accepts file contents as repo context (chapter 7). |
The intra-tenant model
Cross-tenant isolation is hard and unconditional. Within a tenant, v1 takes a deliberate stance: a tenant is a trust boundary, not a permission system. Every actor in a tenant is assumed cooperative-but-fallible — your own agents and teammates. The product defends against mistakes inside the tenant (claims, optimistic preconditions, validation, idempotency) and against adversaries only at the tenant and credential boundaries. The guarantees that never bend, regardless, are the isolation and credential boundaries:
What never bends. Tenant isolation (forced RLS, composite keys, claims-derived tenant in every RPC), the credential boundary (keys hashed at rest, short-lived JWTs, quarantined service role), and the append-only surfaces (activity and decision content cannot be edited or deleted by any authenticated path). None of this bends.
The proofs
Every claim in this chapter is backed by a test that runs in CI or against the live sandbox — and each proof harness is itself tested for its ability to fail.
The pgTAP isolation suite — 145 assertions. It seeds two full
tenants as the service role, then switches to the agent role under tenant A's claims
and attacks: tenant A must see zero of B's rows on every table; raw DML must
die with 42501; NULL claims must fail closed to zero rows, not all rows;
a read-only-scoped actor must read freely and never write; the append-only spines must
reject every UPDATE and DELETE. The suite gates every pull request that touches the
database or the MCP server.
-- raw DML dies at the privilege gate, before RLS is even consulted select throws_ok($$ update public.tasks set title = 'hacked' $$, '42501', NULL, 'raw UPDATE on tasks denied for authenticated'); -- read-only scope: SELECT works, every mutation raises select throws_ok($$ select app.task_create(/* … */) $$, '42501', NULL, 'task_create raises 42501 for a read-only-scoped actor');
The integrity gate — 7 invariants. Independent of any model-graded
evaluation, it queries ground-truth database state and exits non-zero on any violation:
no duplicate event sequence per tenant; no task transitioned by two distinct actors;
replaying a used idempotency key yields no second row; no contended handoff left
unclaimed; decision and activity rows unedited since creation (checksums across two
reads); no events lost across a whoami window; and a deliberate
dependency-cycle attempt refused with the right error.
The concurrency gate — 13 probes. It drives the races ordinary
usage never produces, deliberately, against the live sandbox: sixteen-wide same-key
run_start storms, shared-key sibling forks writing identical content,
cross-actor writes under one pinned run, loop ticks chaining cursors, and the
end-to-end privacy probes from above — a real MCP
server, a real token, a real worktree, and a transcript that must come back clean
(chapter 6 covers the run semantics these defend).
The teeth requirement. A gate that cannot fail proves nothing.
Both gates accept --inject-violation N, which simulates invariant
N failing and must turn the gate red — a unit-style proof that the alarm
actually rings. The isolation suite carries the same demand in prose: a deliberately
broken policy must turn at least one assertion red, because that is the point of the
suite.
Where to read deeper. The full gate machinery — what runs when, how verification-gated completion works, and how the harness stays independent of the thing it grades — is chapter 9.