@bjornpagen/bumbledb 0.1.0 → 0.3.0

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 (112) hide show
  1. package/COOKBOOK.md +1450 -0
  2. package/README.md +69 -30
  3. package/dist/closed.d.ts +157 -38
  4. package/dist/closed.d.ts.map +1 -1
  5. package/dist/closed.js +232 -47
  6. package/dist/closed.js.map +1 -1
  7. package/dist/count.d.ts +64 -17
  8. package/dist/count.d.ts.map +1 -1
  9. package/dist/count.js +37 -14
  10. package/dist/count.js.map +1 -1
  11. package/dist/db.d.ts +27 -8
  12. package/dist/db.d.ts.map +1 -1
  13. package/dist/db.js +33 -10
  14. package/dist/db.js.map +1 -1
  15. package/dist/exhume.d.ts +12 -11
  16. package/dist/exhume.d.ts.map +1 -1
  17. package/dist/exhume.js +7 -6
  18. package/dist/exhume.js.map +1 -1
  19. package/dist/face.d.ts +135 -30
  20. package/dist/face.d.ts.map +1 -1
  21. package/dist/face.js +44 -19
  22. package/dist/face.js.map +1 -1
  23. package/dist/fields.d.ts +130 -130
  24. package/dist/fields.d.ts.map +1 -1
  25. package/dist/fields.js +60 -94
  26. package/dist/fields.js.map +1 -1
  27. package/dist/index.d.ts +29 -24
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +19 -14
  30. package/dist/index.js.map +1 -1
  31. package/dist/law.d.ts +224 -0
  32. package/dist/law.d.ts.map +1 -0
  33. package/dist/law.js +224 -0
  34. package/dist/law.js.map +1 -0
  35. package/dist/lower.d.ts +17 -7
  36. package/dist/lower.d.ts.map +1 -1
  37. package/dist/lower.js +66 -24
  38. package/dist/lower.js.map +1 -1
  39. package/dist/marshal.d.ts +36 -21
  40. package/dist/marshal.d.ts.map +1 -1
  41. package/dist/marshal.js +41 -25
  42. package/dist/marshal.js.map +1 -1
  43. package/dist/native.d.ts +6 -2
  44. package/dist/native.d.ts.map +1 -1
  45. package/dist/native.js +2 -2
  46. package/dist/native.js.map +1 -1
  47. package/dist/query/atom.d.ts +370 -174
  48. package/dist/query/atom.d.ts.map +1 -1
  49. package/dist/query/atom.js +75 -172
  50. package/dist/query/atom.js.map +1 -1
  51. package/dist/query/lower.d.ts +312 -109
  52. package/dist/query/lower.d.ts.map +1 -1
  53. package/dist/query/lower.js +906 -387
  54. package/dist/query/lower.js.map +1 -1
  55. package/dist/query/predicate.d.ts +70 -88
  56. package/dist/query/predicate.d.ts.map +1 -1
  57. package/dist/query/predicate.js +118 -72
  58. package/dist/query/predicate.js.map +1 -1
  59. package/dist/query/run.d.ts +19 -16
  60. package/dist/query/run.d.ts.map +1 -1
  61. package/dist/query/run.js +31 -24
  62. package/dist/query/run.js.map +1 -1
  63. package/dist/query/scope.d.ts +178 -127
  64. package/dist/query/scope.d.ts.map +1 -1
  65. package/dist/query/scope.js +111 -108
  66. package/dist/query/scope.js.map +1 -1
  67. package/dist/query/select.d.ts +102 -80
  68. package/dist/query/select.d.ts.map +1 -1
  69. package/dist/query/select.js +39 -34
  70. package/dist/query/select.js.map +1 -1
  71. package/dist/relation.d.ts +48 -38
  72. package/dist/relation.d.ts.map +1 -1
  73. package/dist/relation.js +24 -19
  74. package/dist/relation.js.map +1 -1
  75. package/dist/schema.d.ts +41 -3
  76. package/dist/schema.d.ts.map +1 -1
  77. package/dist/schema.js +34 -9
  78. package/dist/schema.js.map +1 -1
  79. package/dist/spec.d.ts +7 -5
  80. package/dist/spec.d.ts.map +1 -1
  81. package/dist/spec.js.map +1 -1
  82. package/dist/statements.d.ts +91 -36
  83. package/dist/statements.d.ts.map +1 -1
  84. package/dist/statements.js +42 -12
  85. package/dist/statements.js.map +1 -1
  86. package/package.json +4 -3
  87. package/src/closed.ts +448 -95
  88. package/src/count.ts +112 -18
  89. package/src/db.ts +45 -17
  90. package/src/exhume.ts +12 -11
  91. package/src/face.ts +211 -41
  92. package/src/fields.ts +199 -234
  93. package/src/index.ts +78 -61
  94. package/src/law.ts +519 -0
  95. package/src/lower.ts +82 -27
  96. package/src/marshal.ts +48 -30
  97. package/src/native.ts +8 -4
  98. package/src/query/atom.ts +525 -374
  99. package/src/query/lower.ts +1469 -538
  100. package/src/query/predicate.ts +208 -161
  101. package/src/query/run.ts +35 -25
  102. package/src/query/scope.ts +264 -218
  103. package/src/query/select.ts +168 -93
  104. package/src/relation.ts +47 -47
  105. package/src/schema.ts +69 -18
  106. package/src/spec.ts +7 -5
  107. package/src/statements.ts +124 -41
  108. package/dist/brand.d.ts +0 -59
  109. package/dist/brand.d.ts.map +0 -1
  110. package/dist/brand.js +0 -47
  111. package/dist/brand.js.map +0 -1
  112. package/src/brand.ts +0 -82
package/COOKBOOK.md ADDED
@@ -0,0 +1,1450 @@
1
+ # The cookbook — modeling intuition as schemas, in TypeScript
2
+
3
+ The bumbledb engine's 29 cookbook recipes (`bumbledb/docs/cookbook.md`),
4
+ translated to this SDK's structural API. **This document is illustrative,
5
+ never normative**: where a recipe and an engine architecture chapter disagree,
6
+ the chapter wins (`docs/architecture/README.md` rule 5) — the SDK is the same
7
+ theory in another skin, and the engine cookbook's deference chain
8
+ (`10-data-model.md`, `30-dependencies.md`, `20-query-ir.md`, `70-api.md`)
9
+ applies here unchanged.
10
+
11
+ Every `ts` fence below compiles VERBATIM against the current SDK —
12
+ `ts/test/cookbook-doc.test.ts` extracts each recipe's fences from this
13
+ document mechanically at test time and type-checks them against
14
+ `src/index.ts` (the TS twin of the Rust cookbook's doc-sync pin), so an
15
+ edit here whose code stops compiling fails the build. The runtime half of
16
+ the claim is `ts/test/cookbook.test.ts`'s, over compiled copies of the
17
+ recipes: each schema is constructed through the public surface, admitted
18
+ on a real store (the engine's schema validation is the acceptance
19
+ judgment), its fingerprint asserted stable across a reopen AND equal to
20
+ the per-recipe cross-host golden the Rust cookbook suite also pins
21
+ (`test/fixtures/cookbook-fingerprints.txt` — the two cookbooks teach one
22
+ theory per recipe number, provably), and every query snippet lowered
23
+ through `db.prepare` (the engine's own IR validation).
24
+
25
+ Guarantee labels that name Lean results cite the checked spec in the engine
26
+ repo's `lean/` by theorem name, exactly as the engine cookbook does
27
+ (`scripts/spec-census.sh` verifies every citation resolves); the label always
28
+ names any additional Rust premise.
29
+
30
+ Everything below imports from the one package entry:
31
+
32
+ ```ts
33
+ import {
34
+ ALLEN,
35
+ Db,
36
+ abandon,
37
+ allen,
38
+ bool,
39
+ bytes,
40
+ closed,
41
+ contained,
42
+ eq,
43
+ i64,
44
+ interval,
45
+ key,
46
+ lt,
47
+ mirrors,
48
+ not,
49
+ on,
50
+ pointIn,
51
+ program,
52
+ query,
53
+ relation,
54
+ schema,
55
+ str,
56
+ u64
57
+ } from "@bjornpagen/bumbledb"
58
+ ```
59
+
60
+ ## The class laws — the statements type the columns
61
+
62
+ Relation declarations are pure structure — `kind`, `width`, `element`,
63
+ `fresh`, nothing else. Domains are never declared: `schema()` computes every
64
+ field's equivalence class FROM the statement list, by union-find across every
65
+ paired face (containment, `mirrors`, window targets, ψ-selected faces
66
+ included). Three laws govern the classes:
67
+
68
+ 1. **A fresh field is a generator** and names its class by its declaration
69
+ coordinate (`"Attempt.id"`); a closed relation's id is a generator named
70
+ the same way (`"Kind.id"`).
71
+ 2. **A generator-less class** is named by its least member coordinate in
72
+ relation-declaration × field-declaration order (recipe 5's
73
+ `"Document.payload"`), deterministic and pinned forever.
74
+ 3. **A field in no law is bare, and bare pairs only with bare** in queries —
75
+ a deliberate sum-domain pointer stays legal because you simply write no
76
+ law over it.
77
+
78
+ The wall: at most ONE generator per class — a statement list that unifies two
79
+ fresh coordinates is a contradiction (two mints cannot share a carrier), a
80
+ schema-level compile error with a construction-time runtime twin. The
81
+ dividend is that the statements you already write ARE the typing: a query
82
+ variable first bound in one class refuses to join a field of another (compile
83
+ error, with the same refusal thrown at construction), and no label, link, or
84
+ domain declaration exists anywhere on the surface. When a recipe below says a
85
+ statement "types" a column, this machinery is what it means. Classes never
86
+ touch the fingerprint — identity hashes canonical descriptor bytes, never the
87
+ law-computed names.
88
+
89
+ ## Foundations
90
+
91
+ ## 1. The minimal interval schema
92
+
93
+ Guarantee: Lean theorem + validator/runtime premise — the pointwise key
94
+ enforces per-service disjointness (`lean/Bumbledb/Dependencies.lean:
95
+ pointwise_key_disjoint`); checked intervals supply nonempty values
96
+ (`lean/Bumbledb/Values.lean: interval_nonempty`).
97
+
98
+ One fact per outage window; the pointwise key is the whole temporal design.
99
+
100
+ ```ts
101
+ const Service = relation("Service", { id: u64.fresh, name: str })
102
+ // The window is one value, not a (start, end) column pair: the denotation
103
+ // (a set of points, half-open) is what the judgments below read through.
104
+ // The containment below is what types `service` — the class laws.
105
+ const Outage = relation("Outage", { service: u64, window: interval(i64) })
106
+
107
+ const Uptime = schema("Uptime", { Service, Outage }, [
108
+ contained(on(Outage, "service"), on(Service, "id")),
109
+ // The pointwise key: per service, no two outages share a point — every
110
+ // pair satisfies DISJOINT. SQL:2011's WITHOUT OVERLAPS, as a theorem.
111
+ // `key(R, [...])` is the host flavor of the canonical arrow — this one
112
+ // renders `Outage(service, window) -> Outage`: the key projection
113
+ // determines the tuple, and the arrow closing over its own relation is
114
+ // what makes a key a key (the compile-pin asserts the render).
115
+ key(Outage, ["service", "window"])
116
+ ])
117
+
118
+ // down at instant t — `r.vars` mints the rule's whole variable scope at
119
+ // once, and shorthand punning binds same-named columns:
120
+ const downAt = query(Uptime).rule((r) => {
121
+ const { service, window } = r.vars("service", "window")
122
+ return r
123
+ .match(Outage, { service, window })
124
+ .where(pointIn(r.param("t"), window))
125
+ .select("service")
126
+ })
127
+ // overlapping an incident window (one Allen mask, no operator zoo):
128
+ const overlapping = query(Uptime).rule((r) => {
129
+ const { service, window } = r.vars("service", "window")
130
+ return r
131
+ .match(Outage, { service, window })
132
+ .where(allen(window, ALLEN.intersects, r.param("incident")))
133
+ .select("service", "window")
134
+ })
135
+ // total downtime per service (the denotation's one arithmetic):
136
+ const downtime = query(Uptime).rule((r) => {
137
+ const { service, window } = r.vars("service", "window")
138
+ return r.match(Outage, { service, window }).select("service", r.sum(r.duration("window")))
139
+ })
140
+ ```
141
+
142
+ ## 2. Discriminated unions
143
+
144
+ Guarantee: Lean theorem + validator/runtime premises — key-backed equality
145
+ gives unique source/target correspondence (`lean/Bumbledb/Dependencies.lean:
146
+ keyed_eq_unique_correspondence`); both projections must resolve to declared keys.
147
+
148
+ Sum-typed entities: a closed-relation discriminator plus per-arm child
149
+ relations, glued by bidirectional conditional containments.
150
+
151
+ ```ts
152
+ // The discriminator vocabulary is a closed relation: its ground axioms are
153
+ // axioms, and the handle constants (`Kind.Deterministic`, bare bigints) are
154
+ // the literals on every surface.
155
+ const Kind = closed("Kind", ["Deterministic", "CustomOperator"])
156
+ const Task = relation("Task", { id: u64.fresh, kind: Kind.id })
157
+ const DeterministicGrading = relation("DeterministicGrading", { task: u64, tolerance: i64 })
158
+ const CustomOperatorGrading = relation("CustomOperatorGrading", { task: u64, operator: str })
159
+
160
+ const Grading = schema("Grading", { Kind, Task, DeterministicGrading, CustomOperatorGrading }, [
161
+ contained(on(Task, "kind"), on(Kind, "id")), // the discriminator resolves
162
+ key(DeterministicGrading, ["task"]), // one arm fact per parent
163
+ key(CustomOperatorGrading, ["task"]),
164
+ // Totality (==, left to right): a Deterministic task HAS its arm fact —
165
+ // same commit, always. Arm validity (right to left): an arm fact's parent
166
+ // exists WITH that kind — composite-FK-plus-CHECK, one statement. These
167
+ // mirrors are also what type `task` on both arms: each lands in the
168
+ // "Task.id" generator class.
169
+ mirrors(on(Task.where({ kind: Kind.Deterministic }), "id"), on(DeterministicGrading, "task")),
170
+ mirrors(on(Task.where({ kind: Kind.CustomOperator }), "id"), on(CustomOperatorGrading, "task"))
171
+ // Exclusivity is a theorem, not a statement: one id in two arms would
172
+ // force `kind` to equal two handles against the fresh key on id.
173
+ // The executor spends the same theorem again — recipe 22's free lunch.
174
+ ])
175
+
176
+ // Host dispatch over the discriminator is `match` — exhaustive over the
177
+ // sealed roster by construction (a missing arm is a compile error; an
178
+ // out-of-roster id throws, never misdispatches):
179
+ const gradedBy = (kind: bigint) =>
180
+ Kind.match(kind, {
181
+ Deterministic: () => "tolerance",
182
+ CustomOperator: () => "operator"
183
+ })
184
+ ```
185
+
186
+ ## 3. 0..1 optional attributes
187
+
188
+ Guarantee: Lean theorem + validator/runtime premises — the child key proves at
189
+ most one fact (`lean/Bumbledb/Dependencies.lean: functionality_unique_witness`)
190
+ and containment requires its parent (`lean/Bumbledb/Dependencies.lean:
191
+ contains_iff_view_subset`); absence remains legal.
192
+
193
+ No nulls, anywhere. Optional data is an absent fact in a child relation; the
194
+ child's key plus a one-way containment *is* "nullable column", done honestly.
195
+
196
+ ```ts
197
+ const Business = relation("Business", { id: u64.fresh, name: str })
198
+ const MailingAddress = relation("MailingAddress", { business: u64, line: str, city: str })
199
+
200
+ const Optionality = schema("Optionality", { Business, MailingAddress }, [
201
+ key(MailingAddress, ["business"]), // at most one address...
202
+ contained(on(MailingAddress, "business"), on(Business, "id")) // ...and only for a real business
203
+ // One-way containment on purpose: absence is the fact that isn't. The
204
+ // all-or-nothing column group (line+city together or neither) is
205
+ // unstatable TO VIOLATE — the fact carries both fields or does not exist.
206
+ ])
207
+
208
+ // Negation is plain anti-join (no null branch exists in any operator):
209
+ const unaddressed = query(Optionality).rule((r) => {
210
+ const { b } = r.vars("b")
211
+ return r
212
+ .match(Business, { id: b })
213
+ .where(not(MailingAddress, { business: b }))
214
+ .select("b")
215
+ })
216
+ ```
217
+
218
+ ## 4. Money
219
+
220
+ Guarantee: host discipline + validator premises — fixed-point scale and
221
+ currency grouping live in host domains; containments only resolve references.
222
+
223
+ Fixed-point i64 minor units. Floats are permanently refused (the ledger);
224
+ proration and FX are host arithmetic. `minor` sits in no law — bare, and bare
225
+ pairs only with bare (the class laws), so a stray join against some other
226
+ i64 column refuses unless a statement puts them in one class. Scale and
227
+ currency intent are host discipline; the closed `currency` reference is what
228
+ the schema CAN say, and says.
229
+
230
+ ```ts
231
+ const Currency = closed("Currency", ["Usd", "Eur", "Gbp"])
232
+ const Account = relation("Account", { id: u64.fresh, name: str })
233
+ const Posting = relation("Posting", {
234
+ id: u64.fresh,
235
+ account: u64,
236
+ currency: Currency.id,
237
+ minor: i64
238
+ })
239
+
240
+ const Money = schema("Money", { Currency, Account, Posting }, [
241
+ contained(on(Posting, "account"), on(Account, "id")),
242
+ contained(on(Posting, "currency"), on(Currency, "id"))
243
+ ])
244
+
245
+ // Multi-currency totals: currency is a group key, never summed across —
246
+ // Sum folds wide with one final range check, so totals cannot wrap
247
+ // silently. Bind the fresh id: set semantics would collapse two equal
248
+ // (account, currency, minor) postings without it.
249
+ const totals = query(Money).rule((r) => {
250
+ const { id, account, currency, minor } = r.vars("id", "account", "currency", "minor")
251
+ return r.match(Posting, { id, account, currency, minor }).select("account", "currency", r.sum("minor"))
252
+ })
253
+ ```
254
+
255
+ ## 5. Content addressing
256
+
257
+ Guarantee: validator/runtime premises + host discipline — the payload key and
258
+ containments enforce identity/reference shape; hashing and blob durability stay external.
259
+
260
+ The decision rule for byte-shaped data: **intern what repeats (`str`); inline
261
+ what identifies (`bytes(n)`)**.
262
+
263
+ ```ts
264
+ const Region = closed("Region", ["Us", "Eu"])
265
+ const Document = relation("Document", {
266
+ id: u64.fresh,
267
+ name: str, // repeats: interned, id-equality
268
+ payload: bytes(32) // identifies: the blake3 of the external blob — inline, never interned
269
+ })
270
+ const Replica = relation("Replica", { payload: bytes(32), region: Region.id })
271
+
272
+ const Content = schema("Content", { Region, Document, Replica }, [
273
+ key(Document, ["payload"]), // content-addressed: one doc per digest
274
+ // This containment is the digest columns' typing: neither is fresh, so
275
+ // the pair is a generator-less class named by its least member
276
+ // coordinate — "Document.payload" (the class laws' second rule).
277
+ contained(on(Replica, "payload"), on(Document, "payload")),
278
+ contained(on(Replica, "region"), on(Region, "id"))
279
+ // bytes(n) is identity-only (Eq/Ne, membership): a digest's lexicographic
280
+ // order is an encoding artifact, refused as semantics. Large objects:
281
+ // facts stay fixed-width; the payload lives in external storage,
282
+ // referenced by identity (the large-object refusal).
283
+ ])
284
+
285
+ // a bytes param self-encodes (Uint8Array by inference):
286
+ const byDigest = query(Content).rule((r) => {
287
+ const { id } = r.vars("id")
288
+ return r.match(Document, { id, payload: r.param("digest") }).select("id")
289
+ })
290
+ ```
291
+
292
+ ## Vocabularies
293
+
294
+ ## 6. The vocabulary
295
+
296
+ Guarantee: Lean theorem + validator/runtime premise — the sealed closed
297
+ extension is constant at every instance (`lean/Bumbledb/Schema.lean:
298
+ den_closed_constant`) and the compiled member-set containment admits only
299
+ declared priority handles.
300
+
301
+ The enum idiom's replacement, first-class: a vocabulary is a **closed
302
+ relation** — its ground axioms are declared in the schema, sealed at
303
+ validate, frozen by the fingerprint, virtual in storage. The store holds zero
304
+ vocabulary bytes, and handles are the literals on every surface.
305
+
306
+ ```ts
307
+ // Tier 1: handles only. `closed()` mints one bare-bigint constant per handle
308
+ // (ids = declaration order) — an emission, not a type: the engine's
309
+ // vocabulary stays relational; the host matches on `Priority.Urgent`, or
310
+ // dispatches exhaustively with `Priority.match` (recipe 2).
311
+ const Priority = closed("Priority", ["Low", "Normal", "Urgent"])
312
+
313
+ const Ticket = relation("Ticket", { id: u64.fresh, priority: Priority.id, opened_at: i64 })
314
+
315
+ const Tickets = schema("Tickets", { Priority, Ticket }, [
316
+ // A closed reference is an ordinary u64 under one containment (which also
317
+ // types `priority` into the "Priority.id" generator class); the judgment
318
+ // compiles at validate to a member-set test — one AND, one bit test, no
319
+ // probe (30-dependencies.md).
320
+ contained(on(Ticket, "priority"), on(Priority, "id"))
321
+ ])
322
+
323
+ // Handles are literals in queries exactly as in statements, and the
324
+ // renderer prints them back — the round trip runs on names. The boundary
325
+ // law: intrinsic meaning goes here (changing it is a new theory); policy
326
+ // that drifts without a rebuild is an ordinary relation — a vocabulary is
327
+ // never written, only declared.
328
+ const urgent = query(Tickets).rule((r) => {
329
+ const { t } = r.vars("t")
330
+ return r.match(Ticket, { id: t, priority: Priority.Urgent }).select("t")
331
+ })
332
+ ```
333
+
334
+ ## 7. The classification
335
+
336
+ Guarantee: validator/runtime premise — closed payload facts and the
337
+ ψ-selected containment restrict certificates to the compiled
338
+ mastered-handle set.
339
+
340
+ The fused form: the vocabulary carries its intrinsic facts as **payload
341
+ columns** — one ground axiom per handle, values sealed with the schema, read
342
+ by ψ-selections. Axioms are declared, never written.
343
+
344
+ ```ts
345
+ // Tier 2: payload columns state what each word MEANS, next to the word.
346
+ // A rubric change is a new theory — exactly right for meaning.
347
+ const Kind = closed(
348
+ "Kind",
349
+ { mastered: bool, rank: u64 },
350
+ {
351
+ DirectPass: { mastered: true, rank: 30n },
352
+ JudgedPass: { mastered: true, rank: 20n },
353
+ Failed: { mastered: false, rank: 10n }
354
+ }
355
+ )
356
+ const Attempt = relation("Attempt", { id: u64.fresh, kind: Kind.id })
357
+ const Certificate = relation("Certificate", { attempt: u64, kind: Kind.id })
358
+
359
+ const Review = schema("Review", { Kind, Attempt, Certificate }, [
360
+ contained(on(Attempt, "kind"), on(Kind, "id")),
361
+ key(Certificate, ["attempt"]),
362
+ contained(on(Certificate, "attempt"), on(Attempt, "id")),
363
+ // ψ reads the payload: certificates carry mastered kinds only — the
364
+ // member set {DirectPass, JudgedPass} compiles at validate and the
365
+ // judgment is O(1) at commit (recipe 8 is this statement's own recipe).
366
+ contained(on(Certificate, "kind"), on(Kind.where({ mastered: true }), "id"))
367
+ ])
368
+
369
+ // The classification read duplicates no flag onto Attempt — ψ walks the
370
+ // vocabulary's payload in the query too: a closed relation is matchable
371
+ // exactly like an ordinary one, and the atom folds at prepare into a
372
+ // plan-constant handle set on its sibling.
373
+ const masteredAttempts = query(Review).rule((r) => {
374
+ const { a, k } = r.vars("a", "k")
375
+ return r
376
+ .match(Attempt, { id: a, kind: k })
377
+ .match(Kind, { id: k, mastered: true })
378
+ .select("a")
379
+ })
380
+
381
+ // Host dispatch on the payload tier hands each arm its sealed axiom row:
382
+ const label = (k: bigint) =>
383
+ Kind.match(k, {
384
+ DirectPass: (row) => `mastered, rank ${row.rank}`,
385
+ JudgedPass: (row) => `mastered, rank ${row.rank}`,
386
+ Failed: () => "not mastered"
387
+ })
388
+ ```
389
+
390
+ Two honest boundaries. The fold has limits: payload escaping to the head and
391
+ param-bearing filters don't fold at prepare — the engine falls back to a
392
+ virtual-image join over the sealed extension, and the semantics are identical
393
+ either way. And for an ALREADY-DEPLOYED store, moving from the old
394
+ complement-window workaround to these ψ spellings is a NEW theory — a
395
+ different fingerprint — which is recipe 28's ETL territory: humans own that
396
+ migration.
397
+
398
+ ## 8. The sub-vocabulary
399
+
400
+ Guarantee: validator/runtime premise — ψ over the sealed extension compiles
401
+ the exact paging member set; a nonmember write is commit-rejected.
402
+
403
+ The ψ-selected containment: a reference constrained to the facts of a
404
+ vocabulary that satisfy a payload selection. Because the target is closed and
405
+ sealed, the enforcement plan is not a probe strategy — it is **the answer set
406
+ itself**, fixed when the schema is built.
407
+
408
+ ```ts
409
+ const Severity = closed(
410
+ "Severity",
411
+ { pages: bool },
412
+ {
413
+ Info: { pages: false },
414
+ Warning: { pages: false },
415
+ Critical: { pages: true },
416
+ Fatal: { pages: true }
417
+ }
418
+ )
419
+ const Incident = relation("Incident", { id: u64.fresh, severity: Severity.id })
420
+ const Escalation = relation("Escalation", { incident: u64, severity: Severity.id, at: i64 })
421
+
422
+ const Oncall = schema("Oncall", { Severity, Incident, Escalation }, [
423
+ contained(on(Incident, "severity"), on(Severity, "id")),
424
+ contained(on(Escalation, "incident"), on(Incident, "id")),
425
+ // The sub-vocabulary: an escalation carries a PAGING severity, by
426
+ // statement. ψ over the sealed extension compiles to the member set
427
+ // {Critical, Fatal}; the judgment is one bit test per touched fact,
428
+ // and an escalation at Severity.Info aborts the commit.
429
+ contained(on(Escalation, "severity"), on(Severity.where({ pages: true }), "id"))
430
+ ])
431
+
432
+ // who is being paged — the same ψ, on the read side:
433
+ const paged = query(Oncall).rule((r) => {
434
+ const { i, s } = r.vars("i", "s")
435
+ return r
436
+ .match(Escalation, { incident: i, severity: s })
437
+ .match(Severity, { id: s, pages: true })
438
+ .select("i")
439
+ })
440
+ ```
441
+
442
+ ## Structure
443
+
444
+ ## 9. Ordered collections
445
+
446
+ Guarantee: Lean theorem + validator/runtime premises — mutual point coverage
447
+ plus pointwise keys realizes exact partition
448
+ (`lean/Bumbledb/Dependencies.lean: exact_partition_iff`), and the mixed-width
449
+ interval positions type by element domain
450
+ (`lean/Bumbledb/Schema.lean: Value.points_one_tag_u64`); ordering the result
451
+ remains a host presentation step.
452
+
453
+ The linked-list verdict: successor pointers are control flow smuggled into
454
+ data. Order is a value. The idiomatic ordered collection is an interval
455
+ partition, spelled as a **triple**: the entity, the extent as a 0..1 child
456
+ (empty lists exist, empty intervals do not — presence of the child IS
457
+ nonemptiness), and the unit-slot sidecar (`interval(u64, 1n)` — the width is
458
+ the type: a wrong-width value is unrepresentable).
459
+
460
+ ```ts
461
+ const Playlist = relation("Playlist", { id: u64.fresh, name: str })
462
+ // The extent: a 0..1 child, because empty playlists exist and empty
463
+ // intervals do not — presence of the child IS nonemptiness.
464
+ const Extent = relation("Extent", { playlist: u64, span: interval(u64) })
465
+ // The unit slot: position p occupies [p, p+1) — the width is the type.
466
+ const Slot = relation("Slot", { playlist: u64, slot: interval(u64, 1n), track: str })
467
+
468
+ const Playlists = schema("Playlists", { Playlist, Extent, Slot }, [
469
+ contained(on(Extent, "playlist"), on(Playlist, "id")),
470
+ contained(on(Slot, "playlist"), on(Playlist, "id")),
471
+ key(Extent, ["playlist"]), // 0..1 extent per playlist
472
+ key(Extent, ["playlist", "span"]), // exact target key (recipe 26's note)
473
+ key(Slot, ["playlist", "slot"]), // one occupant per position
474
+ mirrors(on(Extent, ["playlist", "span"]), on(Slot, ["playlist", "slot"])) // slots tile the span exactly
475
+ ])
476
+
477
+ // Positional access is membership — "what plays at position ?pos":
478
+ const playingAt = query(Playlists).rule((r) => {
479
+ const { slot, track } = r.vars("slot", "track")
480
+ return r
481
+ .match(Slot, { playlist: r.param("list"), slot, track })
482
+ .where(pointIn(r.param("pos"), slot))
483
+ .select("track")
484
+ })
485
+ ```
486
+
487
+ Middle insert is honest about its cost: making room at position `k` shifts
488
+ every later slot and grows the extent — O(k) writes in **one delta**, judged
489
+ once at commit. If middle inserts dominate, the demoted escape hatch is the
490
+ spread slot: a scalar `pos: u64` written in gapped strides under the same
491
+ composite key — bumbledb has no lexicographic fractional indexing, because
492
+ string order is refused: there is no "between two strings" to allocate.
493
+
494
+ ## 10. Trees and ASTs
495
+
496
+ Guarantee: Lean theorem + validator/runtime premises for key-backed arms
497
+ (`lean/Bumbledb/Dependencies.lean: keyed_eq_unique_correspondence`); host
498
+ discipline for acyclicity — statements prove arm/edge shape, never a tree theorem.
499
+
500
+ Node header + per-kind arms (recipe 2's pattern); every edge resolves; the
501
+ shape theorems come from keys on the edge relations.
502
+
503
+ ```ts
504
+ const Kind = closed("Kind", ["Lit", "Add"])
505
+ const Node = relation("Node", { id: u64.fresh, kind: Kind.id })
506
+ const Lit = relation("Lit", { node: u64, value: i64 })
507
+ const Add = relation("Add", { node: u64, lhs: u64, rhs: u64 })
508
+ const Parent = relation("Parent", { child: u64, parent: u64 })
509
+
510
+ const Ast = schema("Ast", { Kind, Node, Lit, Add, Parent }, [
511
+ contained(on(Node, "kind"), on(Kind, "id")),
512
+ key(Lit, ["node"]),
513
+ key(Add, ["node"]),
514
+ // Every node's arm is total, valid, and exclusive (recipe 2's theorems):
515
+ mirrors(on(Node.where({ kind: Kind.Lit }), "id"), on(Lit, "node")),
516
+ mirrors(on(Node.where({ kind: Kind.Add }), "id"), on(Add, "node")),
517
+ // Every child edge resolves — no dangling subtrees, judged at commit
518
+ // (these containments also put lhs/rhs in the "Node.id" class, which is
519
+ // exactly what lets the query below join lhs against Lit.node):
520
+ contained(on(Add, "lhs"), on(Node, "id")),
521
+ contained(on(Add, "rhs"), on(Node, "id")),
522
+ // Functional parent (one parent per child) ⇒ the reachable shape is
523
+ // paths-or-cycles; acyclicity itself is outside the ∀∃ vocabulary —
524
+ // host discipline, recorded. Transitive reach is recipe 24's closure.
525
+ key(Parent, ["child"]),
526
+ contained(on(Parent, "child"), on(Node, "id")),
527
+ contained(on(Parent, "parent"), on(Node, "id"))
528
+ ])
529
+
530
+ const lhsLiteral = query(Ast).rule((r) => {
531
+ const { l, v } = r.vars("l", "v")
532
+ return r
533
+ .match(Add, { node: r.param("n"), lhs: l })
534
+ .match(Lit, { node: l, value: v })
535
+ .select("v")
536
+ })
537
+ ```
538
+
539
+ ## 11. Typed graphs
540
+
541
+ Guarantee: validator/runtime premises — endpoint containments type each edge
542
+ and composite keys deduplicate pairs; no transitive graph property is claimed.
543
+
544
+ One relation per edge kind: endpoint containments pin which node kinds each
545
+ edge may touch.
546
+
547
+ ```ts
548
+ const Person = relation("Person", { id: u64.fresh, name: str })
549
+ const Repo = relation("Repo", { id: u64.fresh, name: str })
550
+ const Follows = relation("Follows", { follower: u64, followee: u64 })
551
+ const Maintains = relation("Maintains", { person: u64, repo: u64 })
552
+
553
+ const Graph = schema("Graph", { Person, Repo, Follows, Maintains }, [
554
+ contained(on(Follows, "follower"), on(Person, "id")), // a Person→Person edge, by statement —
555
+ contained(on(Follows, "followee"), on(Person, "id")), // a Follows fact cannot touch a Repo
556
+ key(Follows, ["follower", "followee"]), // at most one edge per pair
557
+ contained(on(Maintains, "person"), on(Person, "id")),
558
+ contained(on(Maintains, "repo"), on(Repo, "id")),
559
+ key(Maintains, ["person", "repo"])
560
+ ])
561
+
562
+ // Mutual follows — joins are explicit var reuse on both ends (both columns
563
+ // live in the "Person.id" class, so the reuse is lawful); `lt` keeps each
564
+ // pair once:
565
+ const mutual = query(Graph).rule((r) => {
566
+ const { a, b } = r.vars("a", "b")
567
+ return r
568
+ .match(Follows, { follower: a, followee: b })
569
+ .match(Follows, { follower: b, followee: a })
570
+ .where(lt(a, b))
571
+ .select("a", "b")
572
+ })
573
+ ```
574
+
575
+ ## 12. Entity-component
576
+
577
+ Guarantee: definition + validator/runtime premises — component keys give 0..1
578
+ and containments require the stated entity/archetype facts.
579
+
580
+ The 0..1 idiom (recipe 3) at scale: components are sidecar relations; an
581
+ entity has a component iff the fact exists; a new component kind is a new
582
+ relation, not a wider fact.
583
+
584
+ ```ts
585
+ const Entity = relation("Entity", { id: u64.fresh, name: str })
586
+ const Transform = relation("Transform", { entity: u64, x: i64, y: i64 })
587
+ const Velocity = relation("Velocity", { entity: u64, dx: i64, dy: i64 })
588
+ const Renderable = relation("Renderable", { entity: u64, mesh: str })
589
+
590
+ const Ecs = schema("Ecs", { Entity, Transform, Velocity, Renderable }, [
591
+ key(Transform, ["entity"]), // each component 0..1 per entity
592
+ contained(on(Transform, "entity"), on(Entity, "id")),
593
+ key(Velocity, ["entity"]),
594
+ contained(on(Velocity, "entity"), on(Entity, "id")),
595
+ key(Renderable, ["entity"]),
596
+ // An archetype rule is one containment: every Renderable has a Transform
597
+ // (and, through it, an Entity — containment composes, and the class
598
+ // composes with it: every `entity` column lands in "Entity.id").
599
+ contained(on(Renderable, "entity"), on(Transform, "entity"))
600
+ ])
601
+
602
+ // The physics join is the component intersection:
603
+ const physics = query(Ecs).rule((r) => {
604
+ const { entity, x, y, dx, dy } = r.vars("entity", "x", "y", "dx", "dy")
605
+ return r
606
+ .match(Transform, { entity, x, y })
607
+ .match(Velocity, { entity, dx, dy })
608
+ .select("entity", "x", "y", "dx", "dy")
609
+ })
610
+ ```
611
+
612
+ ## 13. State machines
613
+
614
+ Guarantee: Lean theorem + validator/runtime premises for the shipped arm
615
+ (`lean/Bumbledb/Dependencies.lean: keyed_eq_unique_correspondence`); host
616
+ discipline for allowed transitions — equality pins state evidence, not paths.
617
+
618
+ States are a discriminated union; per-state data lives in arms; and the
619
+ conditional reference target — a reference to "an order *that is shipped*" —
620
+ is one selected statement, the statement SQL cannot write.
621
+
622
+ ```ts
623
+ const State = closed("State", ["Cart", "Placed", "Shipped"])
624
+ const Order = relation("Order", { id: u64.fresh, state: State.id })
625
+ const Placement = relation("Placement", { order: u64, at: i64 })
626
+ const Shipment = relation("Shipment", { order: u64, carrier: str, at: i64 })
627
+
628
+ const Orders = schema("Orders", { State, Order, Placement, Shipment }, [
629
+ contained(on(Order, "state"), on(State, "id")),
630
+ key(Placement, ["order"]),
631
+ key(Shipment, ["order"]),
632
+ // History accretes: a Shipped order keeps its Placement — one-way
633
+ // containment admits arms from earlier states surviving the transition.
634
+ contained(on(Placement, "order"), on(Order, "id")),
635
+ // The conditional target, both ways: every Shipment references an order
636
+ // THAT IS Shipped (validity), and every Shipped order has its Shipment
637
+ // (totality) — the transition and its evidence commit together.
638
+ mirrors(on(Shipment, "order"), on(Order.where({ state: State.Shipped }), "id"))
639
+ // Transition predicates ("only Placed may ship") are host code under the
640
+ // generation witness — recipe 20; the schema pins the states, not the paths.
641
+ ])
642
+
643
+ const shipped = query(Orders).rule((r) => {
644
+ const { id, carrier } = r.vars("id", "carrier")
645
+ return r
646
+ .match(Order, { id, state: State.Shipped })
647
+ .match(Shipment, { order: id, carrier })
648
+ .select("id", "carrier")
649
+ })
650
+ ```
651
+
652
+ ## Time and coverage
653
+
654
+ ## 14. The calendar core
655
+
656
+ Guarantee: Lean theorem + validator/runtime premises — accepted equality is
657
+ key-backed correspondence (`lean/Bumbledb/Dependencies.lean:
658
+ keyed_eq_unique_correspondence`), while pointwise keys/coverage enforce only
659
+ declared hard policy.
660
+
661
+ Policy as schema: hard rules are pointwise keys, soft rules are the statements
662
+ you decline to write. `Claim.source` carries the accepted attendance's id and
663
+ declares nothing about it — the selected `mirrors` law below is what TYPES
664
+ it: pairing `source` with `Attendance.id` puts it in the `"Attendance.id"`
665
+ generator class. The class flows from the statement; no link declaration
666
+ exists or is needed.
667
+
668
+ ```ts
669
+ const Rsvp = closed("Rsvp", ["Accepted", "Tentative", "Declined"])
670
+ const Arm = closed("Arm", ["Busy", "Ooo"])
671
+
672
+ const Person = relation("Person", { id: u64.fresh, name: str })
673
+ const Room = relation("Room", { id: u64.fresh, name: str })
674
+ const Event = relation("Event", { id: u64.fresh, span: interval(i64) })
675
+ const Attendance = relation("Attendance", {
676
+ id: u64.fresh,
677
+ event: u64,
678
+ person: u64,
679
+ rsvp: Rsvp.id
680
+ })
681
+ const Claim = relation("Claim", {
682
+ source: u64,
683
+ person: u64,
684
+ arm: Arm.id,
685
+ span: interval(i64)
686
+ })
687
+ const Booking = relation("Booking", { room: u64, event: u64, span: interval(i64) })
688
+ const WorkHours = relation("WorkHours", { person: u64, hours: interval(i64) })
689
+
690
+ const Calendar = schema("Calendar", { Rsvp, Arm, Person, Room, Event, Attendance, Claim, Booking, WorkHours }, [
691
+ contained(on(Attendance, "event"), on(Event, "id")),
692
+ contained(on(Attendance, "person"), on(Person, "id")),
693
+ contained(on(Attendance, "rsvp"), on(Rsvp, "id")),
694
+ key(Attendance, ["event", "person"]), // one RSVP per (event, person)
695
+ key(Claim, ["source"]),
696
+ contained(on(Claim, "person"), on(Person, "id")),
697
+ contained(on(Claim, "arm"), on(Arm, "id")),
698
+ // HARD: rooms cannot double-book — the pointwise key (recipe 1's theorem).
699
+ key(Booking, ["room", "span"]),
700
+ // SOFT: people CAN double-book — key(Claim, ["person", "span"]) is simply
701
+ // not declared. Policy is the presence or absence of one statement.
702
+ // Accepting an invitation IS claiming the time (totality + validity) —
703
+ // and this is the statement that types Claim.source:
704
+ mirrors(on(Attendance.where({ rsvp: Rsvp.Accepted }), "id"), on(Claim.where({ arm: Arm.Busy }), "source")),
705
+ // Busy time lies inside working hours, pointwise — coverage rides the
706
+ // target's own key (disjoint + ordered is a theorem, not a request):
707
+ key(WorkHours, ["person", "hours"]),
708
+ contained(on(Claim.where({ arm: Arm.Busy }), ["person", "span"]), on(WorkHours, ["person", "hours"])),
709
+ contained(on(Booking, "room"), on(Room, "id")),
710
+ contained(on(Booking, "event"), on(Event, "id"))
711
+ ])
712
+
713
+ const roomConflicts = query(Calendar).rule((r) => {
714
+ const { room, span } = r.vars("room", "span")
715
+ return r
716
+ .match(Booking, { room, span })
717
+ .where(allen(span, ALLEN.intersects, r.param("want")))
718
+ .select("room", "span")
719
+ })
720
+ const personLoad = query(Calendar).rule((r) => {
721
+ const { person, span } = r.vars("person", "span")
722
+ return r
723
+ .match(Claim, { person, span })
724
+ .where(allen(span, ALLEN.intersects, r.param("window")))
725
+ .select("person", "span")
726
+ })
727
+ ```
728
+
729
+ ## 15. Effective-dated configuration
730
+
731
+ Guarantee: Lean theorem/countermodel + validator/runtime premise — pointwise
732
+ keys plus one-way support inclusion form a disjoint cover
733
+ (`lean/Bumbledb/Dependencies.lean: pointwise_key_disjoint`,
734
+ `coverage_is_support_inclusion`); target overhang is legal
735
+ (`lean/Bumbledb/Countermodels.lean: one_way_overhang`).
736
+
737
+ Versioned rules: no overlaps (pointwise key), no gaps in the policy's source
738
+ lifetime (one-way coverage; version overhang remains legal), and "in force on
739
+ date t" is one membership probe.
740
+
741
+ ```ts
742
+ const Policy = relation("Policy", { id: u64.fresh, live: interval(i64) })
743
+ const Version = relation("Version", { policy: u64, rate_bps: i64, valid: interval(i64) })
744
+
745
+ const Pricing = schema("Pricing", { Policy, Version }, [
746
+ contained(on(Version, "policy"), on(Policy, "id")),
747
+ // No overlapping versions: at any instant, at most one rate is the law.
748
+ key(Version, ["policy", "valid"]),
749
+ // No gaps in the policy lifetime: every source point is covered by
750
+ // versions. Together with the key above this is a disjoint cover, not an
751
+ // exact partition: Version intervals may overhang (recipe 16).
752
+ contained(on(Policy, ["id", "live"]), on(Version, ["policy", "valid"]))
753
+ ])
754
+
755
+ // in force on date t — one membership probe:
756
+ const inForce = query(Pricing).rule((r) => {
757
+ const { rate_bps, valid } = r.vars("rate_bps", "valid")
758
+ return r
759
+ .match(Version, { policy: r.param("p"), rate_bps, valid })
760
+ .where(pointIn(r.param("t"), valid))
761
+ .select("rate_bps")
762
+ })
763
+ // clean successions (half-open makes MEETS exact, no ±1 fudge):
764
+ const successions = query(Pricing).rule((r) => {
765
+ const { p, a, b } = r.vars("p", "a", "b")
766
+ return r
767
+ .match(Version, { policy: p, valid: a })
768
+ .match(Version, { policy: p, valid: b })
769
+ .where(allen(a, ALLEN.meets, b))
770
+ .select("a", "b")
771
+ })
772
+ ```
773
+
774
+ ## 16. Disjoint covers
775
+
776
+ Guarantee: Lean theorem/countermodel + validator/runtime premise —
777
+ `lean/Bumbledb/Dependencies.lean: coverage_is_support_inclusion` proves source
778
+ coverage, not exact partition (`lean/Bumbledb/Countermodels.lean:
779
+ one_way_overhang`).
780
+
781
+ Pay periods, shifts, estimated-tax quarters: a pointwise key plus one-way
782
+ coverage is a **disjoint cover** — no overlaps among pay periods and no holes
783
+ in the fiscal year's source span. Pay periods may extend beyond that span.
784
+
785
+ ```ts
786
+ const FiscalYear = relation("FiscalYear", { id: u64.fresh, span: interval(i64) })
787
+ const PayPeriod = relation("PayPeriod", { year: u64, seq: u64, span: interval(i64) })
788
+
789
+ const Payroll = schema("Payroll", { FiscalYear, PayPeriod }, [
790
+ contained(on(PayPeriod, "year"), on(FiscalYear, "id")),
791
+ key(PayPeriod, ["year", "seq"]), // sequence numbers stay unique
792
+ key(PayPeriod, ["year", "span"]), // disjoint: no shared instant
793
+ // Covering: no holes in the fiscal year's span; overhang is legal.
794
+ contained(on(FiscalYear, ["id", "span"]), on(PayPeriod, ["year", "span"]))
795
+ ])
796
+
797
+ // the period holding date t:
798
+ const holding = query(Payroll).rule((r) => {
799
+ const { seq, span } = r.vars("seq", "span")
800
+ return r
801
+ .match(PayPeriod, { year: r.param("y"), seq, span })
802
+ .where(pointIn(r.param("t"), span))
803
+ .select("seq")
804
+ })
805
+ ```
806
+
807
+ ## 17. Federal income tax
808
+
809
+ Guarantee: validator/runtime premises + host discipline — keys prove bracket
810
+ disjointness and statements prove residency coverage; full bracket coverage and proration are host duties.
811
+
812
+ Brackets are intervals over money; the top bracket is a ray; regimes key on
813
+ (year, status); and proration happens at write time, never at query time.
814
+
815
+ ```ts
816
+ const Status = closed("Status", ["Single", "MarriedJoint", "HeadOfHousehold"])
817
+ const Regime = relation("Regime", { id: u64.fresh, year: i64, status: Status.id })
818
+ const Bracket = relation("Bracket", { regime: u64, income: interval(i64), rate_bps: i64 })
819
+ const Residency = relation("Residency", { person: u64, span: interval(i64) })
820
+ // Split at write: an Earned fact never spans a year boundary — writers
821
+ // split (prorate) at the boundary, so no reader ever clips. The
822
+ // representation move that deletes clip-at-query (gravestone, recipe 23).
823
+ const Earned = relation("Earned", { person: u64, regime: u64, span: interval(i64), minor: i64 })
824
+
825
+ const Tax = schema("Tax", { Status, Regime, Bracket, Residency, Earned }, [
826
+ contained(on(Regime, "status"), on(Status, "id")),
827
+ key(Regime, ["year", "status"]), // one regime per (year, filing status)
828
+ contained(on(Bracket, "regime"), on(Regime, "id")),
829
+ // Brackets are disjoint per regime. Seed data conventionally covers
830
+ // [0, ∞) and the top bracket is a ray, but this key proves disjointness
831
+ // only. end == MAX denotes [s, ∞), an honest value of the representation,
832
+ // not a sentinel (the point-domain law).
833
+ key(Bracket, ["regime", "income"]),
834
+ contained(on(Earned, "regime"), on(Regime, "id")),
835
+ key(Residency, ["person", "span"]),
836
+ // Residency exclusion: income counts only where earned inside a residency
837
+ // period — pointwise coverage, the same judgment as recipe 15's. This
838
+ // pair statement is also what puts the two bare `person` columns in one
839
+ // (generator-less) class: "Residency.person", by least coordinate.
840
+ contained(on(Earned, ["person", "span"]), on(Residency, ["person", "span"]))
841
+ ])
842
+
843
+ // the marginal bracket (membership probes the disjoint bracket set). Tax
844
+ // owed is host arithmetic over the bracket walk — arithmetic beyond the
845
+ // measure is refused (the ledger).
846
+ const marginal = query(Tax).rule((r) => {
847
+ const { reg, b, rate_bps } = r.vars("reg", "b", "rate_bps")
848
+ return r
849
+ .match(Regime, { id: reg, year: r.param("y"), status: r.param("s") })
850
+ .match(Bracket, { regime: reg, income: b, rate_bps })
851
+ .where(pointIn(r.param("taxable"), b))
852
+ .select("rate_bps")
853
+ })
854
+ ```
855
+
856
+ ## 18. Free time and coalescing
857
+
858
+ Guarantee: Lean theorem + runtime query semantics — `pack` coalesces answer
859
+ intervals (`lean/Bumbledb/Query/Aggregates.lean: pack_canonical`,
860
+ `pack_extensional`); it asserts no stored disjointness, completeness, or
861
+ maintenance behavior.
862
+
863
+ `pack` is Snodgrass's coalesce as an aggregate — maximal disjoint segments per
864
+ group, one answer per (group, segment). Coalescing is never a write rule: the
865
+ engine stores the claims it was given.
866
+
867
+ ```ts
868
+ const Person = relation("Person", { id: u64.fresh, name: str })
869
+ const Claim = relation("Claim", { person: u64, span: interval(i64) })
870
+
871
+ const FreeTime = schema("FreeTime", { Person, Claim }, [
872
+ contained(on(Claim, "person"), on(Person, "id"))
873
+ // No pointwise key, on purpose: claims overlap freely and pack coalesces
874
+ // at read time. Wanting them stored-disjoint is recipe 1's key instead.
875
+ ])
876
+
877
+ // busy time, coalesced (adjacent segments merge — the half-open law):
878
+ const busy = query(FreeTime).rule((r) => {
879
+ const { person, span } = r.vars("person", "span")
880
+ return r.match(Claim, { person, span }).select("person", r.pack("span"))
881
+ })
882
+ // raw claimed time (overlaps double-count — often the wrong question):
883
+ const claimed = query(FreeTime).rule((r) => {
884
+ const { person, span } = r.vars("person", "span")
885
+ return r.match(Claim, { person, span }).select("person", r.sum(r.duration("span")))
886
+ })
887
+ // Coalesced totals = the two-query composition (pack, then a host fold) —
888
+ // aggregates never nest; free time (gaps) is the two-line host walk over
889
+ // sorted packed answers — both refusals recorded in the ledger.
890
+ ```
891
+
892
+ ## The write side
893
+
894
+ ## 19. The ledger
895
+
896
+ Guarantee: Lean theorem + runtime invariant for bounded sums
897
+ (`lean/Bumbledb/Query/Aggregates.lean: checkedSum_sound`); host discipline
898
+ for double entry — statements resolve posting references, not arithmetic agreement.
899
+
900
+ The census workload. Balance is a query, never a column.
901
+
902
+ ```ts
903
+ const Account = relation("Account", { id: u64.fresh, name: str })
904
+ const JournalEntry = relation("JournalEntry", { id: u64.fresh, at: i64, memo: str })
905
+ const Posting = relation("Posting", {
906
+ id: u64.fresh,
907
+ entry: u64,
908
+ account: u64,
909
+ minor: i64
910
+ })
911
+
912
+ const Ledger = schema("Ledger", { Account, JournalEntry, Posting }, [
913
+ contained(on(Posting, "entry"), on(JournalEntry, "id")),
914
+ contained(on(Posting, "account"), on(Account, "id"))
915
+ // A stored balance column equaling Sum(postings) is the arithmetic-
916
+ // agreement statement — refused (the ledger): statements prove presence
917
+ // and topology, never that a value equals a computation. Balance is host
918
+ // arithmetic over sum; a materialized rollup is recipe 21's shape.
919
+ ])
920
+
921
+ // balances (bind the fresh id — set semantics collapses duplicates):
922
+ const balances = query(Ledger).rule((r) => {
923
+ const { id, account, minor } = r.vars("id", "account", "minor")
924
+ return r.match(Posting, { id, account, minor }).select("account", r.sum("minor"))
925
+ })
926
+ // double-entry audit (host asserts every total is 0 — discipline, not schema):
927
+ const doubleEntry = query(Ledger).rule((r) => {
928
+ const { id, entry, minor } = r.vars("id", "entry", "minor")
929
+ return r.match(Posting, { id, entry, minor }).select("entry", r.sum("minor"))
930
+ })
931
+ ```
932
+
933
+ ## 20. Conditional writes
934
+
935
+ Guarantee: Lean theorem + generation-witness/runtime premise + host retry
936
+ discipline — snapshot-derived writes detect movement
937
+ (`lean/Bumbledb/Txn.lean: writeFrom_moved`, `witness_conflict_distinct`);
938
+ final-state point reads need no earlier witness.
939
+
940
+ The generation witness: read the model, propose a delta, commit iff the model
941
+ you read is still the model. In the SDK the whole loop is `db.writeWitnessed`
942
+ — retry on movement is built in (every generation move is self-inflicted by
943
+ the host's own interleaved writes), and `abandon(payload)` declines to commit
944
+ without issuing anything.
945
+
946
+ ```ts
947
+ const State = closed("State", ["Queued", "Running", "Done"])
948
+ const Job = relation("Job", { id: u64.fresh, state: State.id, payload: str })
949
+ const Lease = relation("Lease", { job: u64, worker: u64, until: i64 })
950
+
951
+ const Jobs = schema("Jobs", { State, Job, Lease }, [
952
+ contained(on(Job, "state"), on(State, "id")),
953
+ key(Lease, ["job"]),
954
+ // A lease exists iff its job is Running (recipe 13's conditional target):
955
+ // claiming a job and leasing it commit together or not at all.
956
+ mirrors(on(Lease, "job"), on(Job.where({ state: State.Running }), "id"))
957
+ ])
958
+
959
+ // update-where's premise — "still Queued" is the witness:
960
+ const stillQueued = query(Jobs).rule((r) => {
961
+ const { id, payload } = r.vars("id", "payload")
962
+ return r.match(Job, { id, state: State.Queued, payload }).select("id", "payload")
963
+ })
964
+
965
+ const db = await Db.create("./jobs.db", Jobs)
966
+ const prepared = db.prepare(stillQueued)
967
+
968
+ // The witnessed loop: premise reads via `snap`, the delta via `tx`; on a
969
+ // moved generation the WHOLE callback reruns on a fresh snapshot. The other
970
+ // two idioms: insert-select is the same shape (query source answers, insert
971
+ // the derived facts); key-shaped read-modify-write uses `tx.get`/`tx.contains`
972
+ // — final-state point reads need no earlier witness.
973
+ const outcome = db.writeWitnessed(function updateWhere(snap, tx) {
974
+ const queued = snap.execute(prepared, {})
975
+ if (queued.length === 0) {
976
+ return abandon("nothing queued")
977
+ }
978
+ for (const row of queued) {
979
+ tx.delete(Job, { id: row.id, state: State.Queued, payload: row.payload })
980
+ tx.insert(Job, { id: row.id, state: State.Running, payload: row.payload })
981
+ tx.insert(Lease, { job: row.id, worker: 7n, until: 60n })
982
+ }
983
+ return undefined
984
+ })
985
+ ```
986
+
987
+ ## 21. Derived relations
988
+
989
+ Guarantee: Lean theorem + validator/runtime premises for soundness
990
+ (`lean/Bumbledb/Txn.lean: derived_soundness_vs_freshness`); host
991
+ discipline for completeness — containment rejects unsupported facts but never refreshes omissions.
992
+
993
+ The materialized view as a relation under statements — unsoundness the schema
994
+ can name is uncommittable; incompleteness remains representable until the host
995
+ refreshes it.
996
+
997
+ ```ts
998
+ const Arm = closed("Arm", ["Busy", "Ooo"])
999
+ const Claim = relation("Claim", { source: u64, person: u64, arm: Arm.id, span: interval(i64) })
1000
+ const BusySpan = relation("BusySpan", { person: u64, span: interval(i64) })
1001
+
1002
+ const Rollup = schema("Rollup", { Arm, Claim, BusySpan }, [
1003
+ contained(on(Claim, "arm"), on(Arm, "id")),
1004
+ key(Claim, ["source"]),
1005
+ key(Claim, ["person", "span"]),
1006
+ key(BusySpan, ["person", "span"]), // packed ⇒ disjoint: statable
1007
+ // Soundness, pointwise: every stored rollup point is covered by busy
1008
+ // claims — an UNSOUND rollup (claiming busy time that isn't, or surviving
1009
+ // its sources' deletion) cannot commit, judged on every touching commit.
1010
+ contained(on(BusySpan, ["person", "span"]), on(Claim.where({ arm: Arm.Busy }), ["person", "span"]))
1011
+ ])
1012
+
1013
+ // Maintenance is the third witness idiom (recipe 20): re-run the deriving
1014
+ // query on a snapshot, diff, commit witnessed — the rollup cannot commit
1015
+ // against sources it didn't actually read. The deriving query (pack IS the
1016
+ // coalesce):
1017
+ const deriving = query(Rollup).rule((r) => {
1018
+ const { person, span } = r.vars("person", "span")
1019
+ return r.match(Claim, { person, span, arm: Arm.Busy }).select("person", r.pack("span"))
1020
+ })
1021
+ ```
1022
+
1023
+ ## 22. Union reads
1024
+
1025
+ Guarantee: Lean theorem + represented planner/runtime premise — rule union is
1026
+ set-idempotent (`lean/Bumbledb/Query/Denotation.lean: union_idempotent`);
1027
+ key-backed DU arms justify the disjointness optimization
1028
+ (`lean/Bumbledb/Exec/Dedup.lean: disjoint_witness_licence`).
1029
+
1030
+ The whole-DU read is a set of rules: one head, one rule per arm — disjunction
1031
+ is data at the top, never an execution node.
1032
+
1033
+ ```ts
1034
+ const Kind = closed("Kind", ["Card", "Ach"])
1035
+ const Payment = relation("Payment", { id: u64.fresh, kind: Kind.id })
1036
+ const Card = relation("Card", { payment: u64, last4: u64 })
1037
+ const Ach = relation("Ach", { payment: u64, routing: u64 })
1038
+
1039
+ const Payments = schema("Payments", { Kind, Payment, Card, Ach }, [
1040
+ contained(on(Payment, "kind"), on(Kind, "id")),
1041
+ key(Card, ["payment"]),
1042
+ key(Ach, ["payment"]),
1043
+ mirrors(on(Payment.where({ kind: Kind.Card }), "id"), on(Card, "payment")),
1044
+ mirrors(on(Payment.where({ kind: Kind.Ach }), "id"), on(Ach, "payment"))
1045
+ ])
1046
+
1047
+ // One query, two rules (set union). The exclusivity theorem (recipe 2) is
1048
+ // spent a third time here: rules selecting different `kind` handles are
1049
+ // provably disjoint, so the executor elides cross-rule dedup — the free lunch.
1050
+ const wholeDu = query(Payments)
1051
+ .rule((r) => {
1052
+ const { id, n } = r.vars("id", "n")
1053
+ return r
1054
+ .match(Payment, { id, kind: Kind.Card })
1055
+ .match(Card, { payment: id, last4: n })
1056
+ .select("id", "n")
1057
+ })
1058
+ .rule((r) => {
1059
+ const { id, n } = r.vars("id", "n")
1060
+ return r
1061
+ .match(Payment, { id, kind: Kind.Ach })
1062
+ .match(Ach, { payment: id, routing: n })
1063
+ .select("id", "n")
1064
+ })
1065
+ ```
1066
+
1067
+ ## 23. The anti-recipes: five gravestones
1068
+
1069
+ Guarantee: intentionally refused — each gravestone names unsupported vocabulary
1070
+ and its representable replacement; none asserts an engine theorem.
1071
+
1072
+ What not to model. Each gravestone cites its replacement; the block's
1073
+ relations are the replacements, compiled.
1074
+
1075
+ ```ts
1076
+ // GRAVESTONE: successor pointers (a `next` column). A linked list inside a
1077
+ // relation is control flow smuggled into data. REPLACEMENT: the ordering
1078
+ // triple (recipe 9).
1079
+ const Step = relation("Step", { flow: u64, pos: u64, action: str })
1080
+ // GRAVESTONE: floats for scores, rates, money. Permanently refused (the
1081
+ // ledger). REPLACEMENT: fixed-point i64 — basis points (recipe 4).
1082
+ const Score = relation("Score", { subject: u64, bps: i64 })
1083
+ // GRAVESTONE: conditional keys ("at most one active run per student") —
1084
+ // rejected as FDs. REPLACEMENT: the relation split, whose ordinary key IS
1085
+ // the invariant (recipe 13's arm shape).
1086
+ const ActiveRun = relation("ActiveRun", { student: u64, run: u64 })
1087
+ // GRAVESTONE: clip-at-query intervals (facts spanning period boundaries,
1088
+ // every reader clipping). REPLACEMENT: split at write (recipe 17).
1089
+ const Usage = relation("Usage", { meter: u64, period: u64, used: interval(i64) })
1090
+ // GRAVESTONE: uuid keys. uuidv7 is identity + clash-avoidance + clock in
1091
+ // one lie. REPLACEMENT: fresh (minted identity) + an explicit i64 time column.
1092
+ const Event = relation("Event", { id: u64.fresh, at: i64 })
1093
+
1094
+ const Gravestones = schema("Gravestones", { Step, Score, ActiveRun, Usage, Event }, [
1095
+ key(Step, ["flow", "pos"]),
1096
+ key(Score, ["subject"]),
1097
+ key(ActiveRun, ["student"]),
1098
+ key(Usage, ["meter", "used"])
1099
+ ])
1100
+ ```
1101
+
1102
+ ## Host-driven closure
1103
+
1104
+ ## 24. The closure idiom
1105
+
1106
+ Guarantee: host discipline for the loop — the finite `seen` set proves
1107
+ termination for the host run; the engine-native form beside it executes
1108
+ whole under the fixpoint driver, budget-bounded
1109
+ (`lean/Bumbledb/Exec/Fixpoint.lean: program_eval_sound`).
1110
+
1111
+ Reachability, in two dialects. The host-loop idiom remains the depth-bounded
1112
+ answer: the loop runs depth-many rounds and each round is one ∈-set query —
1113
+ an `inSet` probe, microsecond-class. The frontier discipline below *is*
1114
+ semi-naive evaluation's Δ, spent where a loop is a loop: the host. The
1115
+ engine-native form is the same closure as one stratified `program()`.
1116
+
1117
+ ```ts
1118
+ const Node = relation("Node", { id: u64.fresh, name: str })
1119
+ // One parent per child — a forest (recipe 10's edge shape); a root is a
1120
+ // node whose Parent fact is absent (recipe 3's honest 0..1).
1121
+ const Parent = relation("Parent", { child: u64, parent: u64 })
1122
+
1123
+ const Closure = schema("Closure", { Node, Parent }, [
1124
+ key(Parent, ["child"]),
1125
+ contained(on(Parent, "child"), on(Node, "id")),
1126
+ contained(on(Parent, "parent"), on(Node, "id"))
1127
+ ])
1128
+
1129
+ // The loop's one query — the frontier's children, one ∈-set probe:
1130
+ const step = query(Closure).rule((r) => {
1131
+ const { c } = r.vars("c")
1132
+ return r.match(Parent, { child: c, parent: r.inSet("frontier") }).select("c")
1133
+ })
1134
+ ```
1135
+
1136
+ The loop (the compiled, driven copy is in `test/cookbook.test.ts`, over a
1137
+ three-level forest with the exact reachable set asserted):
1138
+
1139
+ ```ts
1140
+ const db = await Db.create("./closure.db", Closure)
1141
+ const stepPrepared = db.prepare(step)
1142
+ const root = 1n // the host's chosen root node id
1143
+
1144
+ const seen = new Set<bigint>([root])
1145
+ let frontier: readonly bigint[] = [root]
1146
+ for (;;) {
1147
+ const next = db.execute(stepPrepared, { frontier }) // one set-param query
1148
+ const fresh = next
1149
+ .map((row) => row.c)
1150
+ .filter((c) => {
1151
+ return !seen.has(c)
1152
+ })
1153
+ if (fresh.length === 0) {
1154
+ break
1155
+ }
1156
+ for (const c of fresh) {
1157
+ seen.add(c)
1158
+ }
1159
+ frontier = fresh
1160
+ }
1161
+ ```
1162
+
1163
+ Termination is the host's theorem: `seen` grows strictly or the loop breaks,
1164
+ inside a finite node set. When the idiom's costs bite — **unbounded or large
1165
+ depth**, or **closure composed into a larger plan** — write the engine-native
1166
+ form instead: `?root` seeds the predicate, and the output joins the finished
1167
+ set back through the theory's own domain relation (an `idb` atom is a join
1168
+ position, so the head rides the `Node` atom):
1169
+
1170
+ ```ts
1171
+ const reach = program(Closure, (p) => {
1172
+ const rec = p.rec("reach")
1173
+ const seeded = rec
1174
+ .rule((r) => {
1175
+ const { c } = r.vars("c")
1176
+ return r
1177
+ .match(Node, { id: c })
1178
+ .where(eq(c, r.param("root")))
1179
+ .select("c")
1180
+ })
1181
+ .rule((r) => {
1182
+ const { c, parent } = r.vars("c", "parent")
1183
+ return r
1184
+ .match(Parent, { child: c, parent })
1185
+ .idb(rec, parent)
1186
+ .select("c")
1187
+ })
1188
+ return p.output((r) => {
1189
+ const { c } = r.vars("c")
1190
+ return r.match(Node, { id: c }).idb(seeded, c).select("c")
1191
+ })
1192
+ })
1193
+ const reachPrepared = db.prepare(reach)
1194
+ ```
1195
+
1196
+ (the test drives both dialects and asserts the same reachable sets, root for
1197
+ root). What stays host-side is the **chain-window class** — interval
1198
+ intersection along paths — which the recursion surface fences out: the idiom
1199
+ carries the window in the host's frontier, one intersection per hop, and that
1200
+ composition has no engine form.
1201
+
1202
+ ## 25. The chart of accounts
1203
+
1204
+ Guarantee: host discipline + runtime aggregate semantics — the host computes
1205
+ closure, then one checked `sum` (`lean/Bumbledb/Query/Aggregates.lean:
1206
+ checkedSum_sound`); the engine-native form folds over a *finished* lower
1207
+ stratum, the one aggregation shape the strata roster admits.
1208
+
1209
+ The ledger workload's real recursion case, in the same two dialects: a
1210
+ hierarchical chart of accounts and a subtree rollup. The host composition —
1211
+ recipe 24's loop accumulates the subtree's ∈-set, then **one `sum` query over
1212
+ the accumulated set** folds the postings. The engine aggregates, the host
1213
+ composes (aggregates never nest). The engine-native form is one program:
1214
+ aggregation *through* a cycle is refused, but a fold over a recursive
1215
+ predicate from a **higher stratum** reads a finished set and is ordinary.
1216
+
1217
+ ```ts
1218
+ const Account = relation("Account", { id: u64.fresh, name: str })
1219
+ const AccountParent = relation("AccountParent", { child: u64, parent: u64 })
1220
+ const Posting = relation("Posting", { id: u64.fresh, account: u64, minor: i64 })
1221
+
1222
+ const Accounts = schema("Accounts", { Account, AccountParent, Posting }, [
1223
+ key(AccountParent, ["child"]), // one parent per account
1224
+ contained(on(AccountParent, "child"), on(Account, "id")),
1225
+ contained(on(AccountParent, "parent"), on(Account, "id")),
1226
+ contained(on(Posting, "account"), on(Account, "id"))
1227
+ ])
1228
+
1229
+ // The two queries the host rollup composes:
1230
+ // the frontier step (recipe 24's loop, verbatim):
1231
+ const frontierStep = query(Accounts).rule((r) => {
1232
+ const { c } = r.vars("c")
1233
+ return r.match(AccountParent, { child: c, parent: r.inSet("frontier") }).select("c")
1234
+ })
1235
+ // the rollup over the accumulated subtree (bind the fresh id — recipe
1236
+ // 19's discipline, spent again; equal postings to one account both count):
1237
+ const subtreeRollup = query(Accounts).rule((r) => {
1238
+ const { id, minor } = r.vars("id", "minor")
1239
+ return r.match(Posting, { id, account: r.inSet("subtree"), minor }).select(r.sum("minor"))
1240
+ })
1241
+ // The engine-native form: the closure stratum converges first, then the
1242
+ // output's fold runs once over the finished subtree.
1243
+ const nativeRollup = program(Accounts, (p) => {
1244
+ const sub = p.rec("sub")
1245
+ const seeded = sub
1246
+ .rule((r) => {
1247
+ const { a } = r.vars("a")
1248
+ return r
1249
+ .match(Account, { id: a })
1250
+ .where(eq(a, r.param("root")))
1251
+ .select("a")
1252
+ })
1253
+ .rule((r) => {
1254
+ const { a, parent } = r.vars("a", "parent")
1255
+ return r
1256
+ .match(AccountParent, { child: a, parent })
1257
+ .idb(sub, parent)
1258
+ .select("a")
1259
+ })
1260
+ return p.output((r) => {
1261
+ const { id, a, minor } = r.vars("id", "a", "minor")
1262
+ return r
1263
+ .match(Posting, { id, account: a, minor })
1264
+ .idb(seeded, a)
1265
+ .select(r.sum("minor"))
1266
+ })
1267
+ })
1268
+ ```
1269
+
1270
+ ## 26. Exact partition
1271
+
1272
+ Guarantee: Lean theorem + validator/runtime premises — mutual point coverage
1273
+ plus pointwise keys realizes exact partition
1274
+ (`lean/Bumbledb/Dependencies.lean: exact_partition_iff`).
1275
+
1276
+ An exact partition needs both coverage directions. The first containment below
1277
+ is the intent-level reference; the two pointwise keys make each side disjoint;
1278
+ the final pair proves equal point supports per policy — forward coverage
1279
+ forbids gaps and reverse coverage forbids overhang.
1280
+
1281
+ The explicit `key(Policy, ["id", "live"])` is load-bearing. Containment
1282
+ targets resolve by their exact projected field set, so the fresh `{id}` key
1283
+ cannot serve the `{id, live}` target and the engine infers no key closure.
1284
+
1285
+ ```ts
1286
+ const Policy = relation("Policy", { id: u64.fresh, live: interval(i64) })
1287
+ const Version = relation("Version", { policy: u64, valid: interval(i64) })
1288
+
1289
+ const ExactPartition = schema("ExactPartition", { Policy, Version }, [
1290
+ contained(on(Version, "policy"), on(Policy, "id")), // reference intent
1291
+ key(Version, ["policy", "valid"]), // disjoint versions
1292
+ key(Policy, ["id", "live"]), // exact target key, not implied by {id}
1293
+ contained(on(Policy, ["id", "live"]), on(Version, ["policy", "valid"])), // no gaps in the policy source span
1294
+ contained(on(Version, ["policy", "valid"]), on(Policy, ["id", "live"])) // no version overhang
1295
+ ])
1296
+ ```
1297
+
1298
+ Together the mutual containments prove equal point supports for each policy;
1299
+ the pointwise keys make those supports genuine partitions rather than
1300
+ overlapping covers. Touching half-open segments remain legal, and the same
1301
+ construction works with any scalar-prefix arity before the final interval
1302
+ position.
1303
+
1304
+ ## 27. Derived facts, maintained
1305
+
1306
+ Guarantee: host discipline + validator/runtime premises — freshness comes from
1307
+ the generation witness; containment proves surviving rollup facts sound only
1308
+ (`lean/Bumbledb/Txn.lean: derived_soundness_vs_freshness`).
1309
+
1310
+ A stored rollup is an ordinary relation with an ordinary soundness statement.
1311
+ Here `pack` derives maximal busy spans, while containment prevents any stored
1312
+ `BusySpan` point that has no busy claim behind it. That is soundness, not a
1313
+ refresh theorem: a missing span remains representable until the host
1314
+ maintenance loop fills it.
1315
+
1316
+ ```ts
1317
+ const Arm = closed("Arm", ["Busy", "Ooo"])
1318
+ const Claim = relation("Claim", { source: u64, person: u64, arm: Arm.id, span: interval(i64) })
1319
+ const BusySpan = relation("BusySpan", { person: u64, span: interval(i64) })
1320
+
1321
+ const MaintainedRollup = schema("MaintainedRollup", { Arm, Claim, BusySpan }, [
1322
+ contained(on(Claim, "arm"), on(Arm, "id")),
1323
+ key(Claim, ["source"]),
1324
+ key(Claim, ["person", "span"]),
1325
+ key(BusySpan, ["person", "span"]),
1326
+ contained(on(BusySpan, ["person", "span"]), on(Claim.where({ arm: Arm.Busy }), ["person", "span"]))
1327
+ ])
1328
+
1329
+ // Derive the desired rollup on the maintenance snapshot:
1330
+ const deriving = query(MaintainedRollup).rule((r) => {
1331
+ const { source, person, span } = r.vars("source", "person", "span")
1332
+ return r.match(Claim, { source, person, arm: Arm.Busy, span }).select("person", r.pack("span"))
1333
+ })
1334
+ ```
1335
+
1336
+ The host loop is `db.writeWitnessed`: derive on the attempt's snapshot, diff,
1337
+ build the delta — recipe 20's third idiom. On a moved generation the SDK
1338
+ throws away the attempt and reruns the whole callback on a fresh snapshot; it
1339
+ never retries a stale diff. Dependencies prove every surviving stored span
1340
+ sound, while the witness proves which source state the derivation saw;
1341
+ neither mechanism proves completeness. The engine's compiled copy
1342
+ (`maintain_busy_spans` in `cookbook.rs`) drives the retry-and-repack loop.
1343
+
1344
+ ## Operating the store
1345
+
1346
+ ## 28. Migration is ETL
1347
+
1348
+ Guarantee: Lean theorem + validator/runtime premises + host discipline —
1349
+ fingerprints refuse reinterpretation, final-state judgments validate each load
1350
+ (`lean/Bumbledb/Txn.lean: etl_lands_valid`), and the host owns
1351
+ the semantic transform and dependency-safe load order.
1352
+
1353
+ There is no in-place migration and never will be: a schema is a theory, the
1354
+ store records the theory's fingerprint, and `Db.open` under a changed theory
1355
+ is a hard fingerprint mismatch — the engine refuses to reinterpret facts it
1356
+ judged under different laws. Migration is extract, transform, load: `scan`
1357
+ exports every fact of a relation as typed values under one snapshot, the host
1358
+ transforms, and inserts (fresh ids resupplied — identity survives) land into
1359
+ a store created under the new theory, judged whole by the ordinary
1360
+ final-state judgment: load containment targets first, and a chunk that lands
1361
+ is already valid.
1362
+
1363
+ The v2 theory below adds what v1 never recorded — *when* a salary applied —
1364
+ as an interval with a pointwise key: one salary per employee per instant. The
1365
+ transform supplies the missing dimension (a ray from the migration epoch).
1366
+
1367
+ ```ts
1368
+ // The old theory, judged and fingerprinted:
1369
+ const EmployeeV1 = relation("Employee", { id: u64.fresh, name: str })
1370
+ const SalaryV1 = relation("Salary", { employee: u64, amount: i64 })
1371
+ const PayrollV1 = schema("PayrollV1", { Employee: EmployeeV1, Salary: SalaryV1 }, [
1372
+ contained(on(SalaryV1, "employee"), on(EmployeeV1, "id"))
1373
+ ])
1374
+
1375
+ // The new theory adds what v1 never recorded:
1376
+ const Employee = relation("Employee", { id: u64.fresh, name: str })
1377
+ const Salary = relation("Salary", { employee: u64, amount: i64, applies: interval(i64) })
1378
+ const Payroll = schema("Payroll", { Employee, Salary }, [
1379
+ contained(on(Salary, "employee"), on(Employee, "id")),
1380
+ key(Salary, ["employee", "applies"]) // one salary per instant
1381
+ ])
1382
+
1383
+ // The post-migration read — salaries in force at an instant:
1384
+ const inForceAt = query(Payroll).rule((r) => {
1385
+ const { e, name, amount, w } = r.vars("e", "name", "amount", "w")
1386
+ return r
1387
+ .match(Employee, { id: e, name })
1388
+ .match(Salary, { employee: e, amount, applies: w })
1389
+ .where(pointIn(r.param("at"), w))
1390
+ .select("name", "amount")
1391
+ })
1392
+ ```
1393
+
1394
+ The engine's compiled test drives the whole loop (export under one snapshot,
1395
+ the fingerprint refusal, load order, identity, mint catch-up, judgment); the
1396
+ SDK pin asserts the two theories carry two distinct fingerprints — the
1397
+ refusal's premise. For stores whose creating schema is gone, `Db.exhume`
1398
+ reads the store's own persisted descriptor — the one schema-independent read
1399
+ path, the E half of ETL.
1400
+
1401
+ ## Composition
1402
+
1403
+ ## 29. The zone ledger
1404
+
1405
+ Guarantee: Lean theorem + validator/runtime premises — per-kind mutual point
1406
+ coverage realizes each arm's exact partition
1407
+ (`lean/Bumbledb/Dependencies.lean: exact_partition_iff`) over one
1408
+ disjointness witness, and the mixed-width `==` positions type by element
1409
+ domain (`lean/Bumbledb/Schema.lean: Value.points_one_tag_u64`); witness
1410
+ segmentation is host discipline (the honesty note below).
1411
+
1412
+ Recipe 9's sidecar, composed: a ledger whose timeline divides into zones of
1413
+ two kinds — unit zones (`interval(u64, 1n)`) and pair zones
1414
+ (`interval(u64, 2n)`), each kind carrying its own payload sidecar. The
1415
+ discriminated-union pattern (recipe 2) applied at interval positions: a
1416
+ kind-discriminated `Zone` witness relation owns **cross-sidecar disjointness**
1417
+ through its one pointwise key, and since each sidecar's point support equals
1418
+ its kind's zone support (the per-kind `mirrors`), a unit slot can never
1419
+ overlap a pair slot even though they live in different relations. The arm
1420
+ widths are enforced **by type**: a `UnitSlot` value is width 1 or does not
1421
+ exist — no runtime width check, nothing to enforce at commit.
1422
+
1423
+ ```ts
1424
+ const Kind = closed("Kind", ["Unit", "Pair"])
1425
+ const Ledger = relation("Ledger", { id: u64.fresh, name: str })
1426
+ // The witness: every zone of the ledger, kind-discriminated; its one
1427
+ // pointwise key is the cross-sidecar disjointness proof.
1428
+ const Zone = relation("Zone", { ledger: u64, kind: Kind.id, at: interval(u64) })
1429
+ const UnitSlot = relation("UnitSlot", { ledger: u64, at: interval(u64, 1n), entry: u64 })
1430
+ const PairSlot = relation("PairSlot", { ledger: u64, at: interval(u64, 2n), entry: u64 })
1431
+
1432
+ const ZoneLedger = schema("ZoneLedger", { Kind, Ledger, Zone, UnitSlot, PairSlot }, [
1433
+ contained(on(Zone, "ledger"), on(Ledger, "id")),
1434
+ contained(on(Zone, "kind"), on(Kind, "id")),
1435
+ key(Zone, ["ledger", "at"]), // all zones disjoint, whatever the kind
1436
+ key(UnitSlot, ["ledger", "at"]),
1437
+ key(PairSlot, ["ledger", "at"]),
1438
+ // Each kind's zones carry exactly its sidecar's points — mixed widths,
1439
+ // one element domain:
1440
+ mirrors(on(Zone.where({ kind: Kind.Unit }), ["ledger", "at"]), on(UnitSlot, ["ledger", "at"])),
1441
+ mirrors(on(Zone.where({ kind: Kind.Pair }), ["ledger", "at"]), on(PairSlot, ["ledger", "at"]))
1442
+ ])
1443
+ ```
1444
+
1445
+ The honesty note — **coalescing insensitivity**: the `mirrors` judgments
1446
+ compare point supports, not rows. A single Unit-kind zone `[4,6)` beside two
1447
+ unit slots `[4,5)`, `[5,6)` satisfies both directions, because nothing forces
1448
+ the witness rows to mirror the sidecar's segmentation — only its points. If
1449
+ per-row correspondence matters, the host writes zones at slot granularity;
1450
+ the schema proves disjointness and coverage either way.