@cruba/crubal 0.1.0 → 0.2.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 (86) hide show
  1. package/README.md +30 -2
  2. package/dist/bin/crubal.js +70 -4
  3. package/dist/bin/crubal.js.map +1 -1
  4. package/dist/src/access.d.ts +126 -0
  5. package/dist/src/access.js +517 -0
  6. package/dist/src/access.js.map +1 -0
  7. package/dist/src/arrival.d.ts +55 -0
  8. package/dist/src/arrival.js +124 -0
  9. package/dist/src/arrival.js.map +1 -0
  10. package/dist/src/bundle.d.ts +66 -0
  11. package/dist/src/bundle.js +753 -0
  12. package/dist/src/bundle.js.map +1 -0
  13. package/dist/src/conditions.d.ts +7 -1
  14. package/dist/src/conditions.js +52 -11
  15. package/dist/src/conditions.js.map +1 -1
  16. package/dist/src/corpus.js +568 -34
  17. package/dist/src/corpus.js.map +1 -1
  18. package/dist/src/defs.d.ts +37 -2
  19. package/dist/src/defs.js +95 -14
  20. package/dist/src/defs.js.map +1 -1
  21. package/dist/src/dictionary.d.ts +15 -0
  22. package/dist/src/dictionary.js +72 -13
  23. package/dist/src/dictionary.js.map +1 -1
  24. package/dist/src/exchange.d.ts +202 -11
  25. package/dist/src/exchange.js +759 -45
  26. package/dist/src/exchange.js.map +1 -1
  27. package/dist/src/forms.d.ts +16 -0
  28. package/dist/src/forms.js +169 -0
  29. package/dist/src/forms.js.map +1 -0
  30. package/dist/src/index.d.ts +18 -6
  31. package/dist/src/index.js +17 -5
  32. package/dist/src/index.js.map +1 -1
  33. package/dist/src/lifecycle.d.ts +84 -0
  34. package/dist/src/lifecycle.js +415 -0
  35. package/dist/src/lifecycle.js.map +1 -0
  36. package/dist/src/log.d.ts +78 -0
  37. package/dist/src/log.js +506 -0
  38. package/dist/src/log.js.map +1 -0
  39. package/dist/src/migrate.d.ts +24 -1
  40. package/dist/src/migrate.js +104 -9
  41. package/dist/src/migrate.js.map +1 -1
  42. package/dist/src/model.d.ts +33 -1
  43. package/dist/src/model.js +158 -2
  44. package/dist/src/model.js.map +1 -1
  45. package/dist/src/packages.d.ts +44 -0
  46. package/dist/src/packages.js +137 -3
  47. package/dist/src/packages.js.map +1 -1
  48. package/dist/src/print-text.js +22 -0
  49. package/dist/src/print-text.js.map +1 -1
  50. package/dist/src/publish.d.ts +27 -0
  51. package/dist/src/publish.js +275 -0
  52. package/dist/src/publish.js.map +1 -0
  53. package/dist/src/read-text.d.ts +2 -0
  54. package/dist/src/read-text.js +72 -4
  55. package/dist/src/read-text.js.map +1 -1
  56. package/dist/src/records.d.ts +31 -0
  57. package/dist/src/records.js +72 -0
  58. package/dist/src/records.js.map +1 -0
  59. package/dist/src/review.d.ts +22 -0
  60. package/dist/src/review.js +198 -0
  61. package/dist/src/review.js.map +1 -0
  62. package/dist/src/rules.d.ts +34 -2
  63. package/dist/src/rules.js +147 -22
  64. package/dist/src/rules.js.map +1 -1
  65. package/dist/src/schedule.d.ts +24 -0
  66. package/dist/src/schedule.js +126 -0
  67. package/dist/src/schedule.js.map +1 -0
  68. package/dist/src/std-packages.d.ts +9 -0
  69. package/dist/src/std-packages.js +268 -0
  70. package/dist/src/std-packages.js.map +1 -0
  71. package/dist/src/store.d.ts +38 -7
  72. package/dist/src/store.js +166 -24
  73. package/dist/src/store.js.map +1 -1
  74. package/dist/src/tree.d.ts +3 -0
  75. package/dist/src/tree.js +36 -0
  76. package/dist/src/tree.js.map +1 -1
  77. package/dist/src/units-table.d.ts +1 -0
  78. package/dist/src/units-table.js +200 -0
  79. package/dist/src/units-table.js.map +1 -0
  80. package/dist/src/units.d.ts +42 -4
  81. package/dist/src/units.js +91 -30
  82. package/dist/src/units.js.map +1 -1
  83. package/dist/src/views.d.ts +108 -6
  84. package/dist/src/views.js +389 -64
  85. package/dist/src/views.js.map +1 -1
  86. package/package.json +5 -3
@@ -5,17 +5,26 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
5
5
  import { join } from "node:path";
6
6
  import { canonical } from "./canonical.js";
7
7
  import { emptyDictionary, readDefinitions, splitCaseInput } from "./defs.js";
8
- import { land, matchParts, rawOf, readContracts, receive, replay, reviewByKey, validate, verifyChain } from "./store.js";
9
- import { checkRecipe, readRecipes, restamp } from "./migrate.js";
8
+ import { land, matchParts, proposeSplit, rawOf, readContracts, receive, replay, reviewByKey, validate, verifyChain } from "./store.js";
9
+ import { checkRecipe, dryRun, readRecipes, restamp } from "./migrate.js";
10
10
  import { checkContracts, Exchange, readRoles, verifyBorder } from "./exchange.js";
11
11
  import { checkPackages } from "./packages.js";
12
12
  import { checkViews, readViews, Views } from "./views.js";
13
+ import { formOf } from "./forms.js";
14
+ import { Journal, rebuild, redactable, redactableWrite, stateJson } from "./log.js";
15
+ import { decide as decideReview } from "./review.js";
16
+ import { resetUnits } from "./units.js";
17
+ import { arrive, decide } from "./arrival.js";
18
+ import { adopt, apply as applyInstructions, archive, attach, clone, detach, emptyStore, giveBack, issue, merge, split, unarchive } from "./lifecycle.js";
19
+ import { Access, checkPermissions, readAccess } from "./access.js";
20
+ import { readPiece } from "./lex.js";
13
21
  import { readUnder } from "./read-text.js";
14
22
  import { readTree } from "./tree.js";
23
+ import { MemoryRecords } from "./records.js";
15
24
  import { checkRules, Engine, readRules } from "./rules.js";
16
25
  import { checkPackage, checkRecord, facetJson, foldRecord, resolve, resolvedJson } from "./dictionary.js";
17
26
  import { serialize } from "./canonical.js";
18
- import { checkWrapper } from "./model.js";
27
+ import { checkWrapper, parseJson } from "./model.js";
19
28
  import { printText } from "./print-text.js";
20
29
  import { readText } from "./read-text.js";
21
30
  import { CrubalError } from "./tree.js";
@@ -26,11 +35,11 @@ const LEVEL_FOLDERS = {
26
35
  store: ["reader", "reader-errors", "dictionary", "dictionary-errors", "rules", "rules-errors", "store", "store-errors"],
27
36
  exchange: [
28
37
  "reader", "reader-errors", "dictionary", "dictionary-errors", "rules", "rules-errors", "store", "store-errors",
29
- "exchange", "exchange-errors", "package-errors",
38
+ "exchange", "exchange-errors", "package-errors", "forms",
30
39
  ],
31
40
  views: [
32
41
  "reader", "reader-errors", "dictionary", "dictionary-errors", "rules", "rules-errors", "store", "store-errors",
33
- "exchange", "exchange-errors", "package-errors", "views", "views-errors",
42
+ "exchange", "exchange-errors", "package-errors", "forms", "views", "views-errors",
34
43
  ],
35
44
  };
36
45
  /** The shared definitions a folder's cases run under: the reader package, then the folder's own files. */
@@ -49,7 +58,7 @@ function loadDictionary(root, folder) {
49
58
  return dict;
50
59
  }
51
60
  function copy(d) {
52
- return { facets: new Map(d.facets), classes: new Map(d.classes), levels: new Map(d.levels), vary: new Map(d.vary), trust: [...d.trust], facetTrust: new Map(d.facetTrust) };
61
+ return { facets: new Map(d.facets), classes: new Map(d.classes), kinds: new Map(d.kinds), levels: new Map(d.levels), vary: new Map(d.vary), varyLines: new Map(d.varyLines), trust: [...d.trust], facetTrust: new Map(d.facetTrust), identify: new Map(d.identify), match: new Map(d.match), archive: new Map(d.archive) };
53
62
  }
54
63
  /** Each expected entry must match an actual one on every field it states; entry sets must be the same. */
55
64
  function compareOutcomes(actual, expected) {
@@ -89,7 +98,7 @@ function runDictionaryCase(folder, caseDir, name, shared) {
89
98
  }
90
99
  }
91
100
  if (existsSync(longFile)) {
92
- const expected = serialize(JSON.parse(readFileSync(join(caseDir, "expected.json"), "utf8")));
101
+ const expected = serialize(parseJson(readFileSync(join(caseDir, "expected.json"), "utf8")));
93
102
  for (const [label, text] of [["shorthand", input], ["long form", readFileSync(longFile, "utf8")]]) {
94
103
  const d = readDefinitions(text);
95
104
  checkPackage(d);
@@ -106,7 +115,7 @@ function runDictionaryCase(folder, caseDir, name, shared) {
106
115
  checkPackage(dict);
107
116
  const resolveLine = defs.match(/^resolve: (\S+)/m);
108
117
  if (resolveLine) {
109
- const expected = JSON.parse(readFileSync(join(caseDir, "expected.json"), "utf8"));
118
+ const expected = parseJson(readFileSync(join(caseDir, "expected.json"), "utf8"));
110
119
  const got = serialize(resolvedJson(resolve(dict, [resolveLine[1]])));
111
120
  return got === serialize(expected.resolved) ? { folder, name, pass: true } : { folder, name, pass: false, detail: `got ${got}` };
112
121
  }
@@ -121,8 +130,8 @@ function runDictionaryCase(folder, caseDir, name, shared) {
121
130
  const problem = compareOutcomes(actual, JSON.parse(readFileSync(outcomesFile, "utf8")));
122
131
  return problem ? { folder, name, pass: false, detail: problem } : { folder, name, pass: true };
123
132
  }
124
- const expected = JSON.parse(readFileSync(join(caseDir, "expected.json"), "utf8"));
125
- checkWrapper(expected);
133
+ const expected = parseJson(readFileSync(join(caseDir, "expected.json"), "utf8"));
134
+ checkWrapper(expected, dict);
126
135
  const want = canonical(expected, dict);
127
136
  const got = canonical(w, dict);
128
137
  if (got !== want)
@@ -161,8 +170,8 @@ function runCase(folder, dir, name, dict) {
161
170
  }
162
171
  }
163
172
  try {
164
- const expected = JSON.parse(readFileSync(join(caseDir, "expected.json"), "utf8"));
165
- checkWrapper(expected);
173
+ const expected = parseJson(readFileSync(join(caseDir, "expected.json"), "utf8"));
174
+ checkWrapper(expected, dict);
166
175
  const want = canonical(expected, dict);
167
176
  const got = readText(input, dict);
168
177
  const gotC = canonical(got, dict);
@@ -225,6 +234,9 @@ function runRulesCase(folder, caseDir, name, shared, sharedRules) {
225
234
  checkPackage(dict);
226
235
  checkRules(rules, dict);
227
236
  const engine = new Engine(dict, rules);
237
+ const nowFile = join(caseDir, "now.txt");
238
+ if (existsSync(nowFile))
239
+ engine.now = readFileSync(nowFile, "utf8").trim();
228
240
  const altFile = join(caseDir, "alternatives.json");
229
241
  const alts = existsSync(altFile) ? JSON.parse(readFileSync(altFile, "utf8")) : {};
230
242
  const last = new Map();
@@ -256,6 +268,7 @@ function subset(actual, expected, label = "") {
256
268
  return null;
257
269
  }
258
270
  function runStoreCase(folder, caseDir, name, shared) {
271
+ resetUnits();
259
272
  const file = (f) => join(caseDir, f);
260
273
  const json = (f) => JSON.parse(readFileSync(file(f), "utf8"));
261
274
  const pass = { folder, name, pass: true };
@@ -275,6 +288,31 @@ function runStoreCase(folder, caseDir, name, shared) {
275
288
  return e.line === want ? pass : fail(`refused at line ${e.line} (${e.message}), expected line ${want}`);
276
289
  }
277
290
  }
291
+ // Redaction (8.5.2): per entry, whether it may go whole, else which of its writes may, and why not.
292
+ if (existsSync(file("expected-redact.json"))) {
293
+ const entries = json("log.json");
294
+ const got = {};
295
+ for (const e of entries) {
296
+ const whole = redactable(e);
297
+ const writes = Array.isArray(e.writes) ? e.writes.filter((w) => typeof w === "object" && w !== null && !Array.isArray(w)) : [];
298
+ got[String(e.log)] = whole === null ? "redactable" : e.witnessed === true ? whole : writes.map((w) => redactableWrite(e, w) ?? "redactable");
299
+ }
300
+ const want = json("expected-redact.json");
301
+ for (const [k, v] of Object.entries(want))
302
+ if (serialize((got[k] ?? "∅")) !== serialize(v))
303
+ return fail(`${k}: got ${serialize((got[k] ?? "∅"))}`);
304
+ return pass;
305
+ }
306
+ // Rebuild (8.5.3): the log alone gives back the state.
307
+ if (existsSync(file("expected-state.json"))) {
308
+ const got = stateJson(rebuild(json("log.json")));
309
+ const want = json("expected-state.json");
310
+ for (const [k, v] of Object.entries(want)) {
311
+ if (serialize((got[k] ?? "∅")) !== serialize(v))
312
+ return fail(`${k}: ${firstDiff(serialize((got[k] ?? "∅")), serialize(v))}`);
313
+ }
314
+ return pass;
315
+ }
278
316
  if (existsSync(file("log.json"))) {
279
317
  const keys = existsSync(file("keys.json")) ? json("keys.json") : undefined;
280
318
  const got = verifyChain(json("log.json"), keys);
@@ -292,11 +330,210 @@ function runStoreCase(folder, caseDir, name, shared) {
292
330
  readDefinitions(defs, dict);
293
331
  const records = readText(data, dict).records.map((r) => foldRecord(r, dict));
294
332
  const one = (r) => canonical({ crubal: "2", records: [r] }, dict);
333
+ // Arrival (9.2a): a sequence of payloads through one contract, each finding or making its record.
334
+ if (existsSync(file("answers.json"))) {
335
+ const text = readFileSync(file("contract.crubal"), "utf8");
336
+ readDefinitions(text, dict);
337
+ const [contract] = readContracts(text);
338
+ if (!contract)
339
+ return fail("no contract");
340
+ // Every arrival is an operation on the journal's draft, logged as entries (8.5).
341
+ const journal = new Journal("test.example", records, "2026-10-15T00:00:00Z");
342
+ const store = journal.draft;
343
+ if (existsSync(file("bindings.json")))
344
+ journal.commit({ migration: "backfill" }, (d) => { for (const [k, v] of Object.entries(json("bindings.json")))
345
+ d.bindings.set(k, String(v)); }, "2026-10-15T00:00:01Z");
346
+ const answersFile = json("answers.json");
347
+ const level = isPlainObject(answersFile) ? String(answersFile.level) : "product";
348
+ const answers = (isPlainObject(answersFile) ? answersFile.answers : answersFile);
349
+ let n = 0;
350
+ const mint = () => `${level.charAt(0).toUpperCase()}-NEW${++n}`;
351
+ const owner = records[0]?.owner ?? "test.example";
352
+ const got = [];
353
+ let held = null;
354
+ answers.forEach((a, i) => {
355
+ const at = `2026-10-15T00:01:${String(i).padStart(2, "0")}Z`;
356
+ if (isPlainObject(a) && isPlainObject(a.decide)) {
357
+ if (!held)
358
+ throw new Error("a decision with nothing held");
359
+ const choice = a.decide.new === true ? { new: true } : { record: String(a.decide.record) };
360
+ const h = held;
361
+ journal.commit({ person: "reviewer" }, (d) => {
362
+ got.push(decide(contract, h, choice, d, dict, { mint, owner, level }).arrival);
363
+ }, at);
364
+ held = null;
365
+ return;
366
+ }
367
+ journal.commit({ contract: contract.name, run: `R-${i + 1}` }, (d) => {
368
+ const r = arrive(contract, JSON.stringify(a), level, d, dict, { mint, owner, intakeId: `I-${i + 1}` });
369
+ if (r.arrival.outcome === "held")
370
+ held = { intake: r.intake, ...(r.arrival.ref !== undefined ? { ref: r.arrival.ref } : {}) };
371
+ got.push(r.arrival);
372
+ }, at);
373
+ });
374
+ const want = json("expected-arrival.json");
375
+ if (got.length !== want.length)
376
+ return fail(`${got.length} arrivals, expected ${want.length}: ${serialize(got)}`);
377
+ for (let i = 0; i < want.length; i++) {
378
+ const p = subset(got[i], want[i], `arrival ${i + 1}: `);
379
+ if (p)
380
+ return fail(p);
381
+ }
382
+ if (existsSync(file("expected.json"))) {
383
+ const want2 = json("expected.json");
384
+ for (const w of want2.records) {
385
+ const r = store.records.get(w.id);
386
+ if (!r)
387
+ return fail(`no record ${w.id}`);
388
+ if (one(r) !== one(w))
389
+ return fail(`${w.id}: ${firstDiff(one(r), one(w))}`);
390
+ }
391
+ }
392
+ return pass;
393
+ }
394
+ // Lifecycle (1.7, 1.12) and instructions (10.7): operations in order, then the records.
395
+ if (existsSync(file("ops.crubal"))) {
396
+ // Every operation runs on the journal's draft and is logged; a change the entries don't describe
397
+ // is refused by the journal itself (8.5).
398
+ const journal = new Journal("test.example", records, "2026-10-15T00:00:00Z");
399
+ const store = journal.draft;
400
+ if (existsSync(file("bindings.json")))
401
+ journal.commit({ migration: "backfill" }, (d) => { for (const [k, v] of Object.entries(json("bindings.json")))
402
+ d.bindings.set(k, String(v)); }, "2026-10-15T00:00:01Z");
403
+ const caseRules = readRules(defs);
404
+ const decisionDefs = { dict, contracts: new Map(readContracts(defs).map((c) => [c.name, c])) };
405
+ let adoptFlag;
406
+ let n = 0;
407
+ for (const op of readTree(readFileSync(file("ops.crubal"), "utf8"))) {
408
+ if (op.kind !== "pair" || !op.key)
409
+ continue;
410
+ const piece = readPiece(op.value, op.line);
411
+ const detail = (k) => piece.detail.find(([n]) => n === k)?.[1].text;
412
+ const [a = "", b] = piece.text.split(" -> ").map((s) => s.trim());
413
+ const key = op.key;
414
+ let problem = null;
415
+ const extra = [];
416
+ journal.commit({ person: "tester" }, () => {
417
+ if (key === "archive")
418
+ archive(store, a, detail("reason") ?? "archived", dict, detail("into"));
419
+ else if (key === "unarchive")
420
+ unarchive(store, a, dict);
421
+ else if (key === "merge" && b)
422
+ merge(store, a, b, dict);
423
+ else if (key === "attach" && b)
424
+ attach(store, a, b, dict, detail("edit") !== "no");
425
+ else if (key === "detach")
426
+ detach(store, a, dict);
427
+ else if (key === "instruct")
428
+ issue(store, piece.text, op.children);
429
+ else if (key === "apply")
430
+ applyInstructions(store, a, dict);
431
+ else if (key === "adopt" && b)
432
+ adoptFlag = adopt(store, a, b, caseRules).flagged ?? adoptFlag;
433
+ else if (key === "give-back" && b)
434
+ giveBack(store, a, b);
435
+ else if (key === "clone" && b)
436
+ clone(store, a, b, detail("owner") ?? "test.example");
437
+ else if (key === "split" && b) {
438
+ const list = (k) => (op.children.find((c) => c.key === k)?.value ?? "").split(",").map((x) => x.trim()).filter(Boolean);
439
+ const vals = {};
440
+ for (const c of op.children.find((c) => c.key === "values")?.children ?? [])
441
+ if (c.key)
442
+ vals[c.key] = dict.facets.get(c.key) ? (readUnder(dict.facets.get(c.key), c.value, dict) ?? c.value) : c.value;
443
+ split(store, a, b, { children: list("children"), values: vals, bindings: list("bindings") });
444
+ }
445
+ else if (key === "suggest") {
446
+ // Test scaffolding: a suggestion on a record, as a gated or lands: suggestion run would leave it.
447
+ const r = store.records.get(a);
448
+ const address = op.children.find((c) => c.key === "address")?.value ?? "";
449
+ const f = dict.facets.get(address);
450
+ const raw = op.children.find((c) => c.key === "value")?.value ?? "";
451
+ const value = f ? (readUnder(f, raw, dict) ?? raw) : raw;
452
+ const item = { value, origin: op.children.find((c) => c.key === "origin")?.value ?? "reported", via: op.children.find((c) => c.key === "via")?.value ?? "L-S" };
453
+ store.records.set(a, { ...r, suggestions: { ...(r.suggestions ?? {}), [address]: [...(r.suggestions?.[address] ?? []), item] } });
454
+ }
455
+ else if (key === "hold") {
456
+ // Test scaffolding: a value held by a rule, with its source kept (6.7).
457
+ const r = store.records.get(a);
458
+ const address = op.children.find((c) => c.key === "address")?.value ?? "";
459
+ const v = r.values[address];
460
+ if (v === undefined) {
461
+ problem = `${a} has nothing at ${address} to hold`;
462
+ return;
463
+ }
464
+ const { [address]: _v, ...values } = r.values;
465
+ const src = (r.sources?.[address] ?? r.sources?.["*"]);
466
+ store.records.set(a, { ...r, values, held: { ...(r.held ?? {}), [address]: { value: v, rule: op.children.find((c) => c.key === "rule")?.value ?? "rule", ...(src ? { source: src } : {}) } } });
467
+ }
468
+ else if (key === "decide") {
469
+ // A review decision (9.5): the item's key on the line, the decision's fields below it.
470
+ const d = { key: piece.text, decision: "" };
471
+ for (const c of op.children) {
472
+ if (!c.key)
473
+ continue;
474
+ if (c.key === "instances" || c.key === "accepts")
475
+ d[c.key] = c.value.split(",").map((x) => x.trim()).filter(Boolean);
476
+ else if (c.key === "value" && typeof d.address === "string" && dict.facets.get(d.address))
477
+ d.value = readUnder(dict.facets.get(d.address), c.value, dict) ?? c.value;
478
+ else
479
+ d[c.key] = c.key === "factor" || c.key === "offset" ? c.value : (dict.facets.get(c.key) ? (readUnder(dict.facets.get(c.key), c.value, dict) ?? c.value) : c.value);
480
+ }
481
+ extra.push(...decideReview(d, store, decisionDefs));
482
+ }
483
+ else if (key === "write") {
484
+ const r = store.records.get(a);
485
+ if (!r) {
486
+ problem = `write to an unknown record ${a}`;
487
+ return;
488
+ }
489
+ const values = { ...r.values };
490
+ for (const c of op.children) {
491
+ const f = c.key ? dict.facets.get(c.key) : undefined;
492
+ if (c.key)
493
+ values[c.key] = f ? (readUnder(f, c.value, dict) ?? c.value) : c.value;
494
+ }
495
+ store.records.set(a, { ...r, values });
496
+ }
497
+ else
498
+ problem = `unknown op ${key}`;
499
+ }, `2026-10-15T00:01:${String(++n).padStart(2, "0")}Z`, extra);
500
+ if (problem)
501
+ return fail(problem);
502
+ }
503
+ if (existsSync(file("expected-flag.json"))) {
504
+ const want = json("expected-flag.json");
505
+ if (want.adopt !== (adoptFlag ?? null))
506
+ return fail(`adopt flagged ${adoptFlag ?? "nothing"}, expected ${serialize(want.adopt)}`);
507
+ }
508
+ if (existsSync(file("expected-entries.json"))) {
509
+ const got = journal.entries.map(({ at: _at, owner: _o, ...e }) => e);
510
+ const wantE = json("expected-entries.json");
511
+ if (serialize(got) !== serialize(wantE))
512
+ return fail(`entries: ${firstDiff(serialize(got), serialize(wantE))}`);
513
+ }
514
+ const want = json("expected.json");
515
+ for (const w of want.records) {
516
+ const r = store.records.get(w.id);
517
+ if (!r)
518
+ return fail(`no record ${w.id}`);
519
+ if (one(r) !== one(w))
520
+ return fail(`${w.id}: ${firstDiff(one(r), one(w))}`);
521
+ }
522
+ if (want.review !== undefined && serialize(store.review) !== serialize(want.review))
523
+ return fail(`review: got ${serialize(store.review)}`);
524
+ if (want.bindings !== undefined && serialize(Object.fromEntries(store.bindings)) !== serialize(want.bindings))
525
+ return fail(`bindings: got ${serialize(Object.fromEntries(store.bindings))}`);
526
+ return pass;
527
+ }
295
528
  if (existsSync(file("recipe.crubal"))) {
296
529
  const [recipe] = readRecipes(readFileSync(file("recipe.crubal"), "utf8"));
297
530
  if (!recipe)
298
531
  return fail("no recipe");
299
532
  checkRecipe(recipe, dict);
533
+ if (existsSync(file("expected-dry-run.json"))) {
534
+ const got = dryRun(records, recipe, dict);
535
+ return serialize(got) === serialize(json("expected-dry-run.json")) ? pass : fail(`dry run: ${serialize(got)}`);
536
+ }
300
537
  const { records: out, review } = restamp(records, recipe, dict);
301
538
  const want = json("expected.json");
302
539
  const got = { ...JSON.parse(canonical({ crubal: "2", package: recipe.to, records: out }, dict)), review };
@@ -374,6 +611,10 @@ function runStoreCase(folder, caseDir, name, shared) {
374
611
  const want = json("expected.json");
375
612
  return serialize(got) === serialize(want) ? pass : fail(`got ${serialize(got)}`);
376
613
  }
614
+ if (existsSync(file("observations.json")) && existsSync(file("expected-proposal.json"))) {
615
+ const got = proposeSplit(records, json("observations.json"));
616
+ return serialize(got) === serialize(json("expected-proposal.json")) ? pass : fail(`proposal: ${serialize(got)}`);
617
+ }
377
618
  if (existsSync(file("observations.json"))) {
378
619
  const want = json("expected-validation.json");
379
620
  const rec = records.find((r) => r.id === want.record);
@@ -405,8 +646,34 @@ function runExchangeCase(folder, caseDir, name, shared, root) {
405
646
  if (folder === "exchange-errors" || folder === "package-errors") {
406
647
  const want = Number(readFileSync(file("error.txt"), "utf8").trim().split(/\s/)[0]);
407
648
  try {
408
- if (folder === "package-errors")
649
+ if (folder === "package-errors") {
409
650
  checkPackages(input);
651
+ checkPackage(readDefinitions(input));
652
+ }
653
+ else if (/^(edge|permissions|ownership): /m.test(input)) {
654
+ // Access (14.12, 14.13): the declarations, every record's owner, and every edge, in order.
655
+ const { defs, data } = splitCaseInput(input);
656
+ const d = copy(shared);
657
+ readDefinitions(defs, d);
658
+ const { perms, edges } = readAccess(defs);
659
+ checkPermissions(perms, d, new Set(readContracts(defs).map((c) => c.name)));
660
+ const recs = data.trim() ? readText(data, d).records.map((r) => foldRecord(r, d)) : [];
661
+ const access = new Access(d, perms, new MemoryRecords(recs.map((r) => [r.id, r])), [], "test.example");
662
+ // A record's refusal names the line its header was written at.
663
+ const lineOf = (id) => {
664
+ const i = input.split("\n").findIndex((l) => new RegExp(`^\\w+: ${id}(\\s|$)`).test(l));
665
+ return i < 0 ? undefined : i + 1;
666
+ };
667
+ access.checkWithin(lineOf);
668
+ for (const r of recs) {
669
+ access.checkOwnership(r, lineOf(r.id));
670
+ access.checkWears(r, lineOf(r.id));
671
+ }
672
+ for (const e of edges) {
673
+ access.checkEdge(e);
674
+ access.edges.set(e.edge, e);
675
+ }
676
+ }
410
677
  else {
411
678
  const d = copy(shared);
412
679
  readDefinitions(input, d);
@@ -431,24 +698,145 @@ function runExchangeCase(folder, caseDir, name, shared, root) {
431
698
  if (defs.trim())
432
699
  readDefinitions(defs, dict);
433
700
  checkPackage(dict);
701
+ // Access (chapter 14): edges over the records, then questions.
702
+ if (existsSync(file("ask.crubal"))) {
703
+ const accessText = readFileSync(file("access.crubal"), "utf8");
704
+ readDefinitions(accessText, dict);
705
+ const { perms, edges } = readAccess(accessText);
706
+ checkPermissions(perms, dict, new Set(readContracts(accessText).map((c) => c.name)));
707
+ const recs = readText(data, dict).records.map((r) => foldRecord(r, dict));
708
+ const now = existsSync(file("now.txt")) ? readFileSync(file("now.txt"), "utf8").trim() : undefined;
709
+ const access = new Access(dict, perms, new MemoryRecords(recs.map((r) => [r.id, r])), edges, "test.example", now);
710
+ access.checkWithin();
711
+ const got = [];
712
+ for (const q of readTree(readFileSync(file("ask.crubal"), "utf8"))) {
713
+ if (q.kind !== "pair" || !q.key)
714
+ continue;
715
+ const words = q.value.trim().split(/\s+/);
716
+ const ask = `${q.key}: ${q.value.trim()}`;
717
+ if (q.key === "may") {
718
+ // may: <caller> <verb> <target> on <record>
719
+ const [caller = "", verb = "", target = "", , record = ""] = words;
720
+ got.push({ ask, result: access.may(caller, verb, target, record) });
721
+ }
722
+ else if (q.key === "mode") {
723
+ const [caller = "", , record = ""] = words;
724
+ got.push({ ask, result: access.effective(caller, record).mode });
725
+ }
726
+ else if (q.key === "stands") {
727
+ // stands: <caller> <partition…> on <record>
728
+ const on = words.indexOf("on");
729
+ got.push({ ask, result: access.standsIn(words[0] ?? "", words[on + 1] ?? "", words.slice(1, on).join(" ")) });
730
+ }
731
+ else if (q.key === "gated") {
732
+ const [caller = "", , record = ""] = words;
733
+ got.push({ ask, result: access.gated(caller, record)?.edge ?? false });
734
+ }
735
+ else if (q.key === "ancestors") {
736
+ got.push({ ask, result: access.ancestors(words[0] ?? "") });
737
+ }
738
+ else if (q.key === "audience") {
739
+ got.push({ ask, result: access.audienceOf(words[0] ?? "") });
740
+ }
741
+ else
742
+ return fail(`unknown ask ${q.key}`);
743
+ }
744
+ const want = json("expected-access.json");
745
+ if (got.length !== want.length)
746
+ return fail(`${got.length} answers, expected ${want.length}`);
747
+ for (let i = 0; i < want.length; i++) {
748
+ const p = subset(got[i], want[i], `${want[i].ask}: `);
749
+ if (p)
750
+ return fail(p);
751
+ }
752
+ return pass;
753
+ }
434
754
  const shared_ = readFileSync(join(root, "exchange", "_contracts.crubal"), "utf8");
435
755
  const contracts = [...readContracts(shared_), ...readContracts(defs)];
436
- checkContracts(contracts, dict);
437
- const ex = new Exchange(dict, contracts, readRoles(defs));
438
- for (const r of readText(data, dict).records.map((x) => foldRecord(x, dict)))
756
+ // A case that defines views lets its stream contracts (12.6.2) carry their projection streams.
757
+ const ownViews = readViews(defs);
758
+ checkContracts(contracts, dict, "", ownViews.views.size ? new Set(ownViews.views.keys()) : undefined);
759
+ const ex = new Exchange(dict, contracts, readRoles(defs), readRules(defs));
760
+ if (ownViews.views.size) {
761
+ checkViews(ownViews, dict);
762
+ ex.views = new Views(dict, ownViews);
763
+ }
764
+ for (const r of readText(data, dict).records.map((x) => foldRecord(x, dict))) {
439
765
  ex.put(r);
766
+ ex.views?.put(r);
767
+ }
768
+ // With access.crubal, the border consults access (chapter 14): permission to run, gates, consumer edges.
769
+ if (existsSync(file("access.crubal"))) {
770
+ const accessText = readFileSync(file("access.crubal"), "utf8");
771
+ const { perms, edges } = readAccess(accessText);
772
+ const now = existsSync(file("now.txt")) ? readFileSync(file("now.txt"), "utf8").trim() : undefined;
773
+ ex.access = new Access(dict, perms, ex.records, edges, "test.example", now);
774
+ }
775
+ // Every run is a transaction (8.5): the border works on a journal's draft and commits each run.
776
+ const journal = new Journal("test.example", [], "2026-01-01T00:00:00Z");
777
+ ex.useJournal(journal);
778
+ journal.commit({ migration: "backfill" }, () => { }, "2026-01-01T00:00:00Z");
440
779
  // The runs, in order.
441
780
  const results = [];
442
781
  let current = {};
782
+ const parseCursor = (s) => (s ? Object.fromEntries(s.split(",").map((p) => p.trim().split("=")).map(([v, n]) => [v ?? "", Number(n)])) : undefined);
443
783
  const flush = () => {
784
+ // The send split (docs/design/runs.md): prepare, then acted or failed; undo names its cause.
785
+ if (current.prepare && current.on) {
786
+ results.push(ex.prepare(current.prepare, current.on, current.caller));
787
+ current = {};
788
+ return;
789
+ }
790
+ if (current.acted) {
791
+ results.push(ex.acted(current.acted));
792
+ current = {};
793
+ return;
794
+ }
795
+ if (current.failed) {
796
+ results.push(ex.failed(current.failed, current.reason ?? "failed"));
797
+ current = {};
798
+ return;
799
+ }
800
+ if (current.undo) {
801
+ results.push(ex.undo(current.undo, current.caller));
802
+ current = {};
803
+ return;
804
+ }
805
+ if (current.decideRun) {
806
+ results.push(ex.decideRun(current.decideRun, current.decision === "drop" ? "drop" : "retry", current.caller));
807
+ current = {};
808
+ return;
809
+ }
810
+ if (current.tick) {
811
+ results.push(ex.tick(current.tick, current.now ?? "", current.caller));
812
+ current = {};
813
+ return;
814
+ }
815
+ if (current.stream) {
816
+ results.push(ex.streamRun(current.stream, current.caller, parseCursor(current.cursor)));
817
+ current = {};
818
+ return;
819
+ }
820
+ if (current.ack) {
821
+ results.push(ex.ack(current.ack, parseCursor(current.cursor) ?? {}));
822
+ current = {};
823
+ return;
824
+ }
825
+ if (current.arrive) {
826
+ results.push(ex.arriveRun(current.arrive, readFileSync(file(current.answer ?? "answer.json"), "utf8"), current.level ?? "product", current.caller, current.on));
827
+ current = {};
828
+ return;
829
+ }
444
830
  if (!current.run || !current.on)
445
831
  return;
446
832
  const c = contracts.find((x) => x.name === current.run);
447
833
  if (current.offer) {
448
- results.push(ex.take(current.run, current.offer, current.on, json("presenter.json")));
834
+ results.push(ex.take(current.run, current.offer, current.on, json(current.presenter ?? "presenter.json")));
449
835
  }
450
- else if (c?.kind === "receive") {
451
- results.push(ex.receiveRun(current.run, current.on, readFileSync(file("answer.json"), "utf8")));
836
+ else if (c?.kind === "receive" || c?.kind === "form" || current.forRun) {
837
+ // A receive lands its payload; a form is a receive whose source is a person (7.2), its answers the
838
+ // payload. A late answer names its run (7.5): the receive closes it and lands with its via.
839
+ results.push(ex.receiveRun(current.run, current.on, readFileSync(file(current.answer ?? "answer.json"), "utf8"), current.caller, current.forRun));
452
840
  }
453
841
  else {
454
842
  results.push(ex.run(current.run, current.on, current.caller));
@@ -460,12 +848,66 @@ function runExchangeCase(folder, caseDir, name, shared, root) {
460
848
  flush();
461
849
  current.run = n.value;
462
850
  }
851
+ else if (n.key === "arrive") {
852
+ flush();
853
+ current.arrive = n.value;
854
+ }
855
+ else if (n.key === "stream") {
856
+ flush();
857
+ current.stream = n.value;
858
+ }
859
+ else if (n.key === "ack") {
860
+ flush();
861
+ current.ack = n.value;
862
+ }
863
+ else if (n.key === "prepare" || n.key === "acted" || n.key === "failed" || n.key === "undo") {
864
+ flush();
865
+ current[n.key] = n.value;
866
+ }
867
+ else if (n.key === "decide-run") {
868
+ flush();
869
+ current.decideRun = n.value;
870
+ }
871
+ else if (n.key === "decision")
872
+ current.decision = n.value;
873
+ else if (n.key === "reason")
874
+ current.reason = n.value;
875
+ else if (n.key === "for-run")
876
+ current.forRun = n.value;
877
+ else if (n.key === "cursor")
878
+ current.cursor = n.value;
879
+ else if (n.key === "tick") {
880
+ flush();
881
+ current.tick = n.value;
882
+ }
883
+ else if (n.key === "now")
884
+ current.now = n.value;
885
+ else if (n.key === "revise") {
886
+ flush();
887
+ const [view = "", version = ""] = n.value.split(" /");
888
+ ex.views?.revise(view.trim(), version.trim());
889
+ }
463
890
  else if (n.key === "on")
464
891
  current.on = n.value;
892
+ else if (n.key === "level")
893
+ current.level = n.value;
894
+ else if (n.key === "answer")
895
+ current.answer = n.value;
465
896
  else if (n.key === "caller")
466
897
  current.caller = n.value;
467
898
  else if (n.key === "offer")
468
899
  current.offer = n.value;
900
+ else if (n.key === "presenter")
901
+ current.presenter = n.value;
902
+ else if (n.key === "give") {
903
+ // The owner's own offer (7.8), as its host would serve it.
904
+ flush();
905
+ results.push(ex.offer(n.value, "test.example"));
906
+ }
907
+ else if (n.key === "expire") {
908
+ flush();
909
+ results.push(ex.expire(n.value));
910
+ }
469
911
  else if (n.key === "write") {
470
912
  flush();
471
913
  const r = ex.get(n.value);
@@ -478,6 +920,7 @@ function runExchangeCase(folder, caseDir, name, shared, root) {
478
920
  values[c.key] = f ? (readUnder(f, c.value, dict) ?? c.value) : c.value;
479
921
  }
480
922
  ex.put({ ...r, values });
923
+ ex.views?.put({ ...r, values });
481
924
  }
482
925
  }
483
926
  flush();
@@ -493,6 +936,24 @@ function runExchangeCase(folder, caseDir, name, shared, root) {
493
936
  return fail(`alternatives/suggestions: got ${extra(got)}`);
494
937
  return pass;
495
938
  }
939
+ if (existsSync(file("expected-entries.json"))) {
940
+ const got = journal.entries.map(({ at: _at, owner: _o, ...e }) => e);
941
+ const wantE = json("expected-entries.json");
942
+ if (serialize(got) !== serialize(wantE))
943
+ return fail(`entries: ${firstDiff(serialize(got), serialize(wantE))}`);
944
+ }
945
+ if (existsSync(file("expected-edges.json"))) {
946
+ const got = [...(ex.access?.edges.values() ?? [])].map(({ line: _l, ...e }) => e);
947
+ const want = json("expected-edges.json");
948
+ for (const w of want) {
949
+ const a = got.find((e) => e.edge === w.edge);
950
+ if (!a)
951
+ return fail(`no edge ${w.edge}`);
952
+ const p = subset(a, w, `${w.edge}: `);
953
+ if (p)
954
+ return fail(p);
955
+ }
956
+ }
496
957
  const wantRaw = json("expected-run.json");
497
958
  const want = (Array.isArray(wantRaw) ? wantRaw : [wantRaw]);
498
959
  if (results.length !== want.length)
@@ -593,7 +1054,7 @@ function runViewsCase(folder, caseDir, name, shared, root) {
593
1054
  checkPackage(dict);
594
1055
  const set = readViews(sharedViews);
595
1056
  const own = readViews(defs);
596
- for (const k of ["shapes", "filters", "views", "roles", "shown"])
1057
+ for (const k of ["shapes", "filters", "views", "roles", "shown", "shownFor"])
597
1058
  for (const [key, val] of own[k])
598
1059
  set[k].set(key, val);
599
1060
  checkViews(set, dict);
@@ -602,10 +1063,25 @@ function runViewsCase(folder, caseDir, name, shared, root) {
602
1063
  const records = readText(text, dict).records.map((r) => foldRecord(r, dict));
603
1064
  for (const r of records)
604
1065
  views.put(r);
605
- const byId = new Map(records.map((r) => [r.id, r]));
1066
+ const byId = new MemoryRecords(records.map((r) => [r.id, r]));
606
1067
  const viewName = run.view;
607
- const opts = { picks: run.picks, ...(run.context ? { context: byId.get(run.context) } : {}), ...(run.page ? { page: run.page } : {}) };
1068
+ // Partitioned views (12.6): the case's edges decide which rows a caller is served.
1069
+ let access;
1070
+ if (existsSync(file("access.crubal"))) {
1071
+ const { perms, edges } = readAccess(readFileSync(file("access.crubal"), "utf8"));
1072
+ access = new Access(dict, perms, byId, edges, "test.example");
1073
+ }
1074
+ const opts = { picks: run.picks, ...(run.context ? { context: byId.get(run.context) } : {}), ...(run.page ? { page: run.page } : {}), ...(access ? { access } : {}) };
608
1075
  const same = (got, want, label) => (serialize(got) === serialize(want) ? pass : fail(`${label}: got ${serialize(got)}`));
1076
+ if (existsSync(file("expected-stream.json"))) {
1077
+ const first = views.stream(viewName, opts);
1078
+ for (const w of readText(readFileSync(file("write.crubal"), "utf8"), dict).records) {
1079
+ const was = byId.get(w.id);
1080
+ views.put(was ? { ...was, values: { ...was.values, ...w.values } } : foldRecord(w, dict));
1081
+ }
1082
+ const second = views.stream(viewName, opts);
1083
+ return same([first, second], json("expected-stream.json"), "stream");
1084
+ }
609
1085
  if (existsSync(file("expected-stale.json"))) {
610
1086
  views.project(viewName, opts);
611
1087
  const stale = new Set();
@@ -626,7 +1102,9 @@ function runViewsCase(folder, caseDir, name, shared, root) {
626
1102
  }
627
1103
  const p = views.project(viewName, opts);
628
1104
  if (existsSync(file("expected-counts.json")))
629
- return same({ records: p.records, counts: p.counts, indexable: p.indexable }, json("expected-counts.json"), "counts");
1105
+ return same({ records: p.records, counts: p.counts, ...(p.nested ? { nested: p.nested } : {}), indexable: p.indexable }, json("expected-counts.json"), "counts");
1106
+ if (existsSync(file("expected-jsonld.json")))
1107
+ return same(p.rows, json("expected-jsonld.json"), "jsonld");
630
1108
  if (existsSync(file("expected-pages.json")))
631
1109
  return same({ pages: p.pages, records: p.records, indexable: p.indexable }, json("expected-pages.json"), "pages");
632
1110
  const wantFile = existsSync(file("expected-portable.json")) ? "expected-portable.json" : "expected-projection.json";
@@ -637,6 +1115,56 @@ function runViewsCase(folder, caseDir, name, shared, root) {
637
1115
  }
638
1116
  }
639
1117
  const isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
1118
+ /**
1119
+ * Forms as data (7.7, docs/design/index-and-forms.md): `form.crubal` names the contract, the record and
1120
+ * optionally the caller (several `form:` blocks give several forms); `access.crubal` makes editability
1121
+ * follow edges and gates; `arrival.json` is a held arrival whose candidates the form offers;
1122
+ * `expected-form.json` is the form (or the forms) exactly.
1123
+ */
1124
+ function runFormsCase(folder, caseDir, name, shared) {
1125
+ const file = (f) => join(caseDir, f);
1126
+ const json = (f) => JSON.parse(readFileSync(file(f), "utf8"));
1127
+ const pass = { folder, name, pass: true };
1128
+ const fail = (detail) => ({ folder, name, pass: false, detail });
1129
+ try {
1130
+ const input = readFileSync(file("input.crubal"), "utf8");
1131
+ const { defs, data } = splitCaseInput(input);
1132
+ const dict = copy(shared);
1133
+ if (defs.trim())
1134
+ readDefinitions(defs, dict);
1135
+ const contracts = readContracts(defs);
1136
+ const records = data.trim() ? readText(data, dict).records.map((r) => foldRecord(r, dict)) : [];
1137
+ const byId = new MemoryRecords(records.map((r) => [r.id, r]));
1138
+ let access;
1139
+ if (existsSync(file("access.crubal"))) {
1140
+ const { perms, edges } = readAccess(readFileSync(file("access.crubal"), "utf8"));
1141
+ access = new Access(dict, perms, byId, edges, "test.example");
1142
+ }
1143
+ const arrival = existsSync(file("arrival.json")) ? json("arrival.json") : undefined;
1144
+ // form.crubal reads like run.crubal: `form:` starts one, `record:` and `caller:` belong to it.
1145
+ const asks = [];
1146
+ for (const n of readTree(readFileSync(file("form.crubal"), "utf8"))) {
1147
+ if (n.key === "form")
1148
+ asks.push({ form: n.value.trim() });
1149
+ else if ((n.key === "record" || n.key === "caller") && asks.length)
1150
+ asks[asks.length - 1][n.key] = n.value.trim();
1151
+ }
1152
+ const forms = [];
1153
+ for (const ask of asks) {
1154
+ const c = contracts.find((x) => x.name === ask.form);
1155
+ if (!c)
1156
+ return fail(`no contract ${ask.form}`);
1157
+ const rec = ask.record ? byId.get(ask.record) : undefined;
1158
+ forms.push(formOf(c, rec, dict, { records: byId, ...(ask.caller ? { caller: ask.caller } : {}), ...(access ? { access } : {}), ...(arrival ? { arrival } : {}) }));
1159
+ }
1160
+ const want = json("expected-form.json");
1161
+ const got = Array.isArray(want) ? forms : (forms[0] ?? {});
1162
+ return serialize(got) === serialize(want) ? pass : fail(`form: ${firstDiff(serialize(got), serialize(want))}`);
1163
+ }
1164
+ catch (e) {
1165
+ return fail(e instanceof Error ? e.message : String(e));
1166
+ }
1167
+ }
640
1168
  /** Run one level's corpus folders under `root`. */
641
1169
  export function runCorpus(root, level) {
642
1170
  const folders = LEVEL_FOLDERS[level];
@@ -653,17 +1181,23 @@ export function runCorpus(root, level) {
653
1181
  continue;
654
1182
  if (!["input.crubal", "log.json", "border.json"].some((f) => existsSync(join(dir, name, f))))
655
1183
  continue;
656
- const r = folder.startsWith("views")
657
- ? runViewsCase(folder, join(dir, name), name, dict, root)
658
- : folder.startsWith("exchange") || folder === "package-errors"
659
- ? runExchangeCase(folder, join(dir, name), name, dict, root)
660
- : folder.startsWith("store")
661
- ? runStoreCase(folder, join(dir, name), name, dict)
662
- : folder.startsWith("rules")
663
- ? runRulesCase(folder, join(dir, name), name, dict, sharedRules)
664
- : folder.startsWith("dictionary")
665
- ? runDictionaryCase(folder, join(dir, name), name, dict)
666
- : runCase(folder, dir, name, dict);
1184
+ if (folder === "store" && existsSync(join(dir, name, "expected-redact.json"))) {
1185
+ results.push(runStoreCase(folder, join(dir, name), name, dict));
1186
+ continue;
1187
+ }
1188
+ const r = folder === "forms"
1189
+ ? runFormsCase(folder, join(dir, name), name, dict)
1190
+ : folder.startsWith("views")
1191
+ ? runViewsCase(folder, join(dir, name), name, dict, root)
1192
+ : folder.startsWith("exchange") || folder === "package-errors"
1193
+ ? runExchangeCase(folder, join(dir, name), name, dict, root)
1194
+ : folder.startsWith("store")
1195
+ ? runStoreCase(folder, join(dir, name), name, dict)
1196
+ : folder.startsWith("rules")
1197
+ ? runRulesCase(folder, join(dir, name), name, dict, sharedRules)
1198
+ : folder.startsWith("dictionary")
1199
+ ? runDictionaryCase(folder, join(dir, name), name, dict)
1200
+ : runCase(folder, dir, name, dict);
667
1201
  if (r)
668
1202
  results.push(r);
669
1203
  }