# 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 `