cvx-kit 0.0.2 → 0.0.4

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.
Files changed (65) hide show
  1. package/README.md +1 -0
  2. package/dist/auth.d.mts +52 -5
  3. package/dist/auth.mjs +1 -1
  4. package/dist/auth.mjs.map +1 -1
  5. package/dist/components/foundation/client.d.mts +40 -3
  6. package/dist/components/foundation/client.mjs +1 -1
  7. package/dist/components/foundation/client.mjs.map +1 -1
  8. package/dist/index.d.mts +6 -5
  9. package/dist/index.mjs +1 -1
  10. package/dist/tenancy.d.mts +34 -0
  11. package/dist/tenancy.mjs +2 -0
  12. package/dist/tenancy.mjs.map +1 -0
  13. package/dist/triggers.d.mts +9 -1
  14. package/dist/triggers.mjs +1 -1
  15. package/dist/triggers.mjs.map +1 -1
  16. package/dist/zod-table.d.mts +21 -1
  17. package/dist/zod-table.mjs +1 -1
  18. package/dist/zod-table.mjs.map +1 -1
  19. package/docs/auth.md +10 -0
  20. package/docs/commands.md +56 -4
  21. package/docs/conventions.md +21 -0
  22. package/docs/explain/modules-and-multitenancy.html +469 -0
  23. package/docs/llms.md +16 -4
  24. package/docs/tenancy.md +179 -0
  25. package/docs/triggers.md +5 -0
  26. package/docs/zod-table.md +23 -0
  27. package/package.json +6 -8
  28. package/src/test.ts +7 -5
  29. package/src/auth.ts +0 -360
  30. package/src/components/approvals/_generated/api.ts +0 -69
  31. package/src/components/approvals/_generated/component.ts +0 -335
  32. package/src/components/approvals/_generated/dataModel.ts +0 -60
  33. package/src/components/approvals/_generated/server.ts +0 -156
  34. package/src/components/approvals/audit.ts +0 -148
  35. package/src/components/approvals/client.ts +0 -321
  36. package/src/components/approvals/constants.ts +0 -51
  37. package/src/components/approvals/convex.config.ts +0 -10
  38. package/src/components/approvals/decisions.ts +0 -376
  39. package/src/components/approvals/functions.ts +0 -27
  40. package/src/components/approvals/requests.ts +0 -240
  41. package/src/components/approvals/schema.ts +0 -23
  42. package/src/components/approvals/validators.ts +0 -272
  43. package/src/components/approvals/workflow.ts +0 -204
  44. package/src/components/approvals/workflow_steps.ts +0 -189
  45. package/src/components/foundation/_generated/api.ts +0 -62
  46. package/src/components/foundation/_generated/component.ts +0 -29
  47. package/src/components/foundation/_generated/dataModel.ts +0 -60
  48. package/src/components/foundation/_generated/server.ts +0 -156
  49. package/src/components/foundation/client.ts +0 -190
  50. package/src/components/foundation/convex.config.ts +0 -3
  51. package/src/components/foundation/functions.ts +0 -10
  52. package/src/components/foundation/modules/command/__tests__/command.test.ts +0 -106
  53. package/src/components/foundation/modules/command/command.ts +0 -154
  54. package/src/components/foundation/modules/observability/__tests__/observability.test.ts +0 -79
  55. package/src/components/foundation/modules/observability/observability.ts +0 -95
  56. package/src/components/foundation/modules/query/__tests__/query.test.ts +0 -25
  57. package/src/components/foundation/modules/query/query.ts +0 -47
  58. package/src/components/foundation/result.ts +0 -77
  59. package/src/components/foundation/schema.ts +0 -4
  60. package/src/components/foundation/telemetry.ts +0 -23
  61. package/src/errors.ts +0 -25
  62. package/src/index.ts +0 -41
  63. package/src/triggers.ts +0 -90
  64. package/src/zod-table.ts +0 -197
  65. /package/docs/{convex-architecture-explainer.html → explain/convex-architecture-explainer.html} +0 -0
@@ -0,0 +1,469 @@
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>Modules, not entities — and tenancy as a structural feature of cvx-kit</title>
7
+ <style>
8
+ :root {
9
+ --bg: #0f1115; --panel: #171a21; --panel2: #1d212b; --ink: #e6e9ef;
10
+ --muted: #9aa3b2; --accent: #7aa2f7; --good: #9ece6a; --warn: #e0af68;
11
+ --bad: #f7768e; --code-bg: #12141a; --border: #2a2f3a;
12
+ }
13
+ html { background: var(--bg); }
14
+ body {
15
+ color: var(--ink); margin: 0; padding: 2rem 1.25rem 4rem;
16
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
17
+ line-height: 1.55;
18
+ }
19
+ main { max-width: 72ch; margin: 0 auto; }
20
+ .wide { max-width: 100ch; margin-left: auto; margin-right: auto; }
21
+ h1 { font-size: 1.6rem; line-height: 1.25; margin: 0 0 .75rem; }
22
+ h2 { font-size: 1.2rem; margin: 2.5rem 0 .5rem; color: var(--accent); }
23
+ h3 { font-size: 1rem; margin: 1.75rem 0 .4rem; }
24
+ p, li { font-size: .95rem; }
25
+ dl.meta {
26
+ display: grid; grid-template-columns: max-content 1fr; gap: .15rem .9rem;
27
+ background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
28
+ padding: .75rem 1rem; font-size: .85rem; margin: 0 0 2rem;
29
+ }
30
+ dl.meta dt { color: var(--muted); font-weight: 600; }
31
+ dl.meta dd { margin: 0; }
32
+ code { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: .85em;
33
+ background: var(--code-bg); padding: .1em .35em; border-radius: 4px; }
34
+ pre {
35
+ background: var(--code-bg); border: 1px solid var(--border); border-radius: 8px;
36
+ padding: .9rem 1rem; overflow-x: auto; font-size: .8rem; line-height: 1.5;
37
+ }
38
+ pre code { background: none; padding: 0; }
39
+ .k { color: #bb9af7; } .s { color: #9ece6a; } .c { color: #565f89; font-style: italic; }
40
+ .f { color: #7aa2f7; } .t { color: #e0af68; } .n { color: #f7768e; }
41
+ .note {
42
+ border-left: 3px solid var(--accent); background: var(--panel);
43
+ padding: .6rem .9rem; border-radius: 0 8px 8px 0; margin: 1rem 0; font-size: .9rem;
44
+ }
45
+ .warn { border-left-color: var(--warn); }
46
+ .danger { border-left-color: var(--bad); }
47
+ table.cmp { border-collapse: collapse; width: 100%; font-size: .85rem; margin: 1rem 0; }
48
+ table.cmp th, table.cmp td { border: 1px solid var(--border); padding: .45rem .6rem; text-align: left; vertical-align: top; }
49
+ table.cmp th { background: var(--panel2); }
50
+ figure { margin: 1.5rem 0; }
51
+ figcaption { color: var(--muted); font-size: .8rem; margin-top: .4rem; }
52
+ .svgbox { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: .75rem; }
53
+ footer { color: var(--muted); font-size: .8rem; margin-top: 3.5rem; border-top: 1px solid var(--border); padding-top: 1rem; }
54
+ strong { color: #fff; }
55
+ </style>
56
+ </head>
57
+ <body>
58
+ <main>
59
+
60
+ <h1>Modules, not entities — and tenancy as a structural feature of cvx-kit</h1>
61
+
62
+ <dl class="meta">
63
+ <dt>Date</dt><dd>2026-08-13</dd>
64
+ <dt>Input shape</dt><dd>concept</dd>
65
+ <dt>Subject</dt><dd>Evolving cvx-kit's domain layer from entity-per-folder to module segregation, and designing multi-tenancy into the kit — grounded in ~/dev/one-office (multi-tenant Convex ERP) and ~/dev/cvx</dd>
66
+ </dl>
67
+
68
+ <p><strong>The short version.</strong> Two upgrades, and they're independent:
69
+ (1) the unit of the domain layer should be the <em>module</em> (a bounded
70
+ context owning several related tables), not the entity — your docs currently
71
+ imply one folder per table, and one-office proves the module shape scales
72
+ better; (2) tenancy in one-office is not a convention, it's a
73
+ <strong>five-layer structural machine</strong> — schema injection,
74
+ registry-generated row-level security, branded contexts, post-image triggers,
75
+ and architecture tests — and almost all of it is generic enough to live in
76
+ cvx-kit as opt-in machinery, with the policy (which identity provider, which
77
+ claim is the tenant) staying injected, exactly like <code>createAuthFunctions</code>
78
+ already works.</p>
79
+
80
+ <h2>Part 1 — The domain unit is a module, not a table</h2>
81
+
82
+ <p>Your current docs say <code>domain/&lt;entity&gt;/</code>, one directory per
83
+ entity, one <code>zodTable</code> per directory. That was true of the app the
84
+ kit was extracted from — an <em>administration</em> app with few entities.
85
+ one-office has dozens of tables and does something different: the domain
86
+ layer is split into <strong>bounded contexts</strong>, each owning
87
+ <em>many</em> tables that change together:</p>
88
+
89
+ <figure class="wide">
90
+ <div class="svgbox">
91
+ <svg viewBox="0 0 880 300" xmlns="http://www.w3.org/2000/svg" font-family="ui-monospace, Menlo, monospace" font-size="12">
92
+ <!-- entity-per-folder side -->
93
+ <text x="110" y="22" fill="#9aa3b2" font-size="13" font-weight="bold">entity-per-folder (current docs)</text>
94
+ <g fill="#1d212b" stroke="#2a2f3a">
95
+ <rect x="20" y="40" width="120" height="34" rx="6"/>
96
+ <rect x="20" y="82" width="120" height="34" rx="6"/>
97
+ <rect x="20" y="124" width="120" height="34" rx="6"/>
98
+ <rect x="20" y="166" width="120" height="34" rx="6"/>
99
+ <rect x="20" y="208" width="120" height="34" rx="6"/>
100
+ <rect x="20" y="250" width="120" height="34" rx="6"/>
101
+ </g>
102
+ <g fill="#e6e9ef">
103
+ <text x="32" y="62">products/</text>
104
+ <text x="32" y="104">prices/</text>
105
+ <text x="32" y="146">unitsOfMeasure/</text>
106
+ <text x="32" y="188">warehouses/</text>
107
+ <text x="32" y="230">stockEntries/</text>
108
+ <text x="32" y="272">itemLedger/</text>
109
+ </g>
110
+ <text x="170" y="160" fill="#f7768e" font-size="20">→</text>
111
+ <text x="155" y="185" fill="#f7768e" font-size="11">100 folders,</text>
112
+ <text x="155" y="200" fill="#f7768e" font-size="11">blurred seams</text>
113
+
114
+ <!-- module side -->
115
+ <text x="330" y="22" fill="#9aa3b2" font-size="13" font-weight="bold">module segregation (one-office)</text>
116
+ <g>
117
+ <rect x="260" y="40" width="270" height="110" rx="8" fill="#171a21" stroke="#7aa2f7"/>
118
+ <text x="275" y="62" fill="#7aa2f7" font-weight="bold">domain/catalog/</text>
119
+ <text x="275" y="82" fill="#e6e9ef">tables: products, prices,</text>
120
+ <text x="275" y="98" fill="#e6e9ef">unitsOfMeasure, …</text>
121
+ <text x="275" y="124" fill="#9aa3b2">schema.ts · table.ts · rules.ts</text>
122
+ <text x="275" y="140" fill="#9aa3b2">commands.ts · queries.ts · shared.ts</text>
123
+ </g>
124
+ <g>
125
+ <rect x="260" y="165" width="270" height="110" rx="8" fill="#171a21" stroke="#7aa2f7"/>
126
+ <text x="275" y="187" fill="#7aa2f7" font-weight="bold">domain/inventory/</text>
127
+ <text x="275" y="207" fill="#e6e9ef">tables: warehouses, stockEntries,</text>
128
+ <text x="275" y="223" fill="#e6e9ef">itemLedger, …</text>
129
+ <text x="275" y="249" fill="#9aa3b2">same fixed file roles,</text>
130
+ <text x="275" y="265" fill="#9aa3b2">module owns several tables</text>
131
+ </g>
132
+ <!-- allowed dependency arrows -->
133
+ <path d="M 530 220 C 590 220 590 95 532 95" fill="none" stroke="#9ece6a" stroke-width="1.5" marker-end="url(#ar)"/>
134
+ <text x="560" y="150" fill="#9ece6a" font-size="11" transform="rotate(0)">rules/queries only</text>
135
+
136
+ <!-- application layer -->
137
+ <g>
138
+ <rect x="660" y="80" width="200" height="70" rx="8" fill="#171a21" stroke="#e0af68"/>
139
+ <text x="675" y="103" fill="#e0af68" font-weight="bold">application/</text>
140
+ <text x="675" y="121" fill="#e6e9ef" font-size="11">cross-module atomic</text>
141
+ <text x="675" y="136" fill="#e6e9ef" font-size="11">workflows (writes)</text>
142
+ </g>
143
+ <path d="M 662 115 C 610 115 600 95 532 90" fill="none" stroke="#e0af68" stroke-width="1.5" marker-end="url(#ar2)"/>
144
+ <path d="M 662 130 C 610 140 600 210 532 212" fill="none" stroke="#e0af68" stroke-width="1.5" marker-end="url(#ar2)"/>
145
+ <defs>
146
+ <marker id="ar" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
147
+ <path d="M0,0 L8,4 L0,8 z" fill="#9ece6a"/></marker>
148
+ <marker id="ar2" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
149
+ <path d="M0,0 L8,4 L0,8 z" fill="#e0af68"/></marker>
150
+ </defs>
151
+ </svg>
152
+ </div>
153
+ <figcaption>Left: what your docs currently prescribe. Right: one-office's
154
+ shape — the same fixed file roles, but the directory is a bounded context
155
+ owning related tables; cross-module writes go up into an
156
+ <code>application/</code> layer, and sibling reads are restricted to
157
+ <code>rules.ts</code>/<code>queries.ts</code>.</figcaption>
158
+ </figure>
159
+
160
+ <p>The crucial insight from one-office: <strong>nothing about the file
161
+ anatomy changes</strong>. Every module still has exactly
162
+ <code>schema.ts · table.ts · constants.ts · commands.ts · queries.ts ·
163
+ rules.ts · shared.ts · triggers.ts · __tests__/</code> — its CODE_CONDUCT
164
+ spells out the identical roles yours does. What changes is the <em>grain</em>:
165
+ <code>domain/catalog/schema.ts</code> declares several
166
+ <code>zodTable</code>s, and <code>domain/catalog/table.ts</code> exports one
167
+ merged <code>catalogTables</code> map. The module registry in
168
+ <code>domain/table.ts</code> then reads like a table of contents for the
169
+ whole business:</p>
170
+
171
+ <pre><code><span class="c">// one-office convex/domain/table.ts — the whole backend in one expression</span>
172
+ <span class="k">export const</span> domainTables = { ...sharedTables, ...tenancyTables, ...catalogTables,
173
+ ...inventoryTables, ...purchasingTables, ...salesTables, ...posTables } <span class="k">as const</span></code></pre>
174
+
175
+ <h3>What defines a module boundary?</h3>
176
+
177
+ <p>Not table count — <em>change cohesion and vocabulary</em>. one-office's
178
+ <code>CONTEXT.md</code> is a ubiquitous-language glossary (each term with
179
+ explicit "Avoid:" antonyms), and the modules fall out of it: things named in
180
+ the same breath (<code>products</code>, <code>prices</code>,
181
+ <code>unitsOfMeasure</code>) live together as <code>catalog/</code>. A good
182
+ test for your docs: <strong>two tables belong in one module when a single
183
+ command routinely writes both, or when one's vocabulary is meaningless
184
+ without the other</strong>. If neither is true, separate modules.</p>
185
+
186
+ <h3>The dependency rules that make it safe</h3>
187
+
188
+ <p>Measured from one-office's actual imports, the discipline is precise —
189
+ and this is what keeps modules from re-blurring into a monolith:</p>
190
+
191
+ <table class="cmp">
192
+ <tr><th>Rule</th><th>Meaning</th></tr>
193
+ <tr><td><strong>Sibling reads: <code>rules.ts</code> / <code>queries.ts</code> only</strong></td>
194
+ <td>inventory may import <code>catalog/rules</code> (pure predicates) and
195
+ <code>catalog/queries</code>. It may <em>never</em> import
196
+ <code>catalog/commands.ts</code> — one module never mutates another's state.</td></tr>
197
+ <tr><td><strong>Cross-module writes: <code>application/</code></strong></td>
198
+ <td>A workflow that must atomically touch sales + inventory + invoicing lives
199
+ in <code>application/postSalesDocument.ts</code>, above both modules. The
200
+ <code>api/</code> adapters then import <code>application/*</code> and their
201
+ own module — never a second module.</td></tr>
202
+ <tr><td><strong><code>domain/shared/</code> stays generic</strong></td>
203
+ <td>Cross-cutting kernels (permissions, idempotency, fixed-point math, the
204
+ command/query policy classes) — never business nouns.</td></tr>
205
+ </table>
206
+
207
+ <div class="note">
208
+ <strong>For cvx-kit this is a docs change, not a code change.</strong> The kit
209
+ has no <code>domain/</code> awareness in code; the entity-per-folder rule
210
+ lives in <code>conventions.md</code>/<code>architecture.md</code>. Rewrite the
211
+ grain: <code>domain/&lt;module&gt;/</code> owning 1..n related tables, the
212
+ sibling-import rule upgraded from "never" to "rules/queries only, never
213
+ commands", and a new optional <code>application/</code> layer for
214
+ cross-module atomic workflows. A single-context app degenerates gracefully:
215
+ one module, or a module per entity — the current shape remains a special
216
+ case of the new rule.</div>
217
+
218
+ <h2>Part 2 — What multi-tenant actually means in one-office</h2>
219
+
220
+ <p>Your origin app was single-org administration: whoever is in the org
221
+ administers the data, end of story. one-office serves many organizations from
222
+ one deployment, so every row must answer "whose row is this?" and every read
223
+ and write must be provably scoped. The design decisions, from its ADR:</p>
224
+
225
+ <ul>
226
+ <li><strong>Tenant = the WorkOS organization id, stored as a plain string
227
+ field named <code>tenant</code> on every business row.</strong> No local
228
+ orgs/users/memberships tables — WorkOS stays the identity authority, Convex
229
+ stores one operational-settings record per org. No shadow auth tables to
230
+ drift.</li>
231
+ <li><strong>The active tenant is always derived server-side from the JWT's
232
+ organization claim</strong> — never accepted as an argument. Org switching is
233
+ a frontend/WorkOS concern; when the user switches, the next request carries a
234
+ different claim.</li>
235
+ <li><strong>Every index on a tenant table is tenant-prefixed</strong>:
236
+ <code>by_tenant</code>, <code>by_tenant_code</code>,
237
+ <code>by_tenant_office_archived_at</code>. Reads are impossible to express
238
+ efficiently <em>without</em> scoping.</li>
239
+ <li><strong>Tables split into two registries</strong>: tenant tables (get all
240
+ the machinery below) and private/global tables (platform state, cross-tenant
241
+ uniqueness claims, reconciliation jobs) — deliberately excluded, and therefore
242
+ the sharpest remaining footgun surface.</li>
243
+ </ul>
244
+
245
+ <h3>Isolation is defense-in-depth, not a convention</h3>
246
+
247
+ <figure class="wide">
248
+ <div class="svgbox">
249
+ <svg viewBox="0 0 880 330" xmlns="http://www.w3.org/2000/svg" font-family="ui-monospace, Menlo, monospace" font-size="12">
250
+ <defs>
251
+ <marker id="fl" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
252
+ <path d="M0,0 L8,4 L0,8 z" fill="#7aa2f7"/></marker>
253
+ </defs>
254
+ <g>
255
+ <rect x="20" y="30" width="160" height="52" rx="8" fill="#171a21" stroke="#7aa2f7"/>
256
+ <text x="35" y="52" fill="#7aa2f7" font-weight="bold">1 · schema</text>
257
+ <text x="35" y="70" fill="#e6e9ef" font-size="11">tenantTable() injects</text>
258
+ </g>
259
+ <text x="35" y="100" fill="#9aa3b2" font-size="11">tenant field; update</text>
260
+ <text x="35" y="114" fill="#9aa3b2" font-size="11">schema strips it</text>
261
+
262
+ <g>
263
+ <rect x="240" y="30" width="170" height="52" rx="8" fill="#171a21" stroke="#7aa2f7"/>
264
+ <text x="255" y="52" fill="#7aa2f7" font-weight="bold">2 · builders</text>
265
+ <text x="255" y="70" fill="#e6e9ef" font-size="11">tenantQuery/Mutation</text>
266
+ </g>
267
+ <text x="255" y="100" fill="#9aa3b2" font-size="11">resolve tenant from JWT,</text>
268
+ <text x="255" y="114" fill="#9aa3b2" font-size="11">wrap db: triggers → RLS</text>
269
+
270
+ <g>
271
+ <rect x="470" y="30" width="170" height="52" rx="8" fill="#171a21" stroke="#7aa2f7"/>
272
+ <text x="485" y="52" fill="#7aa2f7" font-weight="bold">3 · RLS, deny-default</text>
273
+ <text x="485" y="70" fill="#e6e9ef" font-size="11">generated from registry</text>
274
+ </g>
275
+ <text x="485" y="100" fill="#9aa3b2" font-size="11">read/insert/modify ⇒</text>
276
+ <text x="485" y="114" fill="#9aa3b2" font-size="11">doc.tenant === ctx.tenant</text>
277
+
278
+ <g>
279
+ <rect x="700" y="30" width="160" height="52" rx="8" fill="#171a21" stroke="#7aa2f7"/>
280
+ <text x="715" y="52" fill="#7aa2f7" font-weight="bold">4 · triggers</text>
281
+ <text x="715" y="70" fill="#e6e9ef" font-size="11">post-image invariant</text>
282
+ </g>
283
+ <text x="715" y="100" fill="#9aa3b2" font-size="11">tenant can never be</text>
284
+ <text x="715" y="114" fill="#9aa3b2" font-size="11">reassigned on update</text>
285
+
286
+ <path d="M 182 56 L 238 56" stroke="#7aa2f7" stroke-width="1.5" marker-end="url(#fl)"/>
287
+ <path d="M 412 56 L 468 56" stroke="#7aa2f7" stroke-width="1.5" marker-end="url(#fl)"/>
288
+ <path d="M 642 56 L 698 56" stroke="#7aa2f7" stroke-width="1.5" marker-end="url(#fl)"/>
289
+
290
+ <g>
291
+ <rect x="240" y="160" width="400" height="52" rx="8" fill="#171a21" stroke="#9ece6a"/>
292
+ <text x="255" y="182" fill="#9ece6a" font-weight="bold">5 · architecture tests</text>
293
+ <text x="255" y="200" fill="#e6e9ef" font-size="11">raw builders banned outside an explicit allowlist; RLS registry asserted exhaustive</text>
294
+ </g>
295
+ <path d="M 440 158 L 440 120" stroke="#9ece6a" stroke-width="1.5" stroke-dasharray="4 3"/>
296
+
297
+ <g>
298
+ <rect x="20" y="250" width="840" height="60" rx="8" fill="#171a21" stroke="#e0af68"/>
299
+ <text x="35" y="273" fill="#e0af68" font-weight="bold">Sideguards</text>
300
+ <text x="35" y="292" fill="#e6e9ef" font-size="11">branded ctx (unexported unique symbol — a tenant ctx can't be forged) · requireTenantReference for client-supplied ids (404, not 403 — no existence disclosure) · components take tenant as an explicit arg from the host adapter only</text>
301
+ </g>
302
+ </svg>
303
+ </div>
304
+ <figcaption>one-office's isolation stack. Any single layer can be bypassed;
305
+ the composition can't be bypassed <em>accidentally</em>.</figcaption>
306
+ </figure>
307
+
308
+ <p>The three pieces worth reading in full, because they're the ones cvx-kit
309
+ should absorb:</p>
310
+
311
+ <pre><code><span class="c">// (1) one-office domain/shared/schema.ts — tenancy injected at the schema boundary</span>
312
+ <span class="k">export const</span> <span class="f">tenantTable</span> = (tableName, schema, options = {}) =&gt; {
313
+ <span class="k">const</span> definition = <span class="f">zodTable</span>(tableName, (id) =&gt; ({ ...schema(id),
314
+ tenant: z.<span class="f">string</span>().<span class="f">min</span>(<span class="t">1</span>) }), {...})
315
+ <span class="c">// tenant exists on every row, and can NEVER appear in an update</span>
316
+ <span class="k">const</span> tenantSafeUpdate = definition.updateSchema.<span class="f">omit</span>({ tenant: <span class="t">true</span> })
317
+ <span class="k">return</span> { ...definition, updateSchema: tenantSafeUpdate }
318
+ }</code></pre>
319
+
320
+ <pre><code><span class="c">// (2) one-office convex/utils.ts — RLS generated mechanically from the registry.</span>
321
+ <span class="c">// Nothing per-table to remember: a new tenantTable is isolated the moment</span>
322
+ <span class="c">// it's registered, or the exhaustiveness test fails.</span>
323
+ <span class="k">export const</span> <span class="f">tenantRules</span> = (tenant: <span class="t">string</span>) =&gt;
324
+ Object.<span class="f">fromEntries</span>(TENANT_TABLE_NAMES.<span class="f">map</span>((tableName) =&gt; [tableName, {
325
+ read: <span class="k">async</span> (_ctx, doc) =&gt; doc.tenant === tenant,
326
+ insert: <span class="k">async</span> (_ctx, doc) =&gt; doc.tenant === tenant,
327
+ modify: <span class="k">async</span> (_ctx, doc) =&gt; doc.tenant === tenant,
328
+ }]))
329
+ <span class="k">export const</span> TENANT_RLS_CONFIG = Object.<span class="f">freeze</span>({ defaultPolicy: <span class="s">'deny'</span> })
330
+
331
+ <span class="c">// inside tenantMutation: triggers first, then RLS — order matters</span>
332
+ <span class="k">const</span> { db: triggeredDb } = triggers.<span class="f">wrapDB</span>(ctx)
333
+ db: <span class="f">wrapDatabaseWriter</span>({}, triggeredDb, <span class="f">tenantRules</span>(tenant), TENANT_RLS_CONFIG)</code></pre>
334
+
335
+ <pre><code><span class="c">// (3) one-office convex/triggers.ts — RLS authorizes against the PRE-image,</span>
336
+ <span class="c">// so ownership must also hold on the POST-image. Registered for every tenant table.</span>
337
+ <span class="k">export const</span> <span class="f">rejectTenantReassignment</span> = <span class="k">async</span> (_ctx, change) =&gt; {
338
+ <span class="k">if</span> (change.operation === <span class="s">'insert'</span> &amp;&amp; !change.newDoc.tenant) <span class="k">throw</span> CROSS_TENANT_REFERENCE
339
+ <span class="k">if</span> (change.operation === <span class="s">'update'</span> &amp;&amp; change.oldDoc.tenant !== change.newDoc.tenant)
340
+ <span class="k">throw</span> CROSS_TENANT_REFERENCE <span class="c">// 'Tenant ownership cannot be reassigned'</span>
341
+ }</code></pre>
342
+
343
+ <div class="note warn"><strong>Why layer 4 exists at all</strong> is the best
344
+ lesson in the codebase: RLS <code>modify</code> checks the document you're
345
+ <em>changing</em> (pre-image). Without the trigger, a mutation could legally
346
+ load its own tenant's row and patch <code>tenant</code> to another org's id —
347
+ a write RLS happily allows. Every layer covers a hole in the previous one.
348
+ That's the mindset tenancy requires.</div>
349
+
350
+ <h2>Part 3 — How this lands in cvx-kit</h2>
351
+
352
+ <p>The dividing line — and it's the same line the kit already draws for auth —
353
+ is <strong>mechanism in the kit, policy in the host</strong>. WorkOS, the JWT
354
+ claim shape, permission slugs: policy, injected. Field injection, RLS
355
+ generation, trigger invariants, builder wiring: mechanism, and today every
356
+ consumer would have to hand-roll all of it. Proposal, in kit-API terms:</p>
357
+
358
+ <h3>3a. <code>tenantTable</code> in <code>cvx-kit/zod-table</code></h3>
359
+
360
+ <pre><code><span class="k">export const</span> &lt;entities&gt; = <span class="f">tenantTable</span>(<span class="s">'&lt;entities&gt;'</span>, (id) =&gt; ({ ... }), {
361
+ commandFields: [...], publicFields: [...],
362
+ })
363
+ <span class="c">// = zodTable + { tenant: z.string().min(1) } baked in, exactly like timestamps:</span>
364
+ <span class="c">// • excluded from insertSchema/commandInput (server-owned, like createdAt)</span>
365
+ <span class="c">// • stripped from updateSchema — tenant is immutable at the type boundary</span>
366
+ <span class="c">// • never in publicDto unless explicitly allowlisted</span></code></pre>
367
+
368
+ <p>This mirrors the kit's existing timestamp opinion precisely: a
369
+ server-owned field, injected once, unwritable from any client-facing mask.</p>
370
+
371
+ <h3>3b. A tenancy module: registry → rules + trigger</h3>
372
+
373
+ <pre><code><span class="c">// cvx-kit/tenancy (new export)</span>
374
+ <span class="k">export function</span> <span class="f">createTenancy</span>&lt;DataModel&gt;(config: {
375
+ tables: <span class="k">readonly</span> TableNamesInDataModel&lt;DataModel&gt;[] <span class="c">// THE registry</span>
376
+ }) {
377
+ <span class="k">return</span> {
378
+ tables, <span class="c">// re-exported for tests</span>
379
+ <span class="f">rules</span>: (tenant) =&gt; ..., <span class="c">// deny-default RLS map, generated</span>
380
+ <span class="f">registerGuards</span>: (triggers) =&gt; ..., <span class="c">// rejectTenantReassignment on every table</span>
381
+ <span class="f">requireTenantReference</span>: ..., <span class="c">// 404-not-403 cross-reference guard</span>
382
+ }
383
+ }</code></pre>
384
+
385
+ <p>One registry drives everything — the property that made one-office safe is
386
+ that <em>adding a table to the registry is the entire onboarding</em>; there
387
+ is no second place to forget.</p>
388
+
389
+ <h3>3c. Tenant-aware constructors via <code>createAuthFunctions</code></h3>
390
+
391
+ <pre><code><span class="f">createAuthFunctions</span>&lt;DataModel&gt;({
392
+ ...,
393
+ tenancy: { <span class="c">// optional — absent = today's behavior</span>
394
+ tenancy, <span class="c">// the createTenancy(...) instance</span>
395
+ resolve: (bundle) =&gt; bundle.org.organizationId, <span class="c">// policy: claim → tenant, injected</span>
396
+ },
397
+ })
398
+ <span class="c">// adds: tenantQuery, tenantMutation, tenantAction, tenantSystemMutation</span>
399
+ <span class="c">// ctx gains ctx.tenant; mutation db is wrapped triggers → RLS(deny);</span>
400
+ <span class="c">// insert paths stamp tenant from ctx — handlers never write it</span></code></pre>
401
+
402
+ <p>The auth bundle already carries <code>org.organizationId</code> — the kit
403
+ already resolves the org from the JWT and refuses requests without one. The
404
+ tenant is one <code>resolve</code> away. Keeping <code>tenancy</code> optional
405
+ means the single-org administration apps (your origin app) change nothing,
406
+ and a multi-tenant app opts in with ~10 lines.</p>
407
+
408
+ <h3>3d. Alignment that already exists</h3>
409
+
410
+ <p>Two kit pieces anticipated this design and need no change, only
411
+ documentation:</p>
412
+
413
+ <ul>
414
+ <li><strong>Approvals' <code>scopeRef</code> is the tenant slot.</strong> The
415
+ component was already built tenancy-agnostic — pass
416
+ <code>ctx.tenant</code> as <code>scopeRef</code> and per-tenant approval
417
+ isolation falls out.</li>
418
+ <li><strong>Components take <code>tenant</code> as an explicit argument.</strong>
419
+ one-office's rule — the host adapter is the only code allowed to supply it,
420
+ always from <code>ctx.tenant</code>, never from client input — is exactly the
421
+ kit's existing "opaque validated data across the component boundary" rule.</li>
422
+ </ul>
423
+
424
+ <h3>3e. What stays out of the kit</h3>
425
+
426
+ <table class="cmp">
427
+ <tr><th>Concern</th><th>Why it stays host-side</th></tr>
428
+ <tr><td>Org switching, invitations, memberships</td><td>Identity-provider surface (WorkOS UI + webhooks). The kit sees only "the claim changed".</td></tr>
429
+ <tr><td>Permission catalogs (<code>erp.sales.manage</code>…)</td><td>Product vocabulary. The kit's role constructors are the extension point; a permission-claim check plugs into <code>mapRole</code>/role gates.</td></tr>
430
+ <tr><td>Tenant provisioning/archival</td><td>A webhook-driven domain command in the host (one-office: idempotent <code>provisionOrganization</code>).</td></tr>
431
+ <tr><td>Global/private tables</td><td>Deliberately outside the machinery. The kit's job is to make the registry split <em>visible</em> (docs + a conventions rule), not to guard what it can't see.</td></tr>
432
+ </table>
433
+
434
+ <div class="note danger"><strong>The honest footguns to document with the
435
+ feature</strong> — all learned the hard way in one-office: (1) actions have no
436
+ db, so action→internal handoffs must pass the server-derived tenant, never a
437
+ caller value — the internal builders should be asserted to <em>not accept</em>
438
+ a tenant arg; (2) provider ids (memberships, invitations) are global — org-scoped
439
+ mutations must re-check ownership against the active org or you ship a
440
+ cross-tenant IDOR; (3) private/global tables get none of the machinery — every
441
+ one is a per-handler-discipline zone and should be rare and named; (4)
442
+ cross-reference loads must return "not found", never "forbidden" — existence
443
+ is also tenant data.</div>
444
+
445
+ <h3>Suggested order of work</h3>
446
+
447
+ <ol>
448
+ <li><strong>Docs first</strong> (no code risk): rewrite
449
+ <code>conventions.md</code>/<code>architecture.md</code> to module grain +
450
+ sibling rules/queries rule + optional <code>application/</code> layer.</li>
451
+ <li><code>tenantTable</code> in zod-table (small, additive, mirrors timestamps).</li>
452
+ <li><code>cvx-kit/tenancy</code>: registry → rules/guards/reference-check.</li>
453
+ <li><code>tenancy</code> option in <code>createAuthFunctions</code> + the
454
+ <code>tenant*</code> constructors (the only intrusive piece — wrap order
455
+ triggers→RLS, ctx branding, insert stamping).</li>
456
+ <li>Architecture-test recipes in the docs: registry exhaustiveness,
457
+ raw-builder ban already exists — add "tenant tables use tenantTable" and
458
+ "internal builders accept no tenant arg".</li>
459
+ </ol>
460
+
461
+ <p>Steps 1–3 are safe to ship in a patch release; step 4 deserves its own
462
+ minor version and a design pass on the ctx type (one-office's unexported
463
+ <code>unique symbol</code> branding is worth copying so a "tenant ctx" can't
464
+ be hand-assembled).</p>
465
+
466
+ <footer>Composed 2026-08-13 by ce-explain</footer>
467
+ </main>
468
+ </body>
469
+ </html>
package/docs/llms.md CHANGED
@@ -36,7 +36,11 @@ function constructors, a trigger registry, an audited command protocol
36
36
  5. **State changes are commands**: an operations registry with mandatory
37
37
  `classification` and `audit()` per operation, executed via
38
38
  `commands.exec({ operation, handler })`. Audit writes happen in the same
39
- transaction.
39
+ transaction. Optional per-operation `permission` (checked via the
40
+ Foundation's injected `checkPermission`) and `guard(ctx, command)`
41
+ preconditions run BEFORE the handler, plus a registry-wide default guard
42
+ (`new Command(operations, { guard })`). Order: permission → default guard
43
+ → operation guard → handler → audit.
40
44
  6. **Never write timestamps by hand.** `createdAt`/`updatedAt` are maintained
41
45
  by the `timestamps` trigger; `archivedAt` is the app-controlled soft-delete
42
46
  marker.
@@ -47,15 +51,23 @@ function constructors, a trigger registry, an audited command protocol
47
51
  tables, no reaching private children (`components.approvals.workflow`).
48
52
  9. **Operation names are `domain.verb` lowercase-dotted; error codes are
49
53
  UPPER_SNAKE** — otherwise observability silently drops the events.
54
+ 10. **Row-level security is configured, not hand-rolled**: the optional
55
+ `security` config on `createAuthFunctions` takes role-level `rules`
56
+ (works standalone) and/or `tenancy` (a table registry; adds
57
+ `ctx.tenant`, deny-default isolation, and pairs with `tenantTable` +
58
+ `tenantOwnership`). Multi-tenant apps stamp inserts with
59
+ `tenant: ctx.tenant` and re-verify client ids with
60
+ `requireTenantReference`. See `tenancy.md`.
50
61
 
51
62
  ## Exports map
52
63
 
53
64
  | Import | Provides |
54
65
  |---|---|
55
66
  | `cvx-kit` | everything below re-exported (except components' defaults) |
56
- | `cvx-kit/zod-table` | `zodTable`, `zodVariantTable`, `jsonSafeZid`, `TIMESTAMP_FIELDS` |
57
- | `cvx-kit/auth` | `createAuthFunctions`, `createInclude`, `defaultRoleMap` |
58
- | `cvx-kit/triggers` | `createTriggers`, `timestamps`, `appendOnly`, `noDelete`, `Triggers` |
67
+ | `cvx-kit/zod-table` | `zodTable`, `tenantTable`, `createModule`, `zodVariantTable`, `jsonSafeZid`, `TIMESTAMP_FIELDS` |
68
+ | `cvx-kit/auth` | `createAuthFunctions` (incl. optional `security` RLS config), `createInclude`, `defaultRoleMap` |
69
+ | `cvx-kit/tenancy` | `createTenantRules`, `composeRules`, `requireTenantReference`, `assertTenantOwned`, `TENANT_FIELD` |
70
+ | `cvx-kit/triggers` | `createTriggers`, `timestamps`, `appendOnly`, `noDelete`, `tenantOwnership`, `Triggers` |
59
71
  | `cvx-kit/errors` | `KitError`, `defaultErrors`, `ErrorFactory` |
60
72
  | `cvx-kit/components/foundation` | `Foundation`, `executeResultBoundary`, `projectResult`, `Observability`; default export = component config for `app.use` |
61
73
  | `cvx-kit/components/approvals` | `Approvals` client; default export = component config for `app.use` |