# Licensing, updates & renewals (/docs/licensing)
## Your license is perpetual [#your-license-is-perpetual]
A one-time purchase is a **perpetual license**: the software keeps working forever, verified
offline with an Ed25519 signature, no phone-home, no server we can take down or you can lose
access to. The license is valid for the major version you purchased.
## The updates window [#the-updates-window]
Every purchase includes a **12-month updates window** starting at purchase. During the window,
pull any version published to the registry within that window, bug fixes, new modules, minor
releases, all of it.
**Expiry is never punitive.** When the window closes, nothing is revoked: every version you
already pulled keeps installing forever, and so does anything that was published inside your
window, even if you pull it after the window closes. An expired window just stops new releases
from counting, it does not touch what you already have.
## Renewing updates [#renewing-updates]
Renewing buys another 12-month updates window. Renewal price is a flat **40% of the
then-current list price**, rounded to X9 pricing.
**Bundles:**
| Bundle | Renewal (per year) |
| ------------- | ------------------ |
| Compliance | $659 |
| AI-Production | $289 |
| Everything | $899 |
| Provenance | $159 |
| Local-first | $249 |
| Agentic-Dev | $129 |
**À la carte modules**, by list price:
| List price | Renewal (per year) |
| ---------- | ------------------ |
| $299 | $119 |
| $279 | $109 |
| $249 | $99 |
| $199 | $79 |
| $149 | $59 |
| $129 | $49 |
| $99 | $39 |
| $49 | $19 |
## Windows are per-entitlement [#windows-are-per-entitlement]
An updates window belongs to the entitlement it was purchased with, not to your account as a
whole, a bundle purchase and an à-la-carte module purchase each carry their own window. If you
own the same module both à la carte and through a bundle, the more favorable of the two windows
applies.
## Subscriptions work differently [#subscriptions-work-differently]
If you're on a subscription rather than a one-time purchase, updates are included the whole time
the subscription is active, there's no window to track and nothing to renew separately.
## Related [#related]
Refunds are covered separately: see [refund policy](/docs/refunds). The full legal terms of the
license are at [/legal/eula](/legal/eula), if this page and the EULA ever disagree, the EULA
controls.
# Refund policy (/docs/refunds)
## 14-day money-back guarantee [#14-day-money-back-guarantee]
Every Caisson purchase comes with a 14-day money-back guarantee. Request a refund within 14
days of your purchase, for any reason, and you receive a full refund. The guarantee is
unconditional: it applies whether or not you have downloaded, installed, or used the software,
and to every buyer regardless of location or of whether you buy as a consumer or a business.
## How refunds are processed [#how-refunds-are-processed]
Paddle is the Merchant of Record and executes every refund. An approved refund is returned to
your original payment method, where possible, within 14 days of approval.
## How to request a refund [#how-to-request-a-refund]
Contact us at [support@caisson.sh](mailto:support@caisson.sh) with your order number, or contact
Paddle directly through [paddle.net](https://paddle.net). If a single order covered more than
one bundle or module, tell us which item you are refunding, individual line items can be
refunded on their own.
## What happens to your license [#what-happens-to-your-license]
An approved refund revokes the license entitlement granted by the refunded purchase and removes
any unused credits it granted. Access already exercised and credits already spent are not
affected.
## Canonical policy [#canonical-policy]
The canonical policy is the legal page at [/legal/terms](/legal/terms), this page is a
support-retrieval summary; if the two ever disagree, the legal page controls.
# Caisson documentation (/docs)
Caisson is a composable monorepo library: an audited base substrate plus six persona bundles
(Compliance, AI Production, Local-first AI, Agentic-Dev, Provenance, and Everything), a
`create-caisson` generator, with dedicated support. Bundles are compositions of the same packages,
never forks.
These docs are the manual: how each package works, how to compose it, and the contract it upholds.
## Start here [#start-here]
* **[Getting started](/docs/getting-started)**: install the base, wire a tenant, and run the
standards gate.
* **Base substrate**: `auth`, `tenancy-rls`, `billing`, `credits`, `kernel`, and the rest of the
table-stakes core, framed under the differentiators.
* **Compliance**: `compliance`: the config-as-code module registry and the signed evidence-pack
generator.
* **Provenance**: `signing-primitive`, `audit-worm`, `field-crypto`: the fail-closed data layer
and per-tenant evidence signing.
## What “fail-closed by construction” means [#what-fail-closed-by-construction-means]
The guarantees are wired and tested before your first customer, not backfilled after your first
audit:
* **Tenancy**: Postgres row-level security with FORCE. A query with no tenant context returns
nothing.
* **Evidence**: S3 Object-Lock WORM. Evidence cannot be altered or deleted before retention
expires.
* **Audit**: an append-only SHA-256 chain. Tampering breaks the link, and the break is provable.
Every page is available as raw markdown for your AI agent, see
[`/llms.txt`](/llms.txt) and [`/llms-full.txt`](/llms-full.txt).
# Getting started (/docs/getting-started)
## Requirements [#requirements]
* **Bun** (the runtime and package manager, never npm or yarn).
* **Postgres** (Neon over HTTP is the reference host; the data layer is swappable).
* A Postgres database you can run migrations against.
## Get your license key [#get-your-license-key]
A license is issued automatically the first time you complete a purchase, find it at
[`/dashboard/license`](/dashboard/license): one offline-verifiable Ed25519 token per major
version, with a copy-token button. Verify it anytime, offline, with `verifyLicense()` from
`@caisson/license-verify`: no network call required.
## Install [#install]
```bash
bunx @caisson-sh/cli@latest
```
With no flags, `create-caisson` runs **interactively** in a terminal (TTY): it prompts for a
licensed build vs. the free Apache-2.0 sample vs. the full-catalog demo, then your project name
and module selection. A scripted, non-interactive run needs an explicit `--name` plus either
`--edition ` (which auto-selects the bundle's current modules) or at least one
`--module ` (repeatable; also overrides or extends an edition's auto-selection).
Run `bunx @caisson-sh/cli@latest --help` for the full flag reference.
**Free, no license key:** `--sample` and `--demo` need no module selection and no
`CAISSON_LICENSE_TOKEN`. `--sample` installs only public-npm Apache-2.0 packages; `--demo`
composes the full catalog with every commercial module replaced by a local stub, and its
generated `.npmrc` still maps `@caisson:registry` to `registry.caisson.sh`: tokenless, since that
registry serves the open module set to an unauthenticated request.
```bash
bunx @caisson-sh/cli@latest --demo --name my-app # full catalog, commercial modules stubbed
cd my-app
bun install
```
**Licensed install:** every other generated project ships a `.npmrc` pointing `@caisson:registry`
at `registry.caisson.sh` with the auth token read from `CAISSON_LICENSE_TOKEN`: export your
dashboard license key under that name before `bun install`.
```bash
export CAISSON_LICENSE_TOKEN=
bun install
```
## API reference [#api-reference]
### Pin an exact install [#pin-an-exact-install]
A `create-caisson` run carries two independent pins: which release of the generator you run, and
which exact version of each module it resolves. Neither accepts a range, every version string is
matched byte-for-byte against the registry.
**The generator release.** Each published `@caisson-sh/cli` release bundles a snapshot of the
registry index at build time, so the id/version pairs `--module` can resolve against are frozen to
whatever the registry looked like when that CLI version shipped.
```bash
bunx @caisson-sh/cli@0.4.0 --name my-app --module @caisson/kernel@1.4.0
```
`@latest` always resolves against the newest snapshot; pin an explicit npm version (`@0.4.0`) to
freeze the catalog a script runs against. `CAISSON_REGISTRY_INDEX=` points the generator at a
different index file entirely, a CI/local-dev override, not part of a normal install.
**`--module `.** Repeat the flag once per package:
```bash
bunx @caisson-sh/cli@latest --name my-app \
--module @caisson/kernel@1.4.0 \
--module @caisson/auth@2.1.0
```
* Each value splits on the **last** `@`, so a scoped id (`@caisson/kernel`) keeps its leading `@`;
a value with no `@` after position 0 fails immediately (`--module expects `).
* `version` is not a semver range, it must equal one of that module's published versions exactly.
`^1.4.0`, `~1.4`, `1.x`, and `latest` are all rejected.
* The same module id twice in one selection is rejected before generation runs (an ambiguous
"which version wins" case, `package.json` and the generated README would otherwise disagree).
Both checks run **before** any path is built or any file touches disk, so a bad id or version never
reaches the filesystem:
```
unknown module id (not in registry allowlist): "@caisson/typo"
unknown version for @caisson/kernel: "9.9.9"
```
### The validation call chain [#the-validation-call-chain]
`@caisson/cli`'s package export (`.` in `package.json`, resolving to `src/index.ts` in a Bun
workspace or `dist/index.js` published) surfaces the same pipeline the `create-caisson` binary
runs, for a caller that wants to validate or generate without shelling out, like the MCP server's
`generate` tool.
```ts
import {
type Selection,
type RawSelection,
type GeneratedFileSet,
DEPLOY_TARGETS,
parseArgs,
validateSelection,
generate,
runCli,
} from "@caisson/cli";
```
| Export | Signature | Behavior |
| ------------------- | --------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `parseArgs` | `(argv: readonly string[]) => unknown` | Turns argv into a `RawSelection`-shaped object; throws on an unknown flag or a malformed `--module` value. Never touches the registry. |
| `validateSelection` | `(index: RegistryIndex, raw: unknown) => Selection` | Zod `.strict()`-parses `raw`, then checks every module id and version against `index`. Throws on the first unknown id or version, before generation starts. |
| `generate` | `(index: RegistryIndex, raw: unknown, engine?: GeneratorEngine) => { selection: Selection; files: GeneratedFileSet }` | Runs `validateSelection`, then hands the result to `engine` (`templatesEngine` by default). Returns the file set in memory, no disk write. |
| `runCli` | `(argv: readonly string[], deps: { index: RegistryIndex }) => { selection: Selection; files: GeneratedFileSet }` | `parseArgs` + `generate` in one call, the shape a programmatic caller uses. |
`RegistryIndex` is loaded from `@caisson/registry-schema`'s `loadRegistryIndexFromFile`, reading
whichever path the pinning rules above resolve to. `Selection` (also exported as
`SelectionSchema`) is the validated shape `validateSelection`, `generate`, and `runCli` all return:
```ts
type Selection = {
projectName: string; // /^[a-z0-9][a-z0-9-]*$/, 1-64 chars — becomes the output directory
edition?: BundleId; // legacy edition ids normalize to a bundle id at this boundary
modules: { id: string; version: string }[]; // at least 1, no duplicate ids
deployTarget?: "railway" | "fly" | "vercel"; // DEPLOY_TARGETS
framework?: "next";
};
```
Fail-closed in the generation pipeline: `generate` and `runCli` never produce a `Selection` with an
unresolved id or version, because both call `validateSelection` first and it throws rather than
returning a partial result. (Parsing raw input directly via the exported `SelectionSchema` skips
that registry check, it only enforces shape.)
## Point your AI agent at Caisson [#point-your-ai-agent-at-caisson]
Two ways a coding agent (Claude Code, Cursor, or anything that can run a terminal command) can
drive Caisson, pick the one that matches how much wiring you want to do.
### Shell out to the CLI (zero setup) [#shell-out-to-the-cli-zero-setup]
`create-caisson` is a plain CLI, an agent that can run a terminal command invokes it exactly like
you would by hand:
```bash
bunx @caisson-sh/cli@latest --name my-app --edition compliance --module
```
Repeat `--module` per module. `--help` prints the flag reference, not the live catalog, an agent
(or you) gets the current registry ids and exact versions from the MCP server's `list_modules`/
`describe_module` tools (below), or from your dashboard. No extra wiring beyond the
`CAISSON_LICENSE_TOKEN` you already export for `bun install`.
### Wire the MCP server (tool-native) [#wire-the-mcp-server-tool-native]
`@caisson/mcp-server` is a published Apache-2.0 package, it is not a scaffold default, so add it
to your generated project first:
```bash
bun add @caisson/mcp-server
```
It is an auth-gated, entitlement-scoped MCP server exposing `list_modules`, `describe_module`, and
`generate` as tools, so an agent calls them natively instead of shelling out. Host it locally over
stdio with `runStdioServer`: one process per buyer session, the same pattern Claude Desktop /
Claude Code use for any local stdio MCP server:
```ts
import { runStdioServer } from "@caisson/mcp-server";
// Illustrative: mcpOptions (registry index + issued tokens + the onGenerate host hook) is your
// own wiring — see the McpServerOptions type exported by @caisson/mcp-server for the exact shape.
await runStdioServer({
mcp: mcpOptions,
bearer: process.env.CAISSON_MCP_TOKEN!,
});
```
Then point your agent's `.mcp.json` at that process:
```jsonc
{
"mcpServers": {
"caisson": {
"command": "bun",
"args": ["run", "./mcp-server.ts"],
"env": { "CAISSON_MCP_TOKEN": "${CAISSON_MCP_TOKEN}" },
},
},
}
```
The full tool catalog, entitlement scoping, and the network-reachable HTTP transport
(`runHttpServer`) for a hosted deployment are in [MCP server](/docs/base/mcp-server).
## Wire a tenant [#wire-a-tenant]
Every data path goes through `withTenant`: the sole entry point that sets the RLS tenant context.
There is no way to query tenant data without it; that is the point.
```ts
import { withTenant } from "@caisson/tenancy-rls";
await withTenant(tenantId, async (db) => {
// Inside this scope, RLS is enforced. Outside it, queries fail closed.
return db.query.invoices.findMany();
});
```
## Run the gate [#run-the-gate]
One standards gate builds, lints, and tests every package. A package ships only through it.
```bash
bun run check
```
## Next steps [#next-steps]
* Read the **base substrate** docs for `auth`, `tenancy-rls`, `billing`, and `credits`.
* Read the **Compliance** docs to turn on WORM evidence and the audit chain.
* Bring your own framework, the `@caisson/*` packages never import one.
# alerting (/docs/compliance/alerting)
`@caisson/alerting` is Caisson's SOC 2 CC7.2 alert-delivery control: a deterministic
five-stage pipeline between an event and a delivered alert. Every dependency, including
the clock, is injected, dedup, rate-cap, quiet hours, delivery, and audit are pure
functions of their inputs.
## What it does [#what-it-does]
* **Dedup on an open incident's key**: `dedup()` suppresses a repeat event while an
incident sharing its `dedupeKey` is still open, so a flapping check doesn't re-fire an
alert that already has a live incident.
* **Rate-cap to a digest, never a drop**: `rateCap()` checks the recipient's recent send
count against a per-event-type `RateCapPolicy`; once `maxPerWindow` is reached the
outcome flips to `"digest"` instead of `"deliver"`: noisy alert types back off, they
don't vanish.
* **IANA-timezone quiet hours**: `quietHours()` resolves the recipient's local hour via
the stdlib `Intl.DateTimeFormat` (no timezone-database dependency) and holds delivery
inside the configured window, except a `"critical"` severity event always delivers.
* **Four delivery channels behind one port**: email, webhook, Slack, and Telegram all
implement the same `AlertChannel` port; `deliverAll()` runs them via `Promise.all` and
catches every throw into a failed `DeliveryResult`, so one channel being down never
blocks the others.
* **A structured audit row per outcome**: `processAlert()` always calls
`auditSink.record()` exactly once: delivered, suppressed, held, or digested.
## Install [#install]
```bash
bun add @caisson/alerting
```
## Quickstart [#quickstart]
```ts
import {
processAlert,
createEmailChannel,
createWebhookChannel,
createInMemoryAuditSink,
} from "@caisson/alerting";
const result = await processAlert(event, {
openIncidents, // { dedupeKey }[] — from your incident store
recentCount, // this recipient's sends in the current rate-cap window
ratePolicy: { maxPerWindow: 5 },
recipientTz: "America/New_York",
quietPolicy: { startHour: 22, endHour: 7 },
now: new Date(),
channels: [createEmailChannel(emailer), createWebhookChannel({ url })],
auditSink: createInMemoryAuditSink(), // or a Postgres-backed AlertAuditSink
});
// result.outcome: "delivered" | "suppressed" | "held" | "digested"
```
## The pipeline, in order [#the-pipeline-in-order]
```ts
export async function processAlert(
event: AlertEvent,
deps: ProcessAlertDeps,
): Promise {
if (dedup(event, deps.openIncidents)) {
return finish(event, deps, "suppressed", []);
}
if (rateCap(event, deps.recentCount, deps.ratePolicy) === "digest") {
return finish(event, deps, "digested", []);
}
if (quietHours(event, deps.recipientTz, deps.quietPolicy, deps.now) === "hold") {
return finish(event, deps, "held", []);
}
const deliveries = await deliverAll(event, deps.channels);
return finish(event, deps, "delivered", deliveries);
}
```
Each stage can short-circuit to its own outcome before a channel is ever touched, and
`finish()` fires on every path, the audit row is written whether or not anything
actually delivered.
## Drivers [#drivers]
| Channel | Factory | Transport |
| -------- | ------------------------------- | ---------------------------------------------------- |
| Capture | `createCaptureChannel()` | In-memory; test-only. |
| Email | `createEmailChannel(emailer)` | Delegates to an injected `@caisson/email` `Emailer`. |
| Webhook | `createWebhookChannel(config)` | `fetchWithTimeout` POST; optional HMAC signature. |
| Slack | `createSlackChannel(config)` | `fetchWithTimeout` POST to an incoming-webhook URL. |
| Telegram | `createTelegramChannel(config)` | `fetchWithTimeout` POST to a bot-API `sendMessage`. |
Webhook, Slack, and Telegram config URLs pass `@caisson/kernel`'s `assertSafePublicUrl`
at the Zod schema boundary and `assertSafePublicUrlResolved` again at the fetch call, a
DNS-rebinding recheck, and every outbound POST sets `redirect: "error"` so a 3xx can't
hop the request to a private host after the check.
## Configuration surface [#configuration-surface]
`DEFAULT_EVENT_TYPE_REGISTRY` maps `eventType -> { defaultSeverity, channels,
ratePolicy }`, a small seed (four event types), not an exhaustive catalog. Extend it
with your own event types per real usage; `AlertEventSchema` is the one Zod `.strict()`
boundary every stage consumes.
## Composing with the base [#composing-with-the-base]
Alerting is a plain package on top of the base substrate: it reuses `@caisson/kernel`'s
`fetchWithTimeout` and SSRF guards, and delegates its email channel to `@caisson/email`
rather than opening a second email path. Its audit sink writes to a plain, RLS-forced
Postgres table (`alert_audit_log`), explicitly not the hash-chained WORM chain that
`@caisson/audit-worm` owns; the two products stay deliberately distinct.
Alerting is a `workspace:*` dependency the Compliance bundle re-exports at runtime
(`export * from '@caisson/alerting'`), not a promise on a manifest. Buy it standalone,
or get it composed into Compliance.
# frameworks-pack (/docs/compliance/frameworks-pack)
`@caisson/frameworks-pack` ships a typed, Zod-strict canonical-control model plus three
own-authored control packs, SOC 2 Trust Services Criteria, HIPAA Security, and the EU AI
Act, with every control crosswalked to the external framework's requirement ids.
Clean-room authorship: crosswalk references are pointers to an external requirement id
(`CC6.1`, `164.312(a)(2)(i)`, `Art. 9`), never copied control text. The catalog is hand-authored
Caisson prose; the citation is a fact, not a transform of the AICPA/NIST/regulation text.
## Install [#install]
```bash
bun add @caisson/frameworks-pack
```
## What it does [#what-it-does]
* **Canonical control model**: `defineControl` / `defineFramework` builders validate a
control set at author time and fail closed on the first violation (duplicate id, empty
statement, malformed crosswalk reference).
* **Three framework packs**: `soc2Tsc`, `hipaaSecurity`, `euAiAct`: pre-built, validated
`Framework` catalogs, each control mapped to its external requirement id.
* **Named-regime crosswalks**: `soc2Crosswalk`, `pciDssCrosswalk`, `gdprCrosswalk` (and
`regimeCrosswalks`, all three together): the buyer-facing mapping from a regime's control
id to the concrete Caisson package/mechanism that addresses it, with a `claim` of either
`"maps-to"` (domain overlap) or `"implements"` (proven by a live test or CI artifact, and
only ever used when a `proof` pointer backs it), and a required `buyerResponsibility`
column naming what Caisson does not cover.
## Quickstart [#quickstart]
```ts
import { soc2Tsc, hipaaSecurity, euAiAct } from "@caisson/frameworks-pack";
// Each pack is a validated Framework: { id, title, version, description, controls[] }.
for (const control of soc2Tsc.controls) {
console.log(
control.id,
control.crosswalk.map((c) => c.reference),
);
}
```
Author your own control on top of the same model:
```ts
import { defineControl } from "@caisson/frameworks-pack";
const control = defineControl({
id: "ACCESS-CONTROL.MFA",
title: "Multi-factor authentication for privileged access",
family: "Access Control",
statement: "Privileged accounts require a second authentication factor.",
crosswalk: [{ framework: "SOC2-TSC", reference: "CC6.1" }],
});
```
## Regime crosswalks [#regime-crosswalks]
```ts
import { soc2Crosswalk, exportRegimeCrosswalk } from "@caisson/frameworks-pack";
// exportRegimeCrosswalk embeds the disclaimer block IN the returned artifact — a cold
// reader opening the export never sees a mapping row without the scope language beside it.
const artifact = exportRegimeCrosswalk(soc2Crosswalk);
```
Every crosswalk row is `"maps-to"` unless a live repo test or CI artifact
proves the control, in which case it is `"implements"` with a `proof` pointer.
Neither claim is a certification, Caisson holds no SOC 2 report, no HIPAA
attestation, and no EU AI Act conformity assessment on itself. The
`buyerResponsibility` column on every row names what stays yours.
## Composition [#composition]
Depends on `@caisson/kernel` plus `@caisson/oscal-spine`, the commercial OSCAL boundary it
re-exports for source compatibility. The framework catalogs are consumed by
`@caisson/compliance-core`, which assembles the evidence pack; `oscal-spine` generates the
machine-readable OSCAL artifacts against those control ids. All three are members of the
Compliance bundle.
## License [#license]
Commercial module (`LicenseRef-Caisson-Commercial`), part of the Compliance bundle, also
available standalone.
# retention-runner (/docs/compliance/retention-runner)
`@caisson/retention-runner` is Caisson's right-to-erasure module: `runErasure` fans one
subject's erasure out across every registered store, object storage, cascade DB, orphan
sweep, isolates each target's failure so one broken store never blocks the others, and
writes exactly one reason-tagged audit row per run.
## What it does [#what-it-does]
* **Pluggable multi-store erasure**: the `ErasureTarget` port covers object-storage purge,
cascade DB delete, and orphan-record sweep. `runErasure` fans out to every registered
target for one subject.
* **Per-target error isolation**: each target's outcome is caught into a `TargetResult`
(`{ target, ok, error? }`) instead of propagating. A failing object-storage purge doesn't
stop the cascade DB delete from running.
* **One reason-tagged audit row per run**: `RetentionRunResult` carries the trigger
(`auto_90d` | `ccpa_request` | `operator_manual`), every target's outcome, and the run
timestamp, written once through the injected `RetentionAuditSink`. Plain Postgres
audit-logging, not WORM, pair with `@caisson/audit-worm` where a write-once anchor matters.
* **The recurring `auto_90d` sweep rides `@caisson/jobs`**: `defineRetentionTask` returns a
`TaskDefinition`; `enqueueAutoSweep` enqueues it under a singleton key of
`${tenantId}:${subjectId}`, so a subject already queued for a sweep is never double-enqueued.
## Install [#install]
```bash
bun add @caisson/retention-runner @caisson/jobs
```
## Quickstart [#quickstart]
```ts
import {
createObjectStorageTarget,
createCascadeDbTarget,
createOrphanSweepTarget,
createCaptureAuditSink,
runErasure,
} from "@caisson/retention-runner";
const targets = [
createObjectStorageTarget({ client: s3Client }), // your real client — an injected seam
createCascadeDbTarget({ client: pgClient }),
createOrphanSweepTarget({ client: pgClient }),
];
const sink = createCaptureAuditSink(); // swap for the pg driver in prod
// One-shot: a CCPA request or an operator-triggered erasure.
await runErasure(
{ subjectId, tenantId, reason: "ccpa_request" },
targets,
sink,
);
```
## Recurring auto\_90d sweep [#recurring-auto_90d-sweep]
```ts
import { defineRetentionTask, enqueueAutoSweep } from "@caisson/retention-runner";
import { createInMemoryQueue } from "@caisson/jobs";
const queue = createInMemoryQueue([defineRetentionTask({ targets, sink })]);
// enqueueAutoSweep sets the overlap-safe singleton key — a subject already
// queued for a sweep is never double-enqueued, while distinct subjects sweep in parallel.
await enqueueAutoSweep(queue, { subjectId, tenantId });
```
## Drivers [#drivers]
`createObjectStorageTarget` / `createCascadeDbTarget` / `createOrphanSweepTarget` each take
an injected minimal client interface (`purge` / `cascadeDelete` / `sweep`), the real S3 or
Postgres client is a documented seam, never a package dependency. No `aws-sdk` or `pg` import
ships in `retention-runner` itself. `createCaptureTarget` and `createCaptureAuditSink` are the
in-memory drivers for tests and the framework-agnostic reference.
## Configuration surface [#configuration-surface]
`reason` is a closed Zod enum (`erasureReasonSchema`), `auto_90d`, `ccpa_request`, or
`operator_manual`. An unrecognized reason fails the `.strict()` request validation before any
target runs. The audit row lands in `retention_audit`, with row-level security scoped to
`app.current_account` so one tenant's erasure history can't leak into another's query.
Running retention-runner doesn't make you GDPR or CCPA compliant on its own. It ships the
erasure execution and the audit row proving a subject's data was purged across every
registered store, the technical control an auditor checks for.
## Composes with [#composes-with]
`@caisson/compliance` composes `retention-runner` at runtime as a real workspace
dependency. The recurring sweep composes with `@caisson/jobs` for scheduling; pair with
`@caisson/audit-worm` where the audit trail needs a write-once anchor rather than plain
Postgres logging.
# Compliance (/docs/compliance)
The **Compliance** bundle turns the guarantees the other modules already enforce, fail-closed
RLS, WORM storage, per-tenant encryption, into something you can hand an auditor: a deterministic,
byte-stable evidence pack that maps each control to a cited clause, and a hard block if any control
is unresolved rather than a silent guess.
## What's in the bundle [#whats-in-the-bundle]
* **[compliance-core](/docs/compliance/compliance-core)**: the evidence engine: typed collectors
that flag or resolve a control, a byte-stable evidence pack, and an OSCAL export. Never
certifies, every summary line is readiness/posture language only.
* **[frameworks-pack](/docs/compliance/frameworks-pack)**: own-authored, clean-room control
catalogs for SOC 2, HIPAA Security, and the EU AI Act, crosswalked to each framework's
requirement ids.
* **[signing-primitive](/docs/provenance/signing-primitive)**: detached Ed25519 signing over the
evidence pack's canonical manifest, with an optional RFC-3161 trusted-timestamp
countersignature, shared with the Provenance bundle.
* **[audit-worm](/docs/provenance/audit-worm)**: the append-only SHA-256 audit chain plus S3/GCS/
R2 Object-Lock WORM storage the evidence cites, shared with the Provenance bundle.
* **[retention-runner](/docs/compliance/retention-runner)**: the CCPA/GDPR right-to-erasure
runner: fans one subject's erasure across every registered store and writes one reason-tagged
audit row.
* **[alerting](/docs/compliance/alerting)**: a five-stage alert pipeline (dedup, rate-cap-to-
digest, quiet hours, multi-channel send, one audit row), the SOC 2 CC7.2 control.
* **[field-crypto](/docs/provenance/field-crypto)**: per-tenant field encryption, shared with the
AI-Production, Local-first, and Provenance bundles.
* **oscal-spine**: the complete OSCAL surface — deterministic assessment-plan, assessment-results,
POA\&M, catalog, XML, and ISO 27001 SoA exports, plus the pinned NIST 800-53 catalog and OLIR
relationship mapping.
* **access-review**: audit-prep access-review campaigns — a WORM-logged, per-reviewee attested
approve/revoke record over an imported membership snapshot, opened on a jobs-riding cadence and
closed on completion or deadline, with any undecided reviewee flagged unresolved rather than
auto-approved.
* **risk-register**: a framework-agnostic risk register — likelihood × impact scoring with a
computed (never freeform) residual, operator overrides recorded as a chained exception rather
than an edit, crosswalk pointers into any shipped framework pack, and a risk-treatment-plan
evidence artifact.
* **trust-page**: the buyer trust-page generator — a self-contained static HTML + JSON page built
from an evidence pack and its crosswalk rollup through allowlist-based redaction, hostable
anywhere to show prospects a compliance posture.
The bundle also carries the free Apache-2.0 base it builds on (`kernel`, `tenancy-rls`, `migrate`).
## Install [#install]
```bash
export CAISSON_LICENSE_TOKEN=
bunx @caisson-sh/cli@latest --name caisson-app --edition compliance
cd caisson-app
bun install
```
`--edition compliance` auto-selects the Compliance bundle's current modules, the command above scaffolds the whole bundle. Add or swap individual picks with `--module `; see [Getting started](/docs/getting-started) for the full flag reference.
## How it composes [#how-it-composes]
`compliance-core`'s collectors run over facts gathered by the other modules, an RLS posture
snapshot, an `audit-worm` chain anchor, a `field-crypto` key policy, and refuse to guess: a
control the collectors can't evidence blocks the pack rather than passing silently.
`frameworks-pack` supplies the clause catalog each control resolves against, and
`signing-primitive` produces the detached signature over the pack's canonical bytes so a third
party can verify it wasn't edited after generation. `retention-runner` and `alerting` are the
two operating controls (erasure, incident notification) the pack cites as evidence of an active
program, not just point-in-time posture.
### Verifying an exported audit pack [#verifying-an-exported-audit-pack]
The logical audit pack contains `receipts.json`, an auditor README, and a signed canonical manifest
of every exported file name and SHA-256 digest. It deliberately contains no executable verifier: a
program travelling inside the archive it judges cannot establish its own integrity. The seal covers
the manifest, so adding, removing, renaming, or substituting any file in the pack breaks the
signature.
Verification is out of band by design, and it needs one input the pack cannot supply: the issuer's
Ed25519 key fingerprint, obtained through a separately trusted issuer channel. A check that reads
the candidate key from the pack itself proves nothing, so verification refuses PASS when that
fingerprint is absent or differs from the key inside the pack.
The format is inspectable rather than proprietary. The Apache-2.0 `@caisson/kernel` builds the
canonical manifest and the exact bytes the seal signs (`@caisson/kernel/evidence`), and checks each
row's link recompute, its per-length WORM anchor, and its anchor signature
(`@caisson/kernel/audit-verify`).
The sanctioned runner for that flow is the commercial `@caisson/verify-pack`. Caisson does not
distribute it through a package registry, and it is not part of any bundle or module purchase.
## Composing with the base [#composing-with-the-base]
Every collector reads facts gathered at the tenant boundary `@caisson/tenancy-rls` enforces:
`compliance-core` never infers a passing RLS control it can't evidence from a live posture
snapshot.
The Compliance bundle ships the technical controls the frameworks point at. It
does not make an organization compliant; that determination is your
organization's and its auditor's to make.
## Entitlement [#entitlement]
Compliance is a commercial bundle (`LicenseRef-Caisson-Commercial`). Buy the bundle, or any member
module à la carte, a purchase grants the module's entitlement id, checked offline against the
license.
# compliance-core (/docs/compliance/compliance-core)
`@caisson/compliance-core` runs typed collectors over already-gathered substrate facts, assembles
the results into a deterministic, byte-stable evidence pack, and exports the pack through the OSCAL
seam. It never certifies or attests, every summary line is readiness/posture language only.
## Install [#install]
```bash
bun add @caisson/compliance-core
```
Commercial module (`LicenseRef-Caisson-Commercial`). Composed by the Compliance bundle; consumes
`@caisson/kernel`, `@caisson/frameworks-pack`, and `@caisson/field-crypto`: down-only, never the
reverse.
## Flag-never-guess [#flag-never-guess]
A collector never infers a passing status it cannot evidence. Each `CollectorResult` carries one of
three verdicts:
* `pass`: the check ran and was satisfied.
* `flagged`: the check ran and found a real deficiency; a recorded reason is mandatory.
* `unresolved`: the evidence needed to decide was absent; the collector refuses to guess.
```ts
import { flaggedResult, passResult, unresolvedResult } from "@caisson/compliance-core";
```
If **any** control has an `unresolved` result, `generateEvidencePack` throws
`EvidencePackBlockedError` before assembling anything, there is no partial pack.
## Generating a pack [#generating-a-pack]
Collectors are pure: a substrate fact in, a `CollectorResult` out. The facts themselves (an audit
chain anchor, an RLS posture snapshot, a WORM retention term) are gathered at the edge by code that
depends on `audit-worm`/`tenancy-rls`, then handed to the collector.
```ts
import { rlsForceCollector, generateEvidencePack } from "@caisson/compliance-core";
const collector = rlsForceCollector();
const result = collector.collect({
tables: [
{ table: "patients", rowSecurityEnabled: true, rowSecurityForced: true, tenantPolicyPresent: true },
],
});
const pack = generateEvidencePack({
tenantId,
framework,
chainAnchor,
now: new Date(), // injected clock — never hashed into the canonical body
controls: [
{
controlId: "ACCESS-CONTROL.LOGICAL",
title: "Logical access control",
family: "AC",
statement: "...",
crosswalk: [],
evidence: [result],
},
],
});
// pack.manifest — the validated canonical body (no timestamp, no signature)
// pack.canonicalManifest — exact bytes a signer signs
// pack.archive — deterministic ZIP (manifest + per-control evidence + auditor summary)
// pack.sha256 — byte-stable digest, independent of `now`
```
The archive is a dependency-free deterministic ZIP: fixed 1980-epoch entry mtimes, name-sorted
entries, fixed deflate level, identical evidence always serializes to identical bytes, so the pack
is independently golden-checkable.
## Collectors shipped [#collectors-shipped]
`rlsForceCollector`, plus collectors for audit-chain verification, WORM retention, field-crypto
policy, the AI risk register, and impersonation posture, each importable from the package root and
each scoped to one canonical control id.
## OSCAL export [#oscal-export]
```ts
import { toOscalBundle, toOscalAssessmentPlan } from "@caisson/compliance-core";
const bundle = toOscalBundle(pack.manifest, { newId: crypto.randomUUID });
// bundle.assessmentResults — OSCAL Security Assessment Results
// bundle.planOfActionAndMilestones — OSCAL POA&M, one item per flagged evidence item
```
`toOscalAssessmentResults` derives one OSCAL finding per control (`ready` → `satisfied`, `gap` →
`not-satisfied`) and one observation per evidence item. An XML round-trip
(`convertJsonToXml`/`convertAndValidate`) is available via the OSCAL CLI seam for tooling that
requires the XML representation. Delivery to a GRC platform's OSCAL ingest endpoint is an un-wired
port (`OscalExportTransport`), no network call ships in v1.
## Composition [#composition]
`compliance-core` is the evidence-engine carve-out of the Compliance bundle: the bundle composes
this engine with the framework catalogs (`@caisson/frameworks-pack`) and evidence signing, never
the reverse.
# ai-evals (/docs/ai-production/ai-evals)
`@caisson/ai-evals` is an eval harness for AI features: define a dataset of cases, score them with
a grader, and gate a build on a committed baseline instead of a gut feeling. It runs fully offline
and deterministic (no live model call, no secret, no flaky network dependency) so the same suite
produces the same verdict in CI every time.
## Install [#install]
```bash
bun add @caisson/ai-evals
```
## Quickstart [#quickstart]
```ts
import {
defineEval,
exactGrader,
gateAgainstBaseline,
} from "@caisson/ai-evals";
const run = await defineEval({
name: "greeting-quality",
promptVersionId: version.id,
threshold: 0.95,
cases: [{ id: "case-1", input: { name: "Ada" }, output: "Hello, Ada!" }],
scorers: { "exact-match": exactGrader() },
});
const gate = gateAgainstBaseline("__evals__/baseline.json", [run]);
if (!gate.passed) throw new Error("eval regressed past its committed baseline");
```
## Grader taxonomy [#grader-taxonomy]
Six graders in two classes. `exactGrader`, `regexGrader`, `jsonShapeGrader`, and `schemaGrader` run
pure and offline, no model call. `judgeGrader` routes through a `Judge` port for model-graded
scoring. `injectionGrader` is its own fail-closed class: its refusal rubric is a hard-coded
deny-list, so a persuasive input can talk a model judge out of a refusal but can never talk a
deny-list out of one.
```ts
import { schemaGrader, judgeGrader, cassetteJudge } from "@caisson/ai-evals";
import { z } from "zod";
const shapeCheck = schemaGrader(z.object({ ok: z.boolean() }));
const judged = judgeGrader(cassetteJudge(committedCassette), "matches the house tone");
```
## Offline judge via cassette replay [#offline-judge-via-cassette-replay]
`cassetteJudge()` replays recorded verdicts from a committed cassette file, zero network call,
zero provider secret, safe to run in CI. An unrecorded case id is a hard cassette-miss error, not a
silent pass. `recordingJudge()` wraps a real local judge to mint a fresh cassette for review before
you commit it.
## The regression gate [#the-regression-gate]
`gateAgainstBaseline()` compares a fresh run against a committed JSON baseline and fails closed, a
missing baseline, a score below threshold, or any individual scorer regression blocks the gate:
```ts
export function compareToBaseline(
run: EvalRun,
baseline: BaselineFile,
): BaselineComparison {
const findings: RegressionFinding[] = [];
if (run.score + EPS < run.threshold) {
findings.push({
kind: "below-threshold",
actual: run.score,
baseline: run.threshold,
detail: `score ${run.score} < threshold ${run.threshold}`,
});
}
const prior = baseline.evals[run.name];
if (prior === undefined) {
findings.push({
kind: "missing-baseline",
actual: run.score,
detail: `no committed baseline for eval "${run.name}" — bless to record it`,
});
return { eval: run.name, passed: false, findings, blessed: false };
}
// ...
}
```
`BLESS=1 bun run eval` is the one sanctioned path to rewrite the baseline, it merges into existing
entries so a partial run never drops other evals, mirroring the golden-fixture discipline in
`@caisson/testing`.
## Confidence and agreement statistics [#confidence-and-agreement-statistics]
`wilsonLowerBound()` threads an opt-in Wilson confidence floor into the baseline gate, so a small
lucky-draw sample can't pass as reliable. `fleissKappa`, `ensembleAgreement`, and
`counterfactualStability` score an eval suite's own reliability, not just its pass rate.
`classifyExit()` tags *why* a run exited (error, timeout, budget-exhausted, refusal, empty-output)
as a signal orthogonal to pass/fail.
## Reflexivity queue [#reflexivity-queue]
`captureDisagreement()` enqueues a case only when a model verdict and a human verdict disagree;
`consolidateReflexivityQueue()` dedupes and caps the list for operator review. Nothing here
auto-writes a committed dataset, merging a candidate back in stays a human act.
## Spend, and how it composes [#spend-and-how-it-composes]
`recordEvalSpend()` tracks eval-run cost on its own budget-isolated ledger, it never touches the
production credit wallet or `@caisson/ai-meter`. `ai-evals` is a base primitive: the AI Production
bundle's prompt registry and metering compose on top of it to gate quality in the same CI run that
gates spend.
`ai-evals` is sold standalone at $199 and is included in the AI-Production
bundle. It pairs with the bundle's metering and guardrails to gate CI on
regression.
# prompt-registry (/docs/ai-production/prompt-registry)
Prompts hardcoded three layers deep in a route handler, versioned like everything else that
ships. `@caisson/prompt-registry` stores prompt templates as append-only versions and resolves
them by `name@version` or `name@alias`: an edit mints a new row instead of mutating one, and a
mutable alias pointer (`prod`, `canary`) promotes a prompt to production with no redeploy.
## What it does [#what-it-does]
* **Append-only versions**: `registerPrompt` derives the current tip from the kernel's
versioning chain and supersedes it. The first call to a name is v1; each later call is
`tip.version + 1`. A version row cannot be updated or deleted.
* **`name@version` and `name@alias` addressing**: `parsePromptRef` reads a bare name as the
current tip, a numeric suffix as an exact version, and anything else as an alias.
`resolvePrompt` takes that parsed reference straight to the matching row.
* **Promote without a redeploy**: `setAlias` points `prod` or `canary` at a specific version
number. It resolves the target version first, so an alias can never point at a version that
doesn't exist, and it only ever writes the pointer row, never a version.
* **Injection-safe rendering**: `renderPrompt`/`renderVersion` validate raw vars against the
version's own `varSpec` (a strict Zod schema, unknown vars rejected, missing vars fail), then
substitute `{{name}}` placeholders in a single non-recursive pass. Every inserted value is
brace-escaped, so a variable's own content can never open a new placeholder or forge a message
role.
* **Tenant-isolated by default**: `prompt_version` and `prompt_alias` both go through
`buildTenantPolicySql` (FORCE-RLS), and every registry function takes a `TenantExecutor`: a
query outside a `withTenant` scope sees nothing.
## Install [#install]
```bash
bun add @caisson/prompt-registry
```
## Quickstart [#quickstart]
```ts
import { withTenant } from "@caisson/tenancy-rls";
import {
PROMPT_REGISTRY_SCHEMA_SQL,
registerPrompt,
resolvePrompt,
setAlias,
renderVersion,
} from "@caisson/prompt-registry";
// migrate: exec PROMPT_REGISTRY_SCHEMA_SQL once (a numbered migration in prod).
await withTenant(db, accountId, async (tx) => {
const v1 = await registerPrompt(tx, {
accountId,
name: "soc2-summary",
messages: [
{ role: "system", content: "You are {{persona}}." },
{ role: "user", content: "Summarize:\n{{document}}" },
],
varSpec: { persona: "string", document: "string" },
});
await setAlias(tx, {
accountId,
name: "soc2-summary",
alias: "prod",
version: v1.version,
});
const live = await resolvePrompt(tx, accountId, "soc2-summary@prod");
const messages = renderVersion(live, {
persona: "a compliance assistant",
document: untrustedUserInput, // escaped — cannot break out of its slot
});
});
```
## Rolling back [#rolling-back]
Point the alias at an earlier version, nothing is deleted or re-inserted:
```ts
await setAlias(tx, { accountId, name: "soc2-summary", alias: "prod", version: 3 });
```
## The render contract [#the-render-contract]
`renderContent` re-checks the rendered length against the content cap *after* escaping, not
before, escaping can inflate a value, so the cap has to catch the real rendered total. The
single-pass, brace-escaped substitution behavior is locked against a golden fixture
(`src/__golden__/render.json`), so a change that shifts the output has to update the fixture
deliberately.
Prompt registry is a `TenantExecutor`-scoped API you import and call directly:
the same primitive the AI Production Kit's inference gateway resolves prompt
refs through before every model call. There's no standalone server or HTTP route.
## Composition [#composition]
Built on `@caisson/kernel` (versioning + errors) and `@caisson/tenancy-rls` (FORCE-RLS); it
never depends "up" on an edition. It's a base primitive of the **AI-Production** bundle, where
the inference gateway resolves every `promptRef` through it before rendering and metering a
call, alongside `ai-meter` and `guardrails`.
## Test [#test]
```sh
bun test ./src # render golden (BLESS unset) + RLS/versioning integration (PGlite)
```
# ai-meter (/docs/ai-production/ai-meter)
`@caisson/ai-meter` is the money path for metered AI inference: estimate a call's cost
before it runs, reserve that amount up front, then true the charge to the provider's
actual usage once the call completes. Built on the integer credit ledger, so a charge is
never a float and never drifts.
## What it does [#what-it-does]
* **Estimate → reserve → reconcile.** `reserve()` prices a call from a versioned,
per-`provider/model` price book and debits the wallet before the provider is ever
called, a short wallet or an open circuit breaker fails the call with no spend and no
provider round-trip. `reconcile()` trues the reservation to the provider's reported
usage: refunds an over-reservation, charges a shortfall, or leaves the ledger untouched
when the estimate was exact.
* **A per-tenant spend window + soft/hard caps.** Every reserve bumps an atomic
running-spend counter for the tenant's current window (day/month/etc.); crossing a soft
cap warns, crossing a hard cap trips a circuit breaker so every subsequent call fails
closed until an operator resets it.
* **A bundled, overridable price book.** Ships default per-million-token rates for common
provider/model pairs; a buyer can override the whole book or the credit denomination.
`resolvePriceEntry` throws on an unrecognized provider/model instead of metering at zero.
* **Idempotent by construction.** Both `reserve()` and `reconcile()` key off the caller's
`callId`: a retried call settles exactly once instead of double-charging.
* **A pre-call dedup gate.** `checkDedupGate()` flags a prompt that's near-identical to one
already in flight (an agent loop rewording a retry, a user re-asking the same question)
before the price book ever prices it. Detection only, it never auto-skips a call or
moves a credit itself.
## Quickstart [#quickstart]
```ts
import { withTenant } from "@caisson/tenancy-rls";
import { reserve, reconcile } from "@caisson/ai-meter";
await withTenant(db, accountId, async (tx) => {
const reserved = await reserve(tx, {
accountId,
callId,
provider: "openai",
model: "gpt-4o",
lane: "default",
messages: [{ role: "user", content: "hello" }],
});
// ... call the provider, using reserved.reservedCredits to size the request ...
await reconcile(tx, {
accountId,
callId,
provider: "openai",
model: "gpt-4o",
lane: "default",
reservedCredits: reserved.reservedCredits,
usage: { inputTokens: 12, outputTokens: 40, cachedInputTokens: 0 },
windowKey: reserved.windowKey,
});
});
```
## Circuit breaker [#circuit-breaker]
`assertBreakerClosed` runs before every `reserve()`: an open breaker throws
`SpendCapError` (`402`) with no provider call made. A crossed hard cap trips it
(`tripBreaker`); it stays open until an operator calls `resetBreaker`, so a runaway loop
can't spend past the cap on the next retry.
```ts
import { assertBreakerClosed, resetBreaker } from "@caisson/ai-meter";
await assertBreakerClosed(tx, accountId, "default"); // throws SpendCapError if open
// ... after investigating a tripped breaker ...
await resetBreaker(tx, accountId, "default");
```
## Dedup-before-meter gate [#dedup-before-meter-gate]
`checkDedupGate` runs a dependency-free MinHash/LSH similarity check against recent calls
in the same account and scope, ahead of the price-book estimate, a Jaccard similarity
above 0.92 returns `duplicate-of` so the caller can choose to skip or reuse the earlier
result.
## Configuration [#configuration]
`parsePriceBook` and `parseCreditConversion` validate an operator-supplied price book or
credit denomination before it replaces `BUNDLED_PRICE_BOOK` / `CREDIT_CONVERSION`: an
invalid override fails closed rather than metering silently at zero.
## Composing with the base [#composing-with-the-base]
`ai-meter` is a base primitive, it never imports an edition. It runs on the same
Postgres-atomic accounting as `@caisson/credits` and is scoped per tenant through
`@caisson/tenancy-rls`'s `withTenant`. The AI Production Kit's inference gateway composes
`reserve()`/`reconcile()` around the provider call site; `ai-meter` itself never talks to
a provider.
Buy `ai-meter` standalone onto the free base, or get it, plus guardrails and the prompt
registry, composed into the AI-Production bundle.
# guardrails (/docs/ai-production/guardrails)
`@caisson/guardrails` is the chokepoint between your app and a model call. `guardInput`
moderates then redacts PII on the way in; `guardOutput` moderates on the way out. Either leg
throws a `GuardrailError` (422) on a block, a moderator outage never silently lets content
through.
## What it does [#what-it-does]
* **Fail-closed by default**: a moderator timeout or outage blocks the call unless the policy
explicitly sets `failOpen: true`.
* **An unconditional secret-shape gate**: before either leg reaches a moderator, `guard.ts`
runs `looksLikeSecret(text)` and blocks category `"secret"` with no policy field and no
opt-out. A leaked credential never becomes a moderation call, live or not.
* **A swappable `Moderator` port**: `localModerator` is a zero-network regex blocklist;
`providerModerator` wraps an injected async check for a real vendor call; `customModerator`
hooks in your own function.
* **A PII engine**: `detectPii` finds email, SSN, Luhn-validated credit card, and phone spans.
`redactPii` replaces them irreversibly (`mask` → `[EMAIL]`, `hash` → `[EMAIL:ab12…]`);
`tokenizePii` instead seals the original via `@caisson/field-crypto` and swaps in an opaque
placeholder that `detokenizePii` can restore.
* **An FTC "4 Ps" dark-pattern evaluator**: `evaluateFtc4P` scores marketing/UI copy across
prominence, presentation, placement, and proximity; wrap it as a moderator with
`ftc4pModerator` to gate `guardOutput` on your own copy.
* **A metadata-only blocked event**: every block emits `guardrail.blocked` to the kernel
`EventSink` with `blockId`, `stage`, `category`, `policy`, and `failClosed`: never the
flagged text.
## Install [#install]
```sh
bun add @caisson/guardrails
```
## Quickstart [#quickstart]
```ts
import { guardInput, guardOutput, localModerator } from "@caisson/guardrails";
const policy = {
policyName: "default",
moderator: localModerator(["forbidden phrase"]),
};
const runtime = { tenantId: accountId, sink: eventSink };
const { text: safeInput, tokens } = await guardInput(userText, policy, runtime);
// ... send safeInput to the model ...
await guardOutput(modelReply, policy, runtime); // throws GuardrailError if the reply is flagged
```
## PII redaction modes [#pii-redaction-modes]
```ts
import { detectPii, redactPii, tokenizePii, detokenizePii } from "@caisson/guardrails";
const matches = detectPii(text); // email, ssn, credit_card, phone spans
const { text: masked } = redactPii(text, "mask"); // "[EMAIL]" — irreversible
const { text: hashed } = redactPii(text, "hash"); // "[EMAIL:ab12…]" — irreversible, correlatable
// tokenize seals the original via field-crypto; detokenizePii restores it under the same context.
const { text: tokenized, tokens } = tokenizePii(text, ctx);
const restored = detokenizePii(tokenized, tokens, ctx);
```
## Configuration [#configuration]
A `GuardPolicy` carries the moderator, an optional `failOpen` (default `false`), a
`timeoutMs` deadline (`2000ms` default) for `moderateWithDeadline`, an always-on `cheapDeny`
regex pre-screen, and an optional `pii` mode for the input leg. `GuardRuntime` carries the
`tenantId` and the kernel `EventSink` the block event emits to.
## Composition [#composition]
Guardrails is a base primitive, it never imports an edition. It composes `@caisson/kernel`
for the `EventSink`/`looksLikeSecret` primitives and `@caisson/field-crypto` for reversible
PII tokenization; the AI-Production bundle's metered gateway wires `guardInput`/`guardOutput`
around its `infer()`/`embed()` calls.
## Entitlement [#entitlement]
Guardrails ships inside the AI-Production bundle (with ai-meter and prompt-registry) or
standalone.
# AI-Production (/docs/ai-production)
The **AI-Production** bundle is the layer between "the model call works in dev" and "the model
call survives production": every call is metered and capped, every prompt change is scored
against a regression gate before it ships, and every input/output crosses one guardrail boundary.
```bash
HTTP/1.1 402 Payment Required
{
"error": {
"code": "spend_cap_reached",
"message": "Spend cap reached: circuit breaker open",
"details": { "scope": "ai:complete" }
}
}
```
The cap is enforced before the provider is called, in integer credit units, fail-closed, over
budget returns `402`, never an unbounded charge.
## What's in the bundle [#whats-in-the-bundle]
* **[ai-meter](/docs/ai-production/ai-meter)**: Postgres-atomic reserve/reconcile token
metering, per-tenant spend caps, and a circuit breaker. Integer credits only, no floats.
* **[ai-evals](/docs/ai-production/ai-evals)**: a regression gate for prompt and model changes:
`defineEval()` scores a dataset, `gateAgainstBaseline()` fails the build on a real score drop.
* **[guardrails](/docs/ai-production/guardrails)**: a fail-closed guard around every model call:
PII redaction, a swappable moderator, and an unconditional secret-shape gate.
* **[prompt-registry](/docs/ai-production/prompt-registry)**: append-only prompt versioning
with a mutable alias pointer, so promoting or rolling back a prompt is a pointer move, not a
redeploy.
* **[credits](/docs/ai-production/credits)**: the integer credit wallet the spend caps debit
against: append-only ledger, debit-before-spend, fail-closed `402` on an empty balance.
* **[field-crypto](/docs/provenance/field-crypto)**: per-tenant field encryption, shared with
the Compliance, Local-first, and Provenance bundles, for any prompt input or output you store.
## Install [#install]
```bash
export CAISSON_LICENSE_TOKEN=
bunx @caisson-sh/cli@latest --name caisson-app --edition ai-production
cd caisson-app
bun install
```
`--edition ai-production` auto-selects the AI-Production bundle's current modules, the command above scaffolds the whole bundle. Add or swap individual picks with `--module `; see [Getting started](/docs/getting-started) for the full flag reference.
## How it composes [#how-it-composes]
Metering, guardrails, and the eval gate sit at the same seam: a call to a model provider is
metered and checked against the cap before the provider is reached. The eval harness runs the
same prompts offline in CI, so a regression fails the pull request instead of a customer's
session:
```bash
$ bun run eval
FAIL prompts/summarize@v3
faithfulness 0.71 gate >= 0.80
1 regression — exit 1. Build blocked.
```
The prompt registry versions the string both the live call and the eval read, and the credit
wallet is the ledger the spend cap debits against.
## Composing with the base [#composing-with-the-base]
AI-Production reads the tenant id from `@caisson/tenancy-rls`'s bound context, so a spend cap is
never checked against the wrong tenant's budget. Guardrails and the eval harness run independent
of billing, wire `@caisson/billing` separately if a spend cap should also gate a subscription
tier.
## Entitlement [#entitlement]
AI-Production is a commercial bundle (`LicenseRef-Caisson-Commercial`). Buy the bundle, or any
member module à la carte, a purchase grants the module's entitlement id, checked offline against
the license.
# credits (/docs/ai-production/credits)
`@caisson/credits` is the credit wallet: grant, debit, and query an account's balance as
whole integer units, never a float. A debit only records once the wallet can cover it:
insufficient credits throw before any paid work runs, and nothing is written on that path.
## What it does [#what-it-does]
* **Grant and debit**: `grant()` adds credits from a purchase, subscription allotment,
top-up, or a registered feature grant; `debit()` subtracts them for codegen, an AI
feature, or a registered feature debit. Both are idempotent on a supplied
`sourceEventId` or `idempotencyKey`: a retried webhook is absorbed, not double-counted.
* **Debit-before-spend, fail-closed**: an insufficient balance throws
`InsufficientCreditsError` and rolls back the whole transaction. No debit row, no
wallet mutation.
* **FIFO grant consumption**: a debit walks the account's unexpired grants oldest-first
and records which grant(s) it drew from, splitting across grants when one remainder
can't cover it.
* **Append-only ledger**: `getLedger()` reads every grant/debit event for an account;
nothing is ever mutated or deleted.
* **Expiry**: grants default to a 12-month expiry; `sweepExpiredGrants()` claws back
unspent residue past `expires_at`, and `sweepExpiryNotices()` emails accounts inside a
configurable expiring-soon window. Both ship as `@caisson/jobs` task definitions.
* **Clawback**: `clawback()` reverses unspent credits tied to a specific purchase line,
so a partial refund only claws back that line's grant.
## Install [#install]
```bash
bun add @caisson/credits
```
## Quickstart [#quickstart]
Every call runs inside a tenant transaction from `@caisson/tenancy-rls`, which scopes the
wallet and ledger rows to the account via RLS:
```ts
import { withTenant } from "@caisson/tenancy-rls";
import { asCredits } from "@caisson/kernel";
import { grant, debit, balance } from "@caisson/credits";
await withTenant(pg, accountId, async (tx) => {
await grant(tx, {
accountId,
eventType: "purchase",
amount: asCredits(500),
sourceEventId: paddleTransactionId,
});
await debit(tx, {
accountId,
eventType: "codegen_debit",
amount: asCredits(10),
idempotencyKey: requestId,
});
return balance(tx, accountId); // 490
});
```
## Fail-closed on an empty balance [#fail-closed-on-an-empty-balance]
```ts
import { InsufficientCreditsError } from "@caisson/kernel";
try {
await debit(tx, { accountId, eventType: "ai_feature_debit", amount: asCredits(1000) });
} catch (err) {
if (err instanceof InsufficientCreditsError) {
// 402 — nothing was recorded, the wallet is unchanged.
}
}
```
## Reading the ledger [#reading-the-ledger]
`spendableBalance()` is the display-safe figure, the lower of the raw wallet aggregate
and the FIFO sum over unexpired grants, so it never promises more than a debit will
actually cover:
```ts
import { getLedger, spendableBalance } from "@caisson/credits";
const entries = await getLedger(tx, accountId); // every grant/debit event, oldest first
const spendable = await spendableBalance(tx, accountId);
```
## Expiry sweeps [#expiry-sweeps]
```ts
import {
defineCreditExpirySweepTask,
defineCreditExpiryNoticeTask,
} from "@caisson/credits";
const sweepTask = defineCreditExpirySweepTask({ db });
const noticeTask = defineCreditExpiryNoticeTask({
db,
emailer,
recipientFor: (accountId) => lookupAccountEmail(accountId),
dashboardUrl: "https://app.example.com/credits",
});
```
Register both on a `@caisson/jobs` queue and enqueue one payload per account on a cron
tick, both sweeps are idempotent, so a replayed tick is a no-op.
## Composition [#composition]
`@caisson/credits` sits on `@caisson/kernel` (the `Credits`/`RoundedMoney` branded types
and `InsufficientCreditsError`), `@caisson/tenancy-rls` (the `TenantExecutor` every
function takes), and `@caisson/jobs` (the expiry-sweep task definitions). It is a
commercial module in the AI-Production bundle, entitlement is required to install it
from the registry.
# Org controls (/docs/base/org-controls)
`@caisson/org-controls` is the commercial org and operator-controls module, carved out of the
open Base so the Apache-2.0 substrate stays small: WorkOS SSO, a Clerk session-verification
driver, the MANAGE half of the multi-user account model, and the admin-write RLS layer an
operator control plane mutates through. Buyer session **resolution** stays in the open
`@caisson/auth`; buyer tenant **isolation** stays in the open `@caisson/tenancy-rls`.
## What it does [#what-it-does]
* **WorkOS SSO** (`createWorkosSsoProvider`), a framework-agnostic AuthKit/SSO transport
seam: builds the authorization URL, exchanges the callback code for the user's id + email.
Config is injected, never read from env by the package.
* **Clerk session verification** (`createClerkSessionVerifier`), verifies a Clerk session
token against Clerk's JWKS via `@clerk/backend`, then maps the claims onto the kernel's
`SessionContext`. Networkless when you supply `jwtKey`.
* **Owner-gated membership** (`listAccountMembers` / `addAccountMember` /
`removeAccountMember` / `assertCanManageMembers`), invite and remove seats on a shared
account, owner-only.
* **Admin-write RLS** (`withAdminWrite` + the policy builders), the cross-tenant write role
an operator control plane mutates through, DB-separated from the buyer `app` role.
* **Entitlement gate** (`holdsOrgControls`), the fail-closed predicate gating the module's
own surfaces.
## Install [#install]
```bash
bun add @caisson/org-controls
```
## Quickstart, WorkOS SSO [#quickstart-workos-sso]
```ts
import { createWorkosSsoProvider } from "@caisson/org-controls";
const sso = createWorkosSsoProvider({
clientId: process.env.WORKOS_CLIENT_ID!,
apiKey: process.env.WORKOS_API_KEY!,
redirectUri: "https://app.example.com/auth/callback",
});
const url = sso.authorizationUrl(state); // redirect the buyer here
const { userId, email } = await sso.exchangeCode(code); // on the callback
```
## Clerk session verification [#clerk-session-verification]
```ts
import { createClerkSessionVerifier } from "@caisson/org-controls";
const verifier = createClerkSessionVerifier({
jwtKey: process.env.CLERK_JWT_KEY!, // networkless — no per-call JWKS fetch
authorizedParties: ["https://app.example.com"],
});
const session = await verifier.verifySession(clerkToken); // -> SessionContext
```
An active Clerk Organization maps to `accountId`/`role`; with no Organization active, the
session falls back to the personal-account convention (`accountId === userId`, role
`"owner"`). This mapping is stateless, route the verified `userId` through `@caisson/auth`'s
`resolveUserAccounts`/`selectActiveAccount` when you need the DB-authoritative multi-account
resolution instead.
## Membership management [#membership-management]
```ts
import { addAccountMember, listAccountMembers } from "@caisson/org-controls";
// actorRole comes from the caller's resolved session; only "owner" may manage members.
await addAccountMember(db, actorRole, accountId, newUserId); // default role "seat"
const members = await listAccountMembers(db, accountId);
```
## Admin-write RLS [#admin-write-rls]
```ts
import {
withAdminWrite,
buildAdminWritePolicySql,
} from "@caisson/org-controls";
// At DEPLOY, alongside the table's existing tenant-isolation policy:
const sql = buildAdminWritePolicySql("account");
// At call time, in the operator control plane only:
await withAdminWrite(db, async (tx) => {
await tx.query(`UPDATE account SET ... WHERE id = $1`, [accountId]);
});
```
`withAdminWrite` refuses a SUPERUSER/BYPASSRLS role before it ever assumes it, a
misconfigured role fails closed rather than silently widening access.
## Entitlement gate [#entitlement-gate]
```ts
import { holdsOrgControls } from "@caisson/org-controls";
if (!holdsOrgControls(activeEntitlementIds)) {
throw new AuthzError("org-controls entitlement required");
}
```
`activeEntitlementIds` must already be filtered to active grants, the predicate never reads
the database itself, and an empty set denies.
## Composing with the base [#composing-with-the-base]
org-controls composes DOWN onto `@caisson/auth`, `@caisson/tenancy-rls`, and
`@caisson/kernel`: buyer session resolution and buyer tenant isolation are never
reimplemented here, only extended: the owner-only MANAGE surface and the cross-tenant
admin-write role sit beside those open primitives, never in place of them.
org-controls is a $249 standalone commercial module (also included in the Everything
bundle), gate access to its surfaces with `holdsOrgControls`.
# Auth (/docs/base/auth)
`@caisson/auth` is provider-agnostic: it defines the session contract the rest of the base
depends on, a short-lived **EdDSA-signed** account JWT for verifying a caller across services, and
multi-user account membership over row-level security. [better-auth](https://better-auth.com),
self-hosted, is the reference session provider, wired at the app layer, not re-exported from this
package. There is no third-party identity tenant holding your users.
## The contract [#the-contract]
Auth defines two seams. A same-process read (a dashboard route) resolves a `SessionContext`
straight from the session provider. A cross-plane call carries a short-lived EdDSA account JWT
that the receiving service verifies against the issuer's Ed25519 public key, no shared secret, no
network round trip:
```ts
import {
requireSession,
verifyAccountJwt,
type SessionContext,
} from "@caisson/auth";
// A cross-plane caller presents a short-lived EdDSA account JWT; the receiving service verifies
// it against the issuer's Ed25519 public key.
const session: SessionContext = verifyAccountJwt(token, issuerPublicKey);
// The ONE call a protected route makes before a tenant read — throws 401 on no session.
requireSession(session);
```
`session.accountId` is the claim [`tenancy-rls`](/docs/base/tenancy-rls)'s `withTenant` reads:
auth is the only producer of that claim, tenancy-rls the only consumer. That single seam is why a
request can cross from the control plane to the data plane without a shared-secret handshake.
## API reference [#api-reference]
### Session contract [#session-contract]
* **`SessionContext`**: `{ userId, accountId, role }`. `accountId` is the only value the data
layer trusts for RLS.
* **`Role`**: `"owner" | "seat"`.
* **`SessionProvider`**: the interface a runtime implements to resolve a request to a session.
better-auth is the reference implementation; nothing downstream depends on it directly.
* **`requireSession(ctx)`**: guards a protected route. Throws `AuthnError` (401) on `null`,
otherwise returns the session unchanged.
```ts
type Role = "owner" | "seat";
interface SessionContext {
userId: string;
accountId: string;
role: Role;
}
interface SessionProvider {
resolveSession(request: Request): Promise;
}
function requireSession(ctx: SessionContext | null): SessionContext;
```
### Account JWT [#account-jwt]
* **`generateAccountKeyPair()`**: generates an Ed25519 keypair. The issuer holds the private
key; every verifying service holds only the public key.
* **`signAccountJwt(claims, privateKey, options?)`**: mints an EdDSA-signed token. Default TTL
is 900 seconds (15 minutes); `now` is overridable for tests.
* **`verifyAccountJwt(token, publicKey, options?)`**: verifies the signature, expiry, and claim
shape, and returns the `SessionContext` the token asserts. A malformed token, a bad signature, a
token signed by a different key, and an expired `exp` all collapse to the same generic
`AuthnError("Invalid token")` (401), the verifier never leaks *why* a token failed, so a caller
can't use the error to probe for a valid key or a near-expiry window.
```ts
interface AccountClaims {
userId: string;
accountId: string;
role: Role;
}
interface SignOptions {
ttlSeconds?: number; // default 900
now?: number; // seconds; override for tests
}
function generateAccountKeyPair(): {
publicKey: KeyObject;
privateKey: KeyObject;
};
function signAccountJwt(
claims: AccountClaims,
privateKey: KeyObject,
options?: SignOptions,
): string;
function verifyAccountJwt(
token: string,
publicKey: KeyObject,
options?: { now?: number },
): SessionContext;
```
### Account membership [#account-membership]
* **`resolveUserAccounts(db, userId)`**: every account a signed-in user belongs to, scoped by
RLS (`withUser`) so a user reads only their own memberships. Ordered oldest-first, so the
personal account (created at first sign-in) sorts first.
* **`ensurePersonalAccount(db, userId)`**: guarantees a user has at least a personal account
(`accountId === userId`, role `owner`). Idempotent (a second call is a no-op) and runs
account-scoped so the RLS `WITH CHECK` is satisfied.
* **`selectActiveAccount(memberships, requestedAccountId?)`**: pure selection: honors
`requestedAccountId` when it names one of the caller's own memberships, else falls back to the
personal account, else the first (oldest) membership. Returns `null` only when the caller has no
memberships.
```ts
interface AccountMembership {
accountId: string;
userId: string;
role: Role;
}
function resolveUserAccounts(
db: Transactor,
userId: string,
): Promise;
function ensurePersonalAccount(db: Transactor, userId: string): Promise;
function selectActiveAccount(
memberships: readonly AccountMembership[],
requestedAccountId?: string,
): AccountMembership | null;
```
`Transactor` is [`tenancy-rls`](/docs/base/tenancy-rls)'s driver surface, the same one
`withTenant` and `withUser` accept.
### Schema [#schema]
* **`ACCOUNT_MEMBER_SCHEMA_SQL`**: the DDL for the `account_member` table: primary key
`(account_id, user_id)`, a `role` check constraint, and a **dual-GUC** RLS policy. A read passes
when either the tenant GUC (`withTenant`: an owner listing their account's members) or the user
GUC (`withUser`: a signed-in user resolving their own memberships) matches; a write requires the
tenant GUC, so a seat can't insert a membership row into an account they don't already hold.
Emit it into a migration the same way any other base package ships its schema constant.
```ts
const ACCOUNT_MEMBER_SCHEMA_SQL: string;
```
## Related [#related]
The sole consumer of the tenant claim. `withTenant` is the only RLS entry
point.
Buyer tool calls authenticate with their own per-buyer Bearer token
(timing-safe compared), a separate credential from this package's EdDSA
JWT.
# UI (/docs/base/ui)
`@caisson/ui` is the typed token floor and the styled component kit built on it. It ships a set of
`--cs-*` custom properties in **OKLCH**, delivered as exactly two themes (one light, one dark) plus
a catalog of components (Button, Card, Hero, Terminal, DataTable, and more) that read those tokens.
This docs site renders on those same tokens and components.
## The contract [#the-contract]
You re-skin by **swapping token values**, never by forking a component. A component reads a token; it
never hard-codes a hex value, so a brand change is a set of new variables, not a patch across the
component tree.
```css
:root {
--cs-bg: oklch(99% 0 0);
--cs-fg: oklch(20% 0 0);
--cs-accent: oklch(62% 0.19 256);
}
/* Re-skin = new values here. Components read the tokens; they never fork. */
```
## API reference [#api-reference]
`@caisson/ui` has three entry points: `@caisson/ui/tokens` (the raw token contract),
`@caisson/ui/theme` (compose and apply a theme at runtime), and `@caisson/ui/components`
(the styled kit, transpiled from raw `.tsx`: set `transpilePackages: ["@caisson/ui"]` in
`next.config`). `./styles/tokens.css` and `./styles/base.css` are exported for direct
``/`@import` use outside a bundler that resolves `.css` imports.
### Tokens, `@caisson/ui/tokens` [#tokens-caissonuitokens]
```ts
const foundation: Foundation; // type scale, weight, line-height, tracking, 4px space,
// radius, the rem breakpoint ladder, motion, elevation — frozen `as const`
const darkTheme: SemanticTheme; // locked default dark palette
const lightTheme: SemanticTheme; // locked default light palette
const functional: FunctionalTokens; // @deprecated back-compat alias of functionalDark
const functionalDark: FunctionalTokens; // dark-tuned variant
const functionalLight: FunctionalTokens; // light-tuned variant
const fonts: { sans: string; mono: string }; // locked stacks, CSS-var-wrapped with a literal fallback
function semanticThemeToCssVars(theme: SemanticTheme): Record;
function semanticCssLines(theme: SemanticTheme, indent?: number): string[];
const SEMANTIC_VAR_NAMES: ReadonlyArray;
```
Every `SemanticTheme` carries 15 OKLCH roles: `bg`, `surface1`/`surface2`,
`border`/`borderStrong`, `fg`/`fgMuted`, `accent`/`accentHover`/`onAccent`/`accentTint`,
`focus`, `link`, `glowAccent` (the accent instrument-glow shadow), and `scrim` (the
modal/drawer backdrop veil). `semanticThemeToCssVars`/`semanticCssLines` are the single
`--cs-*` mapping consumed by both the build-time CSS generator and the runtime theme API
below, so the two can't drift apart.
### Theme, `@caisson/ui/theme` [#theme-caissonuitheme]
```ts
function createTheme(options?: {
preset?: string; // "caisson" (default) | "pressure" | "bulkhead" | a registered custom id
overrides?: ThemeOverrides; // partial per-mode token overrides, Zod `.strict()`-validated
}): Theme; // { id: string; dark: SemanticTheme; light: SemanticTheme }
function applyTheme(
theme: Theme,
options?: { target?: Document; styleId?: string },
): void;
function themeToCssText(theme: Theme): string;
function themeToCssVars(theme: Theme): {
dark: Record;
light: Record;
};
function registerPreset(preset: ThemePreset): void;
function getPreset(id: string): ThemePreset | undefined;
function listPresets(): readonly ThemePreset[];
const DEFAULT_PRESET_ID: "caisson";
```
`createTheme` throws on an unknown preset id (the error names the registered ones) and on
an override with an unrecognized key or an empty-string token value, every token value is
also denylist-validated against `{ } < > ;`, because `applyTheme`/`themeToCssText`
interpolate it raw into a `