@cruba/crubal 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 (89) 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/address.d.ts +29 -0
  8. package/dist/src/address.js +59 -0
  9. package/dist/src/address.js.map +1 -0
  10. package/dist/src/arrival.d.ts +55 -0
  11. package/dist/src/arrival.js +124 -0
  12. package/dist/src/arrival.js.map +1 -0
  13. package/dist/src/bundle.d.ts +66 -0
  14. package/dist/src/bundle.js +753 -0
  15. package/dist/src/bundle.js.map +1 -0
  16. package/dist/src/conditions.d.ts +7 -1
  17. package/dist/src/conditions.js +52 -11
  18. package/dist/src/conditions.js.map +1 -1
  19. package/dist/src/corpus.js +568 -34
  20. package/dist/src/corpus.js.map +1 -1
  21. package/dist/src/defs.d.ts +37 -2
  22. package/dist/src/defs.js +95 -14
  23. package/dist/src/defs.js.map +1 -1
  24. package/dist/src/dictionary.d.ts +15 -0
  25. package/dist/src/dictionary.js +72 -13
  26. package/dist/src/dictionary.js.map +1 -1
  27. package/dist/src/exchange.d.ts +204 -11
  28. package/dist/src/exchange.js +762 -45
  29. package/dist/src/exchange.js.map +1 -1
  30. package/dist/src/forms.d.ts +16 -0
  31. package/dist/src/forms.js +169 -0
  32. package/dist/src/forms.js.map +1 -0
  33. package/dist/src/index.d.ts +19 -6
  34. package/dist/src/index.js +18 -5
  35. package/dist/src/index.js.map +1 -1
  36. package/dist/src/lifecycle.d.ts +84 -0
  37. package/dist/src/lifecycle.js +415 -0
  38. package/dist/src/lifecycle.js.map +1 -0
  39. package/dist/src/log.d.ts +78 -0
  40. package/dist/src/log.js +506 -0
  41. package/dist/src/log.js.map +1 -0
  42. package/dist/src/migrate.d.ts +24 -1
  43. package/dist/src/migrate.js +104 -9
  44. package/dist/src/migrate.js.map +1 -1
  45. package/dist/src/model.d.ts +33 -1
  46. package/dist/src/model.js +158 -2
  47. package/dist/src/model.js.map +1 -1
  48. package/dist/src/packages.d.ts +44 -0
  49. package/dist/src/packages.js +137 -3
  50. package/dist/src/packages.js.map +1 -1
  51. package/dist/src/print-text.js +22 -0
  52. package/dist/src/print-text.js.map +1 -1
  53. package/dist/src/publish.d.ts +27 -0
  54. package/dist/src/publish.js +275 -0
  55. package/dist/src/publish.js.map +1 -0
  56. package/dist/src/read-text.d.ts +2 -0
  57. package/dist/src/read-text.js +72 -4
  58. package/dist/src/read-text.js.map +1 -1
  59. package/dist/src/records.d.ts +32 -0
  60. package/dist/src/records.js +104 -0
  61. package/dist/src/records.js.map +1 -0
  62. package/dist/src/review.d.ts +22 -0
  63. package/dist/src/review.js +198 -0
  64. package/dist/src/review.js.map +1 -0
  65. package/dist/src/rules.d.ts +38 -3
  66. package/dist/src/rules.js +184 -30
  67. package/dist/src/rules.js.map +1 -1
  68. package/dist/src/schedule.d.ts +24 -0
  69. package/dist/src/schedule.js +126 -0
  70. package/dist/src/schedule.js.map +1 -0
  71. package/dist/src/std-packages.d.ts +9 -0
  72. package/dist/src/std-packages.js +268 -0
  73. package/dist/src/std-packages.js.map +1 -0
  74. package/dist/src/store.d.ts +38 -7
  75. package/dist/src/store.js +166 -24
  76. package/dist/src/store.js.map +1 -1
  77. package/dist/src/tree.d.ts +3 -0
  78. package/dist/src/tree.js +36 -0
  79. package/dist/src/tree.js.map +1 -1
  80. package/dist/src/units-table.d.ts +1 -0
  81. package/dist/src/units-table.js +200 -0
  82. package/dist/src/units-table.js.map +1 -0
  83. package/dist/src/units.d.ts +42 -4
  84. package/dist/src/units.js +91 -30
  85. package/dist/src/units.js.map +1 -1
  86. package/dist/src/views.d.ts +116 -6
  87. package/dist/src/views.js +484 -74
  88. package/dist/src/views.js.map +1 -1
  89. package/package.json +5 -3
@@ -2,9 +2,13 @@
2
2
  // (7.10) and run in seven steps (7.4): trigger and permission, needs, map out, act, map back, rules,
3
3
  // record. Runs are idempotent (7.5). Offer and take share between owners, and only the overlap flows
4
4
  // (7.8). Data crossing a border is verified against the presenter's published key (8.5.1).
5
+ import { arrive } from "./arrival.js";
5
6
  import { serialize } from "./canonical.js";
6
7
  import { evaluate, parseCondition } from "./conditions.js";
7
- import { fieldPath, land, receive, sha256, verifyChain } from "./store.js";
8
+ import { MemoryRecords } from "./records.js";
9
+ import { Engine } from "./rules.js";
10
+ import { isSchedule, readSchedule, ticksDue } from "./schedule.js";
11
+ import { fieldPath, land, receive, sha256, verifyChain, proposeSplit } from "./store.js";
8
12
  import { CrubalError, readTree } from "./tree.js";
9
13
  const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
10
14
  export function readRoles(source) {
@@ -25,16 +29,25 @@ export function readRoles(source) {
25
29
  }
26
30
  return out;
27
31
  }
28
- /** What a caller is, for `who` (7.6): its values, the classes it wears, and its roles' grants. */
29
- function callerStates(caller, roles) {
32
+ /**
33
+ * What a caller is, for `who` (7.6): the classes it wears, its roles' grants, and the menu items it holds
34
+ * on the record by its edges (14.6) when the run has access to consult. A caller with no record of its own
35
+ * here (a hosted person, 14.8) is still what its edges make it.
36
+ */
37
+ function callerStates(caller, roles, held, asked = []) {
30
38
  return (path) => {
31
39
  const s = new Set();
32
- if (path !== "caller" || !caller)
40
+ if (path !== "caller")
41
+ return s;
42
+ if (held)
43
+ for (const q of asked)
44
+ if (held(q.slice(0, q.indexOf(" ")), q.slice(q.indexOf(" ") + 1)))
45
+ s.add(`may:${q}`);
46
+ if (!caller)
33
47
  return s;
34
48
  for (const w of caller.wears ?? [])
35
49
  s.add(`wears:${w}`);
36
- const held = caller.values.roles;
37
- const names = (Array.isArray(held) ? held : held ? [held] : []).map((r) => (isObj(r) && "$link" in r ? String(r.$link) : String(r)));
50
+ const names = (Array.isArray(caller.values.roles) ? caller.values.roles : caller.values.roles ? [caller.values.roles] : []).map((r) => (isObj(r) && "$link" in r ? String(r.$link) : String(r)));
38
51
  for (const r of roles.filter((x) => names.includes(x.name))) {
39
52
  for (const [verb, targets] of Object.entries(r.may))
40
53
  for (const t of targets)
@@ -45,29 +58,49 @@ function callerStates(caller, roles) {
45
58
  }
46
59
  // ─── Publish checks (7.10) ───────────────────────────────────────────────────
47
60
  const PARTS = {
48
- send: ["needs", "gives", "writes", "reads", "when", "who", "auth", "scope", "credential", "retry", "undo", "for", "label", "describe", "version"],
49
- receive: ["needs", "gives", "reads", "when", "who", "origin", "lands", "evidence", "absorb", "ignore", "keep-raw", "prefix", "auth", "scope", "credential", "retry", "undo", "for", "label", "describe", "version"],
50
- store: ["needs", "stores", "when", "who", "for", "label", "describe", "version"],
51
- form: ["needs", "gives", "writes", "reads", "when", "who", "origin", "lands", "for", "label", "describe", "version"],
52
- offer: ["needs", "gives", "to", "mode", "terms", "for", "when", "who", "label", "describe", "version"],
53
- take: ["needs", "from", "reads", "mode", "lands", "origin", "absorb", "ignore", "keep-raw", "prefix", "when", "who", "for", "label", "describe", "version"],
61
+ send: ["needs", "gives", "writes", "reads", "stream", "to", "when", "missed", "who", "audience", "auth", "scope", "credential", "retry", "undo", "for", "label", "describe", "version"],
62
+ receive: ["needs", "gives", "reads", "when", "missed", "who", "audience", "origin", "lands", "evidence", "absorb", "ignore", "keep-raw", "prefix", "ref", "relation", "auth", "scope", "credential", "retry", "undo", "for", "label", "describe", "version"],
63
+ store: ["needs", "stores", "origin", "when", "who", "audience", "for", "label", "describe", "version"],
64
+ form: ["needs", "gives", "writes", "reads", "when", "who", "audience", "origin", "lands", "ref", "relation", "for", "label", "describe", "version"],
65
+ offer: ["needs", "gives", "to", "mode", "terms", "for", "when", "who", "audience", "label", "describe", "version"],
66
+ take: ["needs", "from", "offer", "reads", "mode", "lands", "origin", "absorb", "ignore", "keep-raw", "prefix", "ref", "when", "who", "audience", "for", "label", "describe", "version"],
54
67
  };
68
+ const AUDIENCES = ["public", "member", "staff", "owner"];
69
+ /**
70
+ * The lowest audience that may run a contract (7.2): what it says; else public for one that grants the
71
+ * consumer's edge (a sign-up, a first order: how one becomes a member, 14.4), member for any other form,
72
+ * public otherwise.
73
+ */
74
+ const audienceOf = (c) => c.parts.audience ?? (c.kind === "form" && !c.parts.relation ? "member" : "public");
55
75
  function ourKey(address) {
56
76
  return address.replace(/^\w+::/, "").split(/[.[]/)[0];
57
77
  }
58
- /** Check contracts against a dictionary (7.10). Throws the first problem. */
59
- export function checkContracts(contracts, dict, ownPrefix = "") {
78
+ /** Check contracts against a dictionary (7.10). Throws the first problem. `views` lets stream contracts be checked. */
79
+ export function checkContracts(contracts, dict, ownPrefix = "", views) {
60
80
  const names = new Set(contracts.map((c) => c.name));
61
81
  for (const c of contracts) {
62
82
  const allowed = PARTS[c.kind];
63
83
  if (!allowed)
64
84
  throw new CrubalError(`unknown contract kind ${c.kind}`, c.line);
85
+ if (c.parts.audience && !AUDIENCES.includes(c.parts.audience))
86
+ throw new CrubalError(`${c.name}: audience is public, member, staff or owner, not ${c.parts.audience}`, c.partLines.audience ?? c.line);
65
87
  for (const [part, line] of Object.entries(c.partLines)) {
66
88
  if (part === "kind" || part.includes(":"))
67
89
  continue;
68
90
  if (!allowed.includes(part))
69
91
  throw new CrubalError(`a ${c.kind} contract has no part ${part}`, line);
70
92
  }
93
+ // A stream contract (12.6.2) carries projection entries: nothing is mapped and nothing is needed.
94
+ if (c.stream.length) {
95
+ if (c.writes.length || c.needs.length)
96
+ throw new CrubalError(`${c.name} carries a stream, so it has no writes and no needs`, c.partLines.stream);
97
+ if (!c.parts.to)
98
+ throw new CrubalError(`${c.name} carries a stream, so it names who it goes to`, c.partLines.stream);
99
+ if (views)
100
+ for (const v of c.stream)
101
+ if (!views.has(v))
102
+ throw new CrubalError(`${c.name} streams ${v}, which isn't a view`, c.partLines.stream);
103
+ }
71
104
  for (const l of c.needs)
72
105
  if (!dict.facets.has(l.facet))
73
106
  throw new CrubalError(`${l.facet} is not a defined facet`, l.line);
@@ -93,9 +126,23 @@ export function checkContracts(contracts, dict, ownPrefix = "") {
93
126
  throw new CrubalError(of ? `${v} is an alias of ${of.value}; a value map must name the real value` : `${v} is not a value of ${key}`, s.valueLines[i] ?? s.line);
94
127
  });
95
128
  }
96
- for (const cond of [c.who, c.when])
97
- if (cond && cond !== "asked")
98
- parseCondition(cond, c.line);
129
+ // `when` is a condition, `asked` (a caller starts it), `entry` (a stream contract runs as its views
130
+ // publish) or a schedule (7.11).
131
+ if (c.who)
132
+ parseCondition(c.who, c.line);
133
+ if (c.when && c.when !== "asked" && c.when !== "entry") {
134
+ if (isSchedule(c.when))
135
+ readSchedule(c.when, c.partLines.when);
136
+ else
137
+ parseCondition(c.when, c.line);
138
+ }
139
+ if (c.when === "entry" && !c.stream.length)
140
+ throw new CrubalError(`${c.name} runs on entry but carries no stream`, c.partLines.when);
141
+ const missed = c.parts.missed;
142
+ if (missed && !["once", "all", "skip"].includes(missed))
143
+ throw new CrubalError(`${c.name}: missed is once, all or skip`, c.partLines.missed);
144
+ if (missed && !isSchedule(c.when))
145
+ throw new CrubalError(`${c.name}: missed belongs to a scheduled contract`, c.partLines.missed);
99
146
  if (c.undo && !names.has(c.undo))
100
147
  throw new CrubalError(`${c.name}'s undo names ${c.undo}, which isn't a contract here`, c.partLines.undo);
101
148
  const cred = c.parts.credential;
@@ -137,16 +184,214 @@ export class Exchange {
137
184
  dict;
138
185
  contracts;
139
186
  roles;
140
- records = new Map();
187
+ /** The records, shared with the Access that decides over them (chapter 14). */
188
+ records = new MemoryRecords();
189
+ bindings = new Map();
190
+ /** Set to make the border consult access: `run` needs the caller's `run` item, gated access suggests. */
191
+ access;
192
+ /** The owner whose records these are, when the caller knows it: a store's border names it, so no run looks at a record to learn it. */
193
+ owner;
194
+ /**
195
+ * How an arrival's new record is named (9.2a). A persistent store sets this to mint the next id of the
196
+ * owner (`P-2` after `P-1`), since an Exchange is built per run and its own counter would start again.
197
+ * Without it the memory border's `<L>-NEW<n>` stands.
198
+ */
199
+ mint;
200
+ /** Set to make every run a transaction (8.5): the border works on the journal's draft and commits each run. */
201
+ journal;
202
+ /** Set to let stream contracts (12.6.2) carry the views' projection streams. */
203
+ views;
204
+ /** Per stream contract, the cursor each view's index has acknowledged, with the version it was for. */
205
+ cursors = new Map();
206
+ /** The rules a run's step 6 applies (7.4): the package's, plus the facet shortcuts the engine adds. */
207
+ engine;
208
+ /** Idempotency keys acted on (7.5); a store that outlives the instance seeds and keeps them. */
141
209
  sent = new Set();
210
+ /** Runs so far, which numbers the next run's log; a store continues the count across instances. */
142
211
  runs = 0;
143
- constructor(dict, contracts, roles = []) {
212
+ /** Records minted by arrivals (9.2a), likewise. */
213
+ minted = 0;
214
+ /** The versions a run uses (7.4 step 7, 8.5): the package's, set by a store; the contract's is added per run. */
215
+ versions = {};
216
+ /** The review queue the border writes to (a dead run, an answer naming no run); the journal's draft list. */
217
+ review = [];
218
+ /** Observations this instance made (9.7); a store keeps every one in its state. */
219
+ observations = [];
220
+ observed = 0;
221
+ /** The package's thresholds (11.1): instances to propose a version split, witnesses to confirm. */
222
+ thresholds = {};
223
+ /** Review items already open in the store, for a border that loads them apart from the draft's list. */
224
+ knownReview = [];
225
+ /** Mapped-out payloads kept beside the log, by run (docs/design/runs.md): a store keeps them in outtake. */
226
+ outtake = new Map();
227
+ /** Runs the store knows beyond this instance's entries (a border over a store seeds them): run → contract and record. */
228
+ runsKnown = new Map();
229
+ /** Runs started and not yet acted or failed, by run: what a restarted host asks the adapter about. */
230
+ started = new Map();
231
+ constructor(dict, contracts, roles = [], rules = []) {
144
232
  this.dict = dict;
145
233
  this.contracts = new Map(contracts.map((c) => [c.name, c]));
146
234
  this.roles = roles;
235
+ this.engine = new Engine(dict, rules, () => this.records);
236
+ }
237
+ /**
238
+ * Step 6 of a run (7.4): what came in runs through the rules (chapter 6). A `must` the write breaks
239
+ * refuses it and nothing changes; a hold takes the value out of the current values; a fill or derivation
240
+ * lands with `origin: derived` and the rule as its `via`. Returns the record to keep, or the refusal.
241
+ */
242
+ applyRules(landed, touched) {
243
+ const results = this.engine.write({ ...landed, values: touched });
244
+ const refused = results.find((r) => r.result === "refused");
245
+ if (refused)
246
+ return { refused };
247
+ const after = this.engine.get(landed.id);
248
+ if (!after)
249
+ return { record: landed, summary: {} };
250
+ const values = { ...landed.values };
251
+ const sources = { ...(landed.sources ?? {}) };
252
+ const summary = {};
253
+ const held = { ...(landed.held ?? {}) };
254
+ for (const r of results) {
255
+ if ((r.result === "filled" || r.result === "derived") && r.address && r.value !== undefined && r.rule) {
256
+ values[r.address] = r.value;
257
+ sources[r.address] = { origin: "derived", via: r.rule };
258
+ const bag = (summary[r.result] ?? {});
259
+ bag[r.address] = r.value;
260
+ summary[r.result] = bag;
261
+ }
262
+ if (r.result === "held" && r.address && r.rule) {
263
+ const v = values[r.address];
264
+ if (v !== undefined) {
265
+ const src = sources[r.address];
266
+ held[r.address] = { value: v, rule: r.rule, ...(src ? { source: src } : {}) };
267
+ delete values[r.address];
268
+ delete sources[r.address];
269
+ }
270
+ summary.held = [...(summary.held ?? []), r.address];
271
+ }
272
+ if (r.result === "released" && r.address && held[r.address]) {
273
+ // The held value comes back as it was (6.7).
274
+ const h = held[r.address];
275
+ values[r.address] = h.value;
276
+ if (isObj(h.source))
277
+ sources[r.address] = h.source;
278
+ delete held[r.address];
279
+ summary.released = [...(summary.released ?? []), r.address];
280
+ }
281
+ if (r.result === "flagged" && r.rule)
282
+ summary.flagged = [...(summary.flagged ?? []), r.rule];
283
+ }
284
+ // The held bag is rebuilt from what stayed held: a released key leaves it.
285
+ const { held: _was, ...base } = landed;
286
+ const record = { ...base, values, sources, ...(Object.keys(held).length ? { held } : {}) };
287
+ this.engine.put(record);
288
+ // 6b. What the ancestors derive from below is recomputed now (6.4), nearest first, each landing before
289
+ // the next reads it; the record itself is in the set first, so a rollup over it sees this write.
290
+ if (this.engine.readsLowerLevels) {
291
+ this.records.set(record.id, record);
292
+ const recomputed = this.reproduceAncestors(record.id);
293
+ if (recomputed.length)
294
+ summary.recomputed = recomputed;
295
+ }
296
+ return { record, summary };
297
+ }
298
+ /** Run the ancestors' productions again after a write below them; the ids whose values changed. */
299
+ reproduceAncestors(id) {
300
+ const changed = [];
301
+ for (const upId of this.engine.ancestorsOf(id)) {
302
+ const up = this.records.get(upId);
303
+ if (!up)
304
+ break;
305
+ this.engine.put(up);
306
+ const derived = this.engine.reproduce(upId).filter((r) => (r.result === "derived" || r.result === "filled") && r.address && r.value !== undefined && r.rule);
307
+ if (!derived.length)
308
+ continue;
309
+ const values = { ...up.values };
310
+ const sources = { ...(up.sources ?? {}) };
311
+ let moved = false;
312
+ for (const r of derived) {
313
+ const address = r.address;
314
+ if (serialize(values[address] ?? "∅") === serialize(r.value))
315
+ continue;
316
+ values[address] = r.value;
317
+ sources[address] = { origin: "derived", via: r.rule };
318
+ moved = true;
319
+ }
320
+ if (!moved)
321
+ continue;
322
+ const next = { ...up, values, sources };
323
+ this.records.set(upId, next);
324
+ this.engine.put(next);
325
+ changed.push(upId);
326
+ }
327
+ return changed;
328
+ }
329
+ /** Work on a journal's draft, so every run the border makes is one logged entry (8.5). */
330
+ useJournal(journal) {
331
+ this.journal = journal;
332
+ for (const [id, r] of this.records)
333
+ journal.draft.records.set(id, r);
334
+ for (const [k, v] of this.bindings)
335
+ journal.draft.bindings.set(k, v);
336
+ this.records = journal.draft.records;
337
+ this.bindings = journal.draft.bindings;
338
+ if (this.access) {
339
+ for (const [k, e] of this.access.edges)
340
+ journal.draft.edges.set(k, e);
341
+ this.access.edges = journal.draft.edges;
342
+ }
343
+ this.review = journal.draft.review;
344
+ }
345
+ /** Log a run's transaction: what it changed on the draft, the run's own state, and any observations (8.5). */
346
+ commit(by, log, run, more = []) {
347
+ this.journal?.commit(by, () => { }, `2026-01-01T00:00:${String(this.runs).padStart(2, "0")}Z`, [{ run: log, ...run }, ...more]);
348
+ }
349
+ /** Step 1 of a run (7.4): the menu is the permission (14.6); `who` only narrows. */
350
+ permitted(c, recordId, callerId, arrival = false) {
351
+ // A run needs `run` on the record (14.6); an arrival has no record yet, so it needs `run` anywhere by an
352
+ // admin or control edge, or to be an admitted member taking part (14.4), on the parent when it has one.
353
+ const may = (verb, target) => {
354
+ if (!this.access || !callerId)
355
+ return true;
356
+ if (!arrival || recordId)
357
+ return this.access.may(callerId, verb, target, recordId);
358
+ return this.access.mayAnywhere(callerId, verb, target) || (verb === "run" && (audienceOf(c) === "public" || this.access.audienceOf(callerId) !== "public"));
359
+ };
360
+ if (this.access && callerId && !may("run", c.name))
361
+ return "permission";
362
+ // The contract's audience (7.2, 14.4): consume opens only what the package opens to consumers.
363
+ if (this.access && callerId && AUDIENCES.indexOf(this.access.audienceOf(callerId)) < AUDIENCES.indexOf(audienceOf(c)))
364
+ return "permission";
365
+ if (c.who) {
366
+ const caller = callerId ? this.records.get(callerId) : undefined;
367
+ // `caller may edit Deployment`: held by the caller's edges and the menu on this record (14.6).
368
+ const asked = [...c.who.matchAll(/\bcaller may (\w+) (\S+)/g)].map((m) => `${m[1]} ${m[2]}`);
369
+ const held = this.access && callerId ? (verb, target) => may(verb, target) : undefined;
370
+ const ok = evaluate(parseCondition(c.who), caller ? { ...caller.values } : {}, callerStates(caller, this.roles, held, asked));
371
+ if (ok !== true)
372
+ return "who";
373
+ }
374
+ return null;
375
+ }
376
+ /** The gated edge a caller's access comes through, if every granting edge is gated (14.7). */
377
+ gate(recordId, callerId) {
378
+ return this.access && callerId ? this.access.gated(callerId, recordId) : null;
379
+ }
380
+ /**
381
+ * A contract that creates a record for a caller grants their edge on it (14.4): `mode: consume`, the
382
+ * contract's `relation`, under the owner's grant.
383
+ */
384
+ grantConsumer(c, recordId, callerId) {
385
+ const relation = c.parts.relation;
386
+ if (!this.access || !relation)
387
+ return null;
388
+ const edge = { edge: `E-${c.name}-${recordId}`, who: callerId, relation, mode: "consume", on: recordId, grants: [], by: "owner", line: c.line };
389
+ this.access.edges.set(edge.edge, edge);
390
+ return edge;
147
391
  }
148
392
  put(r) {
149
393
  this.records.set(r.id, r);
394
+ this.engine.put(r);
150
395
  }
151
396
  get(id) {
152
397
  return this.records.get(id);
@@ -168,8 +413,24 @@ export class Exchange {
168
413
  this.runs += 1;
169
414
  return `L-R${this.runs}`;
170
415
  }
171
- /** Steps 1–4 of a send (or form) contract: permission, needs, map out, act once. */
416
+ /**
417
+ * A send (or form) contract's run with no act between (the memory border, docs/design/runs.md): prepare,
418
+ * then acted, in two entries. A host puts its adapter between `prepare` and `acted` or `failed`.
419
+ */
172
420
  run(name, recordId, callerId) {
421
+ const p = this.prepare(name, recordId, callerId);
422
+ if (p.started !== true)
423
+ return p;
424
+ this.acted(String(p.log));
425
+ const { started: _s, ...rest } = p;
426
+ return { ...rest, acted: true };
427
+ }
428
+ /**
429
+ * Steps 1–3 of a send (7.4): permission, needs, map out; then the run is `started`, its idempotency key
430
+ * and the payload's hash logged, the payload itself kept beside the log. A key already started or acted
431
+ * is never sent again (7.5).
432
+ */
433
+ prepare(name, recordId, callerId, cause) {
173
434
  const c = this.contracts.get(name);
174
435
  if (!c)
175
436
  throw new Error(`no contract ${name}`);
@@ -178,13 +439,10 @@ export class Exchange {
178
439
  throw new Error(`no record ${recordId}`);
179
440
  const head = { contract: name, record: recordId, ...(callerId ? { caller: callerId } : {}) };
180
441
  const log = this.nextLog();
181
- // 1. Trigger and permission.
182
- if (c.who) {
183
- const caller = callerId ? this.records.get(callerId) : undefined;
184
- const ok = evaluate(parseCondition(c.who), caller ? { ...caller.values } : {}, callerStates(caller, this.roles));
185
- if (ok !== true)
186
- return { ...head, stopped: "who" };
187
- }
442
+ // 1. Permission and trigger.
443
+ const stopped = this.permitted(c, recordId, callerId);
444
+ if (stopped)
445
+ return { ...head, stopped };
188
446
  // 2. Needs: an unnamed class over the record.
189
447
  const missing = [];
190
448
  const unfit = [];
@@ -215,53 +473,428 @@ export class Exchange {
215
473
  const mapped = typeof v === "string" ? (m.values.find(([ours]) => ours === v)?.[1] ?? v) : v;
216
474
  put(payload, m.to, wire(mapped));
217
475
  }
218
- // 4. Act, at most once per contract, record and payload.
476
+ // 4 begins: at most once per contract, record and payload; a started run is not started again.
219
477
  const key = `${name}|${recordId}|${sha256(serialize(payload))}`;
478
+ const open = [...this.started].find(([, s]) => s.key === key);
479
+ if (open)
480
+ return { ...head, started: false, reason: "a run with this payload is already started", run: open[0] };
220
481
  if (this.sent.has(key))
221
482
  return { ...head, acted: false, reason: "the same payload was already sent" };
222
483
  this.sent.add(key);
223
- return { ...head, acted: true, payload, key, log };
484
+ this.outtake.set(log, payload);
485
+ this.started.set(log, { contract: name, record: recordId, key, attempts: 0, ...(cause ? { cause } : {}) });
486
+ const versions = { ...this.versions, ...(c.parts.version ? { contract: c.parts.version } : {}) };
487
+ this.commit({ contract: name, run: log }, log, { contract: name, record: recordId, key, state: "started", payload: `sha256:${sha256(serialize(payload))}`, ...(Object.keys(versions).length ? { versions } : {}), ...(cause ? { cause } : {}) });
488
+ return { ...head, started: true, payload, key, log, ...(cause ? { cause } : {}) };
489
+ }
490
+ /** The act succeeded: the run is `acted` (docs/design/runs.md). */
491
+ acted(run, at) {
492
+ const s = this.started.get(run);
493
+ if (!s)
494
+ throw new Error(`no started run ${run}`);
495
+ this.started.delete(run);
496
+ this.commit({ contract: s.contract, run }, run, { contract: s.contract, record: s.record, key: s.key, state: "acted", ...(at ? { at } : {}) });
497
+ return { contract: s.contract, record: s.record, run, acted: true };
498
+ }
499
+ /** How many attempts a contract allows (7.5 `retry: N every D`): one without it. */
500
+ attemptsAllowed(c) {
501
+ const m = (c.parts.retry ?? "").match(/^(\d+)\s+every\s+/);
502
+ return m ? 1 + Number(m[1]) : 1;
503
+ }
504
+ /**
505
+ * The act failed: a `failed` entry with the attempt; after the last attempt the contract allows, a
506
+ * review item `run:<contract>` opens, grouped by contract across records, and the run stays failed.
507
+ */
508
+ failed(run, reason) {
509
+ const s = this.started.get(run);
510
+ if (!s)
511
+ throw new Error(`no started run ${run}`);
512
+ const c = this.contracts.get(s.contract);
513
+ s.attempts += 1;
514
+ const allowed = c ? this.attemptsAllowed(c) : 1;
515
+ const dead = s.attempts >= allowed;
516
+ if (dead) {
517
+ this.started.delete(run);
518
+ this.review.push({ key: `run:${s.contract}`, run, record: s.record, reason, attempts: s.attempts });
519
+ }
520
+ this.commit({ contract: s.contract, run }, run, { contract: s.contract, record: s.record, key: s.key, state: "failed", reason, attempt: s.attempts });
521
+ return { contract: s.contract, record: s.record, run, failed: true, attempt: s.attempts, ...(dead ? { dead: true, review: `run:${s.contract}` } : { retry: allowed - s.attempts }) };
522
+ }
523
+ /**
524
+ * A decision on a dead run (runs.md): `retry` releases its key and starts a new run from the record as it
525
+ * now is; `drop` closes the item. Either is a review write.
526
+ */
527
+ decideRun(run, decision, callerId) {
528
+ const w = this.runOf(run);
529
+ if (!w)
530
+ throw new Error(`no run ${run}`);
531
+ const item = this.review.find((it) => it.run === run && it.key === `run:${w.contract}` && it.decision === undefined);
532
+ const key = [...this.sent].find((k) => k.startsWith(`${w.contract}|${w.record}|`));
533
+ if (decision === "retry" && key)
534
+ this.sent.delete(key);
535
+ this.review.push({ ...(item ?? { key: `run:${w.contract}`, run, record: w.record }), decision, ...(decision === "retry" && key ? { releases: key } : {}), ...(callerId ? { by: callerId } : {}) });
536
+ if (decision === "drop")
537
+ return { contract: w.contract, run, decision };
538
+ return { ...this.prepare(w.contract, w.record, callerId), decision, retried: run };
539
+ }
540
+ /** Run the contract that reverses a run (7.5 `undo`): an ordinary send naming its cause. */
541
+ /** The contract and record of a run this instance or its store knows. */
542
+ runOf(run) {
543
+ const known = this.runsKnown.get(run);
544
+ if (known)
545
+ return known;
546
+ for (const x of this.journal?.entries ?? []) {
547
+ const w = Array.isArray(x.writes) ? x.writes.find((y) => isObj(y) && y.run === run) : undefined;
548
+ if (w)
549
+ return { contract: String(w.contract), record: String(w.record ?? "") };
550
+ }
551
+ return undefined;
552
+ }
553
+ undo(run, callerId) {
554
+ const w = this.runOf(run);
555
+ const contract = w ? this.contracts.get(w.contract) : undefined;
556
+ if (!w || !contract)
557
+ throw new Error(`no run ${run}`);
558
+ if (!contract.undo)
559
+ return { contract: contract.name, run, stopped: "undo", reason: `${contract.name} names no undo` };
560
+ return this.prepare(contract.undo, w.record, callerId, run);
224
561
  }
225
562
  needsOf(c) {
226
563
  return c.needs;
227
564
  }
228
- /** Steps 5–6 for a receive contract: map the answer back, compare with the record, and land it. */
229
- receiveRun(name, recordId, body) {
565
+ /**
566
+ * Steps 5–6 for a receive contract: map the answer back, compare with the record, and land it. Under a
567
+ * gated edge (14.7) the values land as suggestions for the edge's reviewer, whatever the contract says.
568
+ */
569
+ receiveRun(name, recordId, body, callerId, forRun) {
230
570
  const c = this.contracts.get(name);
231
571
  const r = this.records.get(recordId);
232
572
  if (!c || !r)
233
573
  throw new Error(`no contract ${name} or record ${recordId}`);
234
- const log = this.nextLog();
574
+ const head = { contract: name, record: recordId, ...(callerId ? { caller: callerId } : {}) };
575
+ // An answer that names a run the store doesn't know is never guessed onto a record (docs/design/runs.md).
576
+ if (forRun !== undefined) {
577
+ const known = this.runOf(forRun)?.contract === name;
578
+ if (!known) {
579
+ this.review.push({ key: `run:${name}`, run: forRun, record: recordId, reason: "an answer named a run the store doesn't know" });
580
+ const l = this.nextLog();
581
+ this.commit({ contract: name, run: l }, l, { contract: name, record: recordId, state: "closed", answerFor: forRun });
582
+ return { ...head, stopped: "run", reason: `no run ${forRun}`, log: l };
583
+ }
584
+ }
585
+ const stopped = this.permitted(c, recordId, callerId);
586
+ if (stopped)
587
+ return { ...head, stopped };
588
+ const log = forRun ?? this.nextLog();
235
589
  const intake = receive(c, body, recordId, this.dict, `I-${log.slice(2)}`);
236
590
  const outcomes = intake.outcomes.map((o) => {
237
591
  if (o.outcome !== "absorbed" || !o.to)
238
592
  return o;
593
+ // A value a must rule won't have is a contradiction citing the rule (9.3): parked, never landed.
594
+ const broken = this.engine.probe({ ...r, values: { [o.to]: o.value } });
595
+ if (broken)
596
+ return { ...o, outcome: "contradiction", cites: { rule: broken.rule ?? "" } };
239
597
  const current = r.values[o.to];
240
598
  if (current === undefined || (isObj(current) && "$empty" in current))
241
599
  return o;
242
- return serialize(current) === serialize(o.value) ? o : { ...o, outcome: "conflict" };
600
+ if (serialize(current) === serialize(o.value))
601
+ return o;
602
+ // A state facet moves (4.2, 9.3): a later value of equal or higher trust is absorbed, not disputed.
603
+ // Equal trust is the common case here (a form of the same origin); the landing decides the rest.
604
+ if (this.dict.facets.get(o.to)?.transitions && (c.origin === "declared" || callerId))
605
+ return o;
606
+ return { ...o, outcome: "conflict" };
243
607
  });
244
608
  const source = { origin: c.origin, via: log };
245
- if (c.lands === "suggestion")
609
+ const gate = this.gate(recordId, callerId);
610
+ let summary = {};
611
+ if (c.lands === "observation") {
612
+ // Evidence (9.7): each reading is an observation on this instance, supporting the record that stores
613
+ // the value; the first observation fills an empty value with origin observed.
614
+ const docs = [];
615
+ let filled;
616
+ for (const o of outcomes) {
617
+ if (!o.to || o.value === undefined || !["absorbed", "conflict", "new"].includes(o.outcome))
618
+ continue;
619
+ const up = r.of ? this.records.get(r.of) : undefined;
620
+ const supports = up && r.values[o.to] === undefined && (up.values[o.to] !== undefined || !this.dict.vary.get(r.level)?.has(o.to)) ? up.id : r.id;
621
+ const doc = { obs: `O-${++this.observed}`, on: recordId, address: o.to, value: o.value, "evidence-type": c.parts.evidence ?? "reading", reading: intake.intake, supports };
622
+ docs.push(doc);
623
+ const target = filled ?? (supports === r.id ? r : up);
624
+ const cur = target.values[o.to];
625
+ if (cur === undefined || (isObj(cur) && "$empty" in cur)) {
626
+ filled = { ...target, values: { ...target.values, [o.to]: o.value }, sources: { ...(target.sources ?? {}), [o.to]: { origin: "observed", via: log } } };
627
+ }
628
+ }
629
+ this.observed = Math.max(this.observed, ...this.observations.map((o) => Number(String(o.obs).replace(/^O-/, "")) || 0));
630
+ for (const d of docs)
631
+ d.obs = `O-${++this.observed}`;
632
+ this.observations.push(...docs);
633
+ if (filled)
634
+ this.put(filled);
635
+ // The version split proposed (9.7.3): at the threshold, a review item names the instances that agree.
636
+ const proposed = [];
637
+ const supported = [...new Set(docs.map((d) => String(d.supports)))].flatMap((id) => { const rec = this.records.get(id); return rec ? [rec] : []; });
638
+ for (const item of proposeSplit(supported, this.observations, this.thresholds.split ?? 3)) {
639
+ const same = (it) => it.key === item.key && it.address === item.address && serialize(it.value) === serialize(item.value) && it.decision === undefined;
640
+ if (!this.review.some(same) && !this.knownReview.some(same)) {
641
+ this.review.push(item);
642
+ proposed.push(item);
643
+ }
644
+ }
645
+ this.commit({ contract: name, run: log }, log, { contract: name, record: recordId, state: "closed" }, docs.map((d) => ({ observation: d })));
646
+ return { ...head, outcomes: outcomes, log, landed: "observation", observations: docs, ...(filled ? { filled: filled.id } : {}), ...(proposed.length ? { proposed } : {}) };
647
+ }
648
+ if (c.lands === "suggestion" || gate)
246
649
  this.put(suggest(r, outcomes, source));
247
- else
248
- this.put(land(r, { outcomes, contract: name }, source, this.dict.trust, this.dict.facetTrust));
249
- return { contract: name, record: recordId, outcomes: outcomes, sources: { origin: c.origin }, log };
650
+ else {
651
+ // A form run by a caller is a person's write (11.3): it ranks as `person`, ahead of what a contract or an earlier declaration left.
652
+ const landed = land(r, { outcomes, contract: name, ...(c.kind === "form" && callerId ? { person: true } : {}) }, source, this.dict.trust, this.dict.facetTrust, new Set([...this.dict.facets.values()].filter((f) => f.transitions).map((f) => f.key)));
653
+ // 6. Rules, over what this run changed.
654
+ const touched = {};
655
+ for (const [k, v] of Object.entries(landed.values))
656
+ if (serialize(r.values[k] ?? "∅") !== serialize(v))
657
+ touched[k] = v;
658
+ const ruled = this.applyRules(landed, touched);
659
+ if ("refused" in ruled)
660
+ return { ...head, stopped: "rule", rule: ruled.refused.rule ?? "", address: ruled.refused.address ?? "", log };
661
+ this.put(ruled.record);
662
+ summary = ruled.summary;
663
+ }
664
+ // A form run by a caller is that person's entry (8.5), as an arrival's is; the run write still names the contract.
665
+ this.commit(callerId && c.kind === "form" ? { person: callerId } : { contract: name, run: log }, log, { contract: name, record: recordId, state: "closed" });
666
+ return { ...head, outcomes: outcomes, sources: { origin: c.origin }, log, landed: gate || c.lands === "suggestion" ? "suggestion" : "value", ...(gate ? { gated: gate.edge, reviewer: gate.reviewer ?? gate.by } : {}), ...(Object.keys(summary).length ? { rules: summary } : {}) };
667
+ }
668
+ /**
669
+ * An arrival through a contract (9.2a): the payload finds or makes its record; a new record made for a
670
+ * caller gets their consumer edge (14.4).
671
+ */
672
+ arriveRun(name, body, level, callerId, of, within) {
673
+ const c = this.contracts.get(name);
674
+ if (!c)
675
+ throw new Error(`no contract ${name}`);
676
+ const log = this.nextLog();
677
+ const domain = this.owner ?? this.access?.owner ?? this.journal?.owner ?? this.records.all()[0]?.owner ?? "";
678
+ // A person owns what the package lets a person own (14.1): the record is theirs, written as `domain:id`.
679
+ const kind = this.dict.levels.get(level) ?? level;
680
+ // Permitted on the record the new one sits under or within (14.2), else anywhere (7.4 step 1).
681
+ const refused = this.permitted(c, of ?? within ?? "", callerId, true);
682
+ if (refused)
683
+ return { contract: name, ...(callerId ? { caller: callerId } : {}), stopped: refused, log };
684
+ const person = callerId && !callerId.includes(":") && (this.access?.perms.ownership.get("person") ?? []).includes(kind);
685
+ const owner = person ? `${domain}:${callerId}` : domain;
686
+ // A refusal by the rules puts everything back: the arrival never happened.
687
+ const prevRecords = new Map(this.records);
688
+ const prevBindings = new Map(this.bindings);
689
+ const { arrival, record } = arrive(c, body, level, this, this.dict, { mint: () => (this.mint ? this.mint(level) : `${level.charAt(0).toUpperCase()}-NEW${++this.minted}`), owner, ...(of ? { of } : {}), ...(within ? { within } : {}), intakeId: `I-${log.slice(2)}` });
690
+ const out = { contract: name, ...(callerId ? { caller: callerId } : {}), arrival: arrival, log };
691
+ if (record) {
692
+ // 6. Rules, over what landed (everything, for a new record).
693
+ const was = prevRecords.get(record.id);
694
+ const touched = {};
695
+ for (const [k, v] of Object.entries(record.values))
696
+ if (!was || serialize(was.values[k] ?? "∅") !== serialize(v))
697
+ touched[k] = v;
698
+ const ruled = this.applyRules(record, touched);
699
+ if ("refused" in ruled) {
700
+ this.records.clear();
701
+ for (const [k, v] of prevRecords)
702
+ this.records.set(k, v);
703
+ this.bindings.clear();
704
+ for (const [k, v] of prevBindings)
705
+ this.bindings.set(k, v);
706
+ if (was)
707
+ this.engine.put(was);
708
+ return { contract: name, ...(callerId ? { caller: callerId } : {}), stopped: "rule", rule: ruled.refused.rule ?? "", address: ruled.refused.address ?? "", log };
709
+ }
710
+ this.put(ruled.record);
711
+ if (Object.keys(ruled.summary).length)
712
+ out.rules = ruled.summary;
713
+ }
714
+ if (record && arrival.outcome === "new" && callerId) {
715
+ const edge = this.grantConsumer(c, record.id, callerId);
716
+ if (edge)
717
+ out.granted = { edge: edge.edge, relation: edge.relation, mode: edge.mode, on: edge.on };
718
+ }
719
+ this.commit(callerId && c.kind === "form" ? { person: callerId } : { contract: name, run: log }, log, { contract: name, ...(record ? { record: record.id } : {}), state: arrival.outcome === "held" ? "started" : "closed" });
720
+ return out;
721
+ }
722
+ /** The arrival type, re-exported for callers that read `arrival`. */
723
+ static arrivalOf(r) {
724
+ return r.arrival;
725
+ }
726
+ /**
727
+ * A stream contract's run (12.6.2): the entries of each view since the cursor, in order. Pushed (the
728
+ * stored cursor, acknowledged by the index) or pulled (the caller's cursor). A view above `public` goes
729
+ * only to a party holding `run` for the contract.
730
+ */
731
+ streamRun(name, callerId, cursor) {
732
+ const c = this.contracts.get(name);
733
+ if (!c)
734
+ throw new Error(`no contract ${name}`);
735
+ if (!c.stream.length)
736
+ throw new Error(`${name} carries no stream`);
737
+ if (!this.views)
738
+ throw new Error("no views to stream from");
739
+ const party = callerId ?? c.parts.to ?? "";
740
+ const head = { contract: name, caller: party };
741
+ for (const v of c.stream) {
742
+ const view = this.views.set.views.get(v);
743
+ if (view && view.show !== "public" && !this.access?.mayAnywhere(party, "run", name))
744
+ return { ...head, stopped: "permission", view: v };
745
+ }
746
+ const log = this.nextLog();
747
+ const stored = this.cursors.get(name) ?? {};
748
+ const entries = {};
749
+ const out = {};
750
+ const batches = [];
751
+ for (const v of c.stream) {
752
+ this.views.stream(v);
753
+ const version = this.views.versionOf(v);
754
+ const held = stored[v];
755
+ const since = cursor?.[v] ?? (held && held.version === version ? held.seq : 0);
756
+ const list = this.views.entriesSince(v, since);
757
+ entries[v] = list;
758
+ out[v] = list.at(-1)?.seq ?? since;
759
+ if (list.length)
760
+ batches.push(`${name}|${v}|${version}|${list[0]?.seq}-${list.at(-1)?.seq}`);
761
+ }
762
+ return { ...head, entries, cursor: out, ...(batches.length ? { key: batches.join(" ") } : {}), log };
763
+ }
764
+ lastTick = new Map();
765
+ /**
766
+ * A scheduled contract's tick (7.11): the host offers its clock as `now`; every tick due since the last
767
+ * one runs (or only the latest, or none, by `missed`), each run keyed by the contract and the tick so a
768
+ * restarted host never runs a tick twice. A send contract with `for` runs over every record it selects;
769
+ * a stream contract pushes; any other runs once with no record.
770
+ */
771
+ tick(name, now, callerId) {
772
+ const c = this.contracts.get(name);
773
+ if (!c)
774
+ throw new Error(`no contract ${name}`);
775
+ if (!isSchedule(c.when))
776
+ throw new Error(`${name} isn't scheduled`);
777
+ const sch = readSchedule(c.when);
778
+ // A schedule begins when its contract is published (7.11): here, at the first clock reading.
779
+ if (!this.lastTick.has(name)) {
780
+ this.lastTick.set(name, now);
781
+ return { contract: name, now, ticks: [], runs: [], began: now };
782
+ }
783
+ const due = ticksDue(sch, this.lastTick.get(name), now);
784
+ const missed = c.parts.missed ?? "once";
785
+ const run = missed === "all" ? due : missed === "once" ? due.slice(-1) : [];
786
+ const skipped = due.filter((t) => !run.includes(t));
787
+ const results = [];
788
+ for (const tick of run) {
789
+ const key = `${name}|${tick}`;
790
+ if (this.sent.has(key))
791
+ continue;
792
+ this.sent.add(key);
793
+ if (c.stream.length) {
794
+ const r = this.streamRun(name, callerId);
795
+ results.push({ tick, ...r });
796
+ continue;
797
+ }
798
+ const targets = c.parts.for ? this.records.forTarget(c.parts.for).map((r) => r.id) : [];
799
+ if (!targets.length) {
800
+ const log = this.nextLog();
801
+ this.commit({ contract: name, run: log }, log, { contract: name, tick, key, state: "acted" });
802
+ results.push({ tick, log, acted: true });
803
+ continue;
804
+ }
805
+ for (const id of targets)
806
+ results.push({ tick, ...this.run(name, id, callerId) });
807
+ }
808
+ if (due.length)
809
+ this.lastTick.set(name, due[due.length - 1]);
810
+ return { contract: name, now, ticks: run, ...(skipped.length ? { skipped } : {}), runs: results };
811
+ }
812
+ /** The index acknowledges what it holds: the store resumes from there (12.6.2). */
813
+ ack(name, cursor) {
814
+ const stored = this.cursors.get(name) ?? {};
815
+ for (const [v, seq] of Object.entries(cursor))
816
+ stored[v] = { version: this.views?.versionOf(v) ?? "", seq };
817
+ this.cursors.set(name, stored);
818
+ return { contract: name, acked: cursor };
819
+ }
820
+ /** The classes a record wears, those of the records above it included (5.5). */
821
+ wearsOf(r) {
822
+ const up = r.of ? this.records.get(r.of) : undefined;
823
+ return [...(up ? this.wearsOf(up) : []), ...(r.wears ?? [])];
250
824
  }
251
- /** Offer and take (7.8): only what the offer gives and the take reads flows. */
252
- take(takeName, offerName, recordId, presenter) {
825
+ /**
826
+ * An offer (7.8): the records the contract's `for` selects, with only the facets it gives, each value with
827
+ * its source; the mode and terms ride along. A value the owner took under `reshare: no` never flows on.
828
+ * Who may see it is the host's decision from `to`.
829
+ */
830
+ offer(name, owner, only) {
831
+ const c = this.contracts.get(name);
832
+ if (!c || c.kind !== "offer")
833
+ throw new Error(`no offer ${name}`);
834
+ const wears = c.parts.for;
835
+ // What the contract is `for` is asked for by target, never the whole catalog (brief 18, the scale rule).
836
+ const pool = only ? only.flatMap((id) => this.records.get(id) ?? []) : wears ? this.records.forTarget(wears) : this.records.all();
837
+ const records = [];
838
+ for (const r of pool) {
839
+ if (r.status?.state === "archived")
840
+ continue;
841
+ if (wears && !this.wearsOf(r).includes(wears))
842
+ continue;
843
+ const values = {};
844
+ const sources = {};
845
+ for (const key of c.gives) {
846
+ const v = r.values[key];
847
+ if (v === undefined || (isObj(v) && "$empty" in v))
848
+ continue;
849
+ const s = r.sources?.[key] ?? r.sources?.["*"];
850
+ if (s?.reshare === false)
851
+ continue;
852
+ values[key] = v;
853
+ if (s)
854
+ sources[key] = { origin: s.origin, via: s.via, ...(s.owner ? { owner: s.owner } : {}) };
855
+ }
856
+ if (!Object.keys(values).length)
857
+ continue;
858
+ records.push({ id: r.id, kind: r.kind, level: r.level, owner: r.owner, values, sources, ...(r.of ? { of: r.of } : {}) });
859
+ }
860
+ return { contract: name, owner, to: c.to ?? "private", mode: c.mode ?? "gathered", terms: { ...c.terms }, gives: [...c.gives], records };
861
+ }
862
+ /**
863
+ * Offer and take (7.8): only what the offer gives and the take reads flows, by the offer's mode.
864
+ * `dump` copies once and never updates; `observed` stores a link and no value; `gathered` copies and
865
+ * takes the presenter's updates as intake, never over a value the consumer entered; `controlled` copies
866
+ * under the presenter's terms, which do override, expire and withdraw. The entry names the presenter's
867
+ * entry the values came from (8.5.1).
868
+ */
869
+ take(takeName, offer, recordId, presenter) {
253
870
  const t = this.contracts.get(takeName);
254
- const o = this.contracts.get(offerName);
871
+ const o = typeof offer === "string" ? this.localOffer(offer) : offer;
255
872
  const r = this.records.get(recordId);
256
873
  if (!t || !o || !r)
257
874
  throw new Error("a take needs its take contract, the offer and the record");
258
875
  if (t.from && t.from !== presenter.owner)
259
876
  return { contract: takeName, record: recordId, stopped: "from" };
877
+ const mode = o.mode;
878
+ if (t.mode && t.mode !== mode)
879
+ return { contract: takeName, record: recordId, stopped: "mode", offered: mode };
260
880
  const log = this.nextLog();
261
881
  const values = { ...r.values };
262
882
  const sources = { ...(r.sources ?? {}) };
263
883
  const alternatives = { ...(r.alternatives ?? {}) };
264
884
  const flows = [];
885
+ let from;
886
+ const fromPresenter = (a) => sources[a]?.owner === presenter.owner;
887
+ if (mode === "observed") {
888
+ // A read-through is never stored (7.8): the record links to the presenter's, naming what it reads.
889
+ for (const m of t.reads)
890
+ if (o.gives.includes(m.from) && presenter.values[m.from] !== undefined)
891
+ flows.push(m.from);
892
+ values["crubal:observes"] = { $link: `${presenter.owner}/${presenter.id}` };
893
+ sources["crubal:observes"] = { origin: "reported", via: log, owner: presenter.owner };
894
+ this.put({ ...r, values, sources });
895
+ this.commit({ take: takeName, from: presenter.owner }, log, { contract: takeName, record: recordId, state: "closed" });
896
+ return { contract: takeName, record: recordId, mode, observes: flows, log };
897
+ }
265
898
  for (const m of t.reads) {
266
899
  const theirs = m.from;
267
900
  if (!o.gives.includes(theirs))
@@ -269,22 +902,84 @@ export class Exchange {
269
902
  const v = presenter.values[theirs];
270
903
  if (v === undefined)
271
904
  continue;
272
- flows.push(theirs);
273
905
  const theirSource = presenter.sources?.[theirs] ?? presenter.sources?.["*"];
906
+ from ??= theirSource?.via ? `${presenter.owner}/${theirSource.via}` : presenter.owner;
274
907
  const src = { origin: theirSource?.origin ?? "reported", via: log, owner: presenter.owner };
908
+ if (mode === "controlled") {
909
+ if (o.terms.expires)
910
+ src.until = o.terms.expires;
911
+ if (o.terms.reshare === "no")
912
+ src.reshare = false;
913
+ if (o.terms.revocable === "yes")
914
+ src.revocable = true;
915
+ }
275
916
  const current = values[m.to];
276
- if (current === undefined || (isObj(current) && "$empty" in current)) {
917
+ const empty = current === undefined || (isObj(current) && "$empty" in current);
918
+ if (empty) {
919
+ values[m.to] = v;
920
+ sources[m.to] = src;
921
+ flows.push(theirs);
922
+ }
923
+ else if (serialize(current) === serialize(v)) {
924
+ if (mode !== "dump" && fromPresenter(m.to))
925
+ sources[m.to] = { ...sources[m.to], ...src, via: sources[m.to]?.via ?? log };
926
+ }
927
+ else if (mode === "dump") {
928
+ // Copied once, never updated.
929
+ }
930
+ else if (mode === "controlled" || fromPresenter(m.to)) {
931
+ // The presenter's update: under controlled terms it wins; gathered, it replaces only its own earlier value.
277
932
  values[m.to] = v;
278
933
  sources[m.to] = src;
934
+ flows.push(theirs);
279
935
  }
280
- else if (serialize(current) !== serialize(v)) {
281
- // A presenter never overrides the consumer's value unless the consumer took controlled terms.
936
+ else {
937
+ // A presenter never overrides a value a person entered on the consumer's side.
282
938
  alternatives[m.to] = [...(alternatives[m.to] ?? []), { value: v, origin: src.origin, via: log, owner: presenter.owner }];
283
939
  }
284
940
  }
941
+ // Withdrawal under revocable terms: a value this presenter gave and no longer offers or holds is removed.
942
+ const withdrawn = [];
943
+ for (const [a, s] of Object.entries(sources)) {
944
+ if (!fromPresenter(a) || !s.revocable)
945
+ continue;
946
+ const line = t.reads.find((m) => m.to === a);
947
+ const still = line && o.gives.includes(line.from) && presenter.values[line.from] !== undefined;
948
+ if (still)
949
+ continue;
950
+ delete values[a];
951
+ delete sources[a];
952
+ withdrawn.push(a);
953
+ }
285
954
  const next = { ...r, values, sources, ...(Object.keys(alternatives).length ? { alternatives } : {}) };
286
955
  this.put(next);
287
- return { contract: takeName, record: recordId, flows, log };
956
+ this.commit({ take: takeName, from: from ?? presenter.owner }, log, { contract: takeName, record: recordId, state: "closed" });
957
+ return { contract: takeName, record: recordId, mode, flows, ...(withdrawn.length ? { withdrawn } : {}), log };
958
+ }
959
+ /** A local offer contract as an offer object, for a take run against a presenter record at hand. */
960
+ localOffer(name) {
961
+ const o = this.contracts.get(name);
962
+ return o ? { contract: name, owner: "", to: o.to ?? "private", mode: o.mode ?? "gathered", terms: { ...o.terms }, gives: [...o.gives], records: [] } : undefined;
963
+ }
964
+ /** Taken values past their terms' `expires` (7.8) are removed, each record one entry; the ids changed. */
965
+ expire(now) {
966
+ const out = [];
967
+ for (const r of this.records.all()) {
968
+ const gone = Object.entries(r.sources ?? {}).filter(([, s]) => typeof s.until === "string" && s.until <= now).map(([a]) => a);
969
+ if (!gone.length)
970
+ continue;
971
+ const values = { ...r.values };
972
+ const sources = { ...(r.sources ?? {}) };
973
+ for (const a of gone) {
974
+ delete values[a];
975
+ delete sources[a];
976
+ }
977
+ this.put({ ...r, values, sources });
978
+ const log = this.nextLog();
979
+ this.commit({ take: "expired", at: now }, log, { contract: "expired", record: r.id, state: "closed" });
980
+ out.push(r.id);
981
+ }
982
+ return out;
288
983
  }
289
984
  }
290
985
  function suggest(r, outcomes, source) {
@@ -296,6 +991,28 @@ function suggest(r, outcomes, source) {
296
991
  }
297
992
  return { ...r, suggestions: s };
298
993
  }
994
+ /**
995
+ * The version gate at a border (10.1): what a receiver does with data written under another version of a
996
+ * package it holds. Newer than ours: hold until we upgrade. Older: restamp through the recipes between.
997
+ * The same, or a package we don't hold, or no version on the wire: take as is.
998
+ */
999
+ export function versionGate(theirs, installed) {
1000
+ if (!theirs)
1001
+ return { kind: "take" };
1002
+ const at = theirs.lastIndexOf(" /");
1003
+ if (at < 0)
1004
+ return { kind: "take" };
1005
+ const pkg = theirs.slice(0, at).trim();
1006
+ const version = theirs.slice(at + 2).trim();
1007
+ const ours = installed.filter((i) => i.package === pkg).map((i) => i.version).sort().at(-1);
1008
+ if (!ours)
1009
+ return { kind: "take" };
1010
+ if (version > ours)
1011
+ return { kind: "hold", package: pkg, theirs: version, ours };
1012
+ if (version < ours)
1013
+ return { kind: "restamp", package: pkg, theirs: version, ours };
1014
+ return { kind: "take" };
1015
+ }
299
1016
  // ─── Borders (8.5.1) ─────────────────────────────────────────────────────────
300
1017
  /** Verify a border wrapper from another owner: its log against the owner's key, and its values against the log. */
301
1018
  export function verifyBorder(w, keys) {