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