@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.
- package/README.md +30 -2
- package/dist/bin/crubal.js +70 -4
- package/dist/bin/crubal.js.map +1 -1
- package/dist/src/access.d.ts +126 -0
- package/dist/src/access.js +517 -0
- package/dist/src/access.js.map +1 -0
- package/dist/src/arrival.d.ts +55 -0
- package/dist/src/arrival.js +124 -0
- package/dist/src/arrival.js.map +1 -0
- package/dist/src/bundle.d.ts +66 -0
- package/dist/src/bundle.js +753 -0
- package/dist/src/bundle.js.map +1 -0
- package/dist/src/conditions.d.ts +7 -1
- package/dist/src/conditions.js +52 -11
- package/dist/src/conditions.js.map +1 -1
- package/dist/src/corpus.js +568 -34
- package/dist/src/corpus.js.map +1 -1
- package/dist/src/defs.d.ts +37 -2
- package/dist/src/defs.js +95 -14
- package/dist/src/defs.js.map +1 -1
- package/dist/src/dictionary.d.ts +15 -0
- package/dist/src/dictionary.js +72 -13
- package/dist/src/dictionary.js.map +1 -1
- package/dist/src/exchange.d.ts +202 -11
- package/dist/src/exchange.js +759 -45
- package/dist/src/exchange.js.map +1 -1
- package/dist/src/forms.d.ts +16 -0
- package/dist/src/forms.js +169 -0
- package/dist/src/forms.js.map +1 -0
- package/dist/src/index.d.ts +18 -6
- package/dist/src/index.js +17 -5
- package/dist/src/index.js.map +1 -1
- package/dist/src/lifecycle.d.ts +84 -0
- package/dist/src/lifecycle.js +415 -0
- package/dist/src/lifecycle.js.map +1 -0
- package/dist/src/log.d.ts +78 -0
- package/dist/src/log.js +506 -0
- package/dist/src/log.js.map +1 -0
- package/dist/src/migrate.d.ts +24 -1
- package/dist/src/migrate.js +104 -9
- package/dist/src/migrate.js.map +1 -1
- package/dist/src/model.d.ts +33 -1
- package/dist/src/model.js +158 -2
- package/dist/src/model.js.map +1 -1
- package/dist/src/packages.d.ts +44 -0
- package/dist/src/packages.js +137 -3
- package/dist/src/packages.js.map +1 -1
- package/dist/src/print-text.js +22 -0
- package/dist/src/print-text.js.map +1 -1
- package/dist/src/publish.d.ts +27 -0
- package/dist/src/publish.js +275 -0
- package/dist/src/publish.js.map +1 -0
- package/dist/src/read-text.d.ts +2 -0
- package/dist/src/read-text.js +72 -4
- package/dist/src/read-text.js.map +1 -1
- package/dist/src/records.d.ts +31 -0
- package/dist/src/records.js +72 -0
- package/dist/src/records.js.map +1 -0
- package/dist/src/review.d.ts +22 -0
- package/dist/src/review.js +198 -0
- package/dist/src/review.js.map +1 -0
- package/dist/src/rules.d.ts +34 -2
- package/dist/src/rules.js +147 -22
- package/dist/src/rules.js.map +1 -1
- package/dist/src/schedule.d.ts +24 -0
- package/dist/src/schedule.js +126 -0
- package/dist/src/schedule.js.map +1 -0
- package/dist/src/std-packages.d.ts +9 -0
- package/dist/src/std-packages.js +268 -0
- package/dist/src/std-packages.js.map +1 -0
- package/dist/src/store.d.ts +38 -7
- package/dist/src/store.js +166 -24
- package/dist/src/store.js.map +1 -1
- package/dist/src/tree.d.ts +3 -0
- package/dist/src/tree.js +36 -0
- package/dist/src/tree.js.map +1 -1
- package/dist/src/units-table.d.ts +1 -0
- package/dist/src/units-table.js +200 -0
- package/dist/src/units-table.js.map +1 -0
- package/dist/src/units.d.ts +42 -4
- package/dist/src/units.js +91 -30
- package/dist/src/units.js.map +1 -1
- package/dist/src/views.d.ts +108 -6
- package/dist/src/views.js +389 -64
- package/dist/src/views.js.map +1 -1
- package/package.json +5 -3
package/dist/src/exchange.js
CHANGED
|
@@ -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 {
|
|
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
|
-
/**
|
|
29
|
-
|
|
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"
|
|
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
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
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.
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
229
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
249
|
-
|
|
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
|
-
/**
|
|
252
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
281
|
-
// A presenter never overrides
|
|
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
|
-
|
|
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) {
|