Diagnostics
The compile-time authz / authn analyzer. Reads the lowered policy IR and flags empty or shadowed permissions, unreachable roles and views, capability-ceiling breaches, the not-over-resource null-trap, and pushdown cost. Zero-false-positive checks fail the compile with an explicit authz.analyzer.allow override surface; heuristic checks warn, with a QUICKBACK_AUTHZ_STRICT opt-in that promotes warnings too. Source-analysis codes check authored action, lib, and template files for raw database handles, Better Auth table writes, in-body role checks, undeclared env reads, and unescaped HTML.
Quickback compiles your authorization model into a single normalized policy IR — relations, permissions, arrows, scopes, grants, and per-resource firewalls — and then runs a set of static checks over it. The analyzer never changes what's emitted; it reports problems it can prove from the policy graph: a permission that can never be satisfied, a scoped role no grant reaches, a resource that hands a scope role more than its profile allows, a negation that can silently pass.
The console channel
Diagnostics render to the existing [quickback:authz] / [quickback:authn]
console channel — the same prefix the compiler has always used for security
warnings, so any CI grep you already key on keeps working. Each line carries
a stable, greppable code:
[quickback:authz] AUTHZ_EMPTY_PERMISSION: permission "event:locked" can never be satisfied — it requires organizerOf AND its negation simultaneously.
at resource=sessions authz.permissions["event:locked"]
hint: Remove the contradictory arm, or split into separate permissions.
override: authz: { analyzer: { allow: [{ check: 'AUTHZ_EMPTY_PERMISSION', target: 'event:locked', reason: '<why this is acceptable>' }] } }The first line stays greppable ([quickback:<channel>] CODE: message); the
source location, fix hint, and — for promoted errors — the exact override
recipe render on continuation lines.
Errors vs warnings
The checks split into two tiers:
- Promoted (compile errors). A trip is a definite misconfiguration —
never a legitimate config. These fail the build by default. Each one can be
explicitly accepted per finding via
authz.analyzer.allow. - Heuristic (warnings) and reports (info). Advisory findings where a
legitimate config could trip the detector. These print and the compile
continues. Set
QUICKBACK_AUTHZ_STRICT=trueto promote these warnings to errors as well (overridden findings stay warnings even under strict — an override is an explicit, reasoned acceptance).
The analyzer remains compile-time only — zero runtime blast radius. A defect in the analyzer itself is caught and downgraded to a single warning, so an analyzer bug can never take down an otherwise-valid compile. Only real findings fail builds.
Authz codes
These read the permission graph projected from the policy IR and run on every compile.
| Code | Tier | What it catches |
|---|---|---|
AUTHZ_EMPTY_PERMISSION | error | A permission that can never be satisfied — allOf(a, not(a)), a fact and its exact negation at the same AND-level. The detector is conservative (exact-leaf contradictions only), so a trip is structurally unsatisfiable. |
AUTHZ_CAPABILITY_CEILING | error | A resource's firewall grants a scope:<kind>:<role> role read access beyond its capability profile — either the role has no profile at all, or the profile doesn't list this resource. The M0.2 pillar guarantee. |
AUTHZ_SHADOWED_ARM | warn | An anyOf arm subsumed by a broader arm: a leaf X and an allOf(X, …) in the same union — the narrower arm can never widen the result. |
AUTHZ_UNREACHABLE_ROLE | warn | A scoped role declared under authz.scopes with no grant profile — it can authorize no read, action, or channel. Dead config, deny-safe. |
AUTHZ_UNREACHABLE_VIEW | warn | A grant's read names a resource:view whose resource has no firewall — no role can reach that view. (A reachability proxy — heuristic by design.) |
AUTHZ_NOT_OVER_RESOURCE | warn | A not whose operand resolves to a resource (SQL) site — NOT EXISTS over a nullable join can silently pass (the null-trap). The site resolution is conservative, so this stays advisory. |
AUTHZ_RECORD_PREDICATE_NOT_ON_LIST | warn | A constant read.access.record predicate (status: { equals: "published" }, id: { equals: "never" }). access.record runs against a loaded row on GET /:id only — GET /, POST /batch, and the MCP list tool stop at the role gate, so the rows still list. Omit read to hide a resource; use the firewall to filter. $ctx-relative predicates are the per-record shape and stay silent. |
AUTHZ_PUSHDOWN_COST | info | Per-permission report: how many SQL subqueries the permission lowers to, plus the max recursive-CTE depth. Advisory, never fails a build. |
AUTHZ_ROOM_ACCESS_INHERITED | warn | A resource-backed realtime room with no access.roles (connections gated on the table's CRUD read.access, so narrowing the room means narrowing CRUD reads) or no access.writeRoles (every admitted caller may write). Declare both on the binding. Printed while the config is checked, before the analyzer runs, so QUICKBACK_AUTHZ_STRICT does not promote it and there is nothing to override. |
A few codes are reserved in the diagnostic enum for the milestones that emit
them and don't fire on a shipped config today: AUTHZ_UNBOUNDED_RECURSION
(an unbounded recursive arrow with no claim path — currently a hard compile
error raised at config validation, before the analyzer runs),
AUTHZ_UNENFORCEABLE_FACT, and AUTHZ_CAVEAT_SITE_MISMATCH (M3 caveats).
The capability ceiling
The check that backs scope grant profiles: a
scoped role is deny-by-default and may touch only what its grants profile
lists. The analyzer walks every resource firewall, follows each
{ permission } arm into the permission graph to find the scope roles it
references, and fails the build if a resource grants a scope role access
the profile doesn't cover. This is what makes the grant profile the single
auditable manifest of what an external principal can see.
Authn codes
Five structural authentication checks — proving properties over the generated route + credential surface rather than the authz policy. All are promoted (compile errors):
| Code | Tier | What it catches |
|---|---|---|
AUTHN_ROUTE_COVERAGE | error | A route that reaches data with an unclassifiable auth gate and isn't declared PUBLIC — the signup / flag-gap bug class (data delivered to an unauthenticated ctx). Fires only on a gate the compiler cannot classify; zero-FP by construction. |
AUTHN_SURFACE_INVARIANT | error | A surface contract breach: an /mcp or agent route not gated bearer-only, or an INTERNAL-gated route mounted on an HTTP-reachable surface. |
AUTHN_SECRET_CONSISTENCY | error | A credential's mint site and verify site don't share key / issuer / audience — every minted token would fail verification (the ws-ticket "401 on every upgrade" trap). |
AUTHN_CREDENTIAL_DELIVERY | error | A fire-and-forget credential send (the OTP-email shape) not anchored to waitUntil / await — the Worker may drop the promise and never deliver (the OTP-500 bug class). |
AUTHN_CLAIM_PROVENANCE | error | An identity ctx field written from request input rather than a credential verifier — client-asserted identity, which is forbidden. Unprovable provenance also fails (fail closed). |
These check definitions are wired and fully unit-tested, but their inputs
— the per-route and per-credential metadata (RouteNode / MintSite /
VerifySite / ClaimWrite) — are not yet emitted by the route, middleware,
and credential generators. Until those generators emit that metadata the
analyzer runs the authn checks against empty route/credential graphs, so
they are effectively no-ops on a real compile. The severity contract is
locked in now: they fail builds the moment they can fire. (The emitted route
surface is separately guarded today by the IR's unbound-route invariant — a
hard error that predates this promotion and owns that surface; one subject,
one error.)
Schema and config codes
These read the lowered project — each table's resolved create/update field
sets, its firewall arms, authz.relationships, and each action's access —
and flag the shapes that silently skip a security mechanism Quickback
already has. They run on every compile.
| Code | Default | What it catches | Opt-out |
|---|---|---|---|
SCHEMA_UNDECLARED_FK | error | A *Id / *_id column that names a feature table in the same database (eventId → events or event), is client-writable (in the effective createable / updatable set) or sits in a firewall or authz.relationships arm, and declares no .references(). Without it no firewalled FK check runs, so a caller can point the row at another tenant's parent. Never fires for auth-owned ids (userId, ownerId, organizationId, teamId, memberId, audit ids) or when no matching feature table exists. | references: { eventId: { references: false, reason: '…' } } in defineTable |
SCHEMA_FK_MATCH_REQUIRED | warn | A declared FK where the child and its target both carry an org column, with no match on the org column. A match on another parent column counts when that column is itself org-matched. | references: { eventId: { match: false, reason: '…' } } in defineTable |
SCHEMA_JSON_UNGUARDED | warn | A JSON column (q.json, json / jsonb) clients can write with no keys policy — anyone who may update the row may set every key of the document. | // quickback-allow SCHEMA_JSON_UNGUARDED: <reason> in the table file |
ACTION_PUBLIC_WITHOUT_RATELIMIT | warn | An action whose access admits PUBLIC with no explicit rateLimit (it shares the project-default write bucket, keyed by client IP), or with rateLimit: false. | // quickback-allow ACTION_PUBLIC_WITHOUT_RATELIMIT: <reason> in the action file |
Why two defaults:
SCHEMA_UNDECLARED_FKis an error because the fix is always available (.references()works on q-DSL and raw Drizzle columns alike) and the gap is a cross-tenant write.SCHEMA_FK_MATCH_REQUIREDis a warning becausematchcan only be written on a q-DSL column — on a raw Drizzle table the only way to clear an error would be an opt-out that misstates intent — and generated CRUD writes already run the firewalled FK check. The remaining gap is action-side writes, which thematchtrigger backstops. UnderQUICKBACK_AUTHZ_STRICT=trueit, and the other two warnings, fail the build.
Every opt-out needs a non-empty reason. The compiler rejects an opt-out
without one, a references entry it does not recognise, or one naming a
column the table does not have.
export default defineTable(guests, {
references: {
cventEventId: { references: false, reason: 'Cvent-side id; no row in this database' },
partnerEventId: { match: false, reason: 'guests may be invited to a partner org event' },
},
// ...
});Migration window
The error honours compiler.securityContracts.failMode. While you add the
missing declarations, set it to 'warn' and every finding prints, marked as
downgraded, without failing the build:
export default defineConfig({
compiler: { securityContracts: { failMode: 'warn' } },
// ...
});failMode: 'warn' also downgrades the
security contract
checks, so treat it as temporary.
The override surface
Every promoted finding can be explicitly accepted — per finding, with a required reason:
authz: {
analyzer: {
allow: [
{
check: 'AUTHZ_CAPABILITY_CEILING',
target: 'sessions/scope:event:organizer',
reason: 'temporary exception while the organizer grant profile is implemented',
},
],
},
}An entry downgrades the exact (check, target) finding back to a warning
that cites the reason. The rendered error prints the exact recipe — copy the
check and target from it. Target formats:
| Check | target format |
|---|---|
AUTHZ_EMPTY_PERMISSION | the permission name — event:locked |
AUTHZ_CAPABILITY_CEILING | <resource>/scope:<kind>:<role> — sessions/scope:event:organizer |
AUTHN_ROUTE_COVERAGE, AUTHN_SURFACE_INVARIANT | <METHOD> <path> — GET /mcp |
AUTHN_SECRET_CONSISTENCY, AUTHN_CREDENTIAL_DELIVERY | the credential kind — ws-ticket |
AUTHN_CLAIM_PROVENANCE | the ctx field — ctx.userId |
Guard rails, all compile errors:
checkmust be a promoted id — unknown ids and keep-warn ids (there is nothing to override) are rejected at config validation.reasonis required and non-empty: overrides are self-documenting.- An
allowentry that matches no current finding is a dead override and fails the compile — fix or remove the stale entry. Override lists can never silently outlive the problems they suppressed.
Reading the pushdown-cost report
AUTHZ_PUSHDOWN_COST is an info line per permission that pushes down to
SQL — it never fails a build, but it surfaces the query cost of an
authorization rule before you ship it:
[quickback:authz] AUTHZ_PUSHDOWN_COST: permission "section:inTree" lowers to 1 SQL subquery, recursive CTE depth 6 (sites: resource).A high subquery count or a deep recursive CTE (from a recursive arrow) is a signal to reconsider the rule — not an error, but worth a look.
Source codes
The checks above read the policy graph. These read your authored source:
action files, the feature lib/ files, and the project's lib/, hooks/,
plugins/, email-templates/, and middlewares/ files. They catch code that
goes around a pillar instead of through it. The analysis is syntactic (no
type checker), it runs on every compile, and it renders on the
[quickback:source] channel:
[quickback:source] ACTION_RAW_DB_WITHOUT_UNSAFE: action "listMyEvents" reaches a raw database handle (drizzle(env.DB)) without declaring `unsafe` — the firewall does not apply to it and no audit row is written.
at site=action listMyEvents features/user-profiles/actions/listMyEvents.ts:61:20
hint: Use the scoped `db`, or declare `unsafe: { reason: "…" }` on the action and use `unsafeDb` (audited).
opt-out: // quickback-allow ACTION_RAW_DB_WITHOUT_UNSAFE: <reason>| Code | Tier | Scope | What it catches |
|---|---|---|---|
ACTION_RAW_DB_WITHOUT_UNSAFE | error | action files, and every project file they import | A raw database handle reached by an action with no unsafe: declaration. |
ACTION_AUTH_DB_WRITE | error (warn in hooks/) | every authored file except plugins/, plus queue, schedule, and email handlers | An insert, update, or delete against a Better Auth table that does not go through Better Auth. |
ACTION_ROLE_CHECK_IN_BODY | warn | the execute function of action files | A role check written in the handler body instead of in the declared access. |
ENV_UNDECLARED | error | every authored file (Cloudflare runtime) | An env.X read where nothing declares X. |
TEMPLATE_UNESCAPED | error | every authored file | A ${} value in an HTML template literal that is not escaped. |
SOURCE_ALLOW_INVALID | error | — | A quickback-allow directive with no reason, an unknown code, or nothing left to suppress. |
"Every project file they import" is transitive. It follows relative imports
and the ~/ alias through the feature lib/ and the project lib/,
hooks/, and plugins/ files. A finding in a shared file names the action
that reached it. Packages from node_modules are not followed.
ACTION_RAW_DB_WITHOUT_UNSAFE
It fires on these, in an action that has no unsafe::
c.get('db')- a database binding read off the environment:
env.DB,c.env.AUTH_DB,env.HYPERDRIVE, and so on. That includesenv.DB.prepare(…). - a drizzle driver import (
drizzle-orm/d1,drizzle-orm/postgres-js, …) - a call to
drizzle(…),createDb(), orcreateAuthDb() auth.$context(Better Auth's adapter)- the action's own
unsafeDb. This only fires in the action file. An imported helper may receiveunsafeDbfrom anunsafecaller.
Opt-out: declare unsafe: { reason: "…" } on the action (or unsafe: true,
or firewall: { exception: true } on a deliberately unscoped action), so the
call is audited. See Unsafe mode. A
quickback-allow directive also works when a raw handle is in a shared file
the action imports but does not call.
ACTION_AUTH_DB_WRITE
Only Better Auth's API writes Better Auth's tables. A direct write skips
Better Auth's hooks, session revocation, and qbrev stamping. It is a
compile error in actions and every file they import, the feature and project
lib/, middlewares/, email-templates/, and the queue, schedule, and email
handlers under services/ — and in every file those import.
In hooks/ it is a warning, whatever failMode says: an account hook
sometimes has no Better Auth endpoint it can call (see
Account hooks).
plugins/ is not swept — a custom Better Auth plugin is part of Better Auth's
API — unless an action imports it. Reads are never flagged.
It fires on .insert(T), .update(T), and .delete(T) when T is:
- a table from the generated auth schema (
…/auth/schema,…/auth-schema): imported by name, through a namespace (authSchema.user,const { member } = authSchema), or destructured from a dynamicawait import("…/auth/schema"), or - anything, when the receiver IS the auth database handle:
authDb,c.get("authDb"),env.AUTH_DB, your configuredauthBinding, a call wrapping one (drizzle(env.AUTH_DB)), or a variable bound to one. A variable that only uses the handle (new Set((await authDb.select()…))) is not one.
It also fires on raw SQL that writes a Better Auth table, in a SQL context — a
sql tagged template, an argument of prepare / run / execute / query /
exec / batch (and the other driver calls), or a const named like SQL
(deleteSql, query, stmt):
INSERT INTO/REPLACE INTO <t>UPDATE [ONLY] <t> [AS alias] SETDELETE FROM [ONLY] <t> [alias] WHERE | USING | RETURNINGTRUNCATE [TABLE] <t>- schema-qualified or quoted (
"auth"."session") - a
${}that is an auth-schema table (sql`DELETE FROM ${user} WHERE …`) - in a file that opens the auth database, a write whose table is any
${}(`DELETE FROM ${where}`) — it cannot be proven not to be an auth table
Prose that reads like SQL (an email subject "Delete from organization") is not in a SQL context and is not flagged.
The check is a syntactic, best-effort lint: SQL assembled across variables or a handle passed through several functions can slip past it. On Postgres, the RLS backstop is what actually refuses a caller-lane write to Better Auth's tables.
The table list is your compile's: every table Better Auth creates for the
plugins you enable, plus whatever the generated src/auth/schema.ts declares,
in SQL (two_factor), export (twoFactor), and plural spellings. A project
table with the same name is yours and is excluded. A project with no Better
Auth provider has nothing to check.
The message names the call to make instead, and the hint says where that call lives for the kind of file — an action, a hook, a handler, or a lib file:
| Write | Call instead |
|---|---|
insert user | auth.api.createUser (admin plugin) or auth.api.signUpEmail |
update user | auth.api.updateUser; to ban, ctx.auth.ban(userId, reason) |
delete user | ctx.auth.deleteAccount(userId), auth.api.deleteUser, or auth.api.removeUser (admin plugin) |
insert session | a sign-in endpoint: signInEmail, signInEmailOTP, signInMagicLink, passkey sign-in |
delete session | auth.api.revokeSession / revokeSessions, or revokeUserSessions |
organization, member, invitation | createOrganization / updateOrganization / deleteOrganization, addMember / ctx.auth.setRole(userId, role) / removeMember, createInvitation / cancelInvitation |
Plain unsafe does not silence it. unsafe lets an action read across
tenants; it is not a licence to write Better Auth's tables.
Opt-out, in an action: when Better Auth has no endpoint for the write,
declare unsafe: { reason, crossTenant: false, authWrites: true }.
authWrites turns unsafe mode on: the call is audited with your reason, and
unsafeDb is in scope. Set crossTenant: false — unsafe mode defaults to
crossTenant: true, which makes the action sysadmin-only. unsafeDb is the
features database on D1, so reach the auth tables through c.get("authDb"):
// features/account/actions/anonymiseAccount.ts
import { z } from "zod";
import { eq } from "drizzle-orm";
import { defineAction } from "../.quickback/define-action";
import { user } from "../../../auth/schema";
export default defineAction({
description: "Anonymise a deleted account",
path: "/anonymise",
input: z.object({}),
access: { roles: ["AUTHENTICATED"] },
unsafe: {
reason: "GDPR anonymisation keeps the row; Better Auth only deletes",
crossTenant: false,
authWrites: true,
},
async execute({ c, ctx }) {
await c.get("authDb").update(user).set({ name: "Deleted user" }).where(eq(user.id, ctx.userId!));
return { ok: true };
},
});authWrites: true with no reason, or with enabled: false, fails the
compile. It covers the action file and the files it imports. A shared file
stays an error while any other importer — another action, a hook, a handler —
reaches it without the same opt-in.
Opt-out, anywhere else (hooks, handlers, lib):
// quickback-allow ACTION_AUTH_DB_WRITE: <reason> on the line or the line
above, or quickback-allow-file for the whole file.
ACTION_ROLE_CHECK_IN_BODY
Inside execute only, it fires on:
- a membership test on a roles array:
ctx.roles.includes(…),(ctx.roles ?? []).some(…), andevery,indexOf,find,findIndex,hason anything namedroles,userRoles, ormemberRoles. - an equality comparison (
===,!==,==,!=) between a role literal and the caller's role. A caller's role is an expression rooted atctxwhose last name containsrole(ctx.userRole,ctx.member?.role), or a bare local with such a name (role).input.roleandrow.roleare data and are not flagged. ['admin', 'owner'].includes(<caller role>)andswitch (<caller role>)with role-literal cases.
A role literal is owner, admin, member, any name in auth.roleHierarchy
or authz.roles, or any role named in any action's access.
It is a warning: role checks in the body still run. The problem is that the
declared access and the route manifest stop describing who can call the
action. Move the gate into access (an anyOf arm per caller class), or name
the role set once in authz.roles.
Opt-out: // quickback-allow ACTION_ROLE_CHECK_IN_BODY: <reason>. Use it
for response shaping that is not a gate.
ENV_UNDECLARED
It fires on env.X, (env as any).X, env["X"], and const { X } = env,
where the environment is env, c.env, ctx.env, or this.env.
process.env and import.meta.env are not checked. X is declared if it is:
- a member of the generated
CloudflareBindingsinterface (src/env.d.ts): the compiler's own bindings plus everything underbindings, includingbindings.varsandbindings.secrets, or - provided by the generated
wrangler.toml:[vars]and per-environment[env.<target>.vars], everybinding = "…", and the[*.secrets] requiredlists.
This is the same set wrangler deploys with, so an ENV_UNDECLARED read is
undefined at runtime unless something outside the config sets it.
Fix: declare it: bindings: { secrets: [{ name: "X" }] } or
bindings: { vars: { X: "…" } }. Opt-out, for a value set outside the
config (for example wrangler deploy --var):
// quickback-allow ENV_UNDECLARED: <reason>. On a non-Cloudflare runtime
the check does not run.
TEMPLATE_UNESCAPED
A template literal counts as HTML when any of these is true:
- its literal text contains a tag:
<p,</div,<!DOCTYPE. - it is passed directly to a function whose name contains
html(c.html(…),sendHtml(…)), or bound to a variable, property, or assignment target whose name ends inhtml(html,bodyHtml,innerHTML; nothtmlR2Key). - the file is under an
email-templates/ortemplates/directory, and the template is not bound to a name containingsubject,text, orplain.
Each ${} value in an HTML template must be one of these:
- a call to an escaping function:
esc(…), or any name containingescapeorsanitize(escapeHtml,escapeAttr,sanitizeUrl) - a
.replace/.replaceAllchain whose patterns together cover&and<(s.replace(/[&<>"']/g, …)) - a template tagged with a tag whose name contains
html,escape, orsanitize(html`…`). An escaping tag also exempts the template itself. - a literal; arithmetic, comparison, or a unary expression;
x.length;Number(…),parseInt(…),parseFloat(…),Math.*(…),.toFixed(…);encodeURIComponent(…),encodeURI(…) - a nested template, a
cond ? a : b,a && b,a || b,a ?? b, ora + bwhose parts are all allowed xs.map(cb).join(…), or[…].join(…), where every valuecbreturns (or every element) is allowed.cbcan be an escaping function (.map(escapeHtml)).- a same-file binding whose every value is allowed: a
const, or alet/varwhen its initializer and every=/+=to it in the declaring block are allowed - a call to a same-file function whose every
returnis allowed. Recursive renderers are fine.
Anything else is unescaped: function parameters, values imported from another
module, and property reads such as input.name. One finding is reported per
template and lists the offending values. A helper whose own template is
reported is not reported again where it is used.
Fix: escape the value at the interpolation, or build the markup with an
escaping html tag. Opt-out, for a value that is already markup (a
fragment from another module's renderer, a server-built constant):
// quickback-allow TEMPLATE_UNESCAPED: <reason> on the line where the
template starts, or above it.
Opting out: quickback-allow
Every source code has the same opt-out: a comment that names the code and gives a reason.
// quickback-allow TEMPLATE_UNESCAPED: badgeSvg is rendered server-side from a fixed icon set
const badge = `<div class="badge">${input.badgeSvg}</div>`;
const showBilling = ctx.roles?.includes("owner"); // quickback-allow ACTION_ROLE_CHECK_IN_BODY: response shaping, not a gate// quickback-allow <CODE>: <reason>covers findings of<CODE>on the same line or the line below it.// quickback-allow-file <CODE>: <reason>, anywhere in a file, covers every<CODE>finding in that file./* … */comments work too.
An allowed finding does not print; the directive in the source is the record.
SOURCE_ALLOW_INVALID (an error) fires when a directive:
- has no reason,
- names an unknown code, or
- suppresses nothing, because the finding was fixed or the file is outside the code's scope. Remove the directive.
Migration window: failMode: "warn"
Source-code errors fail the compile by default. To adopt them on an existing
project, set
compiler.securityContracts.failMode to
"warn". Every source error, including SOURCE_ALLOW_INVALID, then prints as
a warning marked downgraded by compiler.securityContracts.failMode = "warn",
and the compile continues. The same switch governs the generated-output
security contracts. ACTION_ROLE_CHECK_IN_BODY is a warning in both modes.
See also
- Permissions — the
anyOf/allOf/notDSL the empty / shadowed / not-over-resource checks analyze. - Arrows — recursive-CTE depth feeds the pushdown-cost report; the unbounded-recursion guard is a hard error.
- Scopes & capability grants — the grant profiles the capability-ceiling check enforces.
- Firewall — the
{ permission }arms the ceiling check follows into the permission graph.
Feature Areas - Hierarchical Authz Grouping
Group features under an area folder with _area.ts. The area declares a route prefix, admission lanes, and an authz vocabulary that only its own subtree may reference.
Encryption
Field-level encryption in two tiers — .encrypted() (envelope, per-org keys, server-readable) and .sealed() (end-to-end, the server structurally cannot read it). A database breach, or a full Worker compromise, yields only ciphertext.