← the tour · concept
concept x
Capabilities & circuit breaking
Four seams answer one question — "may I do X, here, now?" — and share one shape: a cheap, subject-scoped, watchable boolean.
This page is about authorization — what a known user may do. Authentication (who they are — sessions, passwords) comes first, and is where the mechanism-vs-policy split this whole belt leans on was first drawn.
A real product needs four different answers to "may I?" Identity authorization (has this user been granted permission?), feature flags (is this capability toggled on?), circuit breaking (is this dependency currently healthy?), and billing entitlements (has this account paid for this?). They look like different problems. In orion they are the same primitive — because what they all produce is the same thing: a boolean that can change under a live view. When the answer changes, the loop already knows how to ship that change to every open tab. Nothing new to wire.
| Seam | Driven by | The check | Status |
|---|---|---|---|
authz | identity / grants | can(store, subject, action, resource) | built |
flags | admin toggle / % rollout | enabled(name, context) | built |
breaker | dependency health | available() | built |
| entitlements | billing (Stripe → capabilities) | entitled(account, capability) | planned |
The shared shape is what makes each seam Watchable. Add
any of them to a live view's watch list and the answer changing repaints every
gated region in every open tab — an upgrade and the feature appears, no refresh, no client
code, just the loop doing its ordinary job.
authz — identity and grants
belt/authz.ts is mechanism only. It handles the parts that are easy to get
wrong — the grant cache, cache invalidation keyed so separate databases can't share state,
and the reactivity. How grants load (from role tables, ownership rows, whatever
shape your schema has) is a policy function you provide. The belt contributes the correctness
scaffolding; the feature slice owns the policy.
Grants are (action, resource) pairs with wildcards:
// three levels of scope — exact, type-wildcard, admin
{ action: "delete", resource: "todos:42" } // exactly this todo
{ action: "edit", resource: "todos:*" } // any todo
{ action: "*", resource: "*" } // admin
Grants load once per user and cache at Map-hit cost. Live views call can() on
every repaint — per-event, per-viewer — without a database query. TTL is the backstop; the
real invalidation path is a command calling authz.invalidate(userId) the moment
it changes grants. The cache is keyed by Store first, then subject, so separate
databases (tests, db-per-tenant) can never share cached grants by construction.
import { createAuthz } from "../../belt/authz.ts"
import { toast } from "../patterns/toast.ts"
export const authz = createAuthz(
(store, userId) => grantsFor(store, userId), // your policy function
{ ttlMs: 30_000 },
)
// in a command — the real enforcement point
const user = currentUser(ctx)
if (!user || !authz.can(ctx.store, user.id, "delete", `todos:${id}`)) {
return toast(respond, "Not allowed", { tone: "error" })
}
// invalidate from any command that changes grants
authz.invalidate(userId) // one user's cache
authz.invalidate() // everyone
Enforcement happens at three layers, each doing a different job. The stream refuses and severs
unauthorized connections via live({ authorize, revoked }). The command is the real
enforcement — the law. The render is a courtesy: per-viewer HTML where a button that shouldn't
exist for a viewer simply doesn't exist in their DOM. Different viewers get different HTML from
the same pure view function, re-run per-viewer on every repaint.
Grant someone admin mid-session and their delete buttons appear in their open
page. Revoke their access mid-stream and authorize — re-checked before every
repaint, cheap thanks to the cache — patches a final revoked render and closes
the connection. The region is replaced, not frozen stale.
flags — the no-redeploy toggle
The config split in one sentence: a capability you decide at deploy time is a config field. A capability you want to flip without a redeploy is a runtime flag and lives here. Config is read once at boot and frozen; flags are read per render and change under a running process. Don't reach for flags when a config boolean would do — reach for them when "flip it now, for everyone, no deploy" is the requirement.
Flags support per-user and per-tenant targeting through an evaluation context —
a bag of attributes passed alongside the flag name. Two override lists per flag carry
attr:value tokens: onFor forces on early access for specific users
or tenants; offFor is a kill switch that beats everything. The percentage rollout
uses deterministic FNV-1a bucketing on ${name}:${key}, so the same key always
lands on the same side of a given flag across processes and restarts — no flicker as you ramp.
import { createFlags } from "../../belt/flags.ts"
import { live } from "../../belt/http.ts"
import { html } from "../../belt/html.ts"
export const flags = createFlags(store)
// check — a Map hit, never a query on the render path
flags.enabled("advanced-reports") // everyone or nobody
flags.enabled("advanced-reports", ctx.tenant) // per-tenant key
flags.enabled("advanced-reports", { key: ctx.tenant, plan: account.plan })
// flip from an admin command — publishes nothing; the command publishes
flags.set("advanced-reports", { enabled: true, percent: 20 })
// a live region watches it — every tab repaints the instant a flag flips
export const featureLive = live(
{ topics: ["flags"], watch: [flags] },
(ctx) => flags.enabled("advanced-reports", ctx.tenant)
? html`<section id="reports">…</section>`
: html`<section id="reports"><!-- not enabled --></section>`,
)
The rows live in _orion_flags, a belt-internal SQLite table — same database,
no second system. Flag values cache in memory and refresh on set() or
invalidate(), so enabled() is always a Map hit on the render path.
The exposure hook (onEvaluate) is the seam for experimentation platforms —
LaunchDarkly, Statsig, GrowthBook — if you outgrow the table. Their streaming updates call
invalidate() and gated regions repaint exactly the same way, because the loop
doesn't care where the flag value came from.
breaker — failure-driven capability
The circuit breaker is the failure-driven member of the family. It wraps a flaky external
dependency — a payment API, an email provider, a webhook target — and trips after repeated
failures, so an outage fails fast instead of piling up timeouts that drag the whole process
down. available() is the same cheap synchronous boolean the other seams produce,
just driven by dependency health instead of identity or an admin toggle.
The state machine is the standard circuit breaker, with one dangerous part done correctly: the half-open race. Five consecutive failures open the circuit. After the cooloff window, exactly one probe call goes through — concurrent calls during the probe fail fast so a recovering dependency isn't thundering-herded. A probe success closes the circuit; a probe failure reopens it and resets the cooloff clock.
calls go through → open
fail fast · cooloff → half-open
one probe → closed
or back to open
import { breaker } from "../../belt/breaker.ts"
import { live } from "../../belt/http.ts"
import { html } from "../../belt/html.ts"
import { toast } from "../patterns/toast.ts"
const stripe = breaker(stripe.charge, {
name: "stripe", // registers in the process-global admin panel
failureThreshold: 5,
cooloffMs: 30_000,
})
// in a command — fail fast, tell the requester, don't hammer a dead API
if (!stripe.available()) {
return toast(respond, "Payments briefly unavailable", { tone: "warn" })
}
await stripe(amount, customer)
// a live region watches it — degraded banner appears and clears on its own
live(
{ watch: [stripe] },
() => !stripe.available() ? degradedBanner("payments") : html``,
)
A named breaker registers in the process-global breakers registry — what the
admin Health panel reads. A tripped circuit is a process-wide fact (a Stripe outage is
process-wide, not per-tenant), so the Health panel can list every registered dependency and
its current state. breakers.subscribe() is itself Watchable: the Health panel
repaints the instant any named circuit opens or recovers.
What the breaker deliberately doesn't do: it doesn't define what "too slow" means (wrap
your function with a timeout yourself), it doesn't choose your degraded behavior (that's the
caller's catch), and it doesn't limit concurrency (that's a separate primitive).
A breaker decides availability. Nothing else.
entitlements — gate on the capability, not the plan
Billing entitlements complete the family. The pattern is the same — a watchable boolean per account and capability — but driven by what a customer has paid for, not who they are or whether a dependency is healthy.
The key discipline: gate on the capability, never on the plan name. A check
like if (plan === "pro") buries business logic in every call site. When you
re-tier a plan or rename it, you touch all of them. A check like
if (entitled(account, "advanced-reports")) touches exactly one mapping — the
table that says which capabilities a given plan unlocks. Re-tiering is a one-row update;
the feature code changes nothing.
// one mapping table: plan → capabilities it unlocks
// the billing webhook writes here; the feature reads here
entitled(account, "advanced-reports") // true or false, Watchable
entitled(account, "export-csv")
entitled(account, "api-access")
// never this — plan names are billing implementation, not feature gates
// if (account.plan === "pro") { … } ← buried in every call site
The billing recipe (Stripe checkout and webhooks) maps Stripe events to capability rows. A successful subscription writes the capabilities the plan unlocks; a cancellation removes them. Because entitlements are Watchable, a customer upgrading mid-session sees the gated region appear in their open tab before they've even left the checkout flow.
planned The entitlements seam is designed and stubbed; the billing recipe that drives it is the reference implementation.
Why one shape matters
These four seams look like different concerns at first. They answer different questions,
draw from different sources, and have different failure modes. But the loop doesn't see any
of that. It sees subscribe() — the single method that makes something Watchable.
Any Watchable can join a live view's watch list. When it notifies, the view
re-runs. The HTML that comes back reflects the current answer to every gated question — every
permission, every flag, every breaker state, every entitlement — in one consistent
re-render.
An upgrade appears. A permission revocation severs the stream. A circuit tripping paints the degraded banner. A flag flip repaints every open tab. None of these need client-side handling, no polling, no websocket event schema. They're all just state changes the loop already knows how to ship.
currentUser(ctx) —
and the authorization check is a plain function call in the handler body, not a wrapper
around the handler. No middleware stack to audit, no implicit ambient identity to leak
across requests. The guard is where you can read it.