Ultimate

Policies and authz

One authz system. Authentication is Better Auth, wrapped (Configuration); authorization is ours, because it must be identical in HTTP, WebSocket, jobs, and MCP.

policy: can('post:publish', ({ input, actor }) => ownsPost(actor, input.postId))

can(permission, predicate)

ParameterTypeRequiredMeaning
permissionstringyesa capability id, resource:verb. Must exist in the declared permission set or the build fails (X_PERMISSION_UNKNOWN)
predicate(args: PolicyArgs) => boolean | PolicyDecisionnothe row-level condition. Omitted means the capability alone decides. Synchronous — a live query evaluates it per subscriber per change, so an await here costs one round trip per watcher

PolicyArgs — the one signature, identical on every surface:

MemberTypeNotes
actorActor | null{ kind: 'user' | 'service' | 'agent' | 'anonymous', id, orgId?, roles, scopes }. Anonymous is null-safe; policies branch on it
inputparsed inputalready validated against the primitive's input schema
rowR | nullthe already-loaded row a row-level rule decides about; null when the rule decides on input alone. Never pass a row through input
ctxCtxrepos for lookups, ctx.tz, ctx.logger. Read-only use

Policies live in <feature>/policy.ts and are referenced by name from the feature's actions/ and live/ modules. agent is a first-class actor kind: an MCP caller goes through this same function, with no separate path.

Where a policy is evaluated

SurfaceWhenDenial rendering
HTTP callbefore handle, after input parseX_FORBIDDEN (403), or X_UNAUTHENTICATED (401) if the actor is anonymous
Typed client callserver-side, on the same routesame as HTTP; the client sees the JSON body
Direct server callin-process invocationthrows X_FORBIDDEN
Job executionper attempt, per step boundary — an actor's rights can be revoked mid-workflowthrows X_FORBIDDEN; the job fails, it does not silently skip
MCP tool callbefore the tool body, same evaluationtool error carrying X_FORBIDDEN + fix
Live query subscribeat subscribe timesubscription rejected with X_FORBIDDEN
Live query per delivered rowon every patch, for every row, for the lifetime of the subscriptionthe row is not delivered; no partial object, no id leak
Admin visibilityscreen and field levelthe screen does not render, and its MCP tool is not listed

The decision is surface-blind. The surface selects only how a denial is rendered — never whether authz runs, and never how input was validated.

AspectRule
Projects toHTTP guard, live-query row filter, job actor check, MCP tool gate, admin visibility
Ownsthe yes/no and the denial reason
Nevermutate, query outside the declared repos, or return partial data (filter in the query)

Denials

Same code, three encodings.

X_FORBIDDEN: policy denied the request
  cause: actor user_2 (roles: editor) lacks post:publish on post_9
  fix:   x actions show publishPost --json   # the capability it enforces; grant post:publish to editor in apps/web/shared/policies.ts
{ "code": "X_FORBIDDEN",
  "cause": "actor user_2 (roles: editor) lacks post:publish on post_9",
  "fix": "x actions show publishPost --json   # the capability it enforces; grant post:publish to editor in apps/web/shared/policies.ts",
  "docs": "https://github.com/developerz-ai/ultimate/wiki/Error-Codes" }

A fix is a command to run, a call to paste, or a file to open — never advice. x errors explain <CODE> --json prints the same { code, cause, fix, docs } for any registered code.

CodeWhereStatus
X_FORBIDDENeverywhere a denial is decided: internal, jobs, direct calls, MCP tool errors, and the HTTP edge for an authenticated actor without the capability403 at the HTTP edge, no status elsewhere
X_UNAUTHENTICATEDHTTP edge, anonymous actor hitting a policy that needs a session401

--json and the MCP response carry the identical code and fix string as the terminal and the dev overlay. A denial reason never includes data the actor could not otherwise read.

Missing or unknown policy is a build error

X_ACTION_POLICY_MISSING: action registered without a policy
  cause: action "publishPost" was registered without a policy
  fix:   add `policy: can('publishPost')` to the action definition in the file that exports it
CodeTrigger
X_ACTION_POLICY_MISSINGan action / mutator / query registered without policy
X_POLICY_MISSINGa permission is referenced but no policy defines it
X_PERMISSION_UNKNOWNcan('post:pubish') — a capability not in the declared set. Typos die at build time

There is no policy: 'public' shortcut and no default-allow. A route that is genuinely public is a site/ route with no action behind it; a genuinely public action declares an explicit anonymous-allowing policy, which is greppable and shows up in x policy explain.

Introspection

$ x policy explain publishPost --json
{"ok":true,"command":"policy","summary":"publishPost — allowed for 0 of 5 actor evaluation(s)","findings":[],
 "data":{"subject":"publishPost","kind":"action","grantingRoles":["admin","author","owner"],
  "declarations":[{"name":"publishPost","kind":"action","capability":"post:publish",
   "label":"post:publish","decidable":true,"rows":[
    {"actor":"anonymous","allowed":false,"reason":"no actor for post:publish","deciding":"post:publish"},
    {"actor":"admin","allowed":false,"reason":"post:publish predicate returned false","deciding":"post:publish"},
    {"actor":"author","allowed":false,"reason":"post:publish predicate returned false","deciding":"post:publish"},
    {"actor":"owner","allowed":false,"reason":"post:publish predicate returned false","deciding":"post:publish"},
    {"actor":"reader","allowed":false,"reason":"actor lacks post:publish","deciding":"post:publish"}]}]}}

One row per actor per declaration — every declared role plus anonymous, evaluated once for each action or query that enforces the subject. A permission two declarations enforce therefore reports twice the evaluations, which is why the summary counts evaluations and never roles. deciding is the clause that produced the verdict and reason is why — which is what separates "this role was never granted the permission" from "it holds the grant and the row predicate said no".

The matrix runs outside a request: no input, no row. A rule that reads either decides again on the real call, so a predicate returned false here is a no-input verdict rather than a standing denial — the human render says so under every table. decidable is false for a policy that cannot be evaluated at all outside a request (a predicate dereferencing input.post.id has nothing to dereference); rows is then empty and the render says that instead of printing a table of invented denials.

<subject> resolves in order against a permission, an action name, a query name, then an action's HTTP path — so whichever of the four a throwing surface had to hand, the fix: line it printed is runnable.

WantCommandMCP tool
one subject explainedx policy explain <subject> --jsonnone — no dev tool takes a subject, and policies.list returns the catalog without the per-declaration matrix
every permission + who grants and enforces itx policy list --jsonpolicies.list (no arguments)
unprotected surfacesx verify --json (the boundaries step)manifest.read (there is no budgets tool)

x policy list also names the permissions no action or query enforces. A grant nothing checks is a grant that does nothing, and it is invisible from any single declaration.

Tenancy

Authz and tenancy are separate checks, both mandatory.

X_TENANCY_UNSCOPED: query is not scoped to a tenant
  cause: select on "posts" has no predicate on tenant column "orgId"
  fix:   query through posts repo (ctx.posts.*) — it applies the tenant scope from ctx.actor
RuleDetail
Every query is tenant-scoped or it failsthe predicate comes from ctx.actor.orgId via the repo, never hand-written per call site
Cache keys include tenant + policy scopeframework-generated. A hand-built key missing the tenant is no build error, a convention — but on a primitive the exercise pass reaches it is X_TENANT_LEAK on the proof's after leg (below)
Live queries scope in the matchernot as a post-filter, so a cross-tenant row never enters the fanout path
Vector and FTS search filter in SQLsimilarity search cannot leak across tenants
A reference stays inside its tenantboth ends scoped is a composite key in the database, refused alike for absent and foreign (X_DB_FOREIGN_KEY_VIOLATION). references(() => x.id, { crossTenant: '<reason>' }) is the stated escape (Same-tenant keys)
An agent actor cannot exceed its humanthe actor is the signed-in user's session; no broad-rights service account

The static tenant check

A table with no tenant column that hangs off a tenant-owned one is a finding, As of 2026-11-05. The repository guard scopes only tables that carry the column, so a child row with a foreign key to a scoped parent and no tenant of its own is readable by id from another org. x verify's exercise step walks every registered entity's references and reports each such entity:

X_ENTITY_TENANT_UNDECLARED: a child of a tenant-owned table has no tenant
  cause: attachments has no tenant column and references attachments -> posts, which has one, and declares no shared reason: …
  fix:   orgId: uuid().tenant()   # in entity('attachments', { columns }), then x db gen 'attachments tenant'; or shared: '<why it belongs to no tenant>' in that entity()
MoveWhen
orgId: uuid().tenant(), then x db gen '<table> tenant'the rows belong to an org. A table that already holds rows backfills the column: x g backfill <name> --feature <slice>
entity('<name>', { shared: '<why>' })the rows belong to no org — a price list, a catalog. A blank reason is refused at entity(); a shared entity is not walked through (Entities)

Reported in the exercise step, not policy: the policy step asks whether a permission exists. Source: packages/cli/src/exercise-static.ts.

The dynamic tenant proof

Every action, query and job is called as org B with org A's seeded ids, and red if anything of A's comes back or any row of A's changes, As of 2026-11. It runs inside x exercise and the gate's exercise step, around each primitive's own call: B, then A, then B again. Full mechanics: The tenant proof.

ChecksHow
a read past the guard — a tenantless child by id, raw SQL by input id, crossTenant() under a policy that is not public, a service actor minted from the inputA's row canary, or an id of A's B was not handed, in B's output
an effect past the guard — a mail to A's address, an outbound body, an enqueued job's inputthe same marks, in the effect's payload
a cache key without the tenantthe second B leg, after A filled the entry
a write by input id — update, delete, soft deletea digest of each of A's seeded rows, before and after each B leg
X_TENANT_LEAK: another tenant's row reached a call's output or effect
  cause: action readAttachment, called by tenant B with A's own input before A ran, handed back A's seeded data at output.url: …
  fix:   x exercise readAttachment --json — then read the row behind output.url through the repository scoped to ctx.actor, never by the input id alone, nor by an unscoped statement
Not a failureWhy
publicthe policy admits an anonymous caller: what it returns, anyone may read. The read half is skipped; a crossed write is still red
public, as a declared sweepa job that reads every org on purpose says so with crossTenant(reason, fn), and its input names no org's rows. The proof sees the scope and quotes the reason; the read half is skipped, a crossed write is still red. The same scope in an action or a query, or in a job handed A's id, is still X_TENANT_LEAK
public, as a tenantless joba job declaring tenant: 'none' whose input names none of A's rows — a nightly cleanup across every org, input { occurrenceMs }. No org and no id of B's choosing reaches its run, so B's run is A's run: what it writes it writes for every enqueuer. The write half is skipped, the read half kept. Handed one of A's ids it is crossed as ever; an action or a query handed no id still crosses, since B's org runs it
unprovenA's own call was not reached (that record is already red as X_CONTRACT_EXERCISE_UNREACHED), or a query answered A none of A's rows — B's empty answer then proves nothing

B holds the role A held, minus tenancy:cross. Not checked: a route load and a task are not walked, so raw SQL there is outside the proof; neither is another user in the same org — ownership is a per-app row predicate, a denial test's job (below).

The database's own guard — row security (As of 2026-11-06)

A second enforcer for the same tenant: declaration, below the repository: Postgres row level security, so a raw sql, a report or a hand-written join cannot read another tenant's row either. Off by default — it costs a transaction per bare statement and a role split.

// app.config.ts — the whole app, every tenant table. Per-entity would leave the holes this closes.
import { defineConfig } from '@ultimat3/core';

export const config = defineConfig({ name: 'my-app', database: { rowSecurity: true } });

Then x db gen "row security", commit the migration and packages/db/schema/, x db migrate.

PieceRule
switchdatabase.rowSecurity: true, nothing per entity or per query. 'true' is X_CONFIG_INVALID
migrationx db gen writes the policy once per tenant: table (describeTenancy() names its column), recorded as rowSecurity: true on the snapshot; flag off drops each. x_* and shared: tables get none
driftthe flag against the newest snapshot, no database: X_DB_ROW_SECURITY_DRIFT, fix x db gen "row security". After x db migrate, the same code for a recorded policy the catalog lacks (or holds unforced), or one it holds unrecorded
schema dump10_policies/<table>.sql: enable, force, each policy with every clause. A test database loaded from the dump enforces the policy
roles, test servicesacquireWorkerDatabase({ rowSecurity: true, migrate }) migrates as x_migrator and hands the suite a URL serving as x_app
roles, PGliteits one session is a superuser; the migrate step (x db migrate, ROLE=migrate) holds x_migrator and takes over what the superuser owned in public; x dev and a served role switch to x_app at boot (serveAsAppRole) and grant it DML on every table already there
roles, productionthe operator's: DATABASE_URL of a serving role is a nosuperuser nobypassrls role owning nothing; ROLE=migrate's owns the tables. Grants on tables, the x_* ones included, are the operator's too
x doctorwith the flag on, connects as DATABASE_URL and names X_DB_ROW_SECURITY_BYPASSED; the drift half rides the drift probe
policyenable + force row level security, one ultimate_tenant policy: org_id = nullif(current_setting('x.tenant', true), '')::<type> or current_setting('x.cross', true) = 'on', as using AND with check. Unset fails closed: no rows
the tenantset per transaction through setLocal — select set_config($1, $2, true), parameter-bound and local, so a pooled connection hands nothing on (pooler transaction mode is safe). crossTenant() sets x.cross
bare statementswrapped in one transaction: BEGIN, set_config, the statement, COMMIT
who is subjecta role that is nosuperuser nobypassrls and owns no table. Anything else is X_DB_ROW_SECURITY_BYPASSED at boot. The migrator (bypassrls) owns the tables, so a backfill still sees every row
limitscatches forgetting, not hostile SQL already in the process: a raw set_config('x.cross', 'on', true) lifts it. A crossTenant() opened inside an open transaction does not reach it — its settings were fixed at BEGIN

Testing denials

Generated with the primitive, fails until filled in:

// contract test — generated as a scaffold with the action
test('publishPost denies a non-owner', async ({ seed, actorFor }) => {
  const { post, stranger } = await seed('two-orgs');
  await expect(publishPost.as(actorFor(stranger), { postId: post.id }))
    .rejects.toBeUltimateError('X_FORBIDDEN');
});
Test typeAsserts about authz
x test unitpolicy predicates in isolation — pure logic, no DB
x test contractone denial per policy branch, plus the MCP tool + OpenAPI shape
x test livea policy-failing row is never delivered, including after a reconnect delta
x test joba revoked actor fails the job instead of skipping the step

Fixtures are deterministic: seed('two-orgs') produces identical rows and identical UUIDs every run, so a denial test cannot pass by accident.

Why it is shaped this way

Two authz systems is how every Meteor-like framework died. Meteor had allow/deny rules for the client sync path and plain method bodies for the server path; the two drifted, and the drift was the security model. Anything that lets an agent expose data through a second door — a "public" MCP tool, an "internal" RPC, a sync rule table — is a rejected design, not a config option.

Designs this rejects, permanently:

RejectedWhy
A sync/subscription rule table separate from server authzthe two drift; the drift is the vulnerability
MCP-specific permissions, "trusted tool" mode, agent service accountsan agent must never exceed the human it acts for
An "internal" RPC surface that skips policya second door, and the one no one audits
GraphQLa second schema language and a second authz surface, per resolver
Row-level security as the only mechanismpolicy must also run for jobs and MCP calls, where no DB session carries the actor
Authorization inside handleunreachable from the live-query and MCP paths, so it silently protects one door of four

Source: docs/idea/02-primitives.md, docs/idea/09-ai-first.md. Related: Actions, Queries and live queries, MCP and AI, Error codes.

Edit this page on GitHubThis page as markdown