Row-level security is the entire authorisation story.
Hub has no bespoke permission table and no per-surface access list. Every user, staff, agent or client, is a real database auth user with one profile row, and the database's own row-level policies decide what each one can see and change. The floor: an anonymous caller resolves every predicate to false and sees nothing.
Five predicates, one profile row, and the floor is false.
Revocation is one column. Set a profile inactive and every predicate drops that user on the next request. No key rotation, no token blocklist.
| Predicate | True for | Gates |
|---|---|---|
| is_team_member() | Active team members, humans and agents | The normal workspace |
| is_human_team() | Active team, and not an agent | Workspace settings, space admin, client-content writes, the access panel, approval decisions |
| is_admin() | Active team human with the admin flag | Privilege management |
| has_crm_access() | Admin or CRM-flagged human | The sensitive tier: spend, intel, suggestions. Agents never hold it |
| client_id_of_user() | An active client login | The portal. Each client sees only its own organisation |
Fourteen policies are restrictive, and every one is an agent fence. A restrictive policy is AND-ed with the whole permissive set: it can only ever take access away, and no permissive policy can out-vote it. That's what stops an agent deleting a task, proven in a rolled-back transaction: a human gets one row updated, an agent token gets zero.
Your keys: a bank teller, not a key ring.
Client keys live in a vault and one authorised runtime home. An agent never enters the vault. When a task is routed it gets a signed withdrawal slip: one client, one purpose, expiring within hours. The teller (the broker) checks the slip and does the transaction itself, or hands over a single-purpose pass that expires on its own. Everything, including knocked-back slips, lands on the statement. A stolen slip expires. A stolen key would be forever, which is why there are no keys in agent hands, ever.
- The vaultVault of record · no agent reads it
- Runtime custodyOne authorised home for this key
- A withdrawal slipOne client, one action class, hours
- The tellerDoes the deal, or lends a pass
- Typed executorTyped, allowlisted, and nothing else
- The statementEvery attempt, refusals too
- Tripwire scanGrants past their life, or with no task
- The platformReached, and never handed a key
- 01 · Round locksource-level, now open
- 02 · Master flagoff by default
- 03 · Kill switchper client, or all
- 04 · Allowlisttyped actions only
- 05 · Handlerbound at build time
- 06 · Approvalon an exact payload
- 07 · Spend caprefused whole
- 08 · Egressno open world
- 09 · Credentialresolved, never held
- 10 · Grantlive, scoped, unexpired
- Then, and only then, it actsAny refusal is recorded with the first reason that applied.
| Credential class | Where it lives | Why |
|---|---|---|
| Money and email (accounting, mail send) | Cloud custody behind mandatory human approval gates. The accounting connector stays sole token authority: its refresh tokens are single-use, so a second holder kills the first. | A stolen key here spends money or sends mail as you |
| OAuth tokens (Google, Meta) | Nango minds the tokens. The broker makes the calls. | Refresh lifecycle is their whole job |
| Read-write action keys (CMS, ads) | Vault, fetched by the broker at call time under a grant and an egress allowlist. | Can change client sites. Needs the tightest leash |
| Read-only analytics keys (GA4, Search Console, SEO tools) | With Airbyte, where they're used. | Low stakes, revocable in minutes |
| Platform infrastructure secrets | A separate compartment, unreachable from any client-facing path. | A client-path breach must never reach these |
| The record itself | References and audit rows only, never secrets. | The control-plane pattern |
Custody is a plug, not a place. The broker, grants and audit core are fixed. The custody backend is per-tenant config: a cloud KMS-backed vault as the product default, a local-custody backend for a team that wants its own machine, or bring-your-own KMS for an enterprise tenant. High-stakes classes for cloud-only tenants are protected by mandatory human gates in place of physical locality.
Ten checks in front of every action. Fixed order. Fail-closed. One reason out.
Any one of the ten can refuse. The reason recorded is the first that applied. Step 06 is the one a person makes.
Round lock
Source-level. Open by design.
Master flag
Off by default.
Kill switch
Per client, or all.
Allowlist
Typed actions only.
Handler
Bound at build time.
Approval
On an exact payload.
Spend cap
Refused whole, never partial.
Egress
No open world.
Credential
Resolved, never held.
Grant
Live, scoped, unexpired.
Every step returns a refusal decision rather than throwing, so nothing can silently swallow it, and both allowed and refused attempts write an append-only audit row. Thirty-six client-platform operations are declared: 17 read-only, 4 stage-only, and 15 marked never. Publishing a tag container is one of the fifteen, permanently.
Whose account answers, and who pays.
Who pays for this AI request resolves from the verified identity of the person asking, never from a value the caller supplies. An operator's own build work rides a personal subscription, never stored in Hub. A teammate's default chat is free on the local model; premium quality means their own key, billed to them. Client-carrying production work runs on the organisation's own metered key.
- One question, asked before anything is spentAnswered from the verified identity of the person asking, never from a value the caller supplies. A missing key is a named refusal and an instruction to add one, never a quiet fallback to a shared key.
- You — the operatorYour own sessions, never piped through the Hub
- The build-out phasePlanning, testing and the first runs ride this
- Caps ship at zeroInternal, test and pilot organisations only
- A teammateThe free local model — no key, no cost
- Their own key · built, not switched onFrom a developer console. Never a login
- Billed to them · built, not switched onTheir console, their spend, their limits
- The production fleet · plannedUnattended work, for paying clients
- The launch phase · plannedPer organisation, not a migration
- Not provisioned · plannedTurning it on is a decision with a cost
- Refusal 01Cannot store anything shaped like a subscription login.
- Refusal 02Cannot serve one person's request on another's credential.
- Refusal 03Cannot quietly fall back to a shared key when a personal one is missing.
- Refusal 04A subscription-authenticated worker refuses a job that carries a client.
Which of these have actually been made to fire.
A refusal nobody has ever triggered is a comment in the source code. These were provoked on a real client's data, on a test copy of the platform, and the measurements kept.
| Claim | What was measured |
|---|---|
| Approving a recommendation records a decision and executes nothing | Across 23 approvals in two runs, the executor's counters were read before and after: zero executions, zero outcomes, master switch unset. |
| Both refusal guards fire when provoked | A rejection with a blank reason, and a protected decision pushed through the general-purpose door. Both refused. |
| Nothing on the action path makes a network call | 36 operations declared, 59 broker and 186 executor checks passed with the network replaced by a trap that was never touched. |
| A client login returns only that client's rows | A 998-row, 24-table tenant rebuilt twice with zero drift, read through the portal's own path. |
| Client data cannot be written out of the machine it lives on | Two deliberate attempts to write client data into a tracked directory refused, one via a symlink. |
| Credential-shaped text is stopped mechanically | Live on every commit. Fired twice on 18 August on placeholders. Neither gate was weakened. |
| No figure is published that wasn't re-checked against source | Every figure in 23 generated recommendations re-queried and matched exactly. An impossible control returned nothing. |
| Approval routing cannot bypass a human decision | 4,836 database policies and functions scanned: none reads a routing identifier when deciding whether an action may proceed. |
| One run's cost and duration measured | About $0.24 and just under three minutes, on a harness measurement, not a metered charge. |
Weaker than it looks: the weekly and monthly briefs render from real data, but only 6 of 24 weekly regions carry real numbers. A visible zero there is an empty table, not a result. And the unsolved case, named: a hijacked agent holding a legitimate grant. Egress allowlists on grants are the answer being built.
Three layers of proof, because each catches a different lie.
Unit tests
Pure logic and component behaviour. 454 files, zero failures, measured 31 August.
Policy probes
Assert policy behaviour against a running database from both an allowed and a denied identity. A client-facing policy is tested as a client, over REST, never as a superuser.
Browser smokes
A green build doesn't prove the app loads. Anything touching focus, drag or rects gets a real browser.
More docs
Architecture
The topology: four zones wired around one record, where agents run, and the four ways a worker is started.
Security
Row-level security as the whole authorisation story, the credential broker, the ten-check gate chain, and what has actually been made to fire.
The stack
Next.js, TypeScript, Supabase, Nango, Airbyte, n8n and Vercel: what each does, what's being rebuilt, and what a stranger needs to run it.
Data plane
How client data reaches Hub: Airbyte into a fenced landing zone, thin transforms, a freshness stamp on every number.
Automations & skills
The split: one repo per organisation, a birth certificate for every automation, skills whose home is git.
Status
The capability matrix, five labels, no hedging. What you could use this afternoon versus what needs a switch or a sitting.
Self-hosting
The clone manifest and the transfer sequence: what you stand up, what moves, what never leaves our custody.