# 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. # agent-runner (/docs/agentic-dev/agent-runner) `@caisson/agent-runner` spawns a headless AI coding agent CLI as a detached subprocess in a caller-supplied worktree. It streams the run's `stream-json` output to a durable `.jsonl` transcript that survives the launcher process exiting, then parses that transcript into a structured report, tool calls, files touched, final result. ## What it does [#what-it-does] * **Env built from scratch, not inherited.** `buildEngineEnv()` never spreads `process.env`. It starts empty, copies only the `PASSTHROUGH_KEYS` allowlist (`PATH`, `LANG`, `LC_ALL`, `LC_CTYPE`, `TERM`, `TZ`, `TMPDIR`), then adds the target provider's routing vars and the one auth key the caller passed in, nothing else reaches the child. * **Provider-agnostic profile.** `ProviderConfig` is a Zod-validated `{binary, baseUrlEnv, authEnv, model, configDirEnv, modelEnv, args}` shape, no vendor is hardcoded. The shipped `CLAUDE_CLI_PROFILE` runs the Claude Code CLI headless in `stream-json` mode with `--strict-mcp-config`, so no MCP server can be smuggled into the sandbox. * **Detached, isolated worktree spawn.** `spawn()` launches the agent CLI with its own `HOME` and config dir inside a caller-supplied worktree, detached and unref'd so the run survives the launcher exiting. stdout/stderr write straight to the transcript file descriptor. * **A structured report, not a raw log.** `finalReport()` returns one `RunReport`: result text, files touched, tool-call count, binary, model, timestamps, the shape a caller reviews before trusting the diff. * **Fail-closed run registry.** Every `RunMeta` read off disk is `.strict()`-validated before use, and a `runId` is checked against a UUID shape before it ever becomes a path segment. `status()` self-heals a run whose process died without a recorded outcome. ## Install [#install] ```bash bun add @caisson/agent-runner ``` ## Quickstart [#quickstart] ```ts import { CLAUDE_CLI_PROFILE, createAgentRunner } from "@caisson/agent-runner"; const runner = createAgentRunner({ runsRoot: "/var/lib/caisson/agent-runs" }); const { runId } = runner.spawn({ provider: CLAUDE_CLI_PROFILE, // or any { binary, baseUrlEnv, authEnv, model, args } task: "implement the retry helper per SPEC.md", worktree: "/work/checkouts/feature-retry", // the sandbox; the diff lands here authKey: resolveProviderKey(), // injected — the runner never reads env/dotenv itself baseUrl: "https://api.anthropic.com", }); runner.tail(runId); // compact incremental transcript view runner.status(runId); // running | done | killed | error (+ rolling summary) runner.finalReport(runId); // { result, toolCalls, filesTouched, ... } ``` ## The security contract [#the-security-contract] The child env is the one place a secret could reach a process that egresses to a model provider. It is built from an empty object, never a `process.env` spread: ```ts const env: Record = {}; for (const key of PASSTHROUGH_KEYS) { const value = parentEnv[key]; if (typeof value === "string" && value.length > 0) env[key] = value; } // Isolation + provider routing only — no secret beyond the one provider key. env["HOME"] = opts.home; env[opts.provider.baseUrlEnv] = opts.baseUrl; env[opts.provider.authEnv] = opts.authKey; ``` Only the target provider's own auth key (the one the caller explicitly passed in) reaches the subprocess. A leak-guard test plants eight secret canaries (`OPENROUTER_API_KEY`, `GITHUB_TOKEN`, `AWS_SECRET_ACCESS_KEY`, and five more) into a polluted parent env and asserts none appear in the returned child env, by key or value; a second end-to-end test proves the same for a real spawned subprocess by planting a canary and asserting it never lands in the transcript. The `{task}` and `{model}` placeholders in a provider's `args` substitute only when they are an entire argv element, never spliced into a larger string, a hostile task string can't add, split, or merge argv entries, and there's no shell in the spawn path to inject into. ## What it does not do [#what-it-does-not-do] The contract stops at the worktree: the subprocess produces a diff and a transcript inside the worktree you gave it, and never touches git, opens a PR, or reaches a deploy target. Committing, opening the PR, and deploying stay the caller's job, one call site away. ## Composing with agent-kernel [#composing-with-agent-kernel] `@caisson/agent-runner` is the sandboxed execution primitive; `@caisson/agent-kernel` is the schema/FSM/governance/hooks/audit-chain base each run is accountable to. The Agentic-Dev bundle wires the two together with local hybrid memory, the sandboxed tool-exec gate, and a multi-harness emitter into one governed loop, buy the module alone to run agents from your own tooling, or the bundle for the assembled loop. `@caisson/agent-runner` is $49 à la carte, or included in the Agentic-Dev bundle alongside `@caisson/agent-kernel`. # tool-exec (/docs/agentic-dev/tool-exec) `@caisson/tool-exec` is the security floor Agentic-Dev's tool layer stands on: a governed gate between an agent's tool call and a real process spawn. A call names a registered logical command; anything unregistered is refused before anything spawns. ## What it does [#what-it-does] * **Default-deny command allowlist.** `createToolExec({ allowlist })`: an empty or absent allowlist refuses every call, fail-closed. An unregistered name throws `NotFoundError` before a process is spawned. * **`execFile` arg-arrays only.** Never `execSync`/`exec`/`shell: true`, never a concatenated command string. Each registered command declares a real executable plus a Zod-`.strict()` schema its args must satisfy; args are validated with `parseStrict` and the validated result becomes the exact argv array passed to `execFile`: no agent-supplied value ever reaches a shell. * **Structured argument provenance.** Every call returns `{ command, args, exitCode, stdout, stderr, ok, reason?, at }`, a plain-data audit record, output bounded to 64KB. A non-zero exit resolves in the record rather than throwing; only an unregistered name or a schema failure throws. * **Everything injected.** `cwd`, `timeoutMs`, the spawn seam (`execFn`), and the clock (`now`) are all config, no module-level secrets or constants for endpoints or executables. ## Install [#install] ```bash bun add @caisson/tool-exec ``` ## Quickstart [#quickstart] ```ts import { z } from "zod"; import { createToolExec } from "@caisson/tool-exec"; const toolExec = createToolExec({ allowlist: [ { name: "git-status", command: "/usr/bin/git", argsSchema: z.array(z.string()).max(1).default(["status"]), }, ], cwd: "/repo", timeoutMs: 30_000, }); const result = await toolExec.run("git-status", ["status"], "agent-turn-14"); // result.ok / result.exitCode / result.stdout / result.args (the resolved argv array) ``` A name not on the allowlist, or args that fail the schema, throw before any process spawns. ## Configuration [#configuration] `ToolExecConfig`: * `allowlist: CommandSpec[]`: required. Each entry is `{ name, command, argsSchema }`. * `cwd?: string`: defaults to `process.cwd()`. * `timeoutMs?: number`: defaults to `30_000`. * `execFn?: ExecFn`: inject a spawn double for tests; defaults to a real `execFile` call. * `now?: () => number`: inject a clock for testable provenance timestamps; defaults to `Date.now`. ## Composition [#composition] `tool-exec` depends on `@caisson/kernel` for `NotFoundError` and `parseStrict`: the same default-deny and schema-validation primitives the rest of the base substrate uses, so a tool call's failure modes look like every other kernel-governed boundary. It's the gate Agentic-Dev's agent kernel and agent-runner reach for whenever a running agent needs to touch a real executable. `@caisson/tool-exec` ships under the Agentic-Dev bundle license. # Agentic-Dev (/docs/agentic-dev) The **Agentic-Dev** bundle is the layer your own AI coding agent runs inside, not a layer that replaces your judgment with autonomy. Every agent, skill, and rule is a typed artifact; every act transition is a legal move on a state machine; every shell command an agent runs passes a default-deny gate. The kernel governs the loop, it does not hand the agent the keys. ## What's in the bundle [#whats-in-the-bundle] * **[agent-kernel](/docs/agentic-dev/agent-kernel)**: the engine-neutral base: a Zod schema for agent/skill/rule artifacts, a seven-act lifecycle FSM (spec through ship), allow/deny/mutate governance guards, a hooks dispatcher, and an opt-in tamper-evident audit chain. * **[agent-runner](/docs/agentic-dev/agent-runner)**: spawns a headless coding agent into an isolated worktree with a scrubbed, from-scratch environment: zero secret leak by construction, plus an auditable `.jsonl` transcript and a structured run report. * **[tool-exec](/docs/agentic-dev/tool-exec)**: the governed tool-call primitive: a default-deny command allowlist, Zod-strict argv validation, and `execFile` arg-arrays only, an agent never reaches a shell. * **[local-store](/docs/local-first/local-store)**: the hybrid vector + full-text local memory an agent recalls over, shared with the Local-first bundle. ## Install [#install] ```bash export CAISSON_LICENSE_TOKEN= bunx @caisson-sh/cli@latest --name caisson-app --edition agentic-dev cd caisson-app bun install ``` `--edition agentic-dev` auto-selects the Agentic-Dev 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] `agent-kernel`'s FSM is the spine every governed act moves through, `verify` is the only act with two outgoing edges, so a failed goal-backward verify reopens `plan` rather than advancing toward `ship`. `agent-runner` spawns the actual worker inside that governed loop, in an isolated worktree with an environment built from scratch rather than inherited. `tool-exec` is the gate every shell command from that worker passes through, a command outside the declared allowlist is unreachable, not merely discouraged. `local-store` gives the agent hybrid vector-plus-keyword recall that stays on the machine unless you wire an explicit egress. ## Composing with the base [#composing-with-the-base] `agent-kernel` imports no vendor SDK and runs no LLM, it is composition mechanism only, and both the base `@caisson/cli`/`@caisson/mcp-server` and this bundle's curated content consume it down-only. The same kernel underpins the `create-caisson` generator and the buyer-facing MCP server, so the rules that govern your own workflow are the rules that govern code generation. ## Entitlement [#entitlement] Agentic-Dev 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. ## Related [#related] Install the base, wire a tenant, and run the standards gate. The full manual: every package, how to compose it, and the contract it upholds. # agent-kernel (/docs/agentic-dev/agent-kernel) `@caisson/agent-kernel` is the engine-neutral base for governed AI agent work: a Zod schema for agent/skill/rule artifacts, a seven-act lifecycle state machine (spec through ship), allow/deny/mutate governance guards, a hooks dispatcher, and an opt-in tamper-evident audit-chain recorder. It imports no vendor SDK and runs no LLM, composition mechanism only, consumed down-only by both the base `cli`/`mcp-server` and the Agentic-Dev bundle. ## Install [#install] ```bash bun add @caisson/agent-kernel ``` ## Quickstart [#quickstart] ```ts import { parseArtifact, runLifecycle, transition, HookDispatcher, } from "@caisson/agent-kernel"; const agent = parseArtifact({ kind: "agent", name: "reviewer" /* … */ }); const next = transition("plan", "execute"); // "execute"; transition("spec","execute") throws const trace = runLifecycle([ "spec", "plan", "execute", "verify", "sweep", "eval", "ship", ]); const hooks = new HookDispatcher(); hooks.on("before:execute", (ctx) => { /* … */ }); await hooks.dispatch("before:execute", { act: "execute", phase: "before" }); ``` ## Typed agent/skill/rule schema [#typed-agentskillrule-schema] `AgentArtifact`, `SkillArtifact`, and `RuleArtifact` are a Zod `discriminatedUnion` on `kind`, built on `@caisson/kernel`'s `strictObject`: an unknown field is rejected outright, not silently dropped. A bad artifact fails through `parseArtifact` as a redaction-safe `ValidationError`: never the rejected values. ## The lifecycle FSM [#the-lifecycle-fsm] `ACTS` runs `spec` through `ship` in canonical order. `transition()` is the only way to move between acts and throws on any edge outside the fixed adjacency. The two branches that matter: ```ts const TRANSITIONS: Record = { spec: ["plan"], plan: ["execute"], execute: ["verify"], verify: ["sweep", "plan"], // a failed goal-backward verify reopens plan sweep: ["eval", "ship"], eval: ["ship"], // an eval regression has no edge but ship — fail-stop ship: [], // terminal }; ``` `verify` is the only act with two outgoing edges, a failed VERIFY reopens `plan`, it has no edge to `ship`. `ship: []` makes SHIP a hard terminal state in the type itself, not just a documented convention. ## Governance: allow / deny / mutate [#governance-allow--deny--mutate] `evaluateGuards` folds a `TransitionGuard[]` list fail-closed: the first `deny` short-circuits (remaining guards do not run), a `mutate(ctx)` threads its context into the guards after it, and a guard that **throws is itself treated as a deny**: a buggy guard can never accidentally admit a transition. ```ts import { predicateGuard, evaluateGuards } from "@caisson/agent-kernel"; const requireReview = predicateGuard( (t) => t.context.reviewed === true, "ship requires a recorded review", ); evaluateGuards([requireReview], { from: "sweep", to: "ship", context }); ``` ## Hooks: fail-open on crashes, fail-closed on vetoes [#hooks-fail-open-on-crashes-fail-closed-on-vetoes] `HookDispatcher.dispatch` runs registered `before:`/`after:` act handlers in registration order and awaits each. A handler that throws is isolated, reported to an optional sink (hook name + error type only, never a message or stack), and treated as allow; a handler that returns `deny()` still short-circuits the loop. An unregistered point is a no-op. `commandHandler` runs a fixed argv array through `node:child_process` `execFile`: no shell is spawned, and no `HookContext` value can reach the command's arguments, shell injection through a hook is structurally impossible. ## Opt-in tamper-evident audit chain [#opt-in-tamper-evident-audit-chain] `AuditedLifecycle` wraps every governed transition with the kernel's `chainEntry`/`anchorChain`/`verifyChain` hash-chain primitives, the same mechanism the Compliance bundle's `audit-worm` package uses. Off by default; set `audited: true` with a store (`InMemoryAuditLifecycleStore` ships for offline/CLI use, or bring your own) and each admitted step becomes an append-only, tamper-evident chain entry. ## Composition [#composition] agent-kernel imports no vendor SDK and runs no LLM: it contributes the schema/FSM/ governance/hooks/audit-chain mechanism, never the engine wiring. It sits below the edition line, both base `cli`/`mcp-server` and the Agentic-Dev bundle's curated agent/skill/rule content compose it down-only. agent-kernel is $199 à la carte and included in the Agentic-Dev bundle, which wires it together with [agent-runner](/docs/agentic-dev/agent-runner), the sandboxed [tool-exec](/docs/agentic-dev/tool-exec) gate, and the local hybrid memory ([local-store](/docs/local-first/local-store)) into one governed loop. # Everything (/docs/everything) The **Everything** bundle is the full catalog: every module across [Compliance](/docs/compliance), [AI-Production](/docs/ai-production), [Local-first](/docs/local-first), [Agentic-Dev](/docs/agentic-dev), and [Provenance](/docs/provenance), plus the standalone commercial modules no persona bundle grants, org controls, billing orchestration, and the UI Pro component layer, in one purchase. ## What's in the bundle [#whats-in-the-bundle] Everything contains every sellable SKU in the catalog by construction, it is never listed on an individual module's bundle membership because membership in it is implicit. That includes: * Every module in the five persona/provenance bundles above (field-crypto, audit-worm, retention-runner, alerting, compliance-core, frameworks-pack, signing-primitive, oscal-spine, access-review, risk-register, trust-page, ai-meter, ai-evals, guardrails, prompt-registry, credits, local-store, local-sync, local-inference, local-privacy, agent-kernel, agent-runner, agent-trajectory, tool-exec). * **[org-controls](/docs/base/org-controls)**: WorkOS SSO plus the owner-gated multi-user surface and admin-write RLS layer. * **[billing-orchestration](/docs/base/billing-orchestration)**: the multi-provider billing engine (Paddle, Stripe, LemonSqueezy, Polar) behind one `BillingProvider` port. * **[ui-pro](/docs/base/ui-pro)**: the commercial component tier on the open `@caisson/ui` base: DataTablePro, OpsMatrix, AuditTimeline, CommandPalette, charts, and the interactive primitives. ## Install [#install] ```bash export CAISSON_LICENSE_TOKEN= bunx @caisson-sh/cli@latest --name caisson-app --edition everything cd caisson-app bun install ``` `--edition everything` auto-selects the Everything 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] Every module in Everything composes exactly the way it composes inside its own persona bundle: Everything changes what's licensed, not how the modules wire together. A module that spans several bundles (`field-crypto` across Compliance, AI-Production, Local-first, and Provenance) is granted once, not duplicated per bundle. ## Composing with the base [#composing-with-the-base] Everything sits on the same open-core [base substrate](/docs/base) as every other bundle: `@caisson/kernel`, `@caisson/tenancy-rls`, `@caisson/ui`, `@caisson/billing`, and the rest of the Apache-2.0 packages, and adds the full commercial layer on top. ## Entitlement [#entitlement] Everything is a commercial bundle (`LicenseRef-Caisson-Commercial`), one-time perpetual, the same model as every other bundle. A purchase grants every member module's entitlement id, checked offline against the license. # create-caisson (/docs/cli/create-caisson) ## What it does [#what-it-does] `@caisson/cli` is the generator. You pick a bundle and the modules you want; it assembles a tailored codebase by pulling versioned module sources from the Caisson registry. Bundles are compositions of the same audited base: the generator composes, it never forks. ```bash bunx @caisson-sh/cli@latest my-app cd my-app bun install ``` The first prompt asks what to generate — a licensed build, a free sample, or a demo. Then you name the project and select modules. A bundle is chosen with the `--edition ` flag rather than a wizard question; passing one pre-checks its current members in the module list, and you can still add or remove from there. The base substrate — `kernel`, `auth`, `tenancy-rls`, `ui`, `billing`, `jobs`, `email`, `ai-config`, `mcp-server`, `registry-schema`, `observability`, `rate-limit`, `ds-manifest` — plus the generator tooling `cli`, `migrate`, and `license-verify`, is always free to install, independent of any entitlement. Paid modules such as `credits` are not part of it; they are entitlement-gated. ## The registry contract [#the-registry-contract] Every module the CLI emits is pulled from a **versioned** registry, not copied from a moving template. A generation resolves the module versions it used and pins them directly into the generated `package.json` `dependencies` (no separate lockfile) so the same inputs reproduce the same tree. The CLI and the buyer's AI agent read from the one registry: there is no second, drifting source. Pass `--dry-run` to see exactly what a selection resolves to before anything is written: ```bash $ bunx @caisson-sh/cli@latest my-app --edition compliance --dry-run create-caisson: dry-run — 11 files for "my-app" (compliance edition) .github/workflows/ci.yml .gitignore .npmrc AGENTS.md README.md eslint.config.js package.json src/__golden__/evidence.json src/__golden__/smoke.json src/golden.test.ts tsconfig.json ``` The bundle's modules arrive as pinned `dependencies` in that `package.json` rather than as copied source, which is why the scaffold itself is small. Without `--dry-run` the same command writes the tree and prints the next steps: ```bash $ bunx @caisson-sh/cli@latest my-app --edition compliance create-caisson: generated "my-app" → my-app Next steps: 1. cd my-app 2. Add your Caisson license key to .npmrc (see README.md) 3. bun install # or: npm install 4. bun run build ``` The contract it upholds: a generation is **reproducible** (exact module versions pinned into `package.json`, never a range) and **metered** (codegen-credits are integer units, never floats; a run debits an exact count, or it fails closed before writing). Metering applies to the hosted buyer-MCP path; the local binary above generates for free. ## Drive it from your agent [#drive-it-from-your-agent] The shipped MCP server is auth-gated. Your Claude Code or Cursor agent authenticates, reads the same registry, and runs the same generation the CLI does, so the agent can scaffold and reconfigure the codebase without you leaving the editor. The `generate` tool call returns the generation's id (stable across same-key retries), not the file contents. ```ts // Illustrative: your agent calls the auth-gated MCP server, which resolves modules from the // versioned registry, meters the run, and writes the generated tree to `target`. const result = await caisson.callTool("generate", { edition: "compliance", modules: ["audit-worm", "field-crypto"], target: "./my-app", }); // result.generationId — the canonical generation audit row id, stable across same-key retries ``` ## API reference [#api-reference] ### CLI flags [#cli-flags] | Flag | Value | Description | | ----------------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `` (positional) | slug | Shorthand for `--name `: the advertised `bunx create-caisson my-app` quickstart. An explicit `--name` always wins on conflict. | | `--name ` | a-z0-9, kebab, max 64 chars | Project name; the output directory unless `--out` is set. | | `--module ` | repeatable | An exact `@caisson/@` pin. Split on the *last* `@`, so a scoped id keeps its leading `@`. | | `--edition ` | `compliance` \| `ai-production` \| `local-first` \| `agentic-dev` \| `provenance` \| `everything` | A bundle, exactly these six ids. Alone it auto-selects the bundle's current modules (override with `--module`). The retired ids `ai-kit` / `local-ai` / `agent-dev` are rejected outright. | | `--deploy ` | `railway` \| `fly` \| `vercel` | Layers that deploy target's template directory on top of base (+ bundle). | | `--framework ` | `next` | Layers a wired Next.js App-Router starter demonstrating auth/tenancy/billing/jobs/email/ai-config wiring on the base substrate. | | `--sample ` | e.g. `eu-ai-act-sample` | A free, Apache-2.0 evaluation sample. No `--module`/`--edition`, no license key. Mutually exclusive with `--demo`. | | `--demo` | flag | Full-catalog generation with every commercial module replaced by a watermarked stub. No license key, never for production. | | `--out ` | path | Output directory (defaults to the project name). | | `--dry-run` | flag | Print the file plan; write nothing. | | `--help`, `-h` | flag | Print usage. | Run with no flags in a terminal and `create-caisson` prompts for whatever's missing, licensed build vs. free sample vs. full-catalog demo, project name, modules. Any flag you *did* pass is never re-prompted; a non-TTY invocation (CI, piped stdin) fails closed on a missing required field instead of hanging on a prompt. ### The registry index schema [#the-registry-index-schema] `@caisson/registry-schema` owns the index the CLI, the buyer MCP, and the docs all read against. Every module id **and** version is checked before it reaches a path or a subprocess argument: a bad id never gets far enough to matter: ```ts function assertKnownModule( index: RegistryIndex, id: string, ): asserts id is ModuleId; function assertKnownVersion( index: RegistryIndex, id: string, version: string, ): void; function loadRegistryIndexFromFile(path: string): RegistryIndex; ``` `assertKnownModule` re-checks the `@caisson/` shape and membership in the index; `assertKnownVersion` additionally requires that exact version among the module's published `versions`. Both throw a plain `Error` on a miss, synchronously, before any generation work starts. `loadRegistryIndexFromFile` is the one sanctioned read path (`RegistryIndex.parse` underneath); a malformed or tampered index file throws rather than yielding a half-typed object. ```ts interface RegistryIndex { schemaVersion: 1; modules: { id: string; // "@caisson/" latest: string; // semver versions: { version: string; // semver manifest: ModuleManifest; publishedAt: string; // ISO 8601 gateAttestation: string; // "@" — provenance, not the access control }[]; }[]; } ``` The index is built by a CI-only writer, never hand-appended: a version's presence here is what "published" means. A generation resolves against this index once and pins the exact versions it used into `package.json`; nothing re-resolves against `latest` after that. ### Generation and metering [#generation-and-metering] `@caisson/cli` validates a raw selection against the registry allowlist, then materializes it: in-memory, no disk write yet: ```ts function validateSelection(index: RegistryIndex, raw: unknown): Selection; function generate( index: RegistryIndex, raw: unknown, engine?: GeneratorEngine, // defaults to templatesEngine ): { selection: Selection; files: GeneratedFileSet }; ``` `Selection` is a `.strict()` Zod object, a lowercase slug `projectName`, an optional bundle `edition`, `modules: { id, version }[]` (at least one, no duplicate ids), and optional `deployTarget` / `framework`. `validateSelection` parses it, then runs `assertKnownModule` / `assertKnownVersion` on every module: an unknown id or a stale version throws before the engine is ever invoked. The local `create-caisson` binary calls `generate` plus a disk `FileSetWriter` directly and stops there: it generates for free; the license key gates the package *install*, not the generation. The hosted buyer MCP instead drives the metered path: ```ts function runGeneration( tx: TenantExecutor, deps: GenerationDeps, raw: unknown, meter: MeterInput, ): Promise; interface MeterInput { accountId: string; idempotencyKey: string; // caller-minted UUID; a retry with the same key debits once amount?: number; // integer credits, default 1 } interface GenerationOutcome { selection: Selection; files: GeneratedFileSet; balance: number; idempotent: boolean; generationId: string; // the canonical generation audit row id, stable across retries } ``` `runGeneration` runs in one transaction: validate + compose the file set (bundle member pins, migration assembly, read-only), **debit before writing anything** (a short balance throws before any file touches disk), write via the injected `FileSetWriter`, then record one append-only audit row keyed on `(accountId, idempotencyKey)`. A retried call with the same key debits once and resolves to the same `generationId` instead of a duplicate row. `createFileSetWriter(raw?: unknown)` — Zod-validated to `{ overwrite?: boolean }`, default `overwrite: false` — is the disk writer: every path is checked for null bytes, absolute paths, and `..` segments before anything is written, and the whole set lands via a sibling temp directory + atomic rename; a failed write leaves no partial tree. ### MCP tool schemas [#mcp-tool-schemas] The three base tools every authenticated buyer sees, regardless of entitlement: ```ts // list_modules — no args type ListModulesResult = { modules: string[] }; // the caller's owned entitlement slugs, sorted // describe_module type DescribeModuleArgs = { name: string }; // 1-128 chars type DescribeModuleResult = { name: string; summary: string }; // throws EntitlementError (403) if the caller doesn't own `name` // generate type GenerateArgs = { projectName: string; // ^[a-z0-9][a-z0-9-]*$, max 64 chars edition?: "compliance" | "ai-kit" | "local-ai" | "agent-dev"; modules: { id: string; version: string }[]; // 1-100 entries idempotencyKey?: string; // UUID; minted server-side when omitted, reused verbatim on a retry }; type GenerateResult = { generationId: string }; ``` Note `generate`'s `edition` is the legacy `compliance | ai-kit | local-ai | agent-dev` vocabulary, disjoint from the CLI's `--edition` flag, which accepts only the six current bundle ids (`compliance`, `ai-production`, `local-first`, `agentic-dev`, `provenance`, `everything`) and rejects the three retired legacy names outright. Omit `edition` and pass modules directly if you're targeting a bundle the legacy MCP vocabulary doesn't cover. Every `generate` call is allowlist-checked (id **and** version) against the same registry index the CLI validates against, before any entitlement check runs. Ownership is then checked against the caller's entitlements expanded through their bundle/module purchases, request a module outside that expanded set and the call throws `EntitlementError` (403) naming every id it isn't entitled to. A tool the caller isn't entitled to is invisible: it's excluded from `list_tools`, and calling it directly 404s exactly like a name that doesn't exist. A retired tool name instead answers `410` with a reason. When the host wires a rate limiter, every call is throttled per account before it reaches a handler (`429`). Install the base, wire a tenant, and run the standards gate. The full manual, every package and the contract it upholds. # local-privacy (/docs/local-first/local-privacy) `@caisson/local-privacy` is the Local-first bundle's privacy gate: a strict, zero-egress `PrivacyPolicy` (Zod `.strict()`, closed enums) plus `EgressGuard`, the runtime wrapper over the kernel `fetchWithTimeout` chokepoint. The package imports no vendor SDK and makes no live network call itself, it only decides, before any socket opens, whether a request is allowed to leave. ## What it does [#what-it-does] * **Fail-closed-to-offline.** An empty or omitted allowlist blocks every host. A non-allowlisted host, a non-`https:` scheme, or a malformed URL are all blocked before `fetchWithTimeout` is ever reached, so no socket opens and no bytes leave the device. * **A closed privacy mode.** `privacy` is a Zod enum whose only member is `"local-only"`: there is deliberately no `"hosted"` mode a config value could flip to. * **Purpose-bound sinks.** Every allowlist entry declares one of two sanctioned kinds, `model-fetch` or `rented-backend`. `assertAllowedFor` / `fetchAs` require a host to be allowlisted for the specific kind in use, so a Bearer-credentialed rented-backend request can never reach a host sanctioned only for the model-fetch download, and vice versa. * **`guardedFetch`**: the guard as a bare `(input, init) => Promise` another runtime can install as its sole outbound hook (e.g. transformers.js's `env.fetch`), so that runtime cannot egress out of band. ## Install [#install] ```bash bun add @caisson/local-privacy ``` ## Quickstart [#quickstart] ```ts import { createEgressGuard, localOnlyPolicy } from "@caisson/local-privacy"; const guard = createEgressGuard( localOnlyPolicy([{ host: "huggingface.co", kind: "model-fetch" }]), ); // blocked before any socket opens — huggingface.co isn't allowlisted for "rented-backend" await guard.fetchAs("rented-backend", "https://huggingface.co/model.onnx"); // allowed — sanctioned for model-fetch const res = await guard.fetch("https://huggingface.co/model.onnx"); ``` ## The air-gap default [#the-air-gap-default] `ZERO_EGRESS_POLICY` is `local-only` with an empty allowlist, every outbound host blocked, with no config to flip. It's the baseline an edition installs unless a deployer explicitly opts a sanctioned sink in: ```ts import { ZERO_EGRESS_POLICY, createEgressGuard } from "@caisson/local-privacy"; const guard = createEgressGuard(ZERO_EGRESS_POLICY); await guard.fetch("https://anything.example.com"); // throws: not on the privacy allowlist ``` ## Installing as another runtime's fetch hook [#installing-as-another-runtimes-fetch-hook] `guardedFetch` matches the `(input, init) => Promise` shape a runtime like transformers.js expects for its outbound hook, so the model loader itself cannot reach a host the policy hasn't sanctioned: ```ts env.fetch = guard.guardedFetch; ``` ## Configuration [#configuration] The policy is data, not env vars, construct a `PrivacyPolicy` and pass it to `createEgressGuard`: ```ts import { parsePrivacyPolicy } from "@caisson/local-privacy"; const policy = parsePrivacyPolicy({ privacy: "local-only", allowlist: [ { host: "huggingface.co", kind: "model-fetch" }, { host: "api.your-rented-backend.com", kind: "rented-backend" }, ], }); ``` `allowlist` is capped at 16 entries and defaults to `[]`. `parsePrivacyPolicy` throws a redaction-safe `ValidationError` on any unknown key, bad host, or unknown mode/kind: the one boundary every policy passes through before a guard trusts it. `EgressGuard`'s constructor re-parses defensively, so a hand-built or deserialized policy object that bypassed that boundary still fails closed. ## Composing with the base [#composing-with-the-base] `EgressGuard.fetch` routes every allowed request through the kernel `fetchWithTimeout` chokepoint, the package never opens a socket itself. Errors are the kernel's typed `ValidationError` (malformed URL) and `AuthzError` (blocked scheme or host), carrying only the host and scheme in their details, never the full URL, so a blocked path or query string can't leak a token or PII into a log. local-privacy is sold standalone, or as one of the primitives composing the Local-first bundle alongside `@caisson/local-inference`, `@caisson/local-store`, and `@caisson/local-sync`. # local-store (/docs/local-first/local-store) `@caisson/local-store` is Caisson's on-disk hybrid retrieval engine: `sqlite-vec` (`vec0`) for vector KNN and SQLite FTS5 for keyword search, fused by Reciprocal Rank Fusion (`RRF_K=60`). It runs FTS5-only with no embedder configured, the vector leg degrades cleanly on any backend fault, so retrieval never hard-fails for lack of a model. ## Install [#install] ```bash bun add @caisson/local-store ``` ## Quickstart [#quickstart] ```ts import { LocalStore, openTenantDb, tenantDbPath } from "@caisson/local-store"; // Per-tenant file is the isolation boundary (tenantId comes from authenticated context). const db = openTenantDb("/var/lib/caisson/tenants", tenantId); const store = LocalStore.open({ dim: 3 }); // dimension fixed at table creation store.upsert({ id: "a", text: "the quick brown fox", embedding: [0.9, 0.1, 0.0], }); store.upsert({ id: "b", text: "lazy dog sleeps" }); // FTS-only doc (no embedding) // Hybrid (both legs); omit queryVector to take the always-available FTS5-only path. const hits = store.hybridSearch({ queryText: "fox", queryVector: [1, 0, 0], limit: 10, }); ``` ## Hybrid search [#hybrid-search] `hybridSearch` runs the `vec0` KNN leg and the FTS5 leg independently, then fuses them by Reciprocal Rank Fusion. Either leg can come up empty, a missing query vector, an empty query, or a vec backend fault, and the other still returns results: ```ts hybridSearch(opts: HybridSearchOptions): SearchHit[] { const legLimit = Math.max(limit * 8, 50); const vecRanks = this.vecLeg(opts.queryVector, legLimit); const ftsRanks = this.ftsLeg(opts.queryText, legLimit); // RRF fusion: every leg a doc appears in contributes 1/(RRF_K + rank); sum across legs. const fused = new Map(); for (const [rowid, rank] of vecRanks) fused.set(rowid, (fused.get(rowid) ?? 0) + 1 / (RRF_K + rank)); for (const [rowid, rank] of ftsRanks) fused.set(rowid, (fused.get(rowid) ?? 0) + 1 / (RRF_K + rank)); return [...fused.entries()] .sort((a, b) => b[1] - a[1] || a[0] - b[0]) // score desc, deterministic tie-break by rowid .slice(0, limit) .map(([rowid, score]) => ({ id: this.docId(rowid), score })); } ``` A dimension mismatch on an indexed embedding throws, the package flags rather than guesses. ## File-per-tenant isolation [#file-per-tenant-isolation] `tenantDbPath` and `openTenantDb` resolve one SQLite file per tenant under a root directory. The path is rejected fail-closed on traversal, null bytes, absolute paths, or path separators before anything is opened, a cross-tenant query is not expressible, because a connection only ever holds one tenant's file. ## Embedding is an injected seam [#embedding-is-an-injected-seam] `local-store` never calls an embedding model or opens a socket. `Embedder` is an interface your app or the consuming bundle wires; `embedOrSkip` treats an absent embedder as a first-class mode: retrieval runs on the FTS5 leg alone, not an error, not a silent default model. When you do wire a cloud embedder, `scrubForEgress` runs on every text before it leaves the box, stripping PEM key blocks, URL userinfo passwords, secret-named assignments, and bare token shapes. `guardEmbedder` and `createCloudEmbedder` apply it structurally: ```ts import type { Embedder } from "@caisson/local-store"; import { createCloudEmbedder } from "@caisson/local-store"; const embedder: Embedder = createCloudEmbedder({ // scrubForEgress runs on every text before this fetch — PEM blocks, URL passwords, // secret-named fields, and bare token shapes are redacted first. endpoint: "https://api.example.com/embed", apiKey: process.env.EMBED_API_KEY!, model: "text-embed-3", dim: 3, }); ``` ## Dedup-on-write + retention GC [#dedup-on-write--retention-gc] `decideWrite` hashes normalized content per scope and reinforces an existing duplicate (resets its recency, slides its TTL) instead of writing a second row. `planGc` then evicts in order: expired, decayed below a score floor, or over a per-scope cap, as a pure function of items, config, and the current time. `MemoryItemSchema` is a Zod `.strict()` boundary: UUID ids, bounded text (100k chars) and scope (256 chars), optional string-to-string metadata, an unknown key is rejected, not silently dropped. ## Configuration surface [#configuration-surface] * `LocalStore.open({ dim })`: vector dimension fixed at table creation; a later mismatch throws. * `openTenantDb(root, tenantId)`: the root directory and tenant id resolving to one file. * `parseGcConfig`: `.strict()`-validated retention config (TTL, per-scope cap). ## Composing with the base [#composing-with-the-base] `local-store` runs `bun:sqlite` plus the `sqlite-vec` extension on disk, no separate database to run or pay for, and no vendor SDK import. It contributes the store/fuse/isolation mechanism only; the consuming bundle wires the embedder and the curated content on top. `@caisson/local-store` is the retrieval engine inside the Local-first bundle, alongside on-device inference and the privacy gate. It is also sold standalone at $99 to add hybrid search to any stack without the rest of the bundle. # local-sync (/docs/local-first/local-sync) `@caisson/local-sync` converges any number of per-tenant replicas onto one canonical local store. It's an application-layer changeset log, `bun:sqlite` exposes no `sqlite3session_*` API, so this is the in-house analog, paired with a hybrid-logical-clock (HLC) last-writer-wins merge and a durable tombstone index, so a stale batch can't resurrect a row a peer already deleted. ## What it does [#what-it-does] * **`ChangesetLog`**: per-tenant change capture bound to one already-open SQLite file (the file IS the tenant partition). `recordUpsert` / `recordDelete` mirror your local writes as they happen; `capture(sinceSeq)` packages everything past a watermark into a tenant-bound, replica-stamped `Changeset` a peer can pull. * **`reconcileReplicas`**: a pure, deterministic LWW merge over any number of replicas' changesets, keyed by `HlcStamp` (physical time → replica id → per-replica sequence), so the winner is unambiguous and input-order-independent even under clock skew. * **`reconcileWithTombstones` / `gcTombstones`**: merge over a *persisted* tombstone set (prior deletes participate as virtual deletes across sync rounds), plus horizon-based garbage collection of tombstones that are safe to drop. ## Install [#install] ```bash bun add @caisson/local-sync ``` ## Quickstart [#quickstart] ```ts import { ChangesetLog, reconcileReplicas } from "@caisson/local-sync"; import { Database } from "bun:sqlite"; const log = ChangesetLog.open(new Database(":memory:"), "tenant-a"); log.recordUpsert("notes", "n1", { title: "hello" }); const changeset = log.capture(0); // everything since watermark 0 const converged = reconcileReplicas([changeset]); // [{ table: "notes", pk: "n1", values: { title: "hello" } }] ``` ## The tenant-partition guard [#the-tenant-partition-guard] A `ChangesetLog` is bound to exactly one tenant's file on `open()`. A changeset from a different tenant can't be applied to it, `assertApplicable` fails closed before any merge runs: ```ts const changeset = peerLog.capture(0); // tenant-b's changeset log.assertApplicable(changeset); // throws TenancyError: tenant-partition ``` ## Convergence across sync rounds [#convergence-across-sync-rounds] `reconcileWithTombstones` folds a persisted tombstone set into the merge, so a replica that reconnects late can't undo a delete another peer already made durable: ```ts import { reconcileWithTombstones, gcTombstones } from "@caisson/local-sync"; const { live, tombstones } = reconcileWithTombstones(priorTombstones, changesets); // live: converged live rows, delete-excluded // tombstones: the advanced set to persist for the next round const kept = gcTombstones(tombstones, horizon); // drop tombstones below the convergence horizon ``` A strictly-greater-stamped upsert still legitimately un-deletes a tombstoned row, the guard is against resurrection by a stale, lower-stamped batch, not against later intent. ## Composition [#composition] `local-sync` sits over the base kernel (`@caisson/kernel`) for its error model (`TenancyError`, `ValidationError`) and boundary parsing, a peer-supplied changeset is untrusted input, validated `.strict()` before it touches the merge. It composes into the Local-first bundle alongside `local-store`, `local-inference`, and `local-privacy`. `@caisson/local-sync` is licensed `LicenseRef-Caisson-Commercial`: sold standalone or as part of the Local-first bundle. # Local-first (/docs/local-first) The **Local-first** bundle keeps inference and retrieval on the machine that holds the data. A default-deny egress gate is the contract: nothing crosses the network boundary unless a typed allowlist names it, so "on-device" is enforced in code, not promised in a README. ## What's in the bundle [#whats-in-the-bundle] * **[local-inference](/docs/local-first/local-inference)**: the `InferenceBackend` seam over a MiniLM-class ONNX model via transformers.js, SHA-256 hash-verified before use. On-device by default; a remote target is an explicit opt-in. * **[local-privacy](/docs/local-first/local-privacy)**: the `EgressGuard` runtime wrapper over a strict zero-egress `PrivacyPolicy`: every payload crosses it before it can leave the process, and an empty allowlist blocks every host. * **[local-store](/docs/local-first/local-store)**: hybrid vector + full-text retrieval on disk, one SQLite file per tenant, `sqlite-vec` KNN fused with FTS5 by Reciprocal Rank Fusion, no vector cloud involved. * **[local-sync](/docs/local-first/local-sync)**: two-way offline sync for those per-tenant SQLite files: a changeset log, a hybrid-logical-clock last-writer-wins merge, and tombstone-aware convergence. * **[field-crypto](/docs/provenance/field-crypto)**: per-tenant field encryption for anything stored in the local database, shared with the Compliance, AI-Production, and Provenance bundles. ## Install [#install] ```bash export CAISSON_LICENSE_TOKEN= bunx @caisson-sh/cli@latest --name caisson-app --edition local-first cd caisson-app bun install ``` `--edition local-first` auto-selects the Local-first 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] `local-inference` runs embeddings on-device; `local-store` persists and searches the vectors it produces; `local-sync` reconciles that same per-tenant SQLite file across devices without a server round-trip. `local-privacy`'s `EgressGuard` sits in front of all three, any path that would send a payload off the device passes through it first, and an empty allowlist blocks every host by default. `field-crypto` seals sensitive columns in the local store the same way it seals a Postgres column: a per-tenant derived key, never a shared one. ## Composing with the base [#composing-with-the-base] The tenant boundary is the same one `@caisson/tenancy-rls` enforces server-side: each local SQLite file is scoped to one tenant, so a sync merge or a vector search never crosses into another tenant's data even without a network round-trip to check. ## Entitlement [#entitlement] Local-first is a commercial bundle (`LicenseRef-Caisson-Commercial`), own the source, build unlimited products, no redistribution of the kit. Buy the bundle, or any member module à la carte; a purchase grants the module's entitlement id, checked offline against the license so the bundle keeps working on an air-gapped machine. ## Next steps [#next-steps] Install the base, wire a tenant, and run the standards gate. # local-inference (/docs/local-first/local-inference) `@caisson/local-inference` is one `InferenceBackend` port (`embed()` and `complete()`) with three implementations sharing it: a deterministic offline stub, an on-device ONNX embedder, and metered rented-provider transports. Every outbound byte, on-device or rented, routes through the same `@caisson/local-privacy` egress gate. ## What it does [#what-it-does] * **One port, three backends.** `StubInferenceBackend` is a pure function of its input text (SHA-256-seeded PRNG → unit-norm vector) and the only backend CI exercises. `OnnxEmbeddingBackend` runs a MiniLM-class model on-device via transformers.js, hash-verified before use. `RentedInferenceBackend` wraps a metered `RentedTransport` behind the same port. * **`EMBEDDING_DIM` is the locked contract.** Every backend's `embed()` result is exactly this many floats, the width `@caisson/local-store`'s `vec0` table is opened with. A mismatch throws at the store's dim-guard rather than silently padding or truncating. * **Egress stays purpose-bound.** The ONNX and rented backends both route outbound calls through a `@caisson/local-privacy` `EgressGuard`, re-exported here. ## Install [#install] ```bash bun add @caisson/local-inference ``` ## Quickstart [#quickstart] ```ts import { StubInferenceBackend, EMBEDDING_DIM } from "@caisson/local-inference"; // deterministic, offline — no model, no socket const backend = new StubInferenceBackend({ dim: EMBEDDING_DIM }); const vector = await backend.embed("the quick brown fox"); // Float32Array, unit-norm const { text } = await backend.complete({ prompt: "summarize: fox jumps", maxTokens: 32, }); ``` ## On-device embeddings [#on-device-embeddings] `OnnxEmbeddingBackend` loads a MiniLM-class model lazily on first `embed()` call. The model host is allowlisted through an `EgressGuard`, and every fetched file is SHA-256 verified against a hash-pin before it reaches the runtime, a mismatch fails closed. Completion is intentionally unsupported on this backend; wire a rented backend for generation. ```ts import { OnnxEmbeddingBackend, DEFAULT_ONNX_MODEL } from "@caisson/local-inference"; const backend = new OnnxEmbeddingBackend({ ...DEFAULT_ONNX_MODEL, cacheDir: "./.cache/models", integrity: { "model_quantized.onnx": "…64-char sha256 hex…", }, }); const vector = await backend.embed("the quick brown fox"); ``` Air-gapped deployments pre-seed the cache and pass `offline: true`: zero egress after the initial seed. ## Rented (metered, off by default) [#rented-metered-off-by-default] `RentedInferenceBackend` wraps `createOpenRouterRentedTransport`, `createAzureOpenAIRentedTransport`, or `createBedrockRentedTransport`. It refuses to construct unless its endpoint host is HTTPS and explicitly allowlisted as a `rented-backend` sink in the privacy policy, there is no way to reach a hosted provider without that opt-in, and no silent fallback. Every call emits one metered record through a `meter` sink before returning; the caller wires that sink to `@caisson/credits` or leaves it a no-op in tests. ## Composition [#composition] Both real backends share `@caisson/local-privacy`'s `EgressGuard`: re-exported from this package so a consumer doesn't need a second import for the same policy. `embed()` output feeds `@caisson/local-store`'s `vec0` table directly; the dimension must match at open time. Base primitive, paid: `LicenseRef-Caisson-Commercial`. # field-crypto (/docs/provenance/field-crypto) `@caisson/field-crypto` encrypts individual fields under a distinct tenant key. A KMS-backed deployment wraps each tenant's data-encryption key under a tenant-specific key-encryption key; the dev/self-hosted zero-infra path can instead derive tenant keys with HKDF-SHA256 from an environment-held master key. One tenant's key cannot decrypt another tenant's ciphertext, the isolation is in the key material and authenticated envelope, not in an application check that could be skipped. ## What it does [#what-it-does] * **Per-tenant field encryption**: encrypt and decrypt named fields (a patient SSN, a bank token) scoped to a tenant. The plaintext is never stored. * **Two key-provider modes**: hosted KMS deployments generate a random DEK per tenant/version and persist only its wrapped form; the zero-infra dev/self-hosted path derives per-tenant subkeys deterministically from a root key with HKDF-SHA256. * **AAD-bound AES-256-GCM envelope**: every ciphertext binds tenant, key version, and column (and, for row-bound fields, row id) as GCM's additional authenticated data. A self-describing envelope carries its own format/algorithm/key-version header. * **Key-version rotation with no bulk re-encrypt**: bump a tenant's current version; older envelopes keep decrypting under the version they were written with. * **Crypto-shred erasure**: request deletion of a tenant or subject's key-encryption key through the KMS port without mutating an append-only audit chain. Recoverability follows the provider's deletion receipt: soft-deleted keys remain recoverable until their retention window closes or an irreversible purge succeeds. ## Install [#install] ```bash bun add @caisson/field-crypto ``` ## Quickstart [#quickstart] The zero-infra dev/self-hosted path derives keys from an env-held master secret, no key table, no KMS call: Caisson's hosted production site does not use this path. Its BYOK seal path requires Azure Key Vault and fails closed when the vault, credentials, or purge-protection contract is unavailable. ```bash # 32-byte values, hex-encoded (64 chars) MASTER_FIELD_KEY=... FIELD_CRYPTO_SALT=... ``` ```ts import { DerivedKeyProvider, TenantFieldCrypto } from "@caisson/field-crypto"; const provider = DerivedKeyProvider.fromEnv(); // reads MASTER_FIELD_KEY + FIELD_CRYPTO_SALT const crypto = new TenantFieldCrypto(provider); const sealed = await crypto.encryptField( tenantId, "424-00-1234", "patient.ssn", ); const plain = await crypto.decryptField(tenantId, sealed, "patient.ssn"); // A different tenant's id derives a different subkey → fails closed. await crypto.decryptField(otherTenantId, sealed, "patient.ssn"); // throws: AEAD authentication error ``` ## The guarantee [#the-guarantee] There is no shared field key and no path where tenant B's key decrypts A's data. In the derived dev/self-hosted mode, tenant A's key is a pure function of the root key, A's id, and the key version. In hosted KMS mode, each random DEK is recovered from its append-only wrapped row using the exact Azure KEK version recorded in the wrapped payload. In either mode, ciphertext relocated to another tenant or column fails authentication rather than returning the wrong plaintext. Explicit row-bound `encryptField` calls also reject same-column relocation between rows; the transparent Drizzle column path has no row identifier and does not claim that stronger binding. ## The Drizzle column path [#the-drizzle-column-path] For application code that reads/writes through Drizzle, `encryptedColumn` is transparent: encrypt on write, decrypt on read, driven by an `AsyncLocalStorage` tenant context you bind alongside your RLS `withTenant` call (the encryption boundary equals the RLS tenant boundary): ```ts import { encryptedColumn, withFieldCryptoContext, derivedContext, } from "@caisson/field-crypto"; const patients = pgTable("patients", { id: uuid("id").primaryKey(), ssn: encryptedColumn("patient.ssn")("ssn"), }); await withFieldCryptoContext(derivedContext(provider, tenantId), () => db.insert(patients).values({ id, ssn: "424-00-1234" }), ); ``` Reach an encrypted column with no bound context and `currentFieldCryptoContext()` throws: fail-closed, never a partial or unscoped read. ## Row-bound fields [#row-bound-fields] Fields that need cross-row tamper-evidence (any SEC/HIPAA-sensitive column) use the explicit row-bound path instead of the transparent column, `rowId` must be a client-minted `crypto.randomUUID()` primary key, known before the insert: ```ts import { encryptField, decryptField } from "@caisson/field-crypto"; const sealed = encryptField(ctx, "patient.ssn", rowId, "424-00-1234"); const plain = decryptField(ctx, "patient.ssn", rowId, sealed); ``` ## Rotation [#rotation] ```ts import { KeyVersionRegistry } from "@caisson/field-crypto"; const registry = new KeyVersionRegistry(); registry.rotate(tenantId); // bumps the current version; new writes use it immediately ``` There's no bulk re-encrypt job, every envelope carries its own `key_version`, so a value written under an older version keeps decrypting until its next write lazily re-encrypts it under the current one. ## The KMS port [#the-kms-port] The `FieldKeyProvider` port is the seam: `DerivedKeyProvider` (above) is the zero-infra dev/self-hosted choice; `KmsKeyProvider` wraps a per-tenant data-encryption key under a KMS-held key-encryption key that never leaves the KMS, only the wrapped DEK is persisted. AWS KMS, GCP KMS, and Azure Key Vault clients ship today behind the same three-method `KmsClient` port. Caisson's hosted production site wires the Azure client; it never falls back to a derived or demo key after a KMS failure. ```ts import { KmsKeyProvider, PgWrappedKeyStore, withKmsFieldCryptoContext, } from "@caisson/field-crypto"; // `tenantScopedKmsClient` maps the logical tenant scope to a provider KEK. // Caisson's hosted adapter does this with a deterministic Azure key name. const provider = new KmsKeyProvider( tenantScopedKmsClient, new PgWrappedKeyStore(tx), ); await provider.ensureProvisioned(tenantId); await withKmsFieldCryptoContext(provider, tenantId, async (ctx) => { await writeEncryptedFields(tx, ctx); }); ``` Use `PgWrappedKeyStore` inside an RLS-scoped tenant transaction to persist wrapped DEKs append-only. Call `ensureProvisioned()` before you bind — a bind reads the current key version, so it allocates for read-only callers too — and bind the provider with `withKmsFieldCryptoContext()`. The bind unwraps every historical version for the request, then zeroizes every plaintext DEK in `finally`; old ciphertext stays readable after rotation without a process-lifetime plaintext-key cache. ## Crypto-shred erasure [#crypto-shred-erasure] `cryptoShred` requests destruction of a tenant or subject's KEK through the KMS port and mints an `erasure.crypto-shred` audit payload that carries no PII. Ciphertext becomes irrecoverable only when the provider receipt proves the key is no longer recoverable; an Azure soft-delete receipt alone does not make that claim. The append-only WORM-anchored audit chain's committed bytes never change, and `verifyChain` still passes after the shred, because the chain only ever committed the ciphertext envelope, never plaintext. ```ts import { cryptoShred } from "@caisson/field-crypto"; const { auditPayload } = await cryptoShred(kmsProvider, { keyScopeId: subjectId, tenantId, subjectId, reason: "gdpr-art17", occurredAt: new Date().toISOString(), }); ``` ## Configuration [#configuration] ### Dev and self-hosted derived-key path [#dev-and-self-hosted-derived-key-path] | Var | What | Notes | | ------------------- | ------------------------------------------- | ----------------------------------------------- | | `MASTER_FIELD_KEY` | 32-byte IKM, hex (64 chars) | Hard secret, read once, never logged. | | `FIELD_CRYPTO_SALT` | 32-byte per-deployment salt, hex (64 chars) | Non-secret; cross-deployment domain separation. | Only required for the `DerivedKeyProvider` dev/self-hosted path. ### Caisson hosted production [#caisson-hosted-production] | Var | Required value / purpose | | ---------------------------------- | -------------------------------------------------------------- | | `AZURE_KEY_VAULT_URL` | HTTPS Azure Key Vault URL | | `AZURE_KEY_VAULT_KEY_NAME` | Prefix for deterministic per-tenant KEKs | | `AZURE_KEY_VAULT_WRAP_ALGORITHM` | Exactly `RSA-OAEP-256` | | `AZURE_KEY_VAULT_PURGE_PROTECTION` | Exactly `enabled`; runtime also verifies the key recovery mode | | `AZURE_TENANT_ID` | Required; the service principal the adapter authenticates as | | `AZURE_CLIENT_ID` | Required; the service principal the adapter authenticates as | | `AZURE_CLIENT_SECRET` | Required; the service principal's secret | The hosted adapter creates Azure SDK clients through an explicit `ClientSecretCredential` built from those three required variables. It never falls back to an ambient credential chain, so a missing or misspelled variable fails closed when the client is constructed rather than silently authenticating as whatever identity the host happens to offer. It provisions the tenant KEK when a KMS context is first bound — including a read-only one, since binding reads the current key version — pins every wrapped DEK to the exact Azure KEK version returned by the wrap operation, persists only wrapped DEKs in the tenant-scoped Postgres store, and bounds each provider request. Self-hosted integrations construct their chosen KMS client (`createAwsKmsClient`, `createGcpKmsClient`, or `createAzureKeyVaultKmsClient`) and supply their own credential adapter. ## Composing with the base [#composing-with-the-base] The encryption boundary equals the RLS tenant boundary (`@caisson/tenancy-rls`): bind `withFieldCryptoContext` alongside `withTenant` so a query can never touch an encrypted column outside its tenant scope. `cryptoShred` is designed to sit under `@caisson/audit-worm`: the shred receipt's audit payload is meant to be appended to the WORM chain by the caller, so an erasure is recorded without ever mutating the chain's committed bytes. field-crypto ships the technical control HIPAA and SOC 2 point at for data at rest, a distinct key per tenant and cryptographic proof a ciphertext can't cross tenant boundaries. It does not make an organization compliant; that determination is your organization's and its auditor's to make. field-crypto is sold standalone, or as one of the primitives composing the Compliance bundle alongside `@caisson/audit-worm`, `@caisson/retention-runner`, and the alert pipeline. # signing-primitive (/docs/provenance/signing-primitive) `@caisson/signing-primitive` signs an evidence pack with a key that belongs to the tenant, not to Caisson. It produces a detached Ed25519 signature over the canonicalized manifest body concatenated with the audit chain's tip hash. The signature is detached, so the signed body stays byte-stable and independently verifiable, and the per-tenant identity is deliberately distinct from the Caisson license-issuer key, a buyer proves the provenance of their own evidence with their own identity. ## Install [#install] ```bash bun add @caisson/signing-primitive ``` ## What it does [#what-it-does] * **Per-tenant detached signing**: `signEvidencePack` signs `canonicalize(manifest) ∥ chainAnchor.tipHash` with an `Ed25519Signer` scoped to the tenant. The signature is detached and never injected into the body, so the manifest stays byte-identical after signing. * **Optional trusted timestamp**: supply a `TimestampAuthority` and the signature is countersigned with an RFC-3161 token, attesting the instant the signature existed. * **Fail-closed verify**: `verifyEvidenceSignature` reuses one shared Ed25519 primitive and returns `false` on an unknown algorithm, malformed hex, or wrong-length key or signature. A forgery never passes as valid, and a malformed signing result throws rather than emitting a bad signature. * **Timing-safe compares**: `signaturesEqual` and `timestampCountersignsSignature` compare hex signatures and RFC-3161 message imprints with a constant-time equality, so neither leaks how many leading bytes matched. ## The guarantee [#the-guarantee] The signing key is the tenant's, held per tenant and never the license-issuer key. The signature covers the manifest and the WORM audit-chain tip together, so a signature is bound to the exact evidence state it was produced over, re-anchoring the chain or editing the manifest invalidates it: ```ts import { Ed25519Signer, signEvidencePack, verifyEvidenceSignature, } from "@caisson/signing-primitive"; // A per-tenant signer — the 32-byte seed is the tenant's signing key, never Caisson's. const signer = new Ed25519Signer(keyId, tenantSigningKey); // Sign the pack: detached signature over canonicalize(manifest) ∥ chainAnchor.tipHash. const signature = await signEvidencePack(signer, manifest); // Verify fails closed — any tampering or forgery returns false, never throws through. const ok = await verifyEvidenceSignature(manifest, signature); if (!ok) { throw new Error("evidence signature does not verify"); } ``` `manifest` only needs to structurally satisfy `SignableManifest`: a `chainAnchor.tipHash` field. The evidence-pack manifest produced by `@caisson/compliance-core` matches it; this package never imports that generator, so the signing surface stands alone. ## The timestamp authority port [#the-timestamp-authority-port] The RFC-3161 countersignature is reached through a `TimestampAuthority` port. It attests that a signature existed at a point in time, layered on top of the Ed25519 signature, never replacing it. A live authority implements `countersign(signature)` by POSTing a DER `TimeStampReq` (message imprint `sha256(signature)`) over `fetchWithTimeout` and parsing the response, the port is stable, so it slots in without touching call sites. ```ts import { signEvidencePack, type TimestampAuthority, } from "@caisson/signing-primitive"; const tsa: TimestampAuthority = myRfc3161Authority; // A countersigned signature carries an RFC-3161 timestamp token alongside the Ed25519 bytes. const signature = await signEvidencePack(signer, manifest, { timestampAuthority: tsa, }); ``` For tests, `StubTimestampAuthority` is a network-free double that reproduces the same message imprint a live TSA would attest, against an injected clock: ```ts import { StubTimestampAuthority } from "@caisson/signing-primitive"; const tsa = new StubTimestampAuthority({ now: new Date("2026-01-01") }); ``` ## API reference [#api-reference] * **`Ed25519Signer(keyId, secretKey)`**: the base `Signer`. `secretKey` must be a 32-byte seed; construction throws on an empty `keyId` or a wrong-length key. `publicKey()` and `sign(payload)` are async; the seed is a private field, never enumerable or logged. * **`Signer`**: the signing-identity port (`keyId`, `algorithm`, `publicKey()`, `sign()`). A buyer-supplied KMS asymmetric-sign implementation is a drop-in of this same interface: the secret key never leaves the HSM. * **`signEvidencePack(signer, manifest, options?)`**: returns an `EvidenceSignature` (`algorithm`, `keyId`, `publicKey`, `signature`, optional `timestamp`). Throws on a malformed signing result rather than emitting a bad signature. * **`verifyEvidenceSignature(manifest, signature)`**: `Promise`, fails closed. * **`evidenceSignablePayload(manifest)`**: the exact `Uint8Array` that gets signed: `canonicalize(manifest)` concatenated with `manifest.chainAnchor.tipHash`. Exposed so a caller can hash or log the signed payload without re-deriving it. * **`TimestampAuthority` / `TimestampToken` / `StubTimestampAuthority`**: the RFC-3161 countersign port, its token shape, and the CI-safe test double. * **`signaturesEqual(a, b)`**: timing-safe hex-signature compare. * **`timestampCountersignsSignature(token, signature)`**: recomputes `sha256(signature)` and timing-safe compares it against `token.messageImprint`, confirming a timestamp token actually countersigns *this* signature rather than a different one. ## Configuration [#configuration] There is no env-based configuration, every input is a constructor or call argument, not a read environment variable: * **`Ed25519Signer(keyId, secretKey)`**: `secretKey` is a 32-byte seed you provision per tenant and pass in directly; the module never reads or derives a key from the environment. Construction throws on an empty `keyId` or a wrong-length key, so a misconfigured signer fails at construction, not at first sign. * **`SignEvidencePackOptions.timestampAuthority`**: optional; omit it and `signEvidencePack` returns a signature with no `timestamp` field. Supply any `TimestampAuthority` implementation (a live RFC-3161 client or `StubTimestampAuthority` for tests) to add the countersignature. * **`StubTimestampAuthority({ authority?, now? })`**: both fields default (`"urn:caisson:test-tsa"`, epoch); only meant for tests, never wired to a live TSA. ## Composition [#composition] `@caisson/signing-primitive` depends only on `@caisson/kernel` (`canonicalize`, `safeEqualFixed`), dependencies are down-only, so it never imports the evidence generator or an edition. The **Compliance** and **Provenance** bundles compose it alongside `@caisson/compliance-core` and `@caisson/audit-worm` to sign the evidence packs those packages produce. Sold standalone at $199 or bundled, check current bundle composition and pricing on the [marketplace](/marketplace). # Provenance (/docs/provenance) The **Provenance** bundle is the cryptographic-proof layer: every claim it makes is checkable by a third party without your keys. A signature verifies or it doesn't; a chain link either matches its predecessor's hash or it doesn't; a ciphertext decrypts under the tenant that owns it or it fails closed. None of the three guarantees depends on trusting a log entry. ## What's in the bundle [#whats-in-the-bundle] * **[signing-primitive](/docs/provenance/signing-primitive)**: per-tenant evidence signing: a detached Ed25519 signature over a canonical, chain-anchored manifest body, with an optional RFC-3161 trusted-timestamp countersignature and a fail-closed verify path. * **[audit-worm](/docs/provenance/audit-worm)**: S3/GCS/R2 Object-Lock WORM storage plus an append-only SHA-256 audit chain and a derived-current locked-version table: evidence that cannot be altered before retention expires, and tampering that is provable. * **[field-crypto](/docs/provenance/field-crypto)**: per-tenant field encryption via HKDF key derivation behind a pluggable KMS port: one tenant's key never decrypts another tenant's data. ## Install [#install] ```bash export CAISSON_LICENSE_TOKEN= bunx @caisson-sh/cli@latest --name caisson-app --edition provenance cd caisson-app bun install ``` `--edition provenance` auto-selects the Provenance 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] `audit-worm` is the append-only ledger everything else roots into: each entry chains to its predecessor's hash, and the chain's own committed bytes never include plaintext, only ciphertext envelopes and payload hashes. `signing-primitive` produces a detached signature over that chain's canonical manifest, a third party verifies the signature against the public key, never against your database. `field-crypto`'s crypto-shred erasure destroys a tenant's key-encryption key without mutating a single committed chain byte: `verifyChain` still passes after a shred, because the chain never committed the plaintext it's erasing. ```ts import { cryptoShred } from "@caisson/field-crypto"; const { auditPayload } = await cryptoShred(kmsProvider, { keyScopeId: subjectId, tenantId, subjectId, reason: "gdpr-art17", occurredAt: new Date().toISOString(), }); // auditPayload carries no PII — append it to the audit-worm chain to record the erasure // without ever mutating a previously committed entry. ``` ## Composing with the base [#composing-with-the-base] The encryption boundary equals the RLS tenant boundary (`@caisson/tenancy-rls`): bind `withFieldCryptoContext` alongside `withTenant` so a query can never touch an encrypted column outside its own tenant scope. ## Entitlement [#entitlement] Provenance 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. All three modules are also members of the Compliance bundle; `field-crypto` additionally composes with AI-Production and Local-first. # audit-worm (/docs/provenance/audit-worm) `@caisson/audit-worm` is three composable layers over `@caisson/kernel`'s pure integrity algebra, each enforced by a different mechanism: a write-once `ArtifactStore` backed by Object-Lock, an append-only `AuditChainStore` anchored into that store, and a `LockedVersionStore` whose "current" version is derived, never stored. Every method is tenant-scoped through `withTenant`: a forgotten filter still sees only the caller's rows. ## What it does [#what-it-does] * **WORM evidence storage**: `ArtifactStore.put` is write-once: a second `put` to an existing key throws `ArtifactExistsError`, never overwrites. `S3ArtifactStore`, `GcsArtifactStore`, and `R2ArtifactStore` all bind the same port over a real Object-Lock/Object-Retention backend; `LocalArtifactStore` is a filesystem double for dev/CI that enforces write-once but not the time lock. * **Append-only audit chain**: `AuditChainStore.append` composes the kernel's `canonicalize`/`chainEntry`/`anchorChain` functions, writes to a table whose migration grants the app role SELECT + INSERT only, and mints a fresh WORM anchor after every entry. `verify` catches tamper, reorder, *and* truncation. * **Locked-version table with a derived current**: `LockedVersionStore.insertVersion` appends under an advisory lock with `UNIQUE(account_id, supersedes_id)`; `currentVersion` derives the tip two independent ways and throws if they ever disagree. ## Install [#install] ```bash bun add @caisson/audit-worm ``` ## The guarantee [#the-guarantee] Compliance-mode Object-Lock means immutability is enforced by the storage layer, not by your code. The audit chain makes tampering *detectable*, verify the chain and a single edited or truncated entry surfaces with its sequence number: ```ts const result = await chain.verify("tenant_4f2c"); // { valid: false, brokenAt: 1184 } — the entry at seq 1184 was edited after it was written ``` `verify` treats the WORM store as the trusted length oracle: if an anchor exists for a length beyond what the DB can currently produce, the tail was cut, even though the surviving rows still hash together as a clean prefix. ## Quickstart [#quickstart] ```ts import { AuditChainStore, LocalArtifactStore, retainUntilFrom } from "@caisson/audit-worm"; const store = new LocalArtifactStore("./worm-data"); // swap for S3ArtifactStore in prod const chain = new AuditChainStore({ db, store }); // Append a privileged action to the SHA-256 chain — mints a fresh WORM anchor too. const { entry, anchor } = await chain.append(accountId, { kind: "invoice.export", actor: "user_4f2c", }); // Verify the chain is intact before you hand the log to an auditor. const result = await chain.verify(accountId); if (!result.valid) { throw new Error(`audit chain broken at seq ${result.brokenAt}`); } ``` ```ts // Write evidence under a retention lock. retainUntilFrom enforces the 6-year HIPAA/SEC // floor — a term below it throws rather than silently rounding up. import { buildArtifactKey } from "@caisson/audit-worm"; const key = buildArtifactKey(accountId, "evidence", "soc2-access-review.pdf"); await store.put(key, pdfBytes, { retainUntil: retainUntilFrom(new Date(), 7) }); ``` ```ts // Escalate GOVERNANCE → COMPLIANCE and record the change as chain evidence in one call — // an unrecorded retention change is treated as a FAILED escalation. import { escalateRetention, irreversibleComplianceOptIn, COMPLIANCE_ACKNOWLEDGEMENT, } from "@caisson/audit-worm"; const optIn = irreversibleComplianceOptIn({ bucket: "caisson-worm-prod", acknowledgement: COMPLIANCE_ACKNOWLEDGEMENT, deployment: "production", }); await escalateRetention({ store: s3Store, chain, accountId, key, retainUntil: retainUntilFrom(new Date(), 7), compliance: { optIn }, }); ``` ## The `ArtifactStore` port [#the-artifactstore-port] `put`/`get`/`head`/`extendRetention`: a minimal object-store contract every backend binds. `assertSafeKey` enforces the `{account_id}/…` prefix and rejects every traversal vector (empty/`.`/`..` segments, null bytes, absolute paths); `extendRetention` is strictly monotonic and throws rather than shorten or clamp a lock. Three cloud backends ship today: * **`S3ArtifactStore`**: Object-Lock over `@aws-sdk/client-s3`, `GOVERNANCE` mode by default. `COMPLIANCE` mode (SEC 17a-4 grade, irreversible until `retain_until`) requires a typed `irreversibleComplianceOptIn()` naming the exact bucket, and only builds under `NODE_ENV === "production"`: never under a test runner. * **`GcsArtifactStore`**: the same port over GCS Object Retention Lock. * **`R2ArtifactStore`**: an S3-compatible data plane paired with Cloudflare's separate bucket-lock retention plane. * **`LocalArtifactStore`**: a filesystem double for dev/CI. Write-once is real (`wx` open flag, TOCTOU-safe); the retention time-lock is echoed back as metadata but never enforced. Not court-admissible, only the cloud backends are. The S3 transport is injected as `S3Sendable = Pick`, so CI binds a stub and no live cloud call runs in tests. ## Configuration surface [#configuration-surface] `retainUntilFrom(now, years?)` computes a calendar-correct retention date. `MIN_RETENTION_YEARS` is 6 (HIPAA §164.316(b)(2) and SEC 17a-4 both floor there) and `DEFAULT_RETENTION_YEARS` is 7; a term below the floor throws rather than rounding up. `AuditChainStore` takes an optional `now` clock (for deterministic tests) and `retentionYears` for its own anchor objects. `S3ArtifactStoreConfig` takes the injected `client`, `bucket`, an optional `mode` (`"GOVERNANCE" | "COMPLIANCE"`, default `GOVERNANCE`), a `complianceOptIn` required iff `mode === "COMPLIANCE"`, and an optional per-tenant SSE-KMS key id for encryption at rest. ## Cloud backend construction [#cloud-backend-construction] `S3ArtifactStore` takes `new`; `R2ArtifactStore.create` and `GcsArtifactStore.create` are async factories that verify the bucket's lock capability before the store is usable: each refuses to construct against a bucket with no enabled lock rule covering its key prefix: ```ts import { R2ArtifactStore, createR2LockReader } from "@caisson/audit-worm"; const r2Store = await R2ArtifactStore.create({ client: r2Client, // an S3-compatible client pointed at R2's endpoint bucket: "caisson-worm-prod", lockReader: createR2LockReader({ accountId: cfAccountId, // the lock-rule read is Cloudflare's REST API, not the S3 data plane bucket: "caisson-worm-prod", apiToken: cfApiToken, }), }); ``` ## Locked versions [#locked-versions] `LockedVersionStore` never stores a "current" flag, `currentVersion` derives the tip from a no-successor SQL predicate cross-checked against the kernel's pure `currentVersions` model, and throws if the two ever disagree instead of guessing: ```ts import { LockedVersionStore } from "@caisson/audit-worm"; const versions = new LockedVersionStore({ db }); const v1 = await versions.insertVersion(accountId, { artifactId: "policy-doc-4f2c", provenance: { artifactHash: sha256Hex, lockedBy: userId, reason: "initial publish" }, }); // supersedesId must name the same lineage's current tip — a fork hits ConflictError, not a // silent second "current". const v2 = await versions.insertVersion(accountId, { artifactId: "policy-doc-4f2c", supersedesId: v1.id, provenance: { artifactHash: newHash, lockedBy: userId, reason: "annual review" }, }); const current = await versions.currentVersion(accountId, "policy-doc-4f2c"); // v2 ``` `provenance` is `unknown` on the way in, `provenanceSchema` (`artifactHash`, `lockedBy`, `reason`) Zod-parses it before the INSERT, so a malformed or extra field is rejected at the boundary, never stored. `chainFor` walks a version's full supersede lineage; `isCurrent` checks one id without loading the chain. ## Composing with the base [#composing-with-the-base] `AuditChainStore` and `LockedVersionStore` both take a `Transactor` from `@caisson/tenancy-rls` and run every operation through `withTenant`, so they sit directly on the base's RLS boundary, the same tenant scoping every other Caisson package uses. `audit-worm` depends down on `@caisson/kernel` (the pure chain/version algebra) and `@caisson/tenancy-rls` only; it never depends up on an edition or bundle. audit-worm ships the technical control an auditor checks for, tamper-evident evidence and a hash-chained log of privileged actions. It does not make an organization compliant; that determination is your organization's and its auditor's to make. audit-worm is a paid primitive: sold standalone, or composed at runtime as a real `workspace:*` dependency inside the Compliance bundle alongside `@caisson/field-crypto`, `@caisson/retention-runner`, and the alert pipeline. # 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 `