@graview/core 0.1.26 → 0.1.28

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 (120) hide show
  1. package/dist/capabilities.js +1 -1
  2. package/dist/capabilities.js.map +1 -1
  3. package/dist/capital.d.ts +2 -0
  4. package/dist/capital.d.ts.map +1 -1
  5. package/dist/capital.js +2 -0
  6. package/dist/capital.js.map +1 -1
  7. package/dist/cli/check/fact-rules.js +1 -1
  8. package/dist/cli/check/fact-rules.js.map +1 -1
  9. package/dist/cli/check/hidden-reads.d.ts.map +1 -1
  10. package/dist/cli/check/hidden-reads.js +2 -1
  11. package/dist/cli/check/hidden-reads.js.map +1 -1
  12. package/dist/cli/check/index.d.ts +1 -0
  13. package/dist/cli/check/index.d.ts.map +1 -1
  14. package/dist/cli/check/index.js +1 -0
  15. package/dist/cli/check/index.js.map +1 -1
  16. package/dist/cli/check/people.d.ts +19 -0
  17. package/dist/cli/check/people.d.ts.map +1 -0
  18. package/dist/cli/check/people.js +80 -0
  19. package/dist/cli/check/people.js.map +1 -0
  20. package/dist/cli/check/policy.d.ts.map +1 -1
  21. package/dist/cli/check/policy.js +3 -2
  22. package/dist/cli/check/policy.js.map +1 -1
  23. package/dist/cli/check.d.ts.map +1 -1
  24. package/dist/cli/check.js +2 -1
  25. package/dist/cli/check.js.map +1 -1
  26. package/dist/cli/describe.d.ts.map +1 -1
  27. package/dist/cli/describe.js +6 -0
  28. package/dist/cli/describe.js.map +1 -1
  29. package/dist/days.d.ts +2 -0
  30. package/dist/days.d.ts.map +1 -1
  31. package/dist/days.js +2 -0
  32. package/dist/days.js.map +1 -1
  33. package/dist/document/act-findings.d.ts +1 -1
  34. package/dist/document/act-findings.d.ts.map +1 -1
  35. package/dist/document/act-findings.js +35 -3
  36. package/dist/document/act-findings.js.map +1 -1
  37. package/dist/document/block-context.d.ts.map +1 -1
  38. package/dist/document/block-context.js +2 -1
  39. package/dist/document/block-context.js.map +1 -1
  40. package/dist/document/compile.d.ts.map +1 -1
  41. package/dist/document/compile.js +6 -7
  42. package/dist/document/compile.js.map +1 -1
  43. package/dist/document/compiled.d.ts.map +1 -1
  44. package/dist/document/compiled.js +83 -41
  45. package/dist/document/compiled.js.map +1 -1
  46. package/dist/document/describe-place.d.ts.map +1 -1
  47. package/dist/document/describe-place.js +2 -1
  48. package/dist/document/describe-place.js.map +1 -1
  49. package/dist/document/diff.d.ts.map +1 -1
  50. package/dist/document/diff.js +45 -2
  51. package/dist/document/diff.js.map +1 -1
  52. package/dist/document/edit.d.ts +1 -1
  53. package/dist/document/edit.d.ts.map +1 -1
  54. package/dist/document/edit.js +265 -35
  55. package/dist/document/edit.js.map +1 -1
  56. package/dist/document/expr/evaluate.d.ts.map +1 -1
  57. package/dist/document/expr/evaluate.js +2 -1
  58. package/dist/document/expr/evaluate.js.map +1 -1
  59. package/dist/document/policy-words.d.ts +31 -0
  60. package/dist/document/policy-words.d.ts.map +1 -0
  61. package/dist/document/policy-words.js +54 -0
  62. package/dist/document/policy-words.js.map +1 -0
  63. package/dist/document/respell.d.ts +2 -0
  64. package/dist/document/respell.d.ts.map +1 -1
  65. package/dist/document/respell.js +2 -1
  66. package/dist/document/respell.js.map +1 -1
  67. package/dist/document/rules.d.ts.map +1 -1
  68. package/dist/document/rules.js +2 -1
  69. package/dist/document/rules.js.map +1 -1
  70. package/dist/document/schema.d.ts +8 -0
  71. package/dist/document/schema.d.ts.map +1 -1
  72. package/dist/document/schema.js +6 -0
  73. package/dist/document/schema.js.map +1 -1
  74. package/dist/document/template.d.ts.map +1 -1
  75. package/dist/document/template.js +5 -8
  76. package/dist/document/template.js.map +1 -1
  77. package/dist/document/to-document.d.ts.map +1 -1
  78. package/dist/document/to-document.js +1 -0
  79. package/dist/document/to-document.js.map +1 -1
  80. package/dist/document.schema.json +12 -2
  81. package/dist/installation.d.ts.map +1 -1
  82. package/dist/installation.js +2 -0
  83. package/dist/installation.js.map +1 -1
  84. package/dist/lines.d.ts.map +1 -1
  85. package/dist/lines.js +2 -1
  86. package/dist/lines.js.map +1 -1
  87. package/dist/mutations/define-mutation.d.ts +2 -0
  88. package/dist/mutations/define-mutation.d.ts.map +1 -1
  89. package/dist/mutations/define-mutation.js +1 -0
  90. package/dist/mutations/define-mutation.js.map +1 -1
  91. package/dist/mutations/types.d.ts +6 -0
  92. package/dist/mutations/types.d.ts.map +1 -1
  93. package/dist/permissions/policy.d.ts +8 -2
  94. package/dist/permissions/policy.d.ts.map +1 -1
  95. package/dist/permissions/policy.js +27 -42
  96. package/dist/permissions/policy.js.map +1 -1
  97. package/dist/permissions/sight.d.ts +4 -3
  98. package/dist/permissions/sight.d.ts.map +1 -1
  99. package/dist/permissions/sight.js +10 -9
  100. package/dist/permissions/sight.js.map +1 -1
  101. package/dist/permissions/types.d.ts +17 -0
  102. package/dist/permissions/types.d.ts.map +1 -1
  103. package/dist/permissions/types.js.map +1 -1
  104. package/dist/schema/define-node.js +2 -2
  105. package/dist/schema/define-node.js.map +1 -1
  106. package/dist/schema/json-schema.d.ts.map +1 -1
  107. package/dist/schema/json-schema.js +5 -1
  108. package/dist/schema/json-schema.js.map +1 -1
  109. package/dist/schema/types.d.ts +9 -0
  110. package/dist/schema/types.d.ts.map +1 -1
  111. package/dist/search.d.ts.map +1 -1
  112. package/dist/search.js +2 -6
  113. package/dist/search.js.map +1 -1
  114. package/dist/store.d.ts +4 -1
  115. package/dist/store.d.ts.map +1 -1
  116. package/dist/store.js +79 -75
  117. package/dist/store.js.map +1 -1
  118. package/dist/version.d.ts +1 -1
  119. package/dist/version.js +1 -1
  120. package/package.json +1 -1
@@ -5,7 +5,7 @@ import { error } from "./findings.js";
5
5
  import { coerce } from "./migrate.js";
6
6
  import { outsideRange, rangeWords } from "./range.js";
7
7
  import { farEnd } from "./far-end.js";
8
- import { ActSpec, EDGE_NAME, EdgeSpec, FIELD_NAME, FIELD_TYPES, FieldSpec, KindSpec, NAME, RuleSpec, } from "./schema.js";
8
+ import { ActSpec, EDGE_NAME, EdgeSpec, FIELD_NAME, FIELD_TYPES, FieldSpec, GrantSpec, KindSpec, NAME, RuleSpec, SightSpec, } from "./schema.js";
9
9
  import { SHIPPED_LENSES, isShippedLens, SHIPPED_LENS_NAMES } from "../places.js";
10
10
  import { placeSlug } from "../views/types.js";
11
11
  import { computedOf, validateComputed } from "./computed.js";
@@ -14,10 +14,13 @@ import { validateViews, VIEW_SLOTS } from "./views.js";
14
14
  import { brandFindings } from "./brand-check.js";
15
15
  import { canonicalize } from "./canonical.js";
16
16
  import { quotedAtTheEnd } from "./quote.js";
17
- import { respellDocument } from "./respell.js";
17
+ import { isRecord as isObject, respellDocument } from "./respell.js";
18
+ import { refName } from "./compiled.js";
18
19
  import { accentProblem } from "../theme/accent.js";
19
20
  import { pageFieldFindings, pageNames, renamedOnPage, withoutOnPage } from "./page-fields.js";
20
- import { capitalize } from "../capital.js";
21
+ import { capitalize, listed as listOf } from "../capital.js";
22
+ import { quoted } from "./quote.js";
23
+ import { FIRST_GRANT, FIRST_SIGHT, grantBody, grantGone, grantSentence, NO_GRANT_LEFT, NO_SIGHT_LEFT, peopleKinds, peopleSentence, nounWords, pluralWords as pluralOf, sightGone, sightSentence, sightWords, whoWords } from "./policy-words.js";
21
24
  /** The ops, for tool schemas and the guide. */
22
25
  export const EDIT_OPS = [
23
26
  "add-kind",
@@ -51,6 +54,11 @@ export const EDIT_OPS = [
51
54
  "set-home",
52
55
  "arrange-pages",
53
56
  "set-computed",
57
+ "set-people",
58
+ "add-grant",
59
+ "remove-grant",
60
+ "add-sight",
61
+ "remove-sight",
54
62
  ];
55
63
  /** Words the rule language keeps: a field or relation renamed to one could not be named in a rule. */
56
64
  const KEYWORDS = new Set(["and", "or", "not", "in", "where", "true", "false", "null"]);
@@ -149,12 +157,16 @@ const SHAPES = {
149
157
  description: z.union([z.string().min(1).max(500), z.null()]).optional(),
150
158
  })
151
159
  .strict(),
160
+ // Who the people are, and who may do and see what (FR-170): a grant and a sight as the document holds one.
161
+ "set-people": z.object({ op: z.literal("set-people"), kind: kindName, people: z.boolean() }).strict(),
162
+ "add-grant": z.object({ op: z.literal("add-grant") }).passthrough(),
163
+ "remove-grant": z.object({ op: z.literal("remove-grant") }).passthrough(),
164
+ "add-sight": z.object({ op: z.literal("add-sight") }).passthrough(),
165
+ "remove-sight": z.object({ op: z.literal("remove-sight") }).passthrough(),
152
166
  };
153
167
  const NONE = new Set();
154
168
  const clone = (v) => structuredClone(v);
155
- const asArray = (v) => (v === undefined ? [] : Array.isArray(v) ? v : [v]);
156
- const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
157
- const refName = (v) => (typeof v === "string" && /^\$[A-Za-z][A-Za-z0-9]*$/.test(v) ? v.slice(1) : undefined);
169
+ const asArray = (v) => [v ?? []].flat();
158
170
  const kebab = (camel) => camel.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
159
171
  const words = (name) => kebab(name).replace(/-/g, " ");
160
172
  function hasKind(kinds, kind) {
@@ -605,6 +617,11 @@ class Editor {
605
617
  }
606
618
  return true;
607
619
  }
620
+ /** A shape's refusals, each at its path: a key it does not take, said by name. */
621
+ refuse(i, issues, what) {
622
+ for (const issue of issues)
623
+ this.fail(i, issue.path.map(String).join("."), issue.keys ? `${issue.keys.map((k) => `"${k}"`).join(", ")} ${issue.keys.length === 1 ? "is" : "are"} not part of ${what}` : issue.message);
624
+ }
608
625
  apply(i, edit) {
609
626
  const op = edit["op"];
610
627
  if (typeof op !== "string" || !EDIT_OPS.includes(op)) {
@@ -613,10 +630,7 @@ class Editor {
613
630
  }
614
631
  const parsed = SHAPES[op].safeParse(edit);
615
632
  if (!parsed.success) {
616
- for (const issue of parsed.error.issues) {
617
- const path = issue.path.map(String).join(".");
618
- this.fail(i, path, issue.code === "unrecognized_keys" ? `${issue.keys.map((k) => `"${k}"`).join(", ")} ${issue.keys.length === 1 ? "is" : "are"} not part of ${op}` : issue.message);
619
- }
633
+ this.refuse(i, parsed.error.issues, op);
620
634
  return;
621
635
  }
622
636
  const e = parsed.data;
@@ -784,6 +798,13 @@ class Editor {
784
798
  return this.arrangePages(i, e);
785
799
  case "set-computed":
786
800
  return this.setComputed(i, e);
801
+ case "set-people":
802
+ return this.setPeople(i, e);
803
+ case "add-grant":
804
+ case "remove-grant":
805
+ case "add-sight":
806
+ case "remove-sight":
807
+ return this.policyEdit(i, e);
787
808
  }
788
809
  }
789
810
  /** How a record's page orders and groups its facts (FR-148): judged as the compile judges `kinds.<kind>.page`. */
@@ -1135,6 +1156,120 @@ class Editor {
1135
1156
  else
1136
1157
  this.said.push(`${plural}' "${name}" is ${next.label ? `shown as "${next.label}"` : "shown by its name"}${next.description ? `, described as "${next.description}"` : ""}.`);
1137
1158
  }
1159
+ // ── who the people are, and who may do and see what (FR-170) ──
1160
+ /** The last edit that changed the people or the policy: where what they make together is judged. */
1161
+ lastPolicyEdit = -1;
1162
+ failAs(code, i, path, message, fix) {
1163
+ this.findings.push(error(code, `edits.${i}${path ? `.${path}` : ""}`, message, fix));
1164
+ }
1165
+ setPeople(i, e) {
1166
+ const spec = this.kind(i, e.kind);
1167
+ if (!spec)
1168
+ return;
1169
+ this.lastPolicyEdit = i;
1170
+ const already = (spec.people === true) === e.people;
1171
+ if (e.people && !already) {
1172
+ // Exactly one kind is the people: a principal's id is a record of it, never of one of two.
1173
+ const other = peopleKinds(this.doc.kinds)[0];
1174
+ if (other)
1175
+ return this.failAs("people-ambiguous", i, "kind", `${pluralOf(this.doc.kinds, other)} are already the people who sign in, and an app has one kind for them`, `{"op": "set-people", "kind": "${other}", "people": false} first, in the same change`);
1176
+ spec.people = true;
1177
+ }
1178
+ else if (!e.people)
1179
+ delete spec.people;
1180
+ this.said.push(peopleSentence(this.doc.kinds, e.kind, e.people, already));
1181
+ }
1182
+ /** A grant or a sight as the document holds one, or undefined having said why not. */
1183
+ policyPart(i, e, sight) {
1184
+ const { op: _op, ...rest } = e;
1185
+ const parsed = (sight ? SightSpec : GrantSpec).safeParse(rest);
1186
+ if (!parsed.success)
1187
+ return void this.refuse(i, parsed.error.issues, sight ? "a sight" : "a grant");
1188
+ return parsed.data;
1189
+ }
1190
+ policyEdit(i, e) {
1191
+ const sight = e.op === "add-sight" || e.op === "remove-sight";
1192
+ const part = this.policyPart(i, e, sight);
1193
+ if (!part)
1194
+ return;
1195
+ this.lastPolicyEdit = i;
1196
+ if (e.op === "remove-grant" || e.op === "remove-sight")
1197
+ return this.removePolicyPart(i, part, sight);
1198
+ // What it names is the app's: its kinds, and a grant's acts (a kind's derived edit and remove among them).
1199
+ if (!sight && Array.isArray(part.kinds) && part.kinds.length === 0)
1200
+ return this.fail(i, "kinds", "a grant on no kind gives nothing", "leave kinds out for every kind, or name the kinds it is on");
1201
+ for (const [n, kind] of (Array.isArray(part.kinds) ? part.kinds : []).entries()) {
1202
+ if (!this.doc.kinds[kind])
1203
+ return this.failAs(sight ? "sight-kind" : "edit", i, `kinds.${n}`, `"${kind}" is not a kind this app has; it has ${Object.keys(this.doc.kinds).join(", ")}`);
1204
+ }
1205
+ if (!sight && part.mutations !== "*") {
1206
+ for (const [n, act] of part.mutations.entries()) {
1207
+ const derived = /^(?:edit|remove)-(.+)$/.exec(act);
1208
+ if (this.doc.acts?.[act] || (derived && this.doc.kinds[derived[1]]))
1209
+ continue;
1210
+ return this.failAs("grant-act", i, `mutations.${n}`, `"${act}" is not an act this app has`, `one of ${[...Object.keys(this.doc.acts ?? {}), ...Object.keys(this.doc.kinds).flatMap((kind) => [`edit-${kind}`, `remove-${kind}`])].join(", ")}`);
1211
+ }
1212
+ }
1213
+ const said = [];
1214
+ // A role the app has not declared becomes one of its roles, where it declares them.
1215
+ if (Array.isArray(this.doc.roles) && part.roles !== "*") {
1216
+ for (const role of part.roles) {
1217
+ if (this.doc.roles.includes(role))
1218
+ continue;
1219
+ this.doc.roles.push(role);
1220
+ said.push(`${capitalize(whoWords([role]))} is a new role.`);
1221
+ }
1222
+ }
1223
+ if (!this.doc.policy) {
1224
+ // No policy permits everything; a sight alone would take every act away, so its policy says everyone may still do them.
1225
+ const everything = { roles: "*", mutations: "*" };
1226
+ this.doc.policy = { grants: sight ? [everything] : [] };
1227
+ said.push(FIRST_GRANT, ...(sight ? [grantSentence(this.doc, everything)] : []));
1228
+ }
1229
+ if (sight) {
1230
+ if (!this.doc.policy.sees?.length)
1231
+ said.push(FIRST_SIGHT);
1232
+ (this.doc.policy.sees ??= []).push(part);
1233
+ said.push(sightSentence(this.doc, part));
1234
+ }
1235
+ else {
1236
+ this.doc.policy.grants.push(part);
1237
+ said.push(grantSentence(this.doc, part));
1238
+ }
1239
+ this.said.push(...said);
1240
+ }
1241
+ /**
1242
+ * A GRANT OR A SIGHT, NAMED BY WHAT IT SAYS. A chat names one the way it
1243
+ * would say it — "the grant that lets everyone take back a vote" — so a
1244
+ * removal names it by its keys: roles and acts (or kinds) in any order,
1245
+ * and any other key it gives must match too. Never by its place in the
1246
+ * list, which another change can move.
1247
+ */
1248
+ removePolicyPart(i, part, sight) {
1249
+ const list = (sight ? this.doc.policy?.sees : this.doc.policy?.grants) ?? [];
1250
+ const sameSet = (a, b) => a === b || (Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((x) => b.includes(x)));
1251
+ const keys = sight ? ["own", "via"] : ["kinds", "self", "ownEnd", "describe"];
1252
+ const matches = list.filter((one) => sameSet(one.roles, part.roles) &&
1253
+ (sight ? sameSet(one.kinds, part.kinds) : sameSet(one.mutations, part.mutations)) &&
1254
+ keys.every((key) => part[key] === undefined || (typeof part[key] === "boolean" ? (one[key] ?? false) === part[key] : key === "kinds" ? sameSet(one[key] ?? "*", part[key]) : one[key] === part[key])));
1255
+ const what = sight ? "sight" : "grant";
1256
+ const say = (one) => (sight ? `${whoWords(one.roles)} ${sightWords(this.doc, one)}` : grantBody(this.doc, one));
1257
+ if (matches.length === 0) {
1258
+ const there = list.map(say);
1259
+ return this.fail(i, "", `no ${what} ${sight ? "shows" : "says"} ${say(part)}${there.length ? `; the ${what}s ${sight ? "show" : "say"} ${there.length > 1 ? `${there.slice(0, -1).join("; ")}; and ${there.at(-1)}` : there[0]}` : `; the app has no ${what}s`}`);
1260
+ }
1261
+ if (matches.length > 1)
1262
+ return this.fail(i, "", `${matches.length} ${what}s ${sight ? "show" : "say"} ${say(part)}`, `say ${sight ? "own or via" : "ownEnd, self, kinds or describe"} as the one to remove says them`);
1263
+ const gone = matches[0];
1264
+ list.splice(list.indexOf(gone), 1);
1265
+ this.said.push(sight ? sightGone(this.doc, gone) : grantGone(this.doc, gone));
1266
+ if (sight && list.length === 0) {
1267
+ delete this.doc.policy.sees;
1268
+ this.said.push(NO_SIGHT_LEFT);
1269
+ }
1270
+ else if (!sight && list.length === 0)
1271
+ this.said.push(NO_GRANT_LEFT);
1272
+ }
1138
1273
  // ── kinds ──
1139
1274
  addKind(i, e) {
1140
1275
  const { op: _op, kind, act, ...rest } = e;
@@ -1142,8 +1277,7 @@ class Editor {
1142
1277
  return this.fail(i, "kind", `there is already a kind "${kind}"`);
1143
1278
  const parsed = KindSpec.safeParse(rest);
1144
1279
  if (!parsed.success) {
1145
- for (const issue of parsed.error.issues)
1146
- this.fail(i, issue.path.map(String).join("."), issue.code === "unrecognized_keys" ? `${issue.keys.map((k) => `"${k}"`).join(", ")} ${issue.keys.length === 1 ? "is" : "are"} not part of a kind` : issue.message);
1280
+ this.refuse(i, parsed.error.issues, "a kind");
1147
1281
  return;
1148
1282
  }
1149
1283
  const spec = { ...parsed.data };
@@ -1228,6 +1362,11 @@ class Editor {
1228
1362
  return;
1229
1363
  if (Object.keys(this.doc.kinds).length === 1)
1230
1364
  return this.fail(i, "kind", "an app keeps at least one kind");
1365
+ // The last sight gone would show everyone everything, every other kind included: the policy's to change first, as a relation a sight reads is.
1366
+ const sees = this.doc.policy?.sees ?? [];
1367
+ if (sees.length > 0 && sees.every((sight) => sight.kinds.every((k) => k === kind))) {
1368
+ return this.fail(i, "kind", `every sight shows only ${pluralWords(this.doc, kind)}, so with them gone everyone would see everything`, `remove those sights first, in the same change ({"op": "remove-sight", …}), which says so`);
1369
+ }
1231
1370
  const gone = [];
1232
1371
  /*
1233
1372
  * The relations it declares go with it — and so does what walks them
@@ -1262,8 +1401,8 @@ class Editor {
1262
1401
  for (const [name, act] of Object.entries(this.doc.acts ?? {})) {
1263
1402
  const on = asArray(act.on);
1264
1403
  if (act.creates === kind || (act.effects ?? []).some((x) => x.create === kind) || (on.includes(kind) && on.length === 1)) {
1404
+ gone.push(titled("act", name, act));
1265
1405
  this.removeActQuietly(name);
1266
- gone.push(`act ${name}`);
1267
1406
  }
1268
1407
  else if (on.includes(kind))
1269
1408
  act.on = on.filter((k) => k !== kind);
@@ -1271,7 +1410,7 @@ class Editor {
1271
1410
  for (const [name, rule] of Object.entries(this.doc.rules ?? {})) {
1272
1411
  if (rule.over === kind || exprMentions(this.doc, rule.when, rule.over === "graph" ? NONE : new Set([rule.over]), { t: "kind", from: kind, to: "\u0000" }) || exprMentions(this.doc, rule.require, rule.over === "graph" ? NONE : new Set([rule.over]), { t: "kind", from: kind, to: "\u0000" })) {
1273
1412
  delete this.doc.rules[name];
1274
- gone.push(`rule ${name}`);
1413
+ gone.push(titled("rule", name, rule));
1275
1414
  }
1276
1415
  }
1277
1416
  if (this.doc.views?.[kind] && !Array.isArray(this.doc.views[kind])) {
@@ -1279,9 +1418,21 @@ class Editor {
1279
1418
  if (Object.keys(this.doc.views).length === 0)
1280
1419
  delete this.doc.views;
1281
1420
  }
1282
- for (const g of this.doc.policy?.grants ?? [])
1283
- if (Array.isArray(g.kinds))
1284
- g.kinds = g.kinds.filter((k) => k !== kind);
1421
+ /*
1422
+ * A grant no longer names the kind, nor its derived edit and remove; one
1423
+ * left naming no kind or no act gives nothing, and goes, as a grant does
1424
+ * whose last act is removed — kept, it read as a grant on every kind.
1425
+ */
1426
+ if (this.doc.policy) {
1427
+ const derived = [`edit-${kind}`, `remove-${kind}`];
1428
+ this.doc.policy.grants = this.doc.policy.grants.flatMap((g) => {
1429
+ const kinds = Array.isArray(g.kinds) ? g.kinds.filter((k) => k !== kind) : g.kinds;
1430
+ const mutations = Array.isArray(g.mutations) ? g.mutations.filter((m) => !derived.includes(m) || this.doc.acts?.[m]) : g.mutations;
1431
+ if ((Array.isArray(g.kinds) && g.kinds.length > 0 && kinds.length === 0) || (Array.isArray(mutations) && mutations.length === 0))
1432
+ return [];
1433
+ return [{ ...g, ...(kinds !== undefined ? { kinds } : {}), mutations }];
1434
+ });
1435
+ }
1285
1436
  if (this.doc.policy?.sees) {
1286
1437
  this.doc.policy.sees = this.doc.policy.sees.map((s) => ({ ...s, kinds: s.kinds.filter((k) => k !== kind) })).filter((s) => s.kinds.length > 0);
1287
1438
  if (this.doc.policy.sees.length === 0)
@@ -1319,7 +1470,7 @@ class Editor {
1319
1470
  for (const f of [...this.fills])
1320
1471
  if (f.kind === kind)
1321
1472
  this.fills.splice(this.fills.indexOf(f), 1);
1322
- this.said.push(`The ${kind} kind is removed, and every ${kind} with it${gone.length ? `; so are ${listOf(gone)}` : ""}.`);
1473
+ this.said.push(`The ${kind} kind is removed, and every ${kind} with it${gone.length ? `; so ${gone.length === 1 ? "is" : "are"} ${listOf(gone)}` : ""}.`);
1323
1474
  }
1324
1475
  // ── fields ──
1325
1476
  addField(i, e) {
@@ -1333,8 +1484,7 @@ class Editor {
1333
1484
  return this.fail(i, "field", `"${field}" is a word the rule language keeps; pick another name`);
1334
1485
  const parsed = FieldSpec.safeParse({ ...(given ?? {}), ...inline });
1335
1486
  if (!parsed.success) {
1336
- for (const issue of parsed.error.issues)
1337
- this.fail(i, issue.path.map(String).join("."), issue.code === "unrecognized_keys" ? `${issue.keys.map((k) => `"${k}"`).join(", ")} ${issue.keys.length === 1 ? "is" : "are"} not part of a field` : issue.message);
1487
+ this.refuse(i, parsed.error.issues, "a field");
1338
1488
  return;
1339
1489
  }
1340
1490
  const f = { ...parsed.data };
@@ -1570,8 +1720,7 @@ class Editor {
1570
1720
  return this.fail(i, "relation", `"${relation}" is a word the rule language keeps; pick another name`);
1571
1721
  const parsed = EdgeSpec.safeParse({ ...rest, to: typeof rest.to === "string" && rest.to !== "*" ? [rest.to] : rest.to });
1572
1722
  if (!parsed.success) {
1573
- for (const issue of parsed.error.issues)
1574
- this.fail(i, issue.path.map(String).join("."), issue.code === "unrecognized_keys" ? `${issue.keys.map((k) => `"${k}"`).join(", ")} ${issue.keys.length === 1 ? "is" : "are"} not part of a relation` : issue.message);
1723
+ this.refuse(i, parsed.error.issues, "a relation");
1575
1724
  return;
1576
1725
  }
1577
1726
  const edge = { ...parsed.data };
@@ -1598,6 +1747,10 @@ class Editor {
1598
1747
  if (Object.values(this.doc.kinds).some((s) => s.edges?.[to]))
1599
1748
  return this.fail(i, "to", `another kind already has a relation called "${to}"`);
1600
1749
  const touched = this.rewriteAll({ t: "edge", from: relation, to });
1750
+ // A sight via it names it too.
1751
+ for (const sight of this.doc.policy?.sees ?? [])
1752
+ if (sight.via === relation)
1753
+ sight.via = to;
1601
1754
  for (const k of holders) {
1602
1755
  const s = this.doc.kinds[k];
1603
1756
  const edges = {};
@@ -1623,6 +1776,9 @@ class Editor {
1623
1776
  const others = [...sources(this.doc, relation)].filter((k) => k !== kind);
1624
1777
  if (others.length === 0 && this.lensesDrawnBy(i, { t: "edge", from: relation, to: "\u0000" }))
1625
1778
  return;
1779
+ // A sight via it is the policy's to change first, as a lens drawn by it is: dropped, it could show everybody what it kept to each seat's own.
1780
+ if (others.length === 0 && this.doc.policy?.sees?.some((sight) => sight.via === relation))
1781
+ return this.fail(i, "relation", `a sight shows what "${relation}" joins to each seat's own record`, "change the policy's sight first");
1626
1782
  const gone = [];
1627
1783
  this.dropRelation(kind, relation, gone);
1628
1784
  this.said.push(`The "${relation}" relation from ${kind} is removed, with every link of that kind${gone.length ? `; ${listOf(gone)} ${gone.length === 1 ? "goes" : "go"} with it` : ""}.`);
@@ -1644,8 +1800,7 @@ class Editor {
1644
1800
  return this.fail(i, "act", `there is already an act "${act}"`, "pass replace: true to change it, or pick another name");
1645
1801
  const parsed = ActSpec.safeParse(rest);
1646
1802
  if (!parsed.success) {
1647
- for (const issue of parsed.error.issues)
1648
- this.fail(i, issue.path.map(String).join("."), issue.code === "unrecognized_keys" ? `${issue.keys.map((k) => `"${k}"`).join(", ")} ${issue.keys.length === 1 ? "is" : "are"} not part of an act` : issue.message);
1803
+ this.refuse(i, parsed.error.issues, "an act");
1649
1804
  return;
1650
1805
  }
1651
1806
  const existed = Boolean(this.doc.acts?.[act]);
@@ -1675,7 +1830,7 @@ class Editor {
1675
1830
  continue;
1676
1831
  const kept = rule.repairs.filter((r) => r.act !== act);
1677
1832
  if (kept.length !== rule.repairs.length)
1678
- gone.push(`the "${rule.repairs.find((r) => r.act === act)?.label ?? act}" repair of rule ${name}`);
1833
+ gone.push(`the "${rule.repairs.find((r) => r.act === act)?.label ?? act}" repair of ${titled("rule", name, rule)}`);
1679
1834
  if (kept.length > 0)
1680
1835
  rule.repairs = kept;
1681
1836
  else
@@ -1689,8 +1844,7 @@ class Editor {
1689
1844
  return this.fail(i, "rule", `there is already a rule "${rule}"`, "pass replace: true to change it, or pick another name");
1690
1845
  const parsed = RuleSpec.safeParse(rest);
1691
1846
  if (!parsed.success) {
1692
- for (const issue of parsed.error.issues)
1693
- this.fail(i, issue.path.map(String).join("."), issue.code === "unrecognized_keys" ? `${issue.keys.map((k) => `"${k}"`).join(", ")} ${issue.keys.length === 1 ? "is" : "are"} not part of a rule` : issue.message);
1847
+ this.refuse(i, parsed.error.issues, "a rule");
1694
1848
  return;
1695
1849
  }
1696
1850
  const existed = Boolean(this.doc.rules?.[rule]);
@@ -2032,14 +2186,14 @@ class Editor {
2032
2186
  }
2033
2187
  }
2034
2188
  if (uses) {
2035
- gone.push(`act ${name}`, ...this.removeActQuietly(name));
2189
+ gone.push(titled("act", name, act), ...this.removeActQuietly(name));
2036
2190
  }
2037
2191
  }
2038
2192
  for (const [name, rule] of Object.entries(doc.rules ?? {})) {
2039
2193
  const ctx = rule.over === "graph" ? NONE : new Set([rule.over]);
2040
2194
  if (exprMentions(doc, rule.when, ctx, r) || exprMentions(doc, rule.require, ctx, r) || templateMentions(doc, rule.says, ctx, r)) {
2041
2195
  delete doc.rules[name];
2042
- gone.push(`rule ${name}`);
2196
+ gone.push(titled("rule", name, rule));
2043
2197
  }
2044
2198
  }
2045
2199
  if (doc.rules && Object.keys(doc.rules).length === 0)
@@ -2239,6 +2393,70 @@ class Editor {
2239
2393
  return said;
2240
2394
  }
2241
2395
  }
2396
+ /** What the people and the policy make together, as `graview check` judges it (`checkPeople`), said over the document. */
2397
+ function peopleProblems(doc, at) {
2398
+ const out = [];
2399
+ const kinds = doc.kinds;
2400
+ const people = peopleKinds(kinds);
2401
+ const policy = doc.policy;
2402
+ const reads = policy?.sees?.some((sight) => sight.via !== undefined) ? "a sight via a relation" : policy?.grants.some((grant) => grant.ownEnd) ? "a grant held to a person's own end of a link" : undefined;
2403
+ if (people.length === 0 && reads)
2404
+ out.push(error("people-unsaid", at, `${reads} reads each person's own record, and no kind is the people who sign in`, '{"op": "set-people", "kind": "<the kind whose records are the people>", "people": true}'));
2405
+ for (const sight of policy?.sees ?? []) {
2406
+ const via = sight.via;
2407
+ if (via === undefined)
2408
+ continue;
2409
+ const from = Object.keys(kinds).filter((kind) => kinds[kind].edges?.[via]);
2410
+ if (from.length === 0) {
2411
+ out.push(error("sight-via-unknown-edge", at, `no kind has a relation ${quoted(via)}, so a sight via it shows nothing`, `name a relation that joins ${listOf(sight.kinds.map((kind) => pluralOf(kinds, kind)))} to the people`));
2412
+ continue;
2413
+ }
2414
+ if (people.length === 0)
2415
+ continue;
2416
+ const reaches = (target) => from.some((kind) => {
2417
+ const to = kinds[kind].edges[via].to;
2418
+ return to === "*" || to.includes(target);
2419
+ });
2420
+ for (const kind of sight.kinds) {
2421
+ if ((from.includes(kind) && people.some(reaches)) || (people.some((one) => from.includes(one)) && reaches(kind)))
2422
+ continue;
2423
+ out.push(error("sight-via-misses-people", at, `${quoted(via)} never joins ${pluralOf(kinds, kind)} to ${pluralOf(kinds, people[0])}, so the sight shows no ${nounOf(doc, kind)} to anyone`, `name a relation between ${nounOf(doc, kind)} and ${nounOf(doc, people[0])}, or say own: true`));
2424
+ }
2425
+ }
2426
+ for (const grant of policy?.grants ?? []) {
2427
+ if (!grant.ownEnd)
2428
+ continue;
2429
+ if (grant.mutations === "*") {
2430
+ out.push(error("grant-own-end-without-link", at, "a grant held to a person's own end of a link names every act, and most link nothing", "name the acts it holds: the ones that connect or sever"));
2431
+ continue;
2432
+ }
2433
+ for (const name of grant.mutations) {
2434
+ const ends = linkEnds(doc.acts?.[name]);
2435
+ if (ends === "far")
2436
+ continue;
2437
+ const title = quoted(doc.acts?.[name]?.title ?? name);
2438
+ out.push(ends === "near"
2439
+ ? error("grant-own-end-without-link", at, `${title} takes no record at the other end of what it links, so a grant held to a person's own end of it is never met`, 'take the record at its far end as an argument, or say "$me" there and drop ownEnd')
2440
+ : error("grant-own-end-without-link", at, `${title} connects and severs nothing, so a grant held to a person's own end of a link has no end to hold`, "hold it with self: true for the subject, or name an act that connects or severs"));
2441
+ }
2442
+ }
2443
+ return out;
2444
+ }
2445
+ /** Whether an act links, and whether it takes the record at the far end of a link as an argument: "far", "near" (only its subject, "$me" or what it made) or "none". */
2446
+ function linkEnds(act) {
2447
+ if (!act)
2448
+ return "none";
2449
+ if (act.connects || act.severs)
2450
+ return "far";
2451
+ const own = new Set(["subject", "me", "new", ...(act.effects ?? []).flatMap((e) => (e.create && e.as ? [e.as] : []))]);
2452
+ const links = (act.effects ?? []).filter((e) => e.connect || e.sever);
2453
+ if (links.length === 0)
2454
+ return "none";
2455
+ return links.some((e) => [e.from, e.to].some((end) => {
2456
+ const ref = refName(end);
2457
+ return ref !== undefined && !own.has(ref);
2458
+ })) ? "far" : "near";
2459
+ }
2242
2460
  /** A `$name` reference or bare argument name in a guard, renamed (for an argument that followed its field). */
2243
2461
  function rewriteBinding(source, from, to) {
2244
2462
  try {
@@ -2260,7 +2478,6 @@ function swapToken(name, from, to) {
2260
2478
  }
2261
2479
  return name;
2262
2480
  }
2263
- const listOf = (xs) => (xs.length <= 1 ? xs.join("") : `${xs.slice(0, -1).join(", ")} and ${xs.at(-1)}`);
2264
2481
  /** Map every block of a view, nested ones included; a block mapped to null is dropped. */
2265
2482
  function mapBlocks(blocks, f) {
2266
2483
  const out = [];
@@ -2281,13 +2498,12 @@ function mapBlocks(blocks, f) {
2281
2498
  return out;
2282
2499
  }
2283
2500
  // ── words for what changed ───────────────────────────────────────────────────
2501
+ /** An act or a rule that goes with something, as a person reads it: 'the act "Draw a match"', by its id only when it has no title. */
2502
+ const titled = (what, name, spec) => `the ${what} "${spec.title ?? name}"`;
2284
2503
  const withA = (noun) => `${/^[aeiou]/i.test(noun) ? "an" : "a"} ${noun}`;
2285
- const nounOf = (doc, kind) => doc.kinds[kind]?.noun ?? words(kind);
2504
+ const nounOf = (doc, kind) => nounWords(doc.kinds, kind);
2286
2505
  /** A kind's plural as a person reads it: "packages", "people and teams". */
2287
- function pluralWords(doc, kind) {
2288
- const plural = doc.kinds[kind]?.plural;
2289
- return plural ? plural.charAt(0).toLowerCase() + plural.slice(1) : `${words(kind)}s`;
2290
- }
2506
+ const pluralWords = (doc, kind) => pluralOf(doc.kinds, kind);
2291
2507
  /** The kinds a lens binds, for saying where it stands. */
2292
2508
  function kindsBound(lens) {
2293
2509
  const bindings = lens["bindings"];
@@ -2542,6 +2758,20 @@ export function editDocument(document, edits, options = {}) {
2542
2758
  });
2543
2759
  if (editor.findings.length > 0)
2544
2760
  return { ok: false, findings: editor.findings };
2761
+ /*
2762
+ * WHAT THE PEOPLE AND THE POLICY MAKE TOGETHER, judged once the change is
2763
+ * whole (FR-170): moving the people from one kind to another is two edits,
2764
+ * and the first alone leaves a sight reading nobody. What the change made
2765
+ * wrong is refused at its last such edit; what was wrong before is not
2766
+ * this change's to answer for.
2767
+ */
2768
+ if (editor.lastPolicyEdit >= 0) {
2769
+ const at = `edits.${editor.lastPolicyEdit}`;
2770
+ const before = new Set(peopleProblems(current, at).map((finding) => finding.message));
2771
+ const made = peopleProblems(editor.doc, at).filter((finding) => !before.has(finding.message));
2772
+ if (made.length > 0)
2773
+ return { ok: false, findings: made };
2774
+ }
2545
2775
  /*
2546
2776
  * THE TOOL INTERFACE MOVED, AND SAYS SO (FR-34). A derived act is named for
2547
2777
  * its kind and asks for its fields by name, so a rename changes what an