cvx-kit 0.0.4 → 0.0.6

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.
@@ -1,460 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <title>The Ontology Convex Architecture — the reusable core you keep rebuilding</title>
7
- <style>
8
- :root {
9
- --bg: #f7f6f3; --ink: #1f2328; --muted: #59606a;
10
- --accent: #2a5db0; --accent2: #8a4baf; --ok: #1a7f4b; --warn: #a05a00;
11
- --card: #ffffff; --line: #d9d5cc; --code-bg: #14181f; --code-ink: #d8dee9;
12
- }
13
- @media (prefers-color-scheme: dark) {
14
- :root { --bg:#14171c; --ink:#e6e3dc; --muted:#9aa2ad; --card:#1c2128;
15
- --line:#333a44; --accent:#7aa7e8; --accent2:#c093e0; --ok:#5fc78e; --warn:#e0a24f; }
16
- }
17
- * { box-sizing: border-box; }
18
- body { margin:0; background:var(--bg); color:var(--ink);
19
- font: 16px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; }
20
- main { max-width: 900px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
21
- h1 { font-size: 1.7rem; line-height: 1.25; margin: 0 0 .5rem; }
22
- h2 { font-size: 1.25rem; margin: 2.5rem 0 .5rem; border-bottom: 1px solid var(--line); padding-bottom: .3rem; }
23
- h3 { font-size: 1.05rem; margin: 1.6rem 0 .4rem; }
24
- p, li, dd, dt { max-width: 70ch; }
25
- a { color: var(--accent); }
26
- dl.meta { display:grid; grid-template-columns: max-content 1fr; gap:.15rem 1rem;
27
- font-size:.9rem; color:var(--muted); border:1px solid var(--line);
28
- background:var(--card); border-radius:8px; padding:.75rem 1rem; margin:1rem 0 2rem; }
29
- dl.meta dt { font-weight:600; }
30
- dl.meta dd { margin:0; }
31
- pre { background: var(--code-bg); color: var(--code-ink); padding: .9rem 1rem;
32
- border-radius: 8px; overflow-x: auto; font-size: .82rem; line-height: 1.5; }
33
- code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
34
- p code, li code, td code { background: color-mix(in srgb, var(--line) 45%, transparent);
35
- padding: .05rem .3rem; border-radius: 4px; font-size: .85em; }
36
- .k { color:#7aa7e8; } .s { color:#a3be8c; } .c { color:#7b8496; font-style:italic; }
37
- .f { color:#e0af68; } .t { color:#c093e0; }
38
- .note { border-left: 3px solid var(--accent); background: var(--card);
39
- padding: .6rem .9rem; border-radius: 0 8px 8px 0; margin: 1rem 0; }
40
- .why { border-left: 3px solid var(--accent2); background: var(--card);
41
- padding: .6rem .9rem; border-radius: 0 8px 8px 0; margin: 1rem 0; }
42
- table { border-collapse: collapse; width: 100%; font-size: .88rem; margin: 1rem 0; }
43
- th, td { text-align: left; border-bottom: 1px solid var(--line); padding: .4rem .6rem; vertical-align: top; }
44
- th { color: var(--muted); font-weight: 600; }
45
- .path { font-size:.8rem; color:var(--muted); font-family: ui-monospace, Menlo, monospace; }
46
- figure { margin: 1.5rem 0; }
47
- figcaption { font-size:.85rem; color:var(--muted); margin-top:.4rem; max-width:70ch; }
48
- footer { margin-top: 3rem; color: var(--muted); font-size:.85rem; border-top:1px solid var(--line); padding-top:1rem; }
49
- svg text { font-family: -apple-system, "Segoe UI", Roboto, sans-serif; }
50
- </style>
51
- </head>
52
- <body>
53
- <main>
54
-
55
- <h1>The Ontology Convex Architecture — the reusable core you keep rebuilding</h1>
56
-
57
- <dl class="meta">
58
- <dt>Date</dt><dd>2026-08-13</dd>
59
- <dt>Input shape</dt><dd>concept</dd>
60
- <dt>Subject</dt><dd>The layered Convex implementation in <code>~/dev/sunday/ontology/convex</code> — components, share layer, auth utils, triggers, audit, and durable workflows — read as the extractable core shared by Qualitys, OneOffice, ProjectSaaS, and Agent.io</dd>
61
- </dl>
62
-
63
- <p>You asked what makes this implementation sustainable enough to be worth
64
- packaging. The short answer: <strong>every generic mechanism lives behind a
65
- boundary that is mechanically enforced by tests, and every app-specific
66
- decision is injected into it from outside</strong>. That inversion — kernel
67
- components own the machinery, the host injects the policy — is exactly what
68
- makes a <code>~/dev/cvx</code> npm package feasible. This explainer walks the
69
- stack layer by layer and ends with a portability map: what lifts out clean,
70
- what needs a seam cut, and what stays app-side.</p>
71
-
72
- <h2>1. The whole stack in one picture</h2>
73
-
74
- <figure>
75
- <svg viewBox="0 0 880 560" role="img" aria-label="Layer diagram of the Convex architecture" width="100%">
76
- <defs>
77
- <marker id="arr" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
78
- <path d="M0,0 L8,4 L0,8 z" fill="#59606a"/>
79
- </marker>
80
- </defs>
81
- <!-- API layer -->
82
- <rect x="20" y="20" width="840" height="70" rx="10" fill="#2a5db0" opacity="0.14" stroke="#2a5db0"/>
83
- <text x="40" y="48" font-size="15" font-weight="bold" fill="#2a5db0">api/ — declarative facade</text>
84
- <text x="40" y="70" font-size="12.5" fill="#59606a">authQuery / adminMutation + zod args/returns + domain handler. No logic. No raw query()/mutation().</text>
85
-
86
- <!-- utils band -->
87
- <rect x="20" y="110" width="840" height="70" rx="10" fill="#8a4baf" opacity="0.13" stroke="#8a4baf"/>
88
- <text x="40" y="138" font-size="15" font-weight="bold" fill="#8a4baf">utils.ts — custom function constructors</text>
89
- <text x="40" y="160" font-size="12.5" fill="#59606a">WorkOS AuthKit identity → frozen actor {userId, org, role} · triggers.wrapDB · include() bounded reads · admin/system variants</text>
90
-
91
- <!-- domain -->
92
- <rect x="20" y="200" width="540" height="180" rx="10" fill="#1a7f4b" opacity="0.12" stroke="#1a7f4b"/>
93
- <text x="40" y="228" font-size="15" font-weight="bold" fill="#1a7f4b">domain/ — the only place business rules live</text>
94
- <rect x="40" y="245" width="240" height="55" rx="7" fill="#ffffff" opacity="0.85" stroke="#1a7f4b"/>
95
- <text x="52" y="268" font-size="12.5" font-weight="bold" fill="#1f2328">shared/schema.ts</text>
96
- <text x="52" y="286" font-size="11.5" fill="#59606a">zodTable() entity factory</text>
97
- <rect x="300" y="245" width="240" height="55" rx="7" fill="#ffffff" opacity="0.85" stroke="#1a7f4b"/>
98
- <text x="312" y="268" font-size="12.5" font-weight="bold" fill="#1f2328">shared/command.ts</text>
99
- <text x="312" y="286" font-size="11.5" fill="#59606a">ApplicationCommand: observe + audit</text>
100
- <rect x="40" y="310" width="500" height="55" rx="7" fill="#ffffff" opacity="0.85" stroke="#1a7f4b"/>
101
- <text x="52" y="333" font-size="12.5" font-weight="bold" fill="#1f2328">assignments/ · settlementPayments/</text>
102
- <text x="52" y="351" font-size="11.5" fill="#59606a">command registries, query DTOs, rules, triggers, workflows</text>
103
-
104
- <!-- components -->
105
- <rect x="580" y="200" width="280" height="180" rx="10" fill="#a05a00" opacity="0.12" stroke="#a05a00"/>
106
- <text x="600" y="228" font-size="15" font-weight="bold" fill="#a05a00">components/ (local)</text>
107
- <rect x="600" y="245" width="240" height="55" rx="7" fill="#ffffff" opacity="0.85" stroke="#a05a00"/>
108
- <text x="612" y="268" font-size="12.5" font-weight="bold" fill="#1f2328">foundation</text>
109
- <text x="612" y="286" font-size="11.5" fill="#59606a">0 tables — Command/Query/Obs kernels</text>
110
- <rect x="600" y="310" width="240" height="55" rx="7" fill="#ffffff" opacity="0.85" stroke="#a05a00"/>
111
- <text x="612" y="333" font-size="12.5" font-weight="bold" fill="#1f2328">approvals</text>
112
- <text x="612" y="351" font-size="11.5" fill="#59606a">2 tables + nested workflow + auditLog</text>
113
-
114
- <!-- npm components -->
115
- <rect x="20" y="400" width="840" height="70" rx="10" fill="#59606a" opacity="0.12" stroke="#59606a"/>
116
- <text x="40" y="428" font-size="15" font-weight="bold" fill="#59606a">npm components (mounted in convex.config.ts, wrapped by one root facade each)</text>
117
- <text x="40" y="450" font-size="12.5" fill="#59606a">@convex-dev/workos-authkit → auth.ts · convex-audit-log → audit.ts · @convex-dev/workflow ×2 → workflows.ts · @convex-dev/workpool → pool.ts</text>
118
-
119
- <!-- tests -->
120
- <rect x="20" y="490" width="840" height="52" rx="10" fill="#b03a2e" opacity="0.12" stroke="#b03a2e"/>
121
- <text x="40" y="512" font-size="14" font-weight="bold" fill="#b03a2e">__tests__/architecture.test.ts — the boundary enforcer</text>
122
- <text x="40" y="532" font-size="12" fill="#59606a">bans inline tables/enums, deep imports, sibling owners; proves foundation has no app imports; proves approvals is copy-paste portable</text>
123
-
124
- <line x1="440" y1="90" x2="440" y2="110" stroke="#59606a" stroke-width="1.5" marker-end="url(#arr)"/>
125
- <line x1="290" y1="180" x2="290" y2="200" stroke="#59606a" stroke-width="1.5" marker-end="url(#arr)"/>
126
- <line x1="700" y1="180" x2="700" y2="200" stroke="#59606a" stroke-width="1.5" marker-end="url(#arr)"/>
127
- <line x1="440" y1="380" x2="440" y2="400" stroke="#59606a" stroke-width="1.5" marker-end="url(#arr)"/>
128
- </svg>
129
- <figcaption>Requests enter through <code>api/</code>, are authenticated and
130
- shaped by <code>utils.ts</code>, execute in <code>domain/</code> using kernels
131
- from <code>components/foundation</code>, and side-effect through the npm
132
- component facades. Architecture tests police every arrow.</figcaption>
133
- </figure>
134
-
135
- <p>In prose: <code>convex/schema.ts</code> is four lines
136
- (<code>defineSchema(domainTables)</code>). <code>convex/convex.config.ts</code>
137
- declares typed env vars and mounts five components. Each component gets exactly
138
- one root facade file (<code>foundation.ts</code>, <code>approvals.ts</code>,
139
- <code>audit.ts</code>, <code>auth.ts</code>, <code>workflows.ts</code>,
140
- <code>pool.ts</code>) — the rest of the app never touches
141
- <code>components.*</code> directly. That "one facade per component" rule is
142
- asserted in <span class="path">convex/__tests__/facades.test.ts</span>.</p>
143
-
144
- <h2>2. The share layer: <code>zodTable()</code>, one shape → four boundaries</h2>
145
-
146
- <p>This is the piece you called "the share layer of the main". A single zod
147
- shape per entity is masked into every boundary the entity crosses, so the DB
148
- validator, the wire validator, and the TypeScript type are literally the same
149
- object. From <span class="path">convex/domain/shared/schema.ts:20-103</span>:</p>
150
-
151
- <pre><code><span class="k">const</span> shape = fields(zid) <span class="c">// callback gets zid, so id('assignments') works</span>
152
- <span class="k">const</span> storage = z.object(shape).strict() <span class="c">// what the DB stores</span>
153
- <span class="k">const</span> schema = storage.extend({ _id: zid(tableName), _creationTime: z.number() })
154
- <span class="k">const</span> insertSchema = z.object(omitShape(shape, options.serverFields ?? [])).strict()
155
- <span class="k">const</span> updateSchema = insertSchema.partial()
156
- <span class="k">const</span> commandInput = z.object(pickShape(shape, options.commandFields ?? [])).strict()
157
- <span class="k">const</span> publicDto = z.object(pickShape(shape, options.publicFields ?? [])).strict()
158
- ...
159
- table: defineTable(zodToConvex(storage)), <span class="c">// convex-helpers/server/zod4 bridge</span>
160
- tools: { insert: commandInput,
161
- update: z.object({ data: commandInput.partial(), id: zid(tableName) }).strict() }</code></pre>
162
-
163
- <p>The three masks are the design insight:</p>
164
- <ul>
165
- <li><strong><code>serverFields</code></strong> — fields the server owns
166
- (<code>createdAt</code>, <code>updatedAt</code>): removed from what clients
167
- may insert.</li>
168
- <li><strong><code>commandFields</code></strong> — the tiny subset a command
169
- is allowed to say (e.g. for assignments only
170
- <code>assignedToUserId</code> and <code>snapshot</code>).</li>
171
- <li><strong><code>publicFields</code></strong> — the DTO allowlist.
172
- <code>toPublicDto(row)</code> both projects <em>and re-parses</em>, so
173
- leaking a private field is a runtime error, not a code-review hope.</li>
174
- </ul>
175
-
176
- <div class="why"><strong>Why this is the sustainable move:</strong> redaction,
177
- input narrowing, and typing are usually three hand-maintained artifacts that
178
- drift. Here they are derived, so they cannot drift. There is also
179
- <code>zodVariantTable()</code> for discriminated-union tables, and note you
180
- have an older parallel copy at
181
- <span class="path">src/server/convex/schemas/helper.ts</span> (adds implicit
182
- timestamps and a JSON-safe zid for LLM tools) — the package should merge these
183
- two into one.</div>
184
-
185
- <h2>3. <code>utils.ts</code> — auth-aware function constructors</h2>
186
-
187
- <p>Every public function in the app is built from six constructors, all
188
- <code>zCustom*</code> from <code>convex-helpers/server/zod4</code>:
189
- <code>authQuery/authMutation/authAction</code>,
190
- <code>admin*</code> (adds a role gate), and internal
191
- <code>systemMutation/systemAction</code>. The heart is
192
- <code>authenticatedUser</code>
193
- (<span class="path">convex/utils.ts:185-218</span>):</p>
194
-
195
- <pre><code><span class="k">async function</span> <span class="f">authenticatedUser</span>(ctx: AuthContext) {
196
- <span class="k">const</span> identity = <span class="k">await</span> ctx.auth.getUserIdentity()
197
- <span class="k">const</span> user = identity ? <span class="k">await</span> authKit.getAuthUser(ctx) : <span class="k">null</span>
198
- <span class="k">if</span> (!identity || !user) <span class="k">return</span> App.errors.throw({ code: <span class="s">'UNAUTHENTICATED'</span> })
199
- <span class="c">// … extracts organizationId from the WorkOS JWT, maps role,</span>
200
- <span class="c">// returns a frozen { identity, user, org, role, actor } bundle</span>
201
- }</code></pre>
202
-
203
- <p>Three properties worth copying everywhere:</p>
204
- <ul>
205
- <li><strong>The JWT is not the whole truth for actions.</strong> Action
206
- constructors re-verify membership live against WorkOS
207
- (<code>listOrganizationMemberships</code>, statuses <code>['active']</code>)
208
- and <em>fail closed</em>: any WorkOS error throws <code>FORBIDDEN</code>
209
- (<span class="path">utils.ts:230-251</span>). Queries and mutations trust the
210
- JWT (they're transactional and fast); actions — which touch the outside
211
- world — do not.</li>
212
- <li><strong>Mutations get <code>triggers.wrapDB(ctx)</code> baked in</strong>
213
- (<span class="path">utils.ts:150</span>), so trigger enforcement is
214
- structurally unavoidable — nobody can "forget" to wrap the db.</li>
215
- <li><strong><code>include()</code></strong> is a fluent index-selection
216
- builder (<code>when / matching / otherwise / execute</code>) that hard-caps
217
- every read at 100 rows and throws
218
- <code>INVALID_REQUEST_BOUNDARY</code> on unbounded queries — a bounded-read
219
- discipline instead of hoping nobody writes a full scan.</li>
220
- </ul>
221
-
222
- <p>The WorkOS wiring around it is three small files:
223
- <code>auth.ts</code> (<code>new AuthKit&lt;DataModel&gt;(components.workOSAuthKit, …)</code>
224
- plus webhook-driven user sync via <code>authKit.events</code>),
225
- <code>auth.config.ts</code> (two <code>customJwt</code> providers pointing at
226
- WorkOS issuers/JWKS), and <code>http.ts</code>
227
- (<code>authKit.registerRoutes</code> on a Hono router). The "Convex user
228
- entity" you validate against is the AuthKit component's own user table, read
229
- through <code>authKit.getAuthUser(ctx)</code>.</p>
230
-
231
- <h2>4. Triggers — invariants at the database boundary</h2>
232
-
233
- <p>One app-wide <code>Triggers&lt;DataModel&gt;</code> registry
234
- (<span class="path">convex/triggers.ts</span>), domains register into it. The
235
- current use is small but exemplary
236
- (<span class="path">domain/assignments/triggers.ts</span>):</p>
237
-
238
- <pre><code>triggers.register(table, <span class="k">async</span> (_ctx, change) =&gt; {
239
- <span class="k">if</span> (change.operation !== <span class="s">'insert'</span>) {
240
- <span class="k">throw new</span> Error(<span class="s">`${table} is append-only`</span>)
241
- }
242
- }) <span class="c">// assignmentVotes and assignmentHistory are evidence tables</span></code></pre>
243
-
244
- <p>Because the wrap happens inside the mutation constructors, "append-only
245
- evidence table" is an invariant of the runtime, not a convention. This is the
246
- pattern for anything that must hold no matter which mutation writes:
247
- denormalized counters, cascading cleanups, immutability.</p>
248
-
249
- <h2>5. The foundation component — a kernel that owns nothing</h2>
250
-
251
- <p>The most unusual and most portable piece.
252
- <code>components/foundation</code> is a Convex component with
253
- <strong>zero tables</strong> (<code>defineSchema({})</code>, enforced by test)
254
- and one trivial function (<code>status</code>). Its value is entirely in the
255
- client-side classes it ships:</p>
256
-
257
- <ul>
258
- <li><strong><code>Command&lt;Context, Registry&gt;</code></strong> — a
259
- registry of operations, each <code>{ command, result }</code> as zod
260
- parseables. <code>exec()</code> parses the input, runs the handler,
261
- <em>parses the output too</em>, and rejects unregistered operations with
262
- <code>COMMAND_OPERATION_NOT_CONFIGURED</code>. Execution policy is injected:
263
- the kernel never decides what "run" means.</li>
264
- <li><strong><code>Query&lt;Context, Defaults&gt;</code></strong> — same idea
265
- for reads; the comment in the source says it directly: <em>"Authorization
266
- remains an injected host policy."</em></li>
267
- <li><strong><code>Observability</code></strong> — emits
268
- <code>command.execution</code> events with operation, classification,
269
- outcome, error code, and clamped duration; gated by a thunk; every emission
270
- is wrapped in try/catch because <em>"observability is intentionally
271
- behaviorally inert"</em> — telemetry can never break a transaction.</li>
272
- <li><strong><code>executeResultBoundary</code></strong> — returns a handled
273
- failure as a value <em>only if the transaction has written nothing yet</em>
274
- (checked via <code>ctx.meta.getTransactionMetrics()</code>); otherwise it
275
- rethrows so Convex rolls back. Typed errors without ever committing a
276
- half-done transaction.</li>
277
- </ul>
278
-
279
- <p>The host binds it once (<span class="path">convex/foundation.ts</span>):</p>
280
-
281
- <pre><code><span class="k">export const</span> { Command, Query, observability } = <span class="k">new</span> <span class="t">Foundation</span>(
282
- components.foundation,
283
- { observability: {
284
- enabled: () =&gt; env.COMMAND_OBSERVABILITY_ENABLED === <span class="s">'true'</span>,
285
- classifyError: App.errors.classifyCommand } },
286
- )</code></pre>
287
-
288
- <h2>6. <code>ApplicationCommand</code> — where audit stops being optional</h2>
289
-
290
- <p>The app wraps the kernel once, in
291
- <span class="path">domain/shared/command.ts</span>, and this wrapper is what
292
- makes every command observable <em>and</em> audited:</p>
293
-
294
- <pre><code><span class="k">return</span> observability.observe({ operation, classification: definition.classification }, <span class="k">async</span> () =&gt; {
295
- <span class="k">const</span> result = <span class="k">await</span> execution.run()
296
- <span class="k">const</span> audit = <span class="k">await</span> execution.definition.audit({ command, result }, context)
297
- <span class="k">if</span> (audit) <span class="k">await</span> writeAuditEntry(context, { ...audit, classification })
298
- <span class="k">return</span> result
299
- })</code></pre>
300
-
301
- <p>The type <code>AuditedOperation</code> makes <code>classification</code> and
302
- the <code>audit()</code> factory <strong>mandatory fields of every operation
303
- definition</strong>. You cannot register a command without deciding its audit
304
- story — auditing is type-enforced, not opt-in. <code>writeAuditEntry</code>
305
- maps the app's shape onto the <code>convex-audit-log</code> component
306
- (action, actor, resource, tags, retention category = classification), and the
307
- read side is admin-only queries with bounded windowing and tamper-resistant
308
- cursors (an FNV-1a fingerprint binds the cursor to its filters, so replayed
309
- cursors with different filters are rejected).</p>
310
-
311
- <p>Command results are values, not exceptions: a zod discriminated union on
312
- <code>ok</code> with <code>denied(code)</code> / <code>success(value)</code>
313
- helpers. Combined with <code>executeResultBoundary</code>, a denied command is
314
- data the client can render, while an unexpected error still rolls back.</p>
315
-
316
- <h2>7. Durable workflows — three flavors of the same discipline</h2>
317
-
318
- <table>
319
- <tr><th>Pipeline</th><th>Primitive</th><th>The discipline it shows</th></tr>
320
- <tr><td>Assignment auto-assign</td><td><code>@convex-dev/workflow</code> (own mount)</td>
321
- <td>Plan in a retried action, fan out mutations per item, derive a
322
- <code>blocked | partial | completed</code> status from step results.</td></tr>
323
- <tr><td>Payment materialization</td><td>workflow + <code>@convex-dev/workpool</code>
324
- (parallelism 4, backoff retry)</td>
325
- <td>Lease tokens guard concurrent runs; loops are bounded (an explicit
326
- iteration ceiling that throws rather than spin forever);
327
- <code>defineOnComplete</code> routes pool failures back into the
328
- workflow.</td></tr>
329
- <tr><td>Approvals</td><td>workflow nested <em>inside</em> the component</td>
330
- <td>The component owns its own durable engine — hosts mount nothing
331
- extra.</td></tr>
332
- </table>
333
-
334
- <p>Note the mount naming: the same npm component is mounted twice under
335
- different names (<code>assignmentWorkflow</code>,
336
- <code>paymentMaterializationWorkflow</code>), each with its own
337
- <code>WorkflowManager</code> and pool options — isolation between pipelines
338
- comes free from the component model.</p>
339
-
340
- <h2>8. The approvals component — proof the pattern scales up</h2>
341
-
342
- <p><code>components/approvals</code> is a full generic product: declarative
343
- approval workflows (decision / branch / mutation / action / notify steps) with
344
- quorum, maker-checker, expiry, a compiled-and-frozen descriptor, durable
345
- execution, and its own audit trail. What makes it generic:</p>
346
-
347
- <ul>
348
- <li><strong>Tenancy is an opaque <code>scopeRef</code> string.</strong> The
349
- component never resolves identity; the host passes
350
- <code>ctx.actor.organizationId</code>. No tenant table needed inside.</li>
351
- <li><strong>Callbacks are function handles.</strong> Host mutations are
352
- stored as opaque strings via <code>createFunctionHandle</code>, so the
353
- component calls back into app code it has never imported.</li>
354
- <li><strong>A <code>compatibilityKey</code> guards every run-touching
355
- call</strong> — redefine the workflow shape and old runs refuse to be driven
356
- by the new definition.</li>
357
- <li><strong>Decisions arrive as workflow events</strong>
358
- (<code>step.awaitEvent</code>), so a run durably sleeps until a human
359
- decides, and evidence is replayed to branch callbacks.</li>
360
- </ul>
361
-
362
- <p>The app-side definition reads like configuration
363
- (<span class="path">domain/assignments/approval.ts</span>):</p>
364
-
365
- <pre><code><span class="k">export const</span> assignmentApproval = approvals.define({
366
- name: <span class="s">'assignmentApproval'</span>,
367
- steps: [
368
- approvals.decision(<span class="s">'managerDecision'</span>, { decisions: [<span class="s">'approved'</span>, <span class="s">'rejected'</span>],
369
- quorum: { kind: <span class="s">'count'</span>, approvals: 1 }, makerChecker: <span class="k">true</span>,
370
- expiresAfterMs: ASSIGNMENT_APPROVAL_DURATION_MS }),
371
- approvals.branch(<span class="s">'applyDecision'</span>, { approvedStepKey: <span class="s">'activateAssignment'</span>,
372
- rejectedStepKey: <span class="s">'releaseAssignment'</span> }),
373
- approvals.mutation(<span class="s">'activateAssignment'</span>, { handler: internal.domain.assignments.approval_functions.applyDecision }),
374
- approvals.mutation(<span class="s">'releaseAssignment'</span>, { handler: internal.domain.assignments.approval_functions.applyDecision }),
375
- ],
376
- })</code></pre>
377
-
378
- <div class="note"><strong>The copyability test is the crown jewel.</strong>
379
- <span class="path">components/approvals/__tests__/copyability.test.ts</span>
380
- literally <code>cp -r</code>'s the component into a temp directory with only
381
- <code>node_modules</code> symlinked, generates a minimal host config, and
382
- asserts that <code>tsc</code> and <code>convex codegen --dry-run</code> both
383
- succeed. Portability isn't a claim — it's a CI assertion. Your npm package
384
- should ship this test pattern for every component in it.</div>
385
-
386
- <h2>9. The enforcement layer — why this stays clean</h2>
387
-
388
- <p><span class="path">convex/__tests__/architecture.test.ts</span> loads every
389
- production source file as raw text and fails the build on:</p>
390
-
391
- <ul>
392
- <li><code>defineTable</code>/<code>defineSchema</code> outside files named
393
- <code>schema.ts</code>; inline <code>z.enum([…])</code> literals (all
394
- vocabularies must come from <code>constants.ts</code>).</li>
395
- <li>Deep <code>@server/*</code> imports outside a six-file allowlist;
396
- hand-written <code>makeFunctionReference</code>; banned legacy symbols and
397
- directories (<code>/bridge/</code>, <code>/migration/</code>, …).</li>
398
- <li><code>foundation.ts</code> importing anything beyond the foundation
399
- client; the foundation component importing anything app-shaped at all.</li>
400
- <li>The approvals component mentioning <code>@cvx</code>,
401
- <code>process.env</code>, <code>WorkOS</code>, <code>Neon</code>,
402
- <code>Drizzle</code>, or <code>Redis</code>, and owning any tables beyond
403
- its declared two.</li>
404
- </ul>
405
-
406
- <div class="why"><strong>This is the real answer to "why is it
407
- sustainable".</strong> The layering survives because violating it fails CI,
408
- not because everyone remembers the rules. When you extract the package, these
409
- tests are as much a part of the product as the code: ship a
410
- <code>createArchitectureTest(rules)</code> helper so every consuming project
411
- gets the same guardrails on day one.</div>
412
-
413
- <h2>10. Portability map for the <code>~/dev/cvx</code> package</h2>
414
-
415
- <table>
416
- <tr><th>Piece</th><th>Portability</th><th>What to do</th></tr>
417
- <tr><td><code>components/foundation</code></td>
418
- <td><strong>Lift as-is.</strong> Zero tables, zero app imports, policy injected.</td>
419
- <td>Publish as a component; keep the boundary test.</td></tr>
420
- <tr><td><code>components/approvals</code></td>
421
- <td><strong>Lift as-is.</strong> Copyability is already CI-proven.</td>
422
- <td>Publish; ship the copyability test as a template.</td></tr>
423
- <tr><td><code>zodTable</code> / <code>zodVariantTable</code></td>
424
- <td><strong>Lift with a merge.</strong> Pure functions over zod + convex-helpers.</td>
425
- <td>Unify with the older <code>src/server/convex/schemas/helper.ts</code>
426
- variant (timestamps, jsonSafeZid) into one factory with options.</td></tr>
427
- <tr><td><code>utils.ts</code> constructors</td>
428
- <td><strong>Needs one seam.</strong> Generic over any DataModel except:
429
- AuthKit instance, role mapping, error factory, trigger registry.</td>
430
- <td>Export <code>createAuthFunctions({ authKit, mapRole, errors, triggers })</code>
431
- that returns the six constructors + <code>include()</code>.</td></tr>
432
- <tr><td><code>ApplicationCommand</code> + <code>writeAuditEntry</code></td>
433
- <td><strong>Needs one seam.</strong> Depends on an AuditLog instance and an
434
- observability instance.</td>
435
- <td><code>createApplicationCommand({ observability, writeAudit })</code>;
436
- keep <code>AuditedOperation</code>'s mandatory-audit typing.</td></tr>
437
- <tr><td>WorkOS wiring (<code>auth.ts</code>, <code>auth.config.ts</code>, <code>http.ts</code>)</td>
438
- <td><strong>Templateable.</strong> ~30 lines, identical across projects.</td>
439
- <td>Ship as a scaffold/init function, not runtime code: the env names and
440
- issuer URLs are the only variables.</td></tr>
441
- <tr><td>Audit facade cursor machinery</td>
442
- <td><strong>Liftable.</strong> Fingerprinted cursors + bounded windows are
443
- generic over any AuditLog.</td>
444
- <td>Extract; leave the DTO vocabularies app-side.</td></tr>
445
- <tr><td>Workflow/pool tuning, domain registries, DTOs, constants</td>
446
- <td><strong>Stays app-side.</strong> This is the business.</td>
447
- <td>Document the shapes; the package provides the kernels they plug into.</td></tr>
448
- </table>
449
-
450
- <p>The through-line: this codebase already separated mechanism from policy —
451
- the package boundary you want is the boundary the architecture tests already
452
- enforce. Extraction is mostly <em>moving</em> code, plus cutting the four
453
- injection seams named above (auth deps, error factory, audit writer,
454
- observability config) so the mechanisms stop importing your app's singletons
455
- and start receiving them.</p>
456
-
457
- <footer>Composed 2026-08-13 by ce-explain</footer>
458
- </main>
459
- </body>
460
- </html>