@graview/core 0.1.27 → 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.
@@ -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";
@@ -18,7 +18,9 @@ import { isRecord as isObject, respellDocument } from "./respell.js";
18
18
  import { refName } from "./compiled.js";
19
19
  import { accentProblem } from "../theme/accent.js";
20
20
  import { pageFieldFindings, pageNames, renamedOnPage, withoutOnPage } from "./page-fields.js";
21
- 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";
22
24
  /** The ops, for tool schemas and the guide. */
23
25
  export const EDIT_OPS = [
24
26
  "add-kind",
@@ -52,6 +54,11 @@ export const EDIT_OPS = [
52
54
  "set-home",
53
55
  "arrange-pages",
54
56
  "set-computed",
57
+ "set-people",
58
+ "add-grant",
59
+ "remove-grant",
60
+ "add-sight",
61
+ "remove-sight",
55
62
  ];
56
63
  /** Words the rule language keeps: a field or relation renamed to one could not be named in a rule. */
57
64
  const KEYWORDS = new Set(["and", "or", "not", "in", "where", "true", "false", "null"]);
@@ -150,6 +157,12 @@ const SHAPES = {
150
157
  description: z.union([z.string().min(1).max(500), z.null()]).optional(),
151
158
  })
152
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(),
153
166
  };
154
167
  const NONE = new Set();
155
168
  const clone = (v) => structuredClone(v);
@@ -785,6 +798,13 @@ class Editor {
785
798
  return this.arrangePages(i, e);
786
799
  case "set-computed":
787
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);
788
808
  }
789
809
  }
790
810
  /** How a record's page orders and groups its facts (FR-148): judged as the compile judges `kinds.<kind>.page`. */
@@ -1136,6 +1156,120 @@ class Editor {
1136
1156
  else
1137
1157
  this.said.push(`${plural}' "${name}" is ${next.label ? `shown as "${next.label}"` : "shown by its name"}${next.description ? `, described as "${next.description}"` : ""}.`);
1138
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
+ }
1139
1273
  // ── kinds ──
1140
1274
  addKind(i, e) {
1141
1275
  const { op: _op, kind, act, ...rest } = e;
@@ -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) {
@@ -1679,7 +1830,7 @@ class Editor {
1679
1830
  continue;
1680
1831
  const kept = rule.repairs.filter((r) => r.act !== act);
1681
1832
  if (kept.length !== rule.repairs.length)
1682
- 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)}`);
1683
1834
  if (kept.length > 0)
1684
1835
  rule.repairs = kept;
1685
1836
  else
@@ -2035,14 +2186,14 @@ class Editor {
2035
2186
  }
2036
2187
  }
2037
2188
  if (uses) {
2038
- gone.push(`act ${name}`, ...this.removeActQuietly(name));
2189
+ gone.push(titled("act", name, act), ...this.removeActQuietly(name));
2039
2190
  }
2040
2191
  }
2041
2192
  for (const [name, rule] of Object.entries(doc.rules ?? {})) {
2042
2193
  const ctx = rule.over === "graph" ? NONE : new Set([rule.over]);
2043
2194
  if (exprMentions(doc, rule.when, ctx, r) || exprMentions(doc, rule.require, ctx, r) || templateMentions(doc, rule.says, ctx, r)) {
2044
2195
  delete doc.rules[name];
2045
- gone.push(`rule ${name}`);
2196
+ gone.push(titled("rule", name, rule));
2046
2197
  }
2047
2198
  }
2048
2199
  if (doc.rules && Object.keys(doc.rules).length === 0)
@@ -2242,6 +2393,70 @@ class Editor {
2242
2393
  return said;
2243
2394
  }
2244
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
+ }
2245
2460
  /** A `$name` reference or bare argument name in a guard, renamed (for an argument that followed its field). */
2246
2461
  function rewriteBinding(source, from, to) {
2247
2462
  try {
@@ -2263,7 +2478,6 @@ function swapToken(name, from, to) {
2263
2478
  }
2264
2479
  return name;
2265
2480
  }
2266
- const listOf = (xs) => (xs.length <= 1 ? xs.join("") : `${xs.slice(0, -1).join(", ")} and ${xs.at(-1)}`);
2267
2481
  /** Map every block of a view, nested ones included; a block mapped to null is dropped. */
2268
2482
  function mapBlocks(blocks, f) {
2269
2483
  const out = [];
@@ -2284,13 +2498,12 @@ function mapBlocks(blocks, f) {
2284
2498
  return out;
2285
2499
  }
2286
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}"`;
2287
2503
  const withA = (noun) => `${/^[aeiou]/i.test(noun) ? "an" : "a"} ${noun}`;
2288
- const nounOf = (doc, kind) => doc.kinds[kind]?.noun ?? words(kind);
2504
+ const nounOf = (doc, kind) => nounWords(doc.kinds, kind);
2289
2505
  /** A kind's plural as a person reads it: "packages", "people and teams". */
2290
- function pluralWords(doc, kind) {
2291
- const plural = doc.kinds[kind]?.plural;
2292
- return plural ? plural.charAt(0).toLowerCase() + plural.slice(1) : `${words(kind)}s`;
2293
- }
2506
+ const pluralWords = (doc, kind) => pluralOf(doc.kinds, kind);
2294
2507
  /** The kinds a lens binds, for saying where it stands. */
2295
2508
  function kindsBound(lens) {
2296
2509
  const bindings = lens["bindings"];
@@ -2545,6 +2758,20 @@ export function editDocument(document, edits, options = {}) {
2545
2758
  });
2546
2759
  if (editor.findings.length > 0)
2547
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
+ }
2548
2775
  /*
2549
2776
  * THE TOOL INTERFACE MOVED, AND SAYS SO (FR-34). A derived act is named for
2550
2777
  * its kind and asks for its fields by name, so a rename changes what an