cvx-kit 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -23,6 +23,22 @@ bun add cvx-kit
23
23
  target ES2025, minified, deps external, publint-verified
24
24
  - `bun run build:codegen` — re-run `convex codegen` for both components, then build
25
25
 
26
+ ## Documentation
27
+
28
+ Full docs ship with the package — `node_modules/cvx-kit/docs/` when installed,
29
+ authored in [`src/docs/`](./src/docs/):
30
+
31
+ - [`architecture.md`](./src/docs/architecture.md) — the layered application shape the kit encodes
32
+ - [`conventions.md`](./src/docs/conventions.md) — the doctrine: folder structure, file anatomy, naming grammar, import rules
33
+ - [`zod-table.md`](./src/docs/zod-table.md) — one zod shape per entity, masked into every boundary
34
+ - [`auth.md`](./src/docs/auth.md) — auth-aware function constructors from injected policy
35
+ - [`commands.md`](./src/docs/commands.md) — Foundation, the audited command protocol, observability
36
+ - [`triggers.md`](./src/docs/triggers.md) — trigger registry, timestamps/append-only/no-delete
37
+ - [`approvals.md`](./src/docs/approvals.md) — declarative approval workflows
38
+ - [`maintainability.md`](./src/docs/maintainability.md) — keeping a Convex app maintainable long-term
39
+ - [`migration.md`](./src/docs/migration.md) — restructuring an existing raw Convex project into this architecture
40
+ - [`llms.md`](./src/docs/llms.md) — condensed reference to paste into an LLM/agent context
41
+
26
42
  ## Modules
27
43
 
28
44
  ### `cvx-kit/zod-table`
@@ -0,0 +1,152 @@
1
+ # `cvx-kit/components/approvals` — declarative approval workflows
2
+
3
+ A self-contained Convex component for human-in-the-loop approval flows:
4
+ declarative step lists (decision / branch / mutation / action / notify) with
5
+ count quorum, maker-checker, expiry, compatibility keys, durable execution via
6
+ a **nested** `@convex-dev/workflow`, and its own audit trail via a **nested**
7
+ `convex-audit-log`. The component never imports host code: tenancy is an
8
+ opaque `scopeRef`, resources are opaque `resourceType`/`resourceRef` strings,
9
+ and callbacks cross the boundary as **function handles**.
10
+
11
+ ## Mounting
12
+
13
+ ```ts
14
+ // convex/convex.config.ts
15
+ import approvals from 'cvx-kit/components/approvals'
16
+ app.use(approvals)
17
+ ```
18
+
19
+ The component mounts its own private `workflow` and `auditLog` children. Do
20
+ **not** re-register those children at the app root under the same identity,
21
+ and never reach into `components.approvals.workflow` / `.auditLog` from host
22
+ code — the `Approvals` client class is the only contract.
23
+
24
+ ```ts
25
+ // convex/approvals.ts — host facade, declared once
26
+ import { Approvals } from 'cvx-kit/components/approvals'
27
+ import { components } from './_generated/api'
28
+
29
+ export const approvals = new Approvals(components.approvals)
30
+ ```
31
+
32
+ ## Defining a workflow — in the owning domain
33
+
34
+ Workflow *definitions* are business logic and live in the domain that owns the
35
+ resource, not in the root facade:
36
+
37
+ ```ts
38
+ // convex/domain/documents/approval.ts
39
+ import { approvals } from '../../approvals'
40
+ import { internal } from '../../_generated/api'
41
+
42
+ export const publishApproval = approvals.define({
43
+ name: 'documentPublish',
44
+ steps: [
45
+ approvals.decision('managerDecision', {
46
+ decisions: ['approved', 'rejected'],
47
+ quorum: { kind: 'count', approvals: 1 },
48
+ makerChecker: true, // requester cannot decide
49
+ expiresAfterMs: 7 * 24 * 60 * 60 * 1000,
50
+ }),
51
+ approvals.branch('applyDecision', {
52
+ approvedStepKey: 'publishDocument',
53
+ rejectedStepKey: 'notifyRejected',
54
+ }),
55
+ approvals.mutation('publishDocument', {
56
+ handler: internal.domain.documents.approval_functions.applyDecision,
57
+ }),
58
+ approvals.notify('notifyRejected', {
59
+ handler: internal.domain.documents.approval_functions.notifyRejected,
60
+ retry: true,
61
+ }),
62
+ ],
63
+ })
64
+ ```
65
+
66
+ ### Step kinds
67
+
68
+ | Step | Runs | Notes |
69
+ |---|---|---|
70
+ | `decision(key, { decisions, quorum, makerChecker?, expiresAfterMs? })` | waits for humans | quorum is `{ kind: 'count', approvals: n }`; expiry resolves the run as `expired` |
71
+ | `branch(key, { approvedStepKey, rejectedStepKey })` | routing | jumps by the preceding decision's outcome |
72
+ | `mutation(key, { handler, retry? })` | internal mutation handle | transactional apply step |
73
+ | `action(key, { handler, retry? })` | internal action handle | side effects (third parties) |
74
+ | `notify(key, { handler, retry? })` | internal action handle | semantically a notification; same contract as action |
75
+
76
+ Limits (enforced by the component's validators): ≤ 32 steps per workflow,
77
+ ≤ 8 decisions per decision step, names ≤ 96 chars, references ≤ 256 chars,
78
+ metadata ≤ 32 entries (keys ≤ 64, values ≤ 512 chars).
79
+
80
+ ### Compatibility keys
81
+
82
+ `compatibilityKey` (defaults to `name`) versions the workflow shape. Every
83
+ `decide`/`cancel`/`status`/`restart` call sends it, so a run started under an
84
+ old definition cannot be driven by an incompatible new one. When you change a
85
+ workflow's steps in a breaking way, change the key.
86
+
87
+ ## Driving a run
88
+
89
+ ```ts
90
+ // start — from an authMutation, with ctx.actor as the requester
91
+ const { runId } = await publishApproval.start(ctx, {
92
+ scopeRef: ctx.org.organizationId, // opaque tenancy
93
+ resourceType: 'document',
94
+ resourceRef: documentId, // opaque to the component
95
+ requester: ctx.actor,
96
+ metadata: { title }, // bounded string map
97
+ })
98
+
99
+ // decide — a manager approves or rejects
100
+ await publishApproval.decide(ctx, { runId, decision: 'approved', reason }, ctx.actor)
101
+
102
+ // observe
103
+ await publishApproval.status(ctx, runId) // pending | approved | rejected | expired | canceled
104
+ await publishApproval.evidence(ctx, runId) // per-step decision evidence
105
+ await publishApproval.list(ctx, { scopeRef, ... })
106
+
107
+ // manage
108
+ await publishApproval.cancel(ctx, runId, ctx.actor)
109
+ await publishApproval.restart(ctx, runId)
110
+ await approvals.cleanupAudit(ctx, { olderThanDays: 365, batchSize: 100 })
111
+ ```
112
+
113
+ ## Callback handlers — validate everything coming back
114
+
115
+ Step handlers are **internal** functions (build them with `systemMutation` /
116
+ `systemAction`). They receive `ApprovalCallbackInput`: `{ runId, scopeRef,
117
+ resourceType, resourceRef, metadata?, decision? }` — all opaque strings.
118
+ Because the component cannot know your tables, the handler must re-establish
119
+ trust:
120
+
121
+ ```ts
122
+ export const applyDecision = systemMutation({
123
+ args: approvalCallbackArgs,
124
+ handler: async (ctx, input) => {
125
+ if (input.resourceType !== 'document') throw new Error('WRONG_RESOURCE')
126
+ const id = ctx.db.normalizeId('documents', input.resourceRef)
127
+ if (!id) throw new Error('BAD_REF')
128
+ const document = await ctx.db.get(id)
129
+ // guard against stale runs: the doc must still point at this run
130
+ if (document?.approvalRunId !== input.runId) return
131
+ await ctx.db.patch(id, { reviewState: 'published' })
132
+ },
133
+ })
134
+ ```
135
+
136
+ That `approvalRunId` check is the idempotency/staleness guard from the origin
137
+ app — copy it.
138
+
139
+ ## Rules and footguns
140
+
141
+ 1. Root facade (`convex/approvals.ts`) instantiates the client; workflow
142
+ *definitions* live in the owning domain.
143
+ 2. Handlers get opaque strings — always `normalizeId`, re-check
144
+ `resourceType`, and verify the resource still references the run.
145
+ 3. `makerChecker: true` means the requester's decisions are rejected — design
146
+ your UI accordingly.
147
+ 4. The component keeps its own audit trail (retention category
148
+ `approval-protocol`, cleanup via `cleanupAudit`, retention 1–3650 days,
149
+ batch ≤ 100). Host-side business audit still goes through your command
150
+ protocol — they are different trails.
151
+ 5. Changing steps without changing `compatibilityKey` lets old runs be driven
152
+ by a new incompatible shape; bump the key on breaking changes.
@@ -0,0 +1,124 @@
1
+ # Architecture — the shape of a cvx-kit application
2
+
3
+ cvx-kit was extracted from a production Convex application (~12k LOC backend).
4
+ This document describes the architecture that application follows and that the
5
+ kit is designed to reproduce: strict ownership boundaries, injected policy,
6
+ portable components, and conventions that are *executable* (enforced by tests)
7
+ rather than aspirational.
8
+
9
+ ## The layers
10
+
11
+ ```
12
+ convex/
13
+ convex.config.ts # component mounting — each app.use(x) exactly once
14
+ schema.ts # defineSchema(domainTables) — nothing else
15
+ functions.ts # createAuthFunctions(...) — the ONLY place raw builders appear
16
+ foundation.ts # const { Command, Query, observability } = new Foundation(...)
17
+ approvals.ts # const approvals = new Approvals(components.approvals)
18
+ audit.ts # writeAuditEntry over convex-audit-log + admin readers
19
+ triggers.ts # the single Triggers registry + helper registrations
20
+ auth.ts / auth.config.ts / http.ts # identity provider wiring (e.g. WorkOS AuthKit)
21
+ api/ # PUBLIC surface — thin adapters only
22
+ <documents>.ts # authQuery/authMutation wrapping domain executors
23
+ domain/
24
+ table.ts # merges per-domain table maps into domainTables
25
+ shared/ # genuinely cross-entity helpers only
26
+ <entity>/ # one directory per entity/aggregate
27
+ schema.ts # zodTable declaration (shapes only)
28
+ table.ts # indexes attached to <zodTable>.table
29
+ constants.ts # finite vocabularies (UPPER_SNAKE readonly tuples)
30
+ commands.ts # operation registry + executors (new Command(...))
31
+ queries.ts # domain read logic
32
+ approval.ts # approvals.define(...) workflow definitions
33
+ *_functions.ts # internal callbacks (systemMutation/systemAction)
34
+ ```
35
+
36
+ ### Layer rules
37
+
38
+ 1. **`api/` is the only public surface.** It contains no bare `query(` or
39
+ `mutation(` — only `auth*`/`role*`/`admin*` constructors delegating to
40
+ domain `execute*` functions. Public functions are adapters: parse, pass
41
+ `ctx.actor`, project DTOs.
42
+ 2. **`domain/<entity>/` owns everything about the entity** — its zod shape,
43
+ indexes, vocabularies, commands, queries, workflows. Cross-domain reuse
44
+ goes through `domain/shared/`, never sibling imports of another domain's
45
+ internals.
46
+ 3. **Root files are facades over mounted components.** `foundation.ts`,
47
+ `approvals.ts`, `audit.ts` configure infrastructure; business definitions
48
+ stay in the owning domain.
49
+ 4. **Internal functions use `systemMutation`/`systemAction`** — never raw
50
+ `internalMutation(`/`internalAction(` — so triggers and `include` apply to
51
+ scheduled/callback work too.
52
+
53
+ ## Injection, not import
54
+
55
+ The kit's central design rule: **library code never imports app singletons**.
56
+ Every kit surface takes its policy as a parameter:
57
+
58
+ - `createAuthFunctions` receives the generated builders, `getAuthUser`,
59
+ `mapRole`, `verifyMembership`, `triggers`, `errors`.
60
+ - `Foundation` receives `classifyError`, `writeAudit`, the `enabled` flag.
61
+ - `Approvals` receives only its component reference; host data crosses as
62
+ opaque validated strings.
63
+
64
+ The reverse rule holds for components: a portable component must not import
65
+ host domains, generated host APIs, environment configuration, or vendor SDKs
66
+ (WorkOS, Drizzle, Redis…). Host values cross its public API only as
67
+ **validated, bounded, opaque data** — `scopeRef`, `resourceRef`, function
68
+ handles. Component schemas contain only their own records and never mirror a
69
+ private child component's tables.
70
+
71
+ ## Boundaries as schemas
72
+
73
+ Data crossing any boundary is a zod schema derived from one `zodTable`
74
+ declaration per entity (see `zod-table.md`):
75
+
76
+ - storage → `defineTable` via `zodToConvex`
77
+ - writes → `insertSchema`/`updateSchema` (server fields and timestamps
78
+ excluded)
79
+ - commands → `commandInput` (explicit allowlist)
80
+ - clients → `publicDto` + `toPublicDto` (runtime redaction)
81
+ - LLM tools → `tools.*` and `jsonSafeZid`
82
+
83
+ Everything is `.strict()`. There is exactly one source of truth per entity and
84
+ no boundary that "just trusts" its input.
85
+
86
+ ## Writes as commands
87
+
88
+ State changes flow through the audited command protocol (see `commands.md`):
89
+ parse input → observe → run handler → parse output → write audit — one
90
+ transaction, rollback-consistent. Classifications and audit derivation are
91
+ type-required per operation. Human-gated changes use the approvals component;
92
+ its apply steps are commands too.
93
+
94
+ ## Conventions worth enforcing with tests
95
+
96
+ The origin app enforces its architecture with a test suite that raw-globs the
97
+ source and asserts violations — each rule proved against a synthetic fixture
98
+ first so the checker can't silently pass. Highly recommended for consumers:
99
+
100
+ - **No inline tables/schemas** — `defineTable`/`defineSchema` only in
101
+ `table.ts`/`schema.ts`.
102
+ - **No inline enums** — `z.enum([...])` with an inline array literal is
103
+ banned; vocabularies are named `UPPER_SNAKE` tuples in `constants.ts`.
104
+ - **No deep server imports** — components consumed only through their
105
+ `client.ts` facade; explicit allowlist for exceptions.
106
+ - **No handwritten references** — `makeFunctionReference` banned; use
107
+ generated `internal`/`api`.
108
+ - **No sibling-domain imports** — a domain reaches another only via
109
+ `domain/shared/` or the public API.
110
+ - **Facades mount once** — every `app.use(x)` appears exactly once; facades
111
+ never reach a component's private children.
112
+ - **Component boundary tests** — no host imports, no
113
+ `process.env`/vendor-SDK references inside `components/*`.
114
+ - **Copyability** — copy the component to a tmpdir with only its package
115
+ deps, run `tsc`: proves portability continuously.
116
+
117
+ ## Component mounting order
118
+
119
+ In `convex.config.ts`, mount infrastructure before consumers: auth kit →
120
+ foundation → audit log → approvals → named workflow/workpool instances. Use
121
+ `app.use(x, { name })` when mounting the same component twice; `components.
122
+ <name>` keys off that name. Required env vars can be declared on
123
+ `defineApp({ env: { ... } })` so deploys fail fast — remember to stub them in
124
+ test setup.
package/docs/auth.md ADDED
@@ -0,0 +1,157 @@
1
+ # `cvx-kit/auth` — auth-aware function constructors from injected policy
2
+
3
+ `createAuthFunctions` builds the function constructors your whole backend uses
4
+ in place of raw `query`/`mutation`/`action`. Nothing in the kit imports your
5
+ app's singletons — identity resolution, role vocabulary, membership
6
+ verification, trigger wrapping, and error policy are all **injected** by the
7
+ host. The kit provides the structure; the app provides the policy.
8
+
9
+ ## Setup — declared once per app
10
+
11
+ ```ts
12
+ // convex/functions.ts (the single place constructors are built)
13
+ import { createAuthFunctions, defaultRoleMap } from 'cvx-kit/auth'
14
+ import {
15
+ action, internalAction, internalMutation, mutation, query,
16
+ } from './_generated/server'
17
+ import type { DataModel } from './_generated/dataModel'
18
+ import { triggers } from './triggers'
19
+ import { authKit } from './auth'
20
+
21
+ export const {
22
+ authQuery, authMutation, authAction,
23
+ roleQuery, roleMutation, roleAction,
24
+ adminQuery, adminMutation, adminAction,
25
+ systemMutation, systemAction,
26
+ include, authenticatedUser,
27
+ } = createAuthFunctions<DataModel>({
28
+ query, mutation, action, internalMutation, internalAction,
29
+ getAuthUser: (ctx) => authKit.getAuthUser(ctx),
30
+ mapRole: defaultRoleMap, // or your own vocabulary (see below)
31
+ adminRoles: ['admin'],
32
+ triggers, // every mutation write runs through wrapDB
33
+ verifyMembership: async ({ userId, organizationId }) => {
34
+ const memberships = await authKit.workos.userManagement
35
+ .listOrganizationMemberships({ organizationId, userId, statuses: ['active'] })
36
+ const m = memberships.data.find((c) => c.userId === userId)
37
+ return m ? { organizationId: m.organizationId, roleSlug: m.role.slug } : null
38
+ },
39
+ })
40
+ ```
41
+
42
+ Every function in the app is then built from these — raw `query`/`mutation`
43
+ imports from `_generated/server` appear **only** in this file.
44
+
45
+ ## The constructor families
46
+
47
+ | Constructor | Visibility | Auth | Extra |
48
+ |---|---|---|---|
49
+ | `authQuery` / `authMutation` / `authAction` | public | any authenticated org member | actions live-verify membership |
50
+ | `roleQuery(...roles)` / `roleMutation(...)` / `roleAction(...)` | public | listed roles only | factory — call with your roles |
51
+ | `adminQuery` / `adminMutation` / `adminAction` | public | `config.adminRoles` | pre-built `role*(...adminRoles)` |
52
+ | `systemMutation` | internal | none (trusted caller) | still trigger-wrapped |
53
+ | `systemAction` | internal | none | plain internal action |
54
+
55
+ All are `zCustom*` constructors from convex-helpers, so `args` and `returns`
56
+ take zod schemas directly and compose with `zodTable` masks.
57
+
58
+ ## What handlers receive on `ctx`
59
+
60
+ Authenticated constructors extend the ctx with a frozen auth bundle:
61
+
62
+ - `ctx.identity` — the raw `UserIdentity` (JWT claims)
63
+ - `ctx.user.id` — the synchronized principal id (from `getAuthUser`)
64
+ - `ctx.org` — `{ organizationId, role }`
65
+ - `ctx.role` — the mapped role
66
+ - `ctx.actor` — `{ userId, organizationId, role }` — pass this to audits,
67
+ approvals, and commands as the canonical actor reference
68
+ - `ctx.include` — the bounded query builder (below)
69
+
70
+ `systemMutation` gets `include` and trigger wrapping but no auth bundle.
71
+
72
+ ## Role vocabulary is per-app
73
+
74
+ `mapRole` maps the identity's role slug onto **your** role union; returning
75
+ `null` rejects with `FORBIDDEN`, so unknown roles never pass. The default
76
+ (`defaultRoleMap`) is WorkOS-flavored: `member → writer`, and
77
+ `reader`/`writer`/`admin` pass through.
78
+
79
+ Supply your own vocabulary via the generic:
80
+
81
+ ```ts
82
+ createAuthFunctions<DataModel, 'viewer' | 'editor' | 'owner'>({
83
+ mapRole: (slug) => (slug === 'owner' ? 'owner' : slug === 'editor' ? 'editor' : slug ? 'viewer' : null),
84
+ adminRoles: ['owner'],
85
+ ...
86
+ })
87
+ ```
88
+
89
+ ## Queries/mutations trust the JWT; actions re-verify
90
+
91
+ Queries and mutations read org and role from the identity token. Actions —
92
+ which can run long and call third parties — additionally call
93
+ `verifyMembership` **live** before the handler runs, and the fresher
94
+ role/organization from that check replaces the JWT's. Verification **fails
95
+ closed**: any error or org mismatch throws `FORBIDDEN`. Omit
96
+ `verifyMembership` to let actions trust the JWT like queries do.
97
+
98
+ ## Mutations are structurally trigger-wrapped
99
+
100
+ When `triggers` (or `wrapDB`) is configured, every `authMutation`,
101
+ `roleMutation`, and `systemMutation` runs its writes through
102
+ `triggers.wrapDB(ctx)`. Trigger enforcement (timestamps, append-only,
103
+ no-delete, denormalization) is therefore **structural** — a handler cannot
104
+ forget it, because it never sees an unwrapped `ctx.db`. This is the reason raw
105
+ `mutation` must not be used outside `functions.ts`.
106
+
107
+ ## `include()` — every read is bounded
108
+
109
+ `include` wraps a table query in a fluent selector that (a) picks the first
110
+ matching indexed query and (b) refuses unbounded reads. `execute(limit)`
111
+ rejects limits outside `1..maxRows` (default 100, configurable via
112
+ `maxIncludedQueryRows`).
113
+
114
+ ```ts
115
+ export const list = authQuery({
116
+ args: { ownerId: zid('users').optional(), limit: z.number() },
117
+ returns: z.array(documents.publicDto),
118
+ handler: (ctx, args) =>
119
+ ctx.include(ctx.db.query('documents'))
120
+ .when(args.ownerId, (q, ownerId) =>
121
+ q.withIndex('by_owner', (ix) => ix.eq('ownerId', ownerId)))
122
+ .otherwise((q) => q.withIndex('by_owner'))
123
+ .execute(args.limit, (rows) => rows.map(documents.toPublicDto)),
124
+ })
125
+ ```
126
+
127
+ Selector methods:
128
+
129
+ - `.when(value, select)` — if `value` is non-null and nothing selected yet,
130
+ use `select(query, value)`.
131
+ - `.matching(indexName, range?, shouldMatch?)` — index selection with an
132
+ optional guard boolean.
133
+ - `.otherwise(select)` — fallback, returns the raw `Query`.
134
+ - `.resolve()` — selected query or full table scan (escape hatch).
135
+ - `.execute(limit, transform?)` — bounded `take` + optional projection. This
136
+ is the normal terminal.
137
+
138
+ ## Errors
139
+
140
+ All rejections go through the injected `ErrorFactory` (default: throws
141
+ `KitError` with codes `UNAUTHENTICATED`, `FORBIDDEN`,
142
+ `INVALID_REQUEST_BOUNDARY`). Hosts with their own error taxonomy adapt it:
143
+
144
+ ```ts
145
+ errors: { throw: (input) => { throw App.errors.from(input) } }
146
+ ```
147
+
148
+ ## Rules of thumb
149
+
150
+ 1. Build constructors once in `convex/functions.ts`; import them everywhere
151
+ else. Raw `_generated/server` builders appear nowhere else.
152
+ 2. Public functions: `auth*`/`role*`/`admin*`. Scheduled/internal work:
153
+ `system*`. Never expose an unauthenticated public function unless it is
154
+ deliberately anonymous (and then document why).
155
+ 3. Reads go through `ctx.include(...)` with an explicit limit.
156
+ 4. Use `ctx.actor` as the actor reference in audits and approvals — never
157
+ re-derive identity in a handler.
@@ -0,0 +1,177 @@
1
+ # `cvx-kit/components/foundation` — the audited command protocol
2
+
3
+ The Foundation is a Convex component that **owns zero tables and zero
4
+ capability** — its schema is empty by design. What it provides is the
5
+ application kernel: the `Command` protocol (validate → observe → execute →
6
+ audit, in one transaction), the `Query` kernel, and payload-free
7
+ `Observability`. It is declared **once** per app and everything destructures
8
+ from it; there is no separate command import.
9
+
10
+ ## Mounting and declaring
11
+
12
+ ```ts
13
+ // convex/convex.config.ts
14
+ import { defineApp } from 'convex/server'
15
+ import foundation from 'cvx-kit/components/foundation'
16
+
17
+ const app = defineApp()
18
+ app.use(foundation)
19
+ export default app
20
+ ```
21
+
22
+ ```ts
23
+ // convex/foundation.ts — declared once, the single kernel source
24
+ import { Foundation } from 'cvx-kit/components/foundation'
25
+ import { components } from './_generated/api'
26
+ import { writeAuditEntry } from './audit'
27
+
28
+ export const { Command, Query, observability } = new Foundation(
29
+ components.foundation,
30
+ {
31
+ observability: {
32
+ enabled: () => process.env.COMMAND_OBSERVABILITY_ENABLED === 'true',
33
+ classifyError: (error) => classify(error), // → { outcome: 'denied' | 'failed', errorCode }
34
+ writeAudit: (ctx, entry) => writeAuditEntry(ctx, entry),
35
+ },
36
+ },
37
+ )
38
+ ```
39
+
40
+ Host code consumes the Foundation **only through this facade** — never deep
41
+ imports of the component's internal modules.
42
+
43
+ ## Defining commands — per domain
44
+
45
+ Each domain declares a frozen registry of operations and derives typed
46
+ executors from it. `classification` and `audit()` are **mandatory per
47
+ operation** — auditing is type-enforced, not opt-in.
48
+
49
+ ```ts
50
+ // convex/domain/documents/commands.ts
51
+ import { z } from 'zod'
52
+ import { Command } from '../../foundation'
53
+ import { documents } from './schema'
54
+
55
+ const operations = {
56
+ 'documents.rename': Command.operation({
57
+ command: documents.commandInput.extend({ id: zid('documents') }),
58
+ result: z.object({ ok: z.literal(true) }).strict(),
59
+ classification: 'business',
60
+ audit: ({ command }) => ({
61
+ operation: 'documents.rename',
62
+ actorId: command.actorId,
63
+ aggregate: { type: 'document', id: command.id },
64
+ metadata: { title: command.title },
65
+ }),
66
+ }),
67
+ } as const
68
+
69
+ const commands = new Command<MutationCtx, typeof operations>(operations)
70
+
71
+ export const executeRename = commands.exec({
72
+ operation: 'documents.rename',
73
+ handler: async (ctx, command) => {
74
+ await ctx.db.patch(command.id, { title: command.title })
75
+ return { ok: true }
76
+ },
77
+ })
78
+ ```
79
+
80
+ The public API layer then wraps the executor in an `authMutation` and passes
81
+ `ctx.actor` in. Public functions stay thin adapters; domain logic lives behind
82
+ `execute*` functions.
83
+
84
+ ## What one execution does, in order
85
+
86
+ 1. **Parse the command** through the operation's `command` schema (strict zod).
87
+ 2. **Observe**: start the observation clock.
88
+ 3. **Run the handler**; its return value is parsed through the `result` schema
89
+ — outputs are validated too.
90
+ 4. **Audit**: call the operation's `audit({ command, result }, ctx)`. If it
91
+ returns non-null, the entry (plus the operation's `classification`) is
92
+ written through the injected `writeAudit` — **in the same transaction** as
93
+ the handler's writes. Returning `null` skips the audit (the ontology
94
+ convention: failures are not audited).
95
+ 5. **Emit the observation**: `{ operation, classification, outcome,
96
+ errorCode?, durationMs }` — completed, denied, or failed per
97
+ `classifyError`.
98
+
99
+ If any step throws, the Convex transaction rolls back — handler writes and
100
+ audit entry together. Audit and effects can never disagree.
101
+
102
+ ### Dynamic dispatch
103
+
104
+ `exec` has a second overload for operation selection at runtime:
105
+
106
+ ```ts
107
+ export const executeAny = commands.exec({
108
+ dispatcher: z.object({ kind: z.enum(OPERATION_KINDS) /* from constants.ts */ }),
109
+ select: (input) => `documents.${input.kind}` as const,
110
+ handler,
111
+ })
112
+ ```
113
+
114
+ An unconfigured operation throws `COMMAND_OPERATION_NOT_CONFIGURED`.
115
+
116
+ ## Observability — payload-free by construction
117
+
118
+ Observations carry identifiers and a duration, never payloads. Events are
119
+ silently dropped unless:
120
+
121
+ - `enabled` resolves to `true`,
122
+ - `operation` and `classification` match `/^[A-Za-z][A-Za-z0-9_.-]{0,159}$/`,
123
+ - `errorCode` (when present) matches `/^[A-Z][A-Z0-9_]{0,95}$/`.
124
+
125
+ Practical consequence: **dotted lowercase operation names**
126
+ (`documents.rename`) and **UPPER_SNAKE error codes** are effectively
127
+ mandatory. Every telemetry path is wrapped in try/catch — observability is
128
+ behaviorally inert and can never change command behavior or mask the original
129
+ failure. Default sink is one JSON line on `console.info`
130
+ (`event: 'command.execution'`); inject `emit` to redirect.
131
+
132
+ ## `executeResultBoundary` — typed failures, but only while it's safe
133
+
134
+ ```ts
135
+ import { executeResultBoundary } from 'cvx-kit/components/foundation'
136
+
137
+ const result = await executeResultBoundary(ctx, () => run(), boundary)
138
+ // Result<Value, Failure> = { ok: true, value } | { ok: false, error }
139
+ ```
140
+
141
+ Semantics — read carefully, they are decided by **transaction state**, not by
142
+ the error:
143
+
144
+ - Success → `{ ok: true, value }`.
145
+ - A failure the boundary recognizes (`dataOf(error)` non-undefined) is
146
+ returned as `{ ok: false, error }` **only if the transaction has produced no
147
+ effects yet** (nothing written, nothing scheduled — checked via
148
+ `ctx.meta.getTransactionMetrics()`).
149
+ - If anything was already written or scheduled, the error is **rethrown** so
150
+ Convex rolls the transaction back — a returned failure after effects would
151
+ commit those effects.
152
+ - Unrecognized errors and unreadable metrics always rethrow.
153
+
154
+ Use it at the outermost edge of a mutation when the client needs a typed
155
+ `Result` over the wire instead of a thrown error. `projectResult(result,
156
+ onFailure)` unwraps on the caller side.
157
+
158
+ ## The `Query` kernel
159
+
160
+ `Query` mirrors the pattern for reads: an injected `execute` receives
161
+ `{ context, metadata, run }`, letting the host apply uniform read policy
162
+ (authorization checks, tracing) around every domain query without the domains
163
+ knowing. Metadata defaults are set at construction and merged per-executor.
164
+
165
+ ## Rules and footguns
166
+
167
+ 1. One `new Foundation(...)` per app, in `convex/foundation.ts`. Everything
168
+ else destructures from it.
169
+ 2. Never deep-import the component's `modules/*` or `result.ts` paths from app
170
+ code — the `client.ts` facade is the contract.
171
+ 3. The audit callback receives the ctx untyped (`never`); wiring a query ctx
172
+ into an audited command fails at runtime, not compile time. Commands run in
173
+ mutations.
174
+ 4. Name operations `domain.verb` and error codes `UPPER_SNAKE` or your
175
+ telemetry silently disappears (see regexes above).
176
+ 5. Keep operation registries frozen (`as const`) in the domain's
177
+ `commands.ts`; vocabulary tuples live in `constants.ts`.