← 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.

SeamDriven byThe checkStatus
authzidentity / grantscan(store, subject, action, resource)built
flagsadmin toggle / % rolloutenabled(name, context)built
breakerdependency healthavailable()built
entitlementsbilling (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.

closed
calls go through
open
fail fast · cooloff
half-open
one probe
closed
or back to open
↑  consecutive-failure threshold opens the circuit; probe success closes it  ↑
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.

authz is not middleware A handler that needs a user asks, visibly, at the top — 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.

orion ✦ a belt of stars · built on datastar