@graview/core 0.1.2 → 0.1.4

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 (123) hide show
  1. package/dist/app.d.ts +9 -1
  2. package/dist/app.d.ts.map +1 -1
  3. package/dist/app.js.map +1 -1
  4. package/dist/capabilities.js +1 -1
  5. package/dist/capabilities.js.map +1 -1
  6. package/dist/cli/check/policy.d.ts.map +1 -1
  7. package/dist/cli/check/policy.js +40 -0
  8. package/dist/cli/check/policy.js.map +1 -1
  9. package/dist/cli/index.d.ts +2 -1
  10. package/dist/cli/index.d.ts.map +1 -1
  11. package/dist/cli/index.js.map +1 -1
  12. package/dist/document/canonical.d.ts +2 -1
  13. package/dist/document/canonical.d.ts.map +1 -1
  14. package/dist/document/canonical.js +2 -1
  15. package/dist/document/canonical.js.map +1 -1
  16. package/dist/document/compile.d.ts.map +1 -1
  17. package/dist/document/compile.js +22 -2
  18. package/dist/document/compile.js.map +1 -1
  19. package/dist/document/diff.d.ts.map +1 -1
  20. package/dist/document/diff.js +8 -1
  21. package/dist/document/diff.js.map +1 -1
  22. package/dist/document/edit.d.ts +7 -1
  23. package/dist/document/edit.d.ts.map +1 -1
  24. package/dist/document/edit.js +58 -2
  25. package/dist/document/edit.js.map +1 -1
  26. package/dist/document/index.d.ts +1 -1
  27. package/dist/document/index.d.ts.map +1 -1
  28. package/dist/document/migrate.d.ts.map +1 -1
  29. package/dist/document/migrate.js +3 -2
  30. package/dist/document/migrate.js.map +1 -1
  31. package/dist/document/patch.d.ts.map +1 -1
  32. package/dist/document/patch.js +2 -5
  33. package/dist/document/patch.js.map +1 -1
  34. package/dist/document/schema.d.ts +12 -10
  35. package/dist/document/schema.d.ts.map +1 -1
  36. package/dist/document/schema.js +2 -0
  37. package/dist/document/schema.js.map +1 -1
  38. package/dist/document/to-document.d.ts.map +1 -1
  39. package/dist/document/to-document.js +5 -1
  40. package/dist/document/to-document.js.map +1 -1
  41. package/dist/graph/graph.d.ts +9 -0
  42. package/dist/graph/graph.d.ts.map +1 -1
  43. package/dist/graph/graph.js +18 -4
  44. package/dist/graph/graph.js.map +1 -1
  45. package/dist/index.d.ts +11 -8
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +7 -5
  48. package/dist/index.js.map +1 -1
  49. package/dist/integrity.d.ts.map +1 -1
  50. package/dist/integrity.js +35 -5
  51. package/dist/integrity.js.map +1 -1
  52. package/dist/invariants/types.d.ts +1 -1
  53. package/dist/invariants/types.d.ts.map +1 -1
  54. package/dist/labels.js +1 -1
  55. package/dist/labels.js.map +1 -1
  56. package/dist/mutations/define-mutation.d.ts +22 -2
  57. package/dist/mutations/define-mutation.d.ts.map +1 -1
  58. package/dist/mutations/define-mutation.js +21 -8
  59. package/dist/mutations/define-mutation.js.map +1 -1
  60. package/dist/mutations/node-ref.d.ts +5 -2
  61. package/dist/mutations/node-ref.d.ts.map +1 -1
  62. package/dist/mutations/node-ref.js +28 -7
  63. package/dist/mutations/node-ref.js.map +1 -1
  64. package/dist/mutations/types.d.ts +2 -2
  65. package/dist/mutations/types.d.ts.map +1 -1
  66. package/dist/ops/log.d.ts +22 -0
  67. package/dist/ops/log.d.ts.map +1 -1
  68. package/dist/ops/log.js +14 -0
  69. package/dist/ops/log.js.map +1 -1
  70. package/dist/ops/served.d.ts +80 -0
  71. package/dist/ops/served.d.ts.map +1 -0
  72. package/dist/ops/served.js +316 -0
  73. package/dist/ops/served.js.map +1 -0
  74. package/dist/ops/types.d.ts +8 -0
  75. package/dist/ops/types.d.ts.map +1 -1
  76. package/dist/ops/undo.d.ts +6 -0
  77. package/dist/ops/undo.d.ts.map +1 -1
  78. package/dist/ops/undo.js +3 -0
  79. package/dist/ops/undo.js.map +1 -1
  80. package/dist/ops/withheld.d.ts +63 -8
  81. package/dist/ops/withheld.d.ts.map +1 -1
  82. package/dist/ops/withheld.js +198 -22
  83. package/dist/ops/withheld.js.map +1 -1
  84. package/dist/ops/writers.d.ts +29 -0
  85. package/dist/ops/writers.d.ts.map +1 -0
  86. package/dist/ops/writers.js +98 -0
  87. package/dist/ops/writers.js.map +1 -0
  88. package/dist/permissions/sight.d.ts +33 -4
  89. package/dist/permissions/sight.d.ts.map +1 -1
  90. package/dist/permissions/sight.js +106 -22
  91. package/dist/permissions/sight.js.map +1 -1
  92. package/dist/presence.d.ts +48 -1
  93. package/dist/presence.d.ts.map +1 -1
  94. package/dist/presence.js +51 -2
  95. package/dist/presence.js.map +1 -1
  96. package/dist/refusal.d.ts +38 -0
  97. package/dist/refusal.d.ts.map +1 -0
  98. package/dist/refusal.js +44 -0
  99. package/dist/refusal.js.map +1 -0
  100. package/dist/schema/json-schema.d.ts.map +1 -1
  101. package/dist/schema/json-schema.js +27 -1
  102. package/dist/schema/json-schema.js.map +1 -1
  103. package/dist/schema/schema.d.ts +19 -4
  104. package/dist/schema/schema.d.ts.map +1 -1
  105. package/dist/schema/schema.js.map +1 -1
  106. package/dist/schema/types.d.ts +11 -0
  107. package/dist/schema/types.d.ts.map +1 -1
  108. package/dist/seen.d.ts +60 -1
  109. package/dist/seen.d.ts.map +1 -1
  110. package/dist/seen.js +467 -29
  111. package/dist/seen.js.map +1 -1
  112. package/dist/store.d.ts +151 -10
  113. package/dist/store.d.ts.map +1 -1
  114. package/dist/store.js +228 -40
  115. package/dist/store.js.map +1 -1
  116. package/dist/testing.js.map +1 -1
  117. package/dist/validate-graph.d.ts +2 -1
  118. package/dist/validate-graph.d.ts.map +1 -1
  119. package/dist/validate-graph.js +8 -1
  120. package/dist/validate-graph.js.map +1 -1
  121. package/dist/version.d.ts +1 -1
  122. package/dist/version.js +1 -1
  123. package/package.json +2 -2
package/dist/seen.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { batchesOf, undoneIn } from "./ops/log.js";
2
2
  import { checkUndo } from "./ops/undo.js";
3
- import { redact } from "./ops/withheld.js";
3
+ import { servePrimitives } from "./ops/served.js";
4
+ import { writersOf } from "./ops/writers.js";
5
+ import { namesUnseen, redact, WITHHELD_INTENT } from "./ops/withheld.js";
4
6
  import { actingAs, isSystem } from "./permissions/policy.js";
5
7
  import { recordsOf, sees } from "./permissions/sight.js";
6
8
  const NOTHING = new Set();
@@ -27,13 +29,19 @@ export function hidesFrom(store, principal) {
27
29
  * now; one since removed by the kind the log says it was, and so never by
28
30
  * an edge it no longer has; and a string that names no record at all (a
29
31
  * label in a call's arguments) is nothing to keep from anybody.
32
+ *
33
+ * THE JUDGEMENT FOLLOWS THE STORE (FR-51). It reads the policy, the
34
+ * modules that are off and who made each record as it is asked, not as it
35
+ * was taken: one taken before a commit and asked after it knows the record
36
+ * just made is its maker's own. Asking costs nothing more for that — who
37
+ * made what is an index the log keeps as it goes (`recordsOf`).
30
38
  */
31
39
  export function seesId(store, principal) {
32
- const off = turnedOff(store, principal);
33
- if (!store.policy?.sees?.length && off.size === 0)
34
- return () => true;
35
- const records = recordsOf(store.log);
36
40
  return (id) => {
41
+ const off = turnedOff(store, principal);
42
+ if (!store.policy?.sees?.length && off.size === 0)
43
+ return true;
44
+ const records = recordsOf(store.log);
37
45
  const node = store.graph.getNode(id);
38
46
  if (node)
39
47
  return !off.has(node.kind) && sees(store.policy, principal, node, store.graph, records);
@@ -43,11 +51,371 @@ export function seesId(store, principal) {
43
51
  return !off.has(kind) && sees(store.policy, principal, { id, kind }, { out: () => [], in: () => [] }, records);
44
52
  };
45
53
  }
46
- /** The store's log as one seat may read it: every op in its place, the ones it may not see withheld (FR-16). */
54
+ const NO_LINKS = { out: () => [], in: () => [] };
55
+ /**
56
+ * ONE SEAT'S SIGHT AS OF NOW, each id judged once: `seesId` for a reading
57
+ * of the store that does not change while it is read. What is off, the
58
+ * records the log knows and each answer are taken once here, where
59
+ * `seesId` reads them again for every id — a served log asks of every
60
+ * string in every op, and most are the same few ids.
61
+ */
62
+ function pinnedSight(store, principal, judged = new Map(), unnamed) {
63
+ const off = turnedOff(store, principal);
64
+ if (!store.policy?.sees?.length && off.size === 0)
65
+ return () => true;
66
+ const records = recordsOf(store.log);
67
+ return (id) => {
68
+ const held = judged.get(id);
69
+ if (held !== undefined)
70
+ return held;
71
+ let seen;
72
+ const node = store.graph.getNode(id);
73
+ if (node)
74
+ seen = !off.has(node.kind) && sees(store.policy, principal, node, store.graph, records);
75
+ else {
76
+ const kind = records.kindOf(id);
77
+ // A string that names no record is nothing to keep, and not judged: most strings in a log are not ids. A caller that keeps a reading is told it was asked.
78
+ if (kind === undefined) {
79
+ unnamed?.add(id);
80
+ return true;
81
+ }
82
+ seen = !off.has(kind) && sees(store.policy, principal, { id, kind }, NO_LINKS, records);
83
+ }
84
+ judged.set(id, seen);
85
+ return seen;
86
+ };
87
+ }
88
+ /** Whether a kind's field may be left off a record: declared optional, or not declared at all. Unknown kinds keep every field. */
89
+ function optionalIn(schema) {
90
+ const known = new Map();
91
+ return (kind, field) => {
92
+ const key = `${kind}\u0000${field}`;
93
+ let optional = known.get(key);
94
+ if (optional === undefined) {
95
+ const shape = schema?.tryDefinition(kind)?.fields?.shape;
96
+ const declared = shape?.[field];
97
+ optional = shape !== undefined && (declared === undefined || declared.safeParse?.(undefined).success === true);
98
+ known.set(key, optional);
99
+ }
100
+ return optional;
101
+ };
102
+ }
103
+ /**
104
+ * WHAT ONE SEAT IS SERVED (FR-55) — the judgement every surface built on
105
+ * the seat view reads, so a snapshot, a log, an answer and the wire cannot
106
+ * disagree about it.
107
+ *
108
+ * Its sight (`seesId`) says which records it may be told of. An id is
109
+ * minted from a label, so a seen record's field that holds the id of one
110
+ * it may not see tells it that record's name: such a field is CLEARED in
111
+ * what the seat is served — the record stays, usable, without it — when
112
+ * the kind's declaration lets a record be without it. When the field is
113
+ * one the record cannot do without, clearing it would serve a record that
114
+ * fails its own declaration, and a client refuses to load that: the record
115
+ * is WITHHELD from the seat whole instead, as if its sight did not reach
116
+ * it — not in the graph, the snapshot, the log's primitives or an edge.
117
+ * Which ids a field names is judged by the seat's sight alone, so a record
118
+ * naming one withheld this way still names a record the seat may see.
119
+ */
120
+ export function seatLens(store, principal) {
121
+ return lensFor(store, principal).lens;
122
+ }
123
+ /** A seat's lens, and a way to pin it over judgements the caller keeps. */
124
+ function lensFor(store, principal) {
125
+ const optional = optionalIn(store.schema);
126
+ /*
127
+ * A SEAT IS SERVED ITS OWN WORDS (FR-55). A field whose value this seat
128
+ * wrote — or the person an agent acts for — is served as written, though
129
+ * it name a record the seat may not see: saying back what a seat said
130
+ * tells it nothing, and judging it like anybody else's would tell a seat
131
+ * that guessed an id whether the guess was real.
132
+ */
133
+ const actor = isSystem(principal) ? undefined : actingAs(principal).id;
134
+ const length = () => store.log.length ?? store.log.all().length;
135
+ // The same record served the same way is the same object, so a view read twice is equal by identity.
136
+ const cleared = new WeakMap();
137
+ const graph = store.graph;
138
+ /**
139
+ * The lens over one sight: the seat's own, which follows the store as it
140
+ * is asked, or one pinned to now (`pin`), which judges each id and each
141
+ * record once and is thrown away after the reading it was made for.
142
+ */
143
+ const over = (sees, writers, now, kindOf, pinned) => {
144
+ const servedWith = (node, writer) => {
145
+ if (!sees(node.id))
146
+ return undefined;
147
+ let without;
148
+ for (const field in node) {
149
+ if (!Object.hasOwn(node, field) || field === "id" || field === "kind" || !namesUnseen(node[field], sees))
150
+ continue;
151
+ if (actor !== undefined && writer(field) === actor)
152
+ continue;
153
+ if (!optional(node.kind, field))
154
+ return undefined;
155
+ (without ??= []).push(field);
156
+ }
157
+ if (!without)
158
+ return node;
159
+ const key = without.join("\u0000");
160
+ const held = cleared.get(node);
161
+ if (held?.without === key)
162
+ return held.node;
163
+ const out = Object.fromEntries(Object.entries(node).filter(([field]) => !without.includes(field)));
164
+ cleared.set(node, { without: key, node: out });
165
+ return out;
166
+ };
167
+ const judge = (node) => {
168
+ const at = now();
169
+ return servedWith(node, (field) => writers().writerAt(node.id, field, at));
170
+ };
171
+ // Pinned, a record is served once however often the reading asks for it: no record or id changes under a pin.
172
+ const servedOnce = new WeakMap();
173
+ const served = pinned
174
+ ? (node) => {
175
+ let held = servedOnce.get(node);
176
+ if (!held)
177
+ servedOnce.set(node, (held = { node: judge(node) }));
178
+ return held.node;
179
+ }
180
+ : judge;
181
+ const showsNow = (id) => {
182
+ if (!sees(id))
183
+ return false;
184
+ const node = graph.getNode(id);
185
+ return node === undefined || served(node) !== undefined;
186
+ };
187
+ const shown = pinned?.shown ?? new Map();
188
+ const shows = pinned
189
+ ? (id) => {
190
+ let held = shown.get(id);
191
+ if (held === undefined)
192
+ shown.set(id, (held = showsNow(id)));
193
+ return held;
194
+ }
195
+ : showsNow;
196
+ const timeline = graph.outEdges && graph.inEdges
197
+ ? {
198
+ present: {
199
+ getNode: (id) => graph.getNode(id),
200
+ outEdges: (id) => graph.outEdges(id),
201
+ inEdges: (id) => graph.inEdges(id),
202
+ },
203
+ log: store.log,
204
+ get writers() {
205
+ return writers();
206
+ },
207
+ }
208
+ : undefined;
209
+ const lens = {
210
+ sees,
211
+ shows,
212
+ served,
213
+ servedWith,
214
+ optional,
215
+ kindOf,
216
+ ...(actor !== undefined ? { actor } : {}),
217
+ ...(timeline ? { timeline } : {}),
218
+ // A pinned lens is already of one moment: pinning it again is itself.
219
+ pin: pinned ? () => lens : () => pinWith({ judged: new Map(), shown: new Map() }),
220
+ };
221
+ return lens;
222
+ };
223
+ function pinWith(memo) {
224
+ // Who wrote what is read only when a field names what the seat may not see: a seat that sees all of it never asks.
225
+ let writers;
226
+ const at = length();
227
+ const records = recordsOf(store.log);
228
+ return over(pinnedSight(store, principal, memo.judged, memo.unnamed), () => (writers ??= writersOf(store.log)), () => at, (id) => graph.getNode(id)?.kind ?? records.kindOf(id), memo);
229
+ }
230
+ const lens = over(seesId(store, principal), () => writersOf(store.log), length, (id) => graph.getNode(id)?.kind ?? recordsOf(store.log).kindOf(id));
231
+ return { lens, pinWith };
232
+ }
233
+ const SERVED_LOGS = new WeakMap();
234
+ /**
235
+ * EVERY STRING A SERVED LOG'S READING ASKED ABOUT THAT NAMED NO RECORD THEN,
236
+ * for every seat served from the log: an id a record takes later is one
237
+ * an op served before may name. A string never asked about changed nothing
238
+ * any seat was served — a withheld op drops its sentence, its call and its
239
+ * author unread — so these are all the names that can come to matter.
240
+ * Shared by the seats, so a name one seat asked about only ever sends
241
+ * another to serve its log again; never fewer.
242
+ */
243
+ const UNNAMED = new WeakMap();
244
+ /** The ids a primitive touches, links' ends among them: what can move a record's sight. */
245
+ const touchedIds = (ops) => {
246
+ const ids = new Set();
247
+ for (const op of ops) {
248
+ for (const primitive of op.primitives) {
249
+ if (primitive.op === "add-node" || primitive.op === "remove-node")
250
+ ids.add(primitive.node.id);
251
+ else if (primitive.op === "patch-node")
252
+ ids.add(primitive.id);
253
+ else {
254
+ ids.add(primitive.edge.from);
255
+ ids.add(primitive.edge.to);
256
+ }
257
+ }
258
+ }
259
+ return ids;
260
+ };
261
+ /** The store's log as one seat may read it: every op in its place, the ones it may not see withheld (FR-16), and no id it may not see in any of them (FR-55). */
47
262
  export function logSeenBy(store, principal) {
48
263
  if (!hidesFrom(store, principal))
49
264
  return store.log.all();
50
- return redact(store.log.all(), seesId(store, principal));
265
+ const all = store.log.all();
266
+ const length = store.log.length ?? all.length;
267
+ const horizon = length - all.length;
268
+ const off = [...turnedOff(store, principal)].sort().join("\u0000");
269
+ const judged = isSystem(principal) ? principal : actingAs(principal);
270
+ const key = `${principal.kind}|${judged.id ?? ""}|${(judged.roles ?? []).join(",")}|${principal.onBehalfOf ? "for" : ""}`;
271
+ let seats = SERVED_LOGS.get(store.log);
272
+ if (!seats)
273
+ SERVED_LOGS.set(store.log, (seats = new Map()));
274
+ const held = seats.get(key);
275
+ const { pinWith } = lensFor(store, principal);
276
+ let unnamed = UNNAMED.get(store.log);
277
+ if (!unnamed)
278
+ UNNAMED.set(store.log, (unnamed = new Set()));
279
+ const grown = held !== undefined && held.horizon === horizon && held.off === off && held.length <= length && (held.length === horizon || all[held.length - 1 - horizon] === held.last);
280
+ if (held && grown) {
281
+ if (held.length === length)
282
+ return held.ops;
283
+ const added = all.slice(held.length - horizon);
284
+ // Judged afresh, to compare with what serving the old ops read.
285
+ const now = pinWith({ judged: new Map(), shown: new Map() });
286
+ let still = true;
287
+ const touched = touchedIds(added);
288
+ for (const id of touched) {
289
+ const sight = held.judgements.judged.get(id);
290
+ const served = held.judgements.shown.get(id);
291
+ if ((sight !== undefined && now.sees(id) !== sight) || (served !== undefined && now.shows(id) !== served)) {
292
+ still = false;
293
+ break;
294
+ }
295
+ }
296
+ if (still) {
297
+ // A record new to the log that an op already served named, when it named nothing: that name may be one to keep now.
298
+ for (const id of touched) {
299
+ if (unnamed.has(id)) {
300
+ still = false;
301
+ break;
302
+ }
303
+ }
304
+ }
305
+ if (still) {
306
+ // What the old ops read stands: the new ones are judged over it, and what they read is kept with it.
307
+ const ops = [...held.ops, ...redact(added, pinWith(held.judgements))];
308
+ const served = { horizon, off, length, last: all.at(-1), ops, judgements: held.judgements };
309
+ seats.set(key, served);
310
+ return ops;
311
+ }
312
+ }
313
+ const judgements = { judged: new Map(), shown: new Map(), unnamed };
314
+ const ops = redact(all, pinWith(judgements));
315
+ seats.set(key, { horizon, off, length, last: all.at(-1), ops, judgements });
316
+ return ops;
317
+ }
318
+ /** Whether a rule's finding or violation is one a seat may be told: about records it is served, naming none it may not see. */
319
+ function violationShown(lens, has, violation) {
320
+ return ((violation.subjectId === undefined || !has(violation.subjectId) || lens.shows(violation.subjectId)) &&
321
+ violation.nodeIds.every((id) => !has(id) || lens.shows(id)) &&
322
+ !namesUnseen(violation, lens.sees));
323
+ }
324
+ /**
325
+ * A CHANGE'S ANSWER AS ONE SEAT MAY BE TOLD IT (FR-55) — the diff, the
326
+ * primitives, the ops and the rules it would break or mend of a preview,
327
+ * an apply or an undo, for a surface that hands a seat what its own act
328
+ * did: an act may touch what its own seat may not see. Records and edges
329
+ * it is not served go, a field naming one it may not see is cleared as
330
+ * the view clears it, the ops are redacted as its log is, and a sentence
331
+ * that names what it may not see says only that a change it cannot see
332
+ * happened. With nothing kept from the seat, the answer as it is.
333
+ */
334
+ export function answerSeenBy(store, principal, answer) {
335
+ if (!hidesFrom(store, principal))
336
+ return answer;
337
+ const lens = seatLens(store, principal);
338
+ const has = (id) => store.graph.getNode(id) !== undefined || recordsOf(store.log).kindOf(id) !== undefined;
339
+ const out = { ...answer };
340
+ if (answer.diff) {
341
+ const diff = answer.diff;
342
+ /*
343
+ * EACH SIDE AS WHO WROTE IT THEN (FR-55): the seat's own words are served
344
+ * as written, and the words a change replaced were somebody's before it.
345
+ * Before is judged as of the first op the answer made (now, for a
346
+ * preview); after as of now — and, for a preview, what it would write
347
+ * is the caller's.
348
+ */
349
+ const writers = writersOf(store.log);
350
+ const end = store.log.length ?? store.log.all().length;
351
+ const first = answer.ops?.[0]?.seq ?? end;
352
+ const actor = actingAs(principal).id;
353
+ const wroteAt = (id, seq) => (field) => writers.writerAt(id, field, seq);
354
+ const node = (one, writer) => (lens.shows(one.id) ? lens.servedWith(one, writer) : undefined);
355
+ const nodes = (all, writer) => all.flatMap((one) => node(one, writer(one.id)) ?? []);
356
+ const edge = (one) => lens.shows(one.from) && lens.shows(one.to) && !namesUnseen(one, lens.sees);
357
+ const madeBy = (id, fields) => answer.ops ? wroteAt(id, end) : (field) => (fields === undefined || fields.includes(field) ? actor : writers.writerAt(id, field, end));
358
+ const changed = diff.changedNodes.flatMap((change) => {
359
+ const before = node(change.before, wroteAt(change.before.id, first));
360
+ const after = node(change.after, madeBy(change.after.id, change.fields));
361
+ return before && after ? [{ before, after, fields: change.fields.filter((field) => field in before || field in after) }] : [];
362
+ });
363
+ /*
364
+ * A WRITE THAT CHANGED NOTHING BUT WHOSE WORDS THEY ARE. A seat that
365
+ * wrote the value a field already held, naming what it may not see, made
366
+ * those words its own (FR-55): the store's graph did not move, and the
367
+ * seat's view of the record did. Said as a change, so the answer reads
368
+ * as it would had the field held anything else.
369
+ */
370
+ const listed = new Set(diff.changedNodes.map((change) => change.after.id));
371
+ const patched = new Map();
372
+ for (const primitive of (answer.ops?.flatMap((op) => op.primitives) ?? answer.primitives ?? [])) {
373
+ if (primitive.op !== "patch-node" || listed.has(primitive.id))
374
+ continue;
375
+ patched.set(primitive.id, [...(patched.get(primitive.id) ?? []), ...Object.keys(primitive.after)]);
376
+ }
377
+ const kept = () => [...patched].flatMap(([id, fields]) => {
378
+ const held = store.graph.getNode(id);
379
+ if (!held)
380
+ return [];
381
+ const before = node(held, wroteAt(id, first));
382
+ const after = node(held, madeBy(id, fields));
383
+ return before && after && JSON.stringify(before) !== JSON.stringify(after) ? [{ before, after, fields }] : [];
384
+ });
385
+ const madeTheirs = kept();
386
+ out["diff"] = {
387
+ addedNodes: nodes(diff.addedNodes, (id) => madeBy(id)),
388
+ removedNodes: nodes(diff.removedNodes, (id) => wroteAt(id, first)),
389
+ changedNodes: [...changed, ...madeTheirs],
390
+ addedEdges: diff.addedEdges.filter(edge),
391
+ removedEdges: diff.removedEdges.filter(edge),
392
+ touched: [...diff.touched.filter(lens.shows), ...[...patched.keys()].filter((id) => lens.shows(id) && !diff.touched.includes(id))],
393
+ };
394
+ }
395
+ if (answer.ops)
396
+ out["ops"] = redact(answer.ops, lens);
397
+ if (answer.primitives) {
398
+ // An apply's primitives are its ops'; a preview's are not in the log yet, and are judged from the graph as it stands.
399
+ const ops = out["ops"];
400
+ out["primitives"] = ops
401
+ ? ops.flatMap((op) => op.primitives)
402
+ : lens.timeline
403
+ ? servePrimitives(answer.primitives, lens.timeline, { served: (node, writer) => lens.servedWith(node, writer), edgeClean: (edge) => !namesUnseen(edge, lens.sees) }, actingAs(principal).id)
404
+ : redact([{ id: "", seq: 0, batch: "", author: { kind: "system" }, intent: "", mutation: null, primitives: answer.primitives, inverse: [], reads: [], writes: [], at: "" }], lens)[0].primitives;
405
+ }
406
+ // Records the seat is served, and no id that names nothing: a hidden record and an absent one are left out alike (FR-55).
407
+ if (answer.reads)
408
+ out["reads"] = answer.reads.filter((id) => lens.shows(id) && has(id));
409
+ if (answer.writes)
410
+ out["writes"] = answer.writes.filter(lens.shows);
411
+ if (answer.intent !== undefined && namesUnseen(answer.intent, lens.sees))
412
+ out["intent"] = WITHHELD_INTENT;
413
+ for (const key of ["introduces", "resolves", "violationsAfter"]) {
414
+ const violations = answer[key];
415
+ if (violations)
416
+ out[key] = violations.filter((violation) => violationShown(lens, has, violation));
417
+ }
418
+ return out;
51
419
  }
52
420
  /** A log reading over a seat's redacted ops, for `checkUndo`. */
53
421
  export function readingOf(ops, epochs) {
@@ -71,8 +439,8 @@ const bound = (target, prop) => {
71
439
  * With no `sees`, it is the store, unchanged.
72
440
  */
73
441
  export function seenBy(store, principal) {
74
- // A module off keeps its kinds from every seat as a sight would (FR-12); the view reads which are off as it goes.
75
- if (!store.policy?.sees?.length && store.modules.disabledKinds.size === 0)
442
+ // A module off keeps its kinds from every seat as a sight would (FR-12); the view reads which are off as it goes. The system is kept from nothing.
443
+ if (!hidesFrom(store, principal))
76
444
  return store;
77
445
  // Keyed by the seat the policy judges: an agent for Nick is not the same agent alone.
78
446
  const judged = isSystem(principal) ? principal : actingAs(principal);
@@ -84,9 +452,51 @@ export function seenBy(store, principal) {
84
452
  if (held)
85
453
  return held;
86
454
  const full = store.graph;
87
- const seenNode = (node) => node !== undefined && !turnedOff(store, principal).has(node.kind) && sees(store.policy, principal, node, full, recordsOf(store.log));
88
- const seenId = (id) => seenNode(full.getNode(id));
89
- const seenEdge = (edge) => seenId(edge.from) && seenId(edge.to);
455
+ /*
456
+ * WHAT THIS SEAT IS SERVED (FR-55): a record its sight reaches, with any
457
+ * field that names one it may not see cleared — or, when that field is
458
+ * one the record cannot do without, not the record at all.
459
+ */
460
+ const lens = seatLens(store, principal);
461
+ /**
462
+ * HOW ONE READING JUDGES: a single record by the lens, which follows the
463
+ * store; a whole graph, a snapshot or a neighbourhood by the lens pinned
464
+ * for that one reading, each id and record judged once in it.
465
+ */
466
+ const judging = (seat) => {
467
+ const served = (node) => (node === undefined ? undefined : seat.served(node));
468
+ const seenNode = (node) => served(node) !== undefined;
469
+ const seenId = (id) => seenNode(full.getNode(id));
470
+ const seenEdge = (edge) => seenId(edge.from) && seenId(edge.to) && !namesUnseen(edge, seat.sees);
471
+ const servedAll = (nodes) => nodes.flatMap((node) => served(node) ?? []);
472
+ return { served, seenNode, seenId, seenEdge, servedAll };
473
+ };
474
+ const { served, seenId } = judging(lens);
475
+ const pinned = () => judging(lens.pin?.() ?? lens);
476
+ /** A graph of another moment — an epoch's base, at `seq` — as this seat is served it: its records, as who wrote them then, and its links between them. */
477
+ const servedGraph = (snapshot, seq) => {
478
+ const writers = writersOf(store.log);
479
+ const seat = lens.pin?.() ?? lens;
480
+ const nodes = snapshot.nodes.flatMap((node) => seat.servedWith(node, (field) => writers.writerAt(node.id, field, seq)) ?? []);
481
+ const kept = new Set(nodes.map((node) => node.id));
482
+ return { nodes, edges: snapshot.edges.filter((edge) => kept.has(edge.from) && kept.has(edge.to) && !namesUnseen(edge, seat.sees)) };
483
+ };
484
+ /**
485
+ * AN EPOCH AS THIS SEAT IS SERVED IT: its base as the seat was served it
486
+ * then, and who made each record (`creators`, which a checkpoint carries
487
+ * so a seat's own records stay its own past a compaction) only for the
488
+ * records in that base — a record the seat is not served is not named in
489
+ * the map either, by its id or by its maker's (FR-55). The store keeps
490
+ * the whole map for its own judgement.
491
+ */
492
+ const servedEpoch = (epoch) => {
493
+ const base = servedGraph(epoch.base, epoch.seq);
494
+ if (!epoch.creators)
495
+ return { ...epoch, base };
496
+ const kept = new Set(base.nodes.map((node) => node.id));
497
+ const creators = Object.fromEntries(Object.entries(epoch.creators).filter(([id, maker]) => kept.has(id) && !namesUnseen(maker, lens.sees)));
498
+ return { ...epoch, base, creators };
499
+ };
90
500
  /*
91
501
  * THE LOG, REDACTED RATHER THAN GAPPED (FR-16): every op in its place,
92
502
  * and one that touched what this seat may not see withheld — so the log
@@ -98,38 +508,46 @@ export function seenBy(store, principal) {
98
508
  get(target, prop) {
99
509
  switch (prop) {
100
510
  case "getNode":
101
- return (id) => {
102
- const node = target.getNode(id);
103
- return seenNode(node) ? node : undefined;
104
- };
511
+ return (id) => served(target.getNode(id));
105
512
  case "has":
106
513
  return (id) => seenId(id);
107
514
  case "allNodes":
108
- return () => target.allNodes().filter((node) => seenNode(node));
515
+ return () => pinned().servedAll(target.allNodes());
109
516
  case "nodesOfKind":
110
- return (kind) => target.nodesOfKind(kind).filter((node) => seenNode(node));
517
+ return (kind) => pinned().servedAll(target.nodesOfKind(kind));
111
518
  case "allEdges":
112
- return () => target.allEdges().filter(seenEdge);
519
+ return () => target.allEdges().filter(pinned().seenEdge);
113
520
  case "edgesOfKind":
114
- return (kind) => target.edgesOfKind(kind).filter(seenEdge);
521
+ return (kind) => target.edgesOfKind(kind).filter(pinned().seenEdge);
115
522
  case "out":
116
- return (id, kind) => (seenId(id) ? target.out(id, kind).filter((node) => seenNode(node)) : []);
523
+ return (id, kind) => {
524
+ const now = pinned();
525
+ return now.seenId(id) ? now.servedAll(target.out(id, kind)) : [];
526
+ };
117
527
  case "in":
118
- return (id, kind) => (seenId(id) ? target.in(id, kind).filter((node) => seenNode(node)) : []);
528
+ return (id, kind) => {
529
+ const now = pinned();
530
+ return now.seenId(id) ? now.servedAll(target.in(id, kind)) : [];
531
+ };
119
532
  case "neighbors":
120
- return (id) => (seenId(id) ? target.neighbors(id).filter((node) => seenNode(node)) : []);
533
+ return (id) => {
534
+ const now = pinned();
535
+ return now.seenId(id) ? now.servedAll(target.neighbors(id)) : [];
536
+ };
121
537
  case "outEdges":
122
- return (id, kind) => target.outEdges(id, kind).filter(seenEdge);
538
+ return (id, kind) => target.outEdges(id, kind).filter(pinned().seenEdge);
123
539
  case "inEdges":
124
- return (id, kind) => target.inEdges(id, kind).filter(seenEdge);
540
+ return (id, kind) => target.inEdges(id, kind).filter(pinned().seenEdge);
125
541
  case "snapshot":
126
542
  return () => {
127
543
  const snapshot = target.snapshot();
128
- return { ...snapshot, nodes: snapshot.nodes.filter((node) => seenNode(node)), edges: snapshot.edges.filter(seenEdge) };
544
+ const now = pinned();
545
+ return { ...snapshot, nodes: now.servedAll(snapshot.nodes), edges: snapshot.edges.filter(now.seenEdge) };
129
546
  };
130
547
  case "size": {
131
- const nodes = target.allNodes().filter((node) => seenNode(node)).length;
132
- return { nodes, edges: target.allEdges().filter(seenEdge).length };
548
+ const now = pinned();
549
+ const nodes = target.allNodes().filter((node) => now.seenNode(node)).length;
550
+ return { nodes, edges: target.allEdges().filter(now.seenEdge).length };
133
551
  }
134
552
  default:
135
553
  return bound(target, prop);
@@ -146,6 +564,14 @@ export function seenBy(store, principal) {
146
564
  case "all":
147
565
  case "toJSON":
148
566
  return () => [...ops()];
567
+ // Each epoch's base as this seat is served it, so its log folds from there to what it is served (FR-55).
568
+ case "epochs":
569
+ return () => target.epochs().map((epoch) => servedEpoch(epoch));
570
+ case "lastEpoch":
571
+ return () => {
572
+ const epoch = target.lastEpoch();
573
+ return epoch && servedEpoch(epoch);
574
+ };
149
575
  case "live":
150
576
  return () => {
151
577
  const all = ops();
@@ -178,11 +604,23 @@ export function seenBy(store, principal) {
178
604
  case "canUndo":
179
605
  return (batchIds) => checkUndo(reading(), typeof batchIds === "string" ? [batchIds] : batchIds);
180
606
  case "violations":
181
- return (...args) => target.violations(...args).filter((violation) => (violation.subjectId === undefined || seenId(violation.subjectId)) && violation.nodeIds.every((id) => !full.has(id) || seenId(id)));
607
+ return (...args) => target.violations(...args).filter((violation) => violationShown(lens, (id) => full.has(id), violation));
608
+ // What no longer fits, about records this seat is served and naming none it may not see (FR-55).
609
+ case "findings":
610
+ return () => target.findings().filter((finding) => {
611
+ const ids = finding.id.includes("->") ? finding.id.split("->").map((end, at) => (at === 0 ? end.slice(end.indexOf(":") + 1) : end)) : [finding.id];
612
+ return ids.every((id) => !full.has(id) || seenId(id)) && !namesUnseen(finding, lens.sees);
613
+ });
182
614
  case "seenBy":
183
615
  return (other) => seenBy(target, other);
184
616
  case "seenFor":
185
617
  return principal;
618
+ // A checkpoint taken through the seat's view is the one it is served.
619
+ case "checkpoint":
620
+ return (...args) => {
621
+ const epoch = target.checkpoint(...args);
622
+ return epoch && servedEpoch(epoch);
623
+ };
186
624
  default:
187
625
  return bound(target, prop);
188
626
  }