@rhizomes/rhizomatic 0.1.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 (119) hide show
  1. package/LICENSE-APACHE +201 -0
  2. package/LICENSE-MIT +21 -0
  3. package/README.md +54 -0
  4. package/dist/alias.d.ts +4 -0
  5. package/dist/alias.d.ts.map +1 -0
  6. package/dist/alias.js +34 -0
  7. package/dist/alias.js.map +1 -0
  8. package/dist/cbor.d.ts +24 -0
  9. package/dist/cbor.d.ts.map +1 -0
  10. package/dist/cbor.js +267 -0
  11. package/dist/cbor.js.map +1 -0
  12. package/dist/delta.d.ts +8 -0
  13. package/dist/delta.d.ts.map +1 -0
  14. package/dist/delta.js +92 -0
  15. package/dist/delta.js.map +1 -0
  16. package/dist/derivation.d.ts +29 -0
  17. package/dist/derivation.d.ts.map +1 -0
  18. package/dist/derivation.js +183 -0
  19. package/dist/derivation.js.map +1 -0
  20. package/dist/eval.d.ts +91 -0
  21. package/dist/eval.d.ts.map +1 -0
  22. package/dist/eval.js +318 -0
  23. package/dist/eval.js.map +1 -0
  24. package/dist/hash.d.ts +4 -0
  25. package/dist/hash.d.ts.map +1 -0
  26. package/dist/hash.js +17 -0
  27. package/dist/hash.js.map +1 -0
  28. package/dist/http.d.ts +21 -0
  29. package/dist/http.d.ts.map +1 -0
  30. package/dist/http.js +110 -0
  31. package/dist/http.js.map +1 -0
  32. package/dist/hview.d.ts +15 -0
  33. package/dist/hview.d.ts.map +1 -0
  34. package/dist/hview.js +72 -0
  35. package/dist/hview.js.map +1 -0
  36. package/dist/index.d.ts +23 -0
  37. package/dist/index.d.ts.map +1 -0
  38. package/dist/index.js +22 -0
  39. package/dist/index.js.map +1 -0
  40. package/dist/json-profile.d.ts +4 -0
  41. package/dist/json-profile.d.ts.map +1 -0
  42. package/dist/json-profile.js +97 -0
  43. package/dist/json-profile.js.map +1 -0
  44. package/dist/pack.d.ts +5 -0
  45. package/dist/pack.d.ts.map +1 -0
  46. package/dist/pack.js +227 -0
  47. package/dist/pack.js.map +1 -0
  48. package/dist/peer.d.ts +26 -0
  49. package/dist/peer.d.ts.map +1 -0
  50. package/dist/peer.js +111 -0
  51. package/dist/peer.js.map +1 -0
  52. package/dist/policy.d.ts +46 -0
  53. package/dist/policy.d.ts.map +1 -0
  54. package/dist/policy.js +186 -0
  55. package/dist/policy.js.map +1 -0
  56. package/dist/pred.d.ts +78 -0
  57. package/dist/pred.d.ts.map +1 -0
  58. package/dist/pred.js +228 -0
  59. package/dist/pred.js.map +1 -0
  60. package/dist/reactor.d.ts +67 -0
  61. package/dist/reactor.d.ts.map +1 -0
  62. package/dist/reactor.js +433 -0
  63. package/dist/reactor.js.map +1 -0
  64. package/dist/schema-deltas.d.ts +14 -0
  65. package/dist/schema-deltas.d.ts.map +1 -0
  66. package/dist/schema-deltas.js +87 -0
  67. package/dist/schema-deltas.js.map +1 -0
  68. package/dist/schema.d.ts +17 -0
  69. package/dist/schema.d.ts.map +1 -0
  70. package/dist/schema.js +102 -0
  71. package/dist/schema.js.map +1 -0
  72. package/dist/set.d.ts +18 -0
  73. package/dist/set.d.ts.map +1 -0
  74. package/dist/set.js +83 -0
  75. package/dist/set.js.map +1 -0
  76. package/dist/sign.d.ts +8 -0
  77. package/dist/sign.d.ts.map +1 -0
  78. package/dist/sign.js +44 -0
  79. package/dist/sign.js.map +1 -0
  80. package/dist/term-io.d.ts +13 -0
  81. package/dist/term-io.d.ts.map +1 -0
  82. package/dist/term-io.js +216 -0
  83. package/dist/term-io.js.map +1 -0
  84. package/dist/term-json.d.ts +7 -0
  85. package/dist/term-json.d.ts.map +1 -0
  86. package/dist/term-json.js +362 -0
  87. package/dist/term-json.js.map +1 -0
  88. package/dist/types.d.ts +34 -0
  89. package/dist/types.d.ts.map +1 -0
  90. package/dist/types.js +4 -0
  91. package/dist/types.js.map +1 -0
  92. package/dist/vocab.d.ts +2 -0
  93. package/dist/vocab.d.ts.map +1 -0
  94. package/dist/vocab.js +4 -0
  95. package/dist/vocab.js.map +1 -0
  96. package/package.json +83 -0
  97. package/src/alias.ts +36 -0
  98. package/src/cbor.ts +280 -0
  99. package/src/delta.ts +89 -0
  100. package/src/derivation.ts +229 -0
  101. package/src/eval.ts +401 -0
  102. package/src/hash.ts +19 -0
  103. package/src/http.ts +124 -0
  104. package/src/hview.ts +91 -0
  105. package/src/index.ts +83 -0
  106. package/src/json-profile.ts +96 -0
  107. package/src/pack.ts +239 -0
  108. package/src/peer.ts +126 -0
  109. package/src/policy.ts +216 -0
  110. package/src/pred.ts +307 -0
  111. package/src/reactor.ts +490 -0
  112. package/src/schema-deltas.ts +100 -0
  113. package/src/schema.ts +111 -0
  114. package/src/set.ts +98 -0
  115. package/src/sign.ts +48 -0
  116. package/src/term-io.ts +228 -0
  117. package/src/term-json.ts +364 -0
  118. package/src/types.ts +38 -0
  119. package/src/vocab.ts +3 -0
package/src/reactor.ts ADDED
@@ -0,0 +1,490 @@
1
+ // The reactor core (SPEC-4 §2-3, ERRATA-4): ingest -> validate -> persist -> index. The log is
2
+ // the truth; the four core indexes are derived and reconstructible. Materializations arrive in
3
+ // M2.2; this layer guarantees idempotence and order-convergence.
4
+
5
+ import { evalTerm, type EvalResult, type Term } from "./eval.js";
6
+ import { array, encode } from "./cbor.js";
7
+ import { bytesToHex } from "./hash.js";
8
+ import { hvEntryToCbor, hviewCanonicalHex, type HView } from "./hview.js";
9
+ import { collectRefs } from "./schema.js";
10
+ import { comparePrimitives, type Pred, type ValMatch } from "./pred.js";
11
+ import { viewCanonicalHex } from "./policy.js";
12
+ import type { SchemaRegistry } from "./schema.js";
13
+ import { DeltaSet } from "./set.js";
14
+ import { VOCAB_PREFIX } from "./schema-deltas.js";
15
+ import { verifyDelta } from "./sign.js";
16
+ import type { Delta, Primitive } from "./types.js";
17
+
18
+ // A change event carries: root, affected property paths, responsible delta ids, new content
19
+ // hash (SPEC-4 §5).
20
+ export interface MaterializationChange {
21
+ readonly materialization: string;
22
+ readonly root: string;
23
+ readonly changedProps: readonly string[];
24
+ readonly responsibleDeltaIds: readonly string[];
25
+ readonly newHex: string;
26
+ }
27
+
28
+ interface Materialization {
29
+ readonly name: string;
30
+ readonly term: Term;
31
+ readonly roots: readonly string[];
32
+ readonly registry: SchemaRegistry | undefined;
33
+ readonly rootAnchored: boolean;
34
+ readonly views: Map<string, HView>;
35
+ readonly hexes: Map<string, string>;
36
+ readonly propHexes: Map<string, Map<string, string>>;
37
+ readonly supportEntities: Map<string, Set<string>>;
38
+ evalCount: number;
39
+ }
40
+
41
+ export type IngestResult =
42
+ | { readonly status: "accepted" }
43
+ | { readonly status: "duplicate" }
44
+ | { readonly status: "rejected"; readonly reason: string };
45
+
46
+ export class Reactor {
47
+ // The append-only log in arrival order (v0: in-memory; the log is still the truth — V2).
48
+ private readonly log: Delta[] = [];
49
+ private readonly set = new DeltaSet();
50
+ // target index: EntityId -> delta ids whose pointers target that entity (SPEC-4 §3)
51
+ private readonly targetIndex = new Map<string, Set<string>>();
52
+ // negation index: delta id -> ids of negations targeting it (SPEC-4 §3)
53
+ private readonly negationIndex = new Map<string, Set<string>>();
54
+ private readonly materializations = new Map<string, Materialization>();
55
+ // value index: role -> canonical primitive key -> { value, ids } (V1: keyed by role)
56
+ private readonly valueIndex = new Map<
57
+ string,
58
+ Map<string, { value: Primitive; ids: Set<string> }>
59
+ >();
60
+
61
+ // Validate -> persist -> index. Idempotent by id; rejected deltas leave no trace (V3).
62
+ ingest(delta: Delta): IngestResult {
63
+ if (this.set.has(delta.id)) return { status: "duplicate" };
64
+ // A present signature must verify; unsigned deltas remain legal at L1 (D9).
65
+ if (delta.sig !== undefined && verifyDelta(delta) !== "verified") {
66
+ return { status: "rejected", reason: "signature does not verify" };
67
+ }
68
+ try {
69
+ this.set.add(delta); // recomputes the content address and runs L1 validation
70
+ } catch (e) {
71
+ return { status: "rejected", reason: e instanceof Error ? e.message : String(e) };
72
+ }
73
+ this.log.push(delta);
74
+ this.index(delta);
75
+ for (const cb of this.rawSubscribers) cb(delta);
76
+ this.lastChanges = this.dispatchAndUpdate([delta]);
77
+ return { status: "accepted" };
78
+ }
79
+
80
+ private index(delta: Delta): void {
81
+ for (const ptr of delta.claims.pointers) {
82
+ switch (ptr.target.kind) {
83
+ case "entity": {
84
+ const id = ptr.target.entity.id;
85
+ let bucket = this.targetIndex.get(id);
86
+ if (bucket === undefined) {
87
+ bucket = new Set();
88
+ this.targetIndex.set(id, bucket);
89
+ }
90
+ bucket.add(delta.id);
91
+ break;
92
+ }
93
+ case "delta": {
94
+ if (ptr.role === "negates") {
95
+ const target = ptr.target.deltaRef.delta;
96
+ let bucket = this.negationIndex.get(target);
97
+ if (bucket === undefined) {
98
+ bucket = new Set();
99
+ this.negationIndex.set(target, bucket);
100
+ }
101
+ bucket.add(delta.id);
102
+ }
103
+ break;
104
+ }
105
+ case "primitive": {
106
+ let roleBucket = this.valueIndex.get(ptr.role);
107
+ if (roleBucket === undefined) {
108
+ roleBucket = new Map();
109
+ this.valueIndex.set(ptr.role, roleBucket);
110
+ }
111
+ const key = viewCanonicalHex(ptr.target.value);
112
+ let entry = roleBucket.get(key);
113
+ if (entry === undefined) {
114
+ entry = { value: ptr.target.value, ids: new Set() };
115
+ roleBucket.set(key, entry);
116
+ }
117
+ entry.ids.add(delta.id);
118
+ break;
119
+ }
120
+ }
121
+ }
122
+ }
123
+
124
+ // --- queries over the core indexes (sorted ids — canonical enumeration order) ---
125
+
126
+ byTarget(entityId: string): string[] {
127
+ return [...(this.targetIndex.get(entityId) ?? [])].sort();
128
+ }
129
+
130
+ negationsOf(deltaId: string): string[] {
131
+ return [...(this.negationIndex.get(deltaId) ?? [])].sort();
132
+ }
133
+
134
+ // Range/equality queries over primitive payloads filed under a role (V1; ValMatch per SPEC-2 §3).
135
+ byValue(role: string, match: (v: Primitive) => boolean): string[] {
136
+ const bucket = this.valueIndex.get(role);
137
+ if (bucket === undefined) return [];
138
+ const out: string[] = [];
139
+ for (const { value, ids } of bucket.values()) {
140
+ if (match(value)) out.push(...ids);
141
+ }
142
+ return out.sort();
143
+ }
144
+
145
+ byValueBetween(role: string, lo: Primitive, hi: Primitive): string[] {
146
+ return this.byValue(
147
+ role,
148
+ (v) => comparePrimitives(v, lo) >= 0 && comparePrimitives(v, hi) <= 0,
149
+ );
150
+ }
151
+
152
+ // --- the log and the set ---
153
+
154
+ get size(): number {
155
+ return this.set.size;
156
+ }
157
+
158
+ has(id: string): boolean {
159
+ return this.set.has(id);
160
+ }
161
+
162
+ get(id: string): Delta | undefined {
163
+ return this.set.get(id);
164
+ }
165
+
166
+ // Arrival order — a transport artifact, never consulted by evaluation (SPEC-4 §2).
167
+ arrivalLog(): readonly Delta[] {
168
+ return this.log;
169
+ }
170
+
171
+ digest(): string {
172
+ return this.set.digest();
173
+ }
174
+
175
+ snapshot(): DeltaSet {
176
+ return DeltaSet.from(this.set);
177
+ }
178
+
179
+ // Batch evaluation over the current set — the oracle hookup (SPEC-4 §1). Read-your-writes
180
+ // holds trivially: ingest is synchronous, so an accepted delta is visible immediately (§6).
181
+ eval(term: Term, root?: string, registry?: SchemaRegistry): EvalResult {
182
+ return evalTerm(term, this.set, root, registry);
183
+ }
184
+
185
+ // --- materializations (SPEC-4 §4, ERRATA-4 V5) ---
186
+
187
+ private lastChanges: MaterializationChange[] = [];
188
+
189
+ // Register a live materialization: an HView-sort term (a function of $root) kept
190
+ // incrementally equal to batch evaluation at each root (SPEC-4 §1).
191
+ register(name: string, term: Term, roots: readonly string[], registry?: SchemaRegistry): void {
192
+ if (this.materializations.has(name)) throw new Error(`duplicate materialization: ${name}`);
193
+ const mat: Materialization = {
194
+ name,
195
+ term,
196
+ roots: [...roots],
197
+ registry,
198
+ rootAnchored: isRootAnchored(term, registry),
199
+ views: new Map(),
200
+ hexes: new Map(),
201
+ propHexes: new Map(),
202
+ supportEntities: new Map(),
203
+ evalCount: 0,
204
+ };
205
+ for (const root of mat.roots) void this.refresh(mat, root);
206
+ this.materializations.set(name, mat);
207
+ }
208
+
209
+ materializedHex(name: string, root: string): string | undefined {
210
+ return this.materializations.get(name)?.hexes.get(root);
211
+ }
212
+
213
+ materializedView(name: string, root: string): HView | undefined {
214
+ return this.materializations.get(name)?.views.get(root);
215
+ }
216
+
217
+ evalCountOf(name: string): number {
218
+ return this.materializations.get(name)?.evalCount ?? 0;
219
+ }
220
+
221
+ changesFromLastIngest(): readonly MaterializationChange[] {
222
+ return this.lastChanges;
223
+ }
224
+
225
+ private refresh(mat: Materialization, root: string): string[] | undefined {
226
+ const result = evalTerm(mat.term, this.set, root, mat.registry);
227
+ if (result.sort !== "hview") throw new Error("materialized terms must be HView-sort");
228
+ mat.evalCount += 1;
229
+ const hex = hviewCanonicalHex(result.hview);
230
+ const changed = mat.hexes.get(root) !== hex;
231
+ const newPropHexes = propHexesOf(result.hview);
232
+ const changedProps = changed
233
+ ? diffProps(mat.propHexes.get(root) ?? new Map(), newPropHexes)
234
+ : undefined;
235
+ mat.views.set(root, result.hview);
236
+ mat.hexes.set(root, hex);
237
+ mat.propHexes.set(root, newPropHexes);
238
+ const entities = new Set<string>([root]);
239
+ collectNestedIds(result.hview, entities);
240
+ mat.supportEntities.set(root, entities);
241
+ return changedProps;
242
+ }
243
+
244
+ // Sound dispatch (V5): over-match allowed, under-match forbidden.
245
+ private dispatchAndUpdate(deltas: readonly Delta[]): MaterializationChange[] {
246
+ const responsible = deltas.map((d) => d.id);
247
+ const changes: MaterializationChange[] = [];
248
+ for (const mat of this.materializations.values()) {
249
+ for (const root of mat.roots) {
250
+ if (!deltas.some((d) => this.affects(d, mat, root))) continue;
251
+ const changedProps = this.refresh(mat, root);
252
+ if (changedProps !== undefined) {
253
+ changes.push({
254
+ materialization: mat.name,
255
+ root,
256
+ changedProps,
257
+ responsibleDeltaIds: responsible,
258
+ newHex: mat.hexes.get(root)!,
259
+ });
260
+ }
261
+ }
262
+ }
263
+ for (const c of changes) {
264
+ for (const cb of this.matSubscribers.get(c.materialization) ?? []) cb(c);
265
+ }
266
+ return changes;
267
+ }
268
+
269
+ private affects(delta: Delta, mat: Materialization, root: string): boolean {
270
+ if (!mat.rootAnchored) return true; // broad dispatch for non-anchored terms (V5)
271
+ const support = mat.supportEntities.get(root) ?? new Set([root]);
272
+ if (this.targetsSupport(delta, support)) return true;
273
+ // negation chains: walk each negates target downward toward base data (V5)
274
+ for (const ptr of delta.claims.pointers) {
275
+ if (ptr.role !== "negates" || ptr.target.kind !== "delta") continue;
276
+ if (this.chainTouchesSupport(ptr.target.deltaRef.delta, support, 0)) return true;
277
+ }
278
+ return false;
279
+ }
280
+
281
+ private targetsSupport(delta: Delta, support: ReadonlySet<string>): boolean {
282
+ return delta.claims.pointers.some(
283
+ (p) => p.target.kind === "entity" && support.has(p.target.entity.id),
284
+ );
285
+ }
286
+
287
+ private chainTouchesSupport(id: string, support: ReadonlySet<string>, depth: number): boolean {
288
+ if (depth > 64) return true; // adversarial-depth guard: over-match rather than recurse forever
289
+ const target = this.set.get(id);
290
+ if (target === undefined) return false; // unknown target: nothing materialized depends on it
291
+ if (this.targetsSupport(target, support)) return true;
292
+ for (const ptr of target.claims.pointers) {
293
+ if (ptr.role !== "negates" || ptr.target.kind !== "delta") continue;
294
+ if (this.chainTouchesSupport(ptr.target.deltaRef.delta, support, depth + 1)) return true;
295
+ }
296
+ return false;
297
+ }
298
+
299
+ // --- subscriptions (SPEC-4 §5) ---
300
+
301
+ private readonly rawSubscribers: Array<(d: Delta) => void> = [];
302
+ private readonly matSubscribers = new Map<string, Array<(c: MaterializationChange) => void>>();
303
+
304
+ // The raw stream: every accepted delta (federation relays, audit, mirrors).
305
+ subscribeRaw(cb: (delta: Delta) => void): void {
306
+ this.rawSubscribers.push(cb);
307
+ }
308
+
309
+ // Change events on a registered materialization's HyperViews.
310
+ subscribe(materialization: string, cb: (change: MaterializationChange) => void): void {
311
+ const list = this.matSubscribers.get(materialization);
312
+ if (list === undefined) this.matSubscribers.set(materialization, [cb]);
313
+ else list.push(cb);
314
+ }
315
+
316
+ // --- atomic batch ingestion (SPEC-1 §9, SPEC-4 §6) ---
317
+
318
+ // Manifest-keyed atomic ingestion: validate everything first; all members become visible to
319
+ // dispatch in one step, or none do. The transaction vocabulary supplies the batch boundary;
320
+ // the reactor supplies the courtesy.
321
+ ingestBundle(manifest: Delta, members: readonly Delta[]): IngestResult {
322
+ const fresh = [...members, manifest].filter((d) => !this.set.has(d.id));
323
+ // Validate all before admitting any (atomic acceptance).
324
+ for (const d of fresh) {
325
+ if (d.sig !== undefined && verifyDelta(d) !== "verified") {
326
+ return { status: "rejected", reason: `bundle member ${d.id}: signature does not verify` };
327
+ }
328
+ try {
329
+ const probe = new DeltaSet();
330
+ probe.add(d);
331
+ } catch (e) {
332
+ return {
333
+ status: "rejected",
334
+ reason: `bundle member ${d.id}: ${e instanceof Error ? e.message : String(e)}`,
335
+ };
336
+ }
337
+ }
338
+ // The manifest must commit to every supplied member by content address (SPEC-1 §9).
339
+ const committed = new Set(manifestMemberIds(manifest));
340
+ for (const m of members) {
341
+ if (!committed.has(m.id)) {
342
+ return { status: "rejected", reason: `member ${m.id} is not claimed by the manifest` };
343
+ }
344
+ }
345
+ if (fresh.length === 0) return { status: "duplicate" };
346
+ for (const d of fresh) {
347
+ this.set.add(d);
348
+ this.log.push(d);
349
+ this.index(d);
350
+ for (const cb of this.rawSubscribers) cb(d);
351
+ }
352
+ this.lastChanges = this.dispatchAndUpdate(fresh);
353
+ return { status: "accepted" };
354
+ }
355
+
356
+ // Completeness is verifiable, not enforced (SPEC-1 §9): a hash check.
357
+ holdsAllMembers(manifestId: string): boolean {
358
+ const manifest = this.set.get(manifestId);
359
+ if (manifest === undefined) return false;
360
+ return manifestMemberIds(manifest).every((id) => this.set.has(id));
361
+ }
362
+ }
363
+
364
+ // --- the rdb.txn vocabulary (SPEC-1 §9) ---
365
+
366
+ export function makeManifestClaims(
367
+ author: string,
368
+ timestamp: number,
369
+ memberIds: readonly string[],
370
+ options?: { readonly prior?: string; readonly intent?: string },
371
+ ): import("./types.js").Claims {
372
+ const pointers: import("./types.js").Pointer[] = memberIds.map((id) => ({
373
+ role: `${VOCAB_PREFIX}.txn.member`,
374
+ target: { kind: "delta", deltaRef: { delta: id } },
375
+ }));
376
+ if (options?.prior !== undefined) {
377
+ pointers.push({
378
+ role: `${VOCAB_PREFIX}.txn.prior`,
379
+ target: { kind: "delta", deltaRef: { delta: options.prior } },
380
+ });
381
+ }
382
+ if (options?.intent !== undefined) {
383
+ pointers.push({
384
+ role: `${VOCAB_PREFIX}.txn.intent`,
385
+ target: { kind: "primitive", value: options.intent },
386
+ });
387
+ }
388
+ return { timestamp, author, pointers };
389
+ }
390
+
391
+ export function manifestMemberIds(manifest: Delta): string[] {
392
+ return manifest.claims.pointers
393
+ .filter((p) => p.role === `${VOCAB_PREFIX}.txn.member` && p.target.kind === "delta")
394
+ .map((p) => (p.target as { deltaRef: { delta: string } }).deltaRef.delta);
395
+ }
396
+
397
+ // Per-property canonical hexes, for change-path diffing (SPEC-4 §5).
398
+ function propHexesOf(h: HView): Map<string, string> {
399
+ const out = new Map<string, string>();
400
+ for (const [prop, entries] of h.props) {
401
+ out.set(prop, bytesToHex(encode(array(entries.map(hvEntryToCbor)))));
402
+ }
403
+ return out;
404
+ }
405
+
406
+ function diffProps(before: Map<string, string>, after: Map<string, string>): string[] {
407
+ const changed = new Set<string>();
408
+ for (const [prop, hex] of after) if (before.get(prop) !== hex) changed.add(prop);
409
+ for (const prop of before.keys()) if (!after.has(prop)) changed.add(prop);
410
+ return [...changed].sort();
411
+ }
412
+
413
+ // Collect every nested (expanded) HView id, recursively — the support-entity set (V5).
414
+ function collectNestedIds(h: HView, out: Set<string>): void {
415
+ for (const entries of h.props.values()) {
416
+ for (const e of entries) {
417
+ if (e.expanded === undefined) continue;
418
+ for (const nested of e.expanded.values()) {
419
+ out.add(nested.id);
420
+ collectNestedIds(nested, out);
421
+ }
422
+ }
423
+ }
424
+ }
425
+
426
+ // Does this predicate conjunctively REQUIRE a pointer at $root? (V5 anchoring analyzer)
427
+ function predRequiresRoot(pred: Pred): boolean {
428
+ switch (pred.kind) {
429
+ case "hasPointer":
430
+ return pred.ppred.targetEntity?.kind === "root";
431
+ case "and":
432
+ return predRequiresRoot(pred.left) || predRequiresRoot(pred.right);
433
+ case "or":
434
+ return predRequiresRoot(pred.left) && predRequiresRoot(pred.right);
435
+ default:
436
+ return false;
437
+ }
438
+ }
439
+
440
+ // Does every group in this pipeline sit above a root-requiring select?
441
+ function pipelineAnchored(t: Term): boolean {
442
+ switch (t.kind) {
443
+ case "input":
444
+ return false;
445
+ case "select":
446
+ return predRequiresRoot(t.pred) || pipelineAnchored(t.of);
447
+ case "mask":
448
+ return pipelineAnchored(t.of);
449
+ case "union":
450
+ return pipelineAnchored(t.left) && pipelineAnchored(t.right);
451
+ default:
452
+ return false;
453
+ }
454
+ }
455
+
456
+ function termAnchored(t: Term): boolean {
457
+ switch (t.kind) {
458
+ case "group":
459
+ return pipelineAnchored(t.of);
460
+ case "prune":
461
+ case "expand":
462
+ case "resolve":
463
+ return termAnchored(t.of);
464
+ case "fix":
465
+ return true; // anchoring of the referenced schema is checked via the registry walk below
466
+ default:
467
+ return false;
468
+ }
469
+ }
470
+
471
+ // Root anchoring across the term and every transitively referenced schema body (V5).
472
+ export function isRootAnchored(term: Term, registry: SchemaRegistry | undefined): boolean {
473
+ if (!termAnchored(term)) return false;
474
+ const seen = new Set<string>();
475
+ const queue = [...collectRefs(term)];
476
+ while (queue.length > 0) {
477
+ const ref = queue.pop()!;
478
+ const key = ref.kind === "name" ? `n:${ref.name}` : `h:${ref.hash}`;
479
+ if (seen.has(key)) continue;
480
+ seen.add(key);
481
+ const schema = registry?.resolve(ref);
482
+ if (schema === undefined) return false; // unresolvable: be conservative, dispatch broadly
483
+ if (!termAnchored(schema.body)) return false;
484
+ queue.push(...collectRefs(schema.body));
485
+ }
486
+ return true;
487
+ }
488
+
489
+ // Re-export for tests that need a ValMatch-shaped probe without re-deriving it.
490
+ export type { ValMatch };
@@ -0,0 +1,100 @@
1
+ // Schemas as deltas (SPEC-3 §5, ERRATA-3 S1-S3): the at-rest, federated form of a schema, and the
2
+ // rdb.SchemaSchema bootstrap — the one hand-specified schema, through which all others are read.
3
+
4
+ import { decode } from "./cbor.js";
5
+ import type { Term } from "./eval.js";
6
+ import { evalTerm } from "./eval.js";
7
+ import { hexToBytes } from "@noble/hashes/utils";
8
+ import type { HyperSchema } from "./schema.js";
9
+ import { DeltaSet } from "./set.js";
10
+ import { cborToJson, termCanonicalHex } from "./term-io.js";
11
+ import { parseTerm } from "./term-json.js";
12
+ import type { Claims } from "./types.js";
13
+ import { VOCAB_PREFIX } from "./vocab.js";
14
+
15
+ export { VOCAB_PREFIX } from "./vocab.js";
16
+
17
+ const ROLE_DEFINES = `${VOCAB_PREFIX}.schema.defines`;
18
+ const ROLE_NAME = `${VOCAB_PREFIX}.schema.name`;
19
+ const ROLE_ALG = `${VOCAB_PREFIX}.schema.alg`;
20
+ const ROLE_TERM = `${VOCAB_PREFIX}.schema.term`;
21
+
22
+ // The bootstrap (S2): the canonical idiom, hand-specified. Everything else is read using it.
23
+ export const SCHEMA_SCHEMA: HyperSchema = {
24
+ name: `${VOCAB_PREFIX}.SchemaSchema`,
25
+ alg: 1,
26
+ body: parseTerm({
27
+ op: "group",
28
+ key: "byTargetContext",
29
+ in: {
30
+ op: "select",
31
+ pred: { hasPointer: { targetEntity: { var: "root" } } },
32
+ // mask BEFORE select (ERRATA-3 S5): negations target deltas, not the root, so a
33
+ // select-first idiom would exclude them before mask could suppress anything.
34
+ in: { op: "mask", policy: "drop", in: "input" },
35
+ },
36
+ }),
37
+ };
38
+
39
+ // Publish a schema definition as claims (S1). The caller signs/timestamps as any other authorship.
40
+ export function publishSchemaClaims(
41
+ schema: HyperSchema,
42
+ schemaEntity: string,
43
+ author: string,
44
+ timestamp: number,
45
+ ): Claims {
46
+ return {
47
+ timestamp,
48
+ author,
49
+ pointers: [
50
+ {
51
+ role: ROLE_DEFINES,
52
+ target: { kind: "entity", entity: { id: schemaEntity, context: "definition" } },
53
+ },
54
+ { role: ROLE_NAME, target: { kind: "primitive", value: schema.name } },
55
+ { role: ROLE_ALG, target: { kind: "primitive", value: schema.alg } },
56
+ { role: ROLE_TERM, target: { kind: "primitive", value: termCanonicalHex(schema.body) } },
57
+ ],
58
+ };
59
+ }
60
+
61
+ function primitiveOf(claims: Claims, role: string): string | number | undefined {
62
+ for (const p of claims.pointers) {
63
+ if (p.role === role && p.target.kind === "primitive" && typeof p.target.value !== "boolean") {
64
+ return p.target.value;
65
+ }
66
+ }
67
+ return undefined;
68
+ }
69
+
70
+ // Load a schema definition from the rhizome (S3): evaluate the bootstrap at the schema entity,
71
+ // take the latest surviving definition (claimed timestamp, lexById tiebreak — a policy choice),
72
+ // decode the term, and verify canonicality by re-encoding.
73
+ export function loadSchema(dset: DeltaSet, schemaEntity: string): HyperSchema {
74
+ const result = evalTerm(SCHEMA_SCHEMA.body, dset, schemaEntity);
75
+ if (result.sort !== "hview") throw new Error("bootstrap body must yield an HView");
76
+ const defs = result.hview.props.get("definition") ?? [];
77
+ if (defs.length === 0) throw new Error(`no surviving schema definition for ${schemaEntity}`);
78
+ const latest = [...defs].sort((a, b) => {
79
+ const dt = b.delta.claims.timestamp - a.delta.claims.timestamp;
80
+ if (dt !== 0) return dt;
81
+ return a.delta.id < b.delta.id ? -1 : 1;
82
+ })[0]!;
83
+ const name = primitiveOf(latest.delta.claims, ROLE_NAME);
84
+ const alg = primitiveOf(latest.delta.claims, ROLE_ALG);
85
+ const termHex = primitiveOf(latest.delta.claims, ROLE_TERM);
86
+ if (typeof name !== "string" || typeof alg !== "number" || typeof termHex !== "string") {
87
+ throw new Error(`malformed schema definition delta ${latest.delta.id}`);
88
+ }
89
+ const bytes = hexToBytes(termHex);
90
+ const term: Term = parseTerm(cborToJson(decode(bytes)));
91
+ // Reject non-canonical blobs: the term must re-encode to exactly the published bytes (S3).
92
+ if (termCanonicalHex(term) !== termHex) {
93
+ throw new Error(`schema definition ${latest.delta.id} carries a non-canonical term blob`);
94
+ }
95
+ return { name, alg, body: term };
96
+ }
97
+
98
+ export function definitionRoles(): { defines: string; name: string; alg: string; term: string } {
99
+ return { defines: ROLE_DEFINES, name: ROLE_NAME, alg: ROLE_ALG, term: ROLE_TERM };
100
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,111 @@
1
+ // HyperSchemas and the schema registry (SPEC-3 §2-3 §6, ERRATA-2 E10/E13). The registry indexes
2
+ // schemas by name AND by term hash; pinned refs resolve by hash and are immutable by construction.
3
+
4
+ import type { SchemaRefT, Term } from "./eval.js";
5
+ import { termHash } from "./term-io.js";
6
+
7
+ export interface HyperSchema {
8
+ readonly name: string;
9
+ readonly alg: number; // L2 algebra version
10
+ readonly body: Term; // an HView-sort term, a function of the ambient root
11
+ }
12
+
13
+ // refs are derived from the body — every expand/fix schema reference (E10).
14
+ export function collectRefs(term: Term): SchemaRefT[] {
15
+ const out: SchemaRefT[] = [];
16
+ const walk = (t: Term): void => {
17
+ switch (t.kind) {
18
+ case "input":
19
+ return;
20
+ case "select":
21
+ case "mask":
22
+ case "group":
23
+ case "prune":
24
+ case "resolve":
25
+ walk(t.of);
26
+ return;
27
+ case "union":
28
+ walk(t.left);
29
+ walk(t.right);
30
+ return;
31
+ case "expand":
32
+ out.push(t.schema);
33
+ walk(t.of);
34
+ return;
35
+ case "fix":
36
+ out.push(t.schema);
37
+ return;
38
+ }
39
+ };
40
+ walk(term);
41
+ return out;
42
+ }
43
+
44
+ export class SchemaRegistry {
45
+ private constructor(
46
+ private readonly byName: ReadonlyMap<string, HyperSchema>,
47
+ private readonly byHash: ReadonlyMap<string, HyperSchema>,
48
+ ) {}
49
+
50
+ // Rejects duplicate names, unresolved refs, and reference cycles (SPEC-3 §3).
51
+ // Data cycles remain legal — the DAG constraint is on programs, not data.
52
+ static build(schemas: readonly HyperSchema[]): SchemaRegistry {
53
+ const byName = new Map<string, HyperSchema>();
54
+ const byHash = new Map<string, HyperSchema>();
55
+ const hashOf = new Map<string, string>(); // name -> term hash
56
+ for (const s of schemas) {
57
+ if (byName.has(s.name)) throw new Error(`duplicate schema name: ${s.name}`);
58
+ byName.set(s.name, s);
59
+ const h = termHash(s.body);
60
+ hashOf.set(s.name, h);
61
+ // Two names MAY share a body hash; first registration wins the hash index.
62
+ if (!byHash.has(h)) byHash.set(h, s);
63
+ }
64
+ const resolveName = (ref: SchemaRefT, from: string): string => {
65
+ if (ref.kind === "name") {
66
+ const s = byName.get(ref.name);
67
+ if (s === undefined)
68
+ throw new Error(`schema ${from} references unknown schema ${ref.name}`);
69
+ return s.name;
70
+ }
71
+ const s = byHash.get(ref.hash);
72
+ if (s === undefined) {
73
+ throw new Error(`schema ${from} references unknown pinned schema ${ref.hash} (E13)`);
74
+ }
75
+ return s.name;
76
+ };
77
+ const refs = new Map<string, string[]>();
78
+ for (const s of schemas) {
79
+ refs.set(
80
+ s.name,
81
+ collectRefs(s.body).map((r) => resolveName(r, s.name)),
82
+ );
83
+ }
84
+ // DFS cycle detection over the resolved reference graph.
85
+ const state = new Map<string, "visiting" | "done">();
86
+ const visit = (name: string, path: string[]): void => {
87
+ const st = state.get(name);
88
+ if (st === "done") return;
89
+ if (st === "visiting") {
90
+ throw new Error(`schema reference cycle: ${[...path, name].join(" -> ")} (SPEC-3 §3)`);
91
+ }
92
+ state.set(name, "visiting");
93
+ for (const r of refs.get(name) ?? []) visit(r, [...path, name]);
94
+ state.set(name, "done");
95
+ };
96
+ for (const s of schemas) visit(s.name, []);
97
+ return new SchemaRegistry(byName, byHash);
98
+ }
99
+
100
+ get(name: string): HyperSchema | undefined {
101
+ return this.byName.get(name);
102
+ }
103
+
104
+ getByHash(hash: string): HyperSchema | undefined {
105
+ return this.byHash.get(hash);
106
+ }
107
+
108
+ resolve(ref: SchemaRefT): HyperSchema | undefined {
109
+ return ref.kind === "name" ? this.byName.get(ref.name) : this.byHash.get(ref.hash);
110
+ }
111
+ }