@githonllc/avouch 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,1001 @@
1
+ // Format-level ontology checker (rules R1–R12). Pure functions: no I/O. Project facts arrive through SourceFacts (contract.ts).
2
+ // Fail closed: a missing anchor, a parse failure or a missing field is a violation, never a skip.
3
+ import { IDENT, RULES } from "./contract.js";
4
+ import { derivedPropertyType, isObj, newReads } from "./expr.js";
5
+ import { analyzeConditions, asReads, parseScope, quotesOf, stage1Violations } from "./stage1-check.js";
6
+ export { IDENT };
7
+ const CLASSES = ["canonical", "derived", "policy", "unknown"];
8
+ const CARDINALITY = /^(1|N):(1|N|M)$/;
9
+ export function check(ontology, facts) {
10
+ const out = [];
11
+ const info = [];
12
+ const config = facts.config;
13
+ const v = (rule, key, msg, kind = "generic") => out.push({ rule, key, msg, kind });
14
+ const isStr = (x) => typeof x === "string" && x.length > 0;
15
+ const arr = (x) => (Array.isArray(x) ? x : []);
16
+ // membership in an ontology mapping: own keys only (`in` would follow the prototype chain, e.g. "toString")
17
+ const own = (m, k) => typeof k === "string" && Object.hasOwn(m, k);
18
+ if (!isObj(ontology))
19
+ return { violations: [{ rule: "R1", key: "root", msg: "ontology is not a mapping", kind: "structure" }], waived: [], info };
20
+ const ont = ontology;
21
+ const scope = parseScope(ont); // shared with stage 1 (R2 field lists) and R6
22
+ const conditionExprs = analyzeConditions(ont); // each expression condition type-checked once: R1 (stage 1), R4, R12
23
+ // rule switches: a rule without an entry runs and is reported; a disabled rule drops its violations and diagnostics
24
+ const disabled = new Map();
25
+ for (const r of RULES) {
26
+ const sw = config.rules[r];
27
+ if (sw === undefined)
28
+ v(r, `config.rules.${r}`, `rule ${r} has no entry in config.rules`, "rule_not_configured");
29
+ else if (sw !== true) {
30
+ disabled.set(r, sw.disabled);
31
+ info.push(`rule ${r} disabled: ${sw.disabled}`);
32
+ }
33
+ }
34
+ const missingFact = (rule, name) => {
35
+ if (!disabled.has(rule))
36
+ v(rule, `facts.${name}`, `rule ${rule} needs the fact ${name}, which the source adapter does not provide`, "fact_missing");
37
+ };
38
+ if (facts.storeCatalog === undefined)
39
+ missingFact("R2", "storeCatalog");
40
+ if (facts.stateMachines === undefined)
41
+ missingFact("R3", "stateMachines");
42
+ if (facts.transitionTriggers === undefined)
43
+ missingFact("R3", "transitionTriggers");
44
+ if (facts.contextMembers === undefined)
45
+ missingFact("R5", "contextMembers");
46
+ if (facts.evidence === undefined) {
47
+ missingFact("R6", "evidence");
48
+ missingFact("R7", "evidence");
49
+ }
50
+ if (facts.eventCatalog === undefined)
51
+ missingFact("R8", "eventCatalog");
52
+ if (facts.scenarios === undefined)
53
+ missingFact("R9", "scenarios");
54
+ if (facts.permissions === undefined)
55
+ missingFact("R10", "permissions");
56
+ const section = (anchor, rule, key) => {
57
+ if (!isStr(anchor)) {
58
+ v("R1", key, `doc anchor missing`);
59
+ return null;
60
+ }
61
+ const s = facts.section(anchor);
62
+ if (s === "missing")
63
+ v(rule, key, `anchor ${anchor} is not found in the source`);
64
+ else if (s === "ambiguous")
65
+ v(rule, key, `anchor ${anchor} matches more than one source section`);
66
+ else
67
+ return s;
68
+ return null;
69
+ };
70
+ // ------------------------------------------------------------------------------------------ R1
71
+ if (ont.formatVersion !== 1)
72
+ v("R1", "formatVersion", "formatVersion must be 1");
73
+ const contexts = isObj(ont.contexts) ? ont.contexts : {};
74
+ if (!isObj(ont.contexts) || Object.keys(contexts).length === 0)
75
+ v("R1", "contexts", "contexts missing");
76
+ const objects = isObj(ont.objectTypes) ? ont.objectTypes : {};
77
+ if (!isObj(ont.objectTypes))
78
+ v("R1", "objectTypes", "objectTypes missing");
79
+ const objType = (o) => own(objects, o) && isObj(objects[o]); // a declared object type that is a mapping
80
+ const outOfScopeMap = isObj(ont.outOfScopeObjectTypes) ? ont.outOfScopeObjectTypes : {};
81
+ if (ont.outOfScopeObjectTypes !== undefined && !isObj(ont.outOfScopeObjectTypes))
82
+ v("R1", "outOfScopeObjectTypes", "must be a mapping name -> reason");
83
+ for (const [n, r] of Object.entries(outOfScopeMap))
84
+ if (!isStr(r))
85
+ v("R1", `outOfScopeObjectTypes.${n}`, "needs a reason");
86
+ const outOfScope = new Set(Object.keys(outOfScopeMap));
87
+ const infra = new Set(arr(ont.infrastructureStores));
88
+ if (ont.infrastructureStores !== undefined && !Array.isArray(ont.infrastructureStores))
89
+ v("R1", "infrastructureStores", "must be a list");
90
+ const links = arr(ont.linkTypes);
91
+ if (!Array.isArray(ont.linkTypes))
92
+ v("R1", "linkTypes", "linkTypes missing");
93
+ const commands = isObj(ont.actionTypes) ? ont.actionTypes : {};
94
+ if (!isObj(ont.actionTypes))
95
+ v("R1", "actionTypes", "actionTypes missing");
96
+ const derived = isObj(ont.derivedProperties) ? ont.derivedProperties : {};
97
+ const stateMachines = isObj(ont.stateMachines) ? ont.stateMachines : {};
98
+ if (ont.stateMachines !== undefined && !isObj(ont.stateMachines))
99
+ v("R1", "stateMachines", "must be a mapping objectType -> state machine");
100
+ for (const n of Object.keys(stateMachines))
101
+ if (!objType(n))
102
+ v("R1", `stateMachines.${n}`, "not a declared object type");
103
+ const gaps = arr(ont.knownSourceGaps);
104
+ if (!Array.isArray(ont.knownSourceGaps))
105
+ v("R1", "knownSourceGaps", "missing (use [])");
106
+ for (const [name, o] of Object.entries(objects)) {
107
+ const p = `objectTypes.${name}`;
108
+ if (!isObj(o)) {
109
+ v("R1", p, "not a mapping");
110
+ continue;
111
+ }
112
+ if (!own(contexts, o.context))
113
+ v("R1", p, `context ${o.context} not declared`);
114
+ if (!isStr(o.datasource))
115
+ v("R1", p, "datasource missing");
116
+ if (!isStr(o.doc))
117
+ v("R1", p, "doc missing");
118
+ if (!isObj(o.properties))
119
+ v("R1", p, "properties missing (use {})");
120
+ for (const [f, nl] of Object.entries(isObj(o.non_link_fields) ? o.non_link_fields : {}))
121
+ if (!isObj(nl) || !isStr(nl.reason) || !isObj(nl.cite))
122
+ v("R1", `${p}.non_link_fields.${f}`, "needs reason and cite");
123
+ if (o.non_link_fields !== undefined && !isObj(o.non_link_fields))
124
+ v("R1", p, "non_link_fields must be a mapping");
125
+ for (const [pn, pr] of Object.entries(isObj(o.properties) ? o.properties : {})) {
126
+ if (!isObj(pr) || !CLASSES.includes(pr.class))
127
+ v("R1", `${p}.properties.${pn}`, `class must be one of ${CLASSES}`);
128
+ else if (pr.class !== "unknown" && !isObj(pr.cite))
129
+ v("R1", `${p}.properties.${pn}`, "class other than unknown needs a cite");
130
+ const cv = isObj(pr) ? pr.canonical_values : undefined;
131
+ if (cv !== undefined) {
132
+ const sm = stateMachines[name];
133
+ const states = isObj(sm) ? new Set([sm.initial, ...arr(sm.terminal), ...arr(sm.transitions).flatMap((t) => [t?.from, t?.to])]) : null;
134
+ if (!isObj(cv) || !Array.isArray(cv.values) || cv.values.length === 0 || !isObj(cv.cite))
135
+ v("R1", `${p}.properties.${pn}.canonical_values`, "needs values[] and cite");
136
+ else if (pr.class === "canonical")
137
+ v("R1", `${p}.properties.${pn}.canonical_values`, "only meaningful on a non-canonical property");
138
+ else if (pn === config.stateProperty && states)
139
+ for (const x of cv.values)
140
+ if (!states.has(x))
141
+ v("R1", `${p}.properties.${pn}.canonical_values`, `${x} is not a ${name} state`);
142
+ }
143
+ const rf = isObj(pr) ? pr.materializedFrom : undefined;
144
+ if (rf !== undefined && (!isObj(rf) || !Array.isArray(rf.reads) || rf.reads.length === 0 || !Array.isArray(rf.cites) || rf.cites.length === 0 || !rf.cites.every((c) => isObj(c) && isObj(c.cite))))
145
+ v("R1", `${p}.properties.${pn}.materializedFrom`, "needs reads[] and cites[{cite}]");
146
+ }
147
+ }
148
+ const propClass = (ref) => {
149
+ const [o, p, ...rest] = ref.split(".");
150
+ if (rest.length || !objType(o) || !isObj(objects[o].properties) || !own(objects[o].properties, p) || !isObj(objects[o].properties[p]))
151
+ return null;
152
+ const cls = objects[o].properties[p].class;
153
+ return CLASSES.includes(cls) ? cls : null; // a property without a valid class does not resolve (R1 reports it)
154
+ };
155
+ const linkIds = new Set();
156
+ for (const [i, l] of links.entries()) {
157
+ const p = `linkTypes[${i}]`;
158
+ if (!isObj(l) || !isStr(l.id)) {
159
+ v("R1", p, "link id missing");
160
+ continue;
161
+ }
162
+ if (linkIds.has(l.id))
163
+ v("R1", `linkTypes.${l.id}`, "duplicate link id");
164
+ linkIds.add(l.id);
165
+ if (!objType(l.from) || !objType(l.to))
166
+ v("R1", `linkTypes.${l.id}`, `from/to must be declared object types`);
167
+ if (!CARDINALITY.test(String(l.cardinality)))
168
+ v("R1", `linkTypes.${l.id}`, "cardinality must be like N:1");
169
+ if (!isStr(l.via) || !isStr(l.doc))
170
+ v("R1", `linkTypes.${l.id}`, "via and doc are required");
171
+ if (l.table !== undefined && !isStr(l.table))
172
+ v("R1", `linkTypes.${l.id}`, "table must be a table name");
173
+ if (l.create_rule !== undefined) {
174
+ const r = l.create_rule;
175
+ if (!isObj(r) || !own(contexts, r.context) || !Array.isArray(r.with_creates) || !isObj(r.cite))
176
+ v("R1", `linkTypes.${l.id}.create_rule`, "needs context, with_creates[], cite");
177
+ else if (r.exceptions !== undefined && (!isObj(r.exceptions) || !Array.isArray(r.exceptions.commands) || !isObj(r.exceptions.cite)))
178
+ v("R1", `linkTypes.${l.id}.create_rule.exceptions`, "needs commands[] and cite");
179
+ }
180
+ }
181
+ for (const [dn, d] of Object.entries(derived)) {
182
+ if (!isObj(d) || !objType(d.of) || !isStr(d.doc) || !isStr(d.doc_term) || Array.isArray(d.reads) === (d.expr !== undefined))
183
+ v("R1", `derivedProperties.${dn}`, "needs of, doc, doc_term, and exactly one of expr / reads[]");
184
+ else
185
+ for (const r of arr(d.reads))
186
+ if (propClass(r) === null)
187
+ v("R1", `derivedProperties.${dn}`, `read ${r} does not resolve to a declared property`);
188
+ }
189
+ for (const [name, o] of Object.entries(objects))
190
+ for (const [pn, pr] of Object.entries(isObj(o?.properties) ? o.properties : {}))
191
+ for (const r of arr(pr?.materializedFrom?.reads))
192
+ if (propClass(r) === null)
193
+ v("R1", `objectTypes.${name}.properties.${pn}.materializedFrom`, `read ${r} does not resolve`);
194
+ const evidenceSeen = new Map();
195
+ for (const [cid, c] of Object.entries(commands)) {
196
+ const p = `actionTypes.${cid}`;
197
+ if (!config.actionIdPattern.test(cid))
198
+ v("R1", p, `action id must match ${config.actionIdPattern}`);
199
+ if (!isObj(c)) {
200
+ v("R1", p, "not a mapping");
201
+ continue;
202
+ }
203
+ for (const f of ["doc", "doc_term"])
204
+ if (!isStr(c[f]))
205
+ v("R1", p, `${f} missing`);
206
+ if (!own(contexts, c.context))
207
+ v("R1", p, `context ${c.context} not declared`);
208
+ if (c.permission === undefined)
209
+ v("R1", p, "permission missing");
210
+ else if (c.permission !== "unknown") {
211
+ const pm = c.permission;
212
+ // One keys-form object ({keys, cite, conditional_keys?, conditional_cite?}) or {none, cite}; `allowNone` is false inside any_of.
213
+ const permShape = (pm, allowNone) => {
214
+ const ks = isObj(pm) && Array.isArray(pm.keys) && pm.keys.length > 0 && pm.keys.every(isStr) && new Set(pm.keys).size === pm.keys.length;
215
+ if (!(isObj(pm) && isObj(pm.cite) && pm.key === undefined && pm.any_of === undefined && (allowNone ? (isStr(pm.none) ? pm.keys === undefined : ks) : pm.none === undefined && ks)))
216
+ return "permission must be unknown, {keys: [distinct keys], cite}, {none, cite} or {any_of: [{keys, cite}, ...]}";
217
+ // Optional conditional keys: only alongside keys, with their own cite quoting the condition.
218
+ if (pm.conditional_keys !== undefined || pm.conditional_cite !== undefined) {
219
+ const ck = pm.conditional_keys;
220
+ const cks = Array.isArray(ck) && ck.length > 0 && ck.every(isStr) && new Set(ck).size === ck.length;
221
+ if (!(Array.isArray(pm.keys) && cks && isObj(pm.conditional_cite)))
222
+ return "permission.conditional_keys needs keys, [distinct keys] and conditional_cite";
223
+ }
224
+ // Optional principals: the actor kinds the form admits, with their own cite.
225
+ const pr = pm.principals, kinds = isObj(pr) ? pr.kinds : undefined;
226
+ if (pr !== undefined && !(isObj(pr) && isObj(pr.cite) && Array.isArray(kinds) && kinds.length > 0 && kinds.every(isStr) && new Set(kinds).size === kinds.length))
227
+ return "permission principals must be {kinds: [distinct kinds], cite}";
228
+ return null;
229
+ };
230
+ if (isObj(pm) && pm.any_of !== undefined) {
231
+ const alts = pm.any_of;
232
+ if (!(Object.keys(pm).length === 1 && Array.isArray(alts) && alts.length >= 2))
233
+ v("R1", p, "permission.any_of must be the only key and a list of at least 2 alternatives");
234
+ else {
235
+ const bad = alts.map((a) => permShape(a, false));
236
+ bad.forEach((m, i) => m && v("R1", p, `permission.any_of[${i}]: ${m}`));
237
+ // b is redundant iff b.keys ⊇ a.keys ∪ a.conditional_keys for another alternative a: whoever satisfies b holds all of a's keys, whether or not a's condition applies.
238
+ // Only equal condition-free alternatives are redundant to each other; then only the later index is reported.
239
+ if (!bad.some(Boolean)) {
240
+ const need = (x) => [...x.keys, ...(x.conditional_keys ?? [])];
241
+ // b also admits no actor kind that a does not: a has no principals, or b's kinds are a subset of a's
242
+ const kinds = (x) => (isObj(x.principals) ? x.principals.kinds : null);
243
+ const narrower = (b, a) => kinds(a) === null || (kinds(b) !== null && kinds(b).every((k) => kinds(a).includes(k)));
244
+ const covers = (b, a) => need(a).every((k) => b.keys.includes(k)) && narrower(b, a);
245
+ alts.forEach((b, j) => {
246
+ const i = alts.findIndex((a, i) => i !== j && covers(b, a) && !(covers(a, b) && j < i));
247
+ if (i >= 0)
248
+ v("R1", p, `permission.any_of[${j}] is redundant: its keys include all keys of any_of[${i}]`);
249
+ });
250
+ }
251
+ }
252
+ }
253
+ else {
254
+ const m = permShape(pm, true);
255
+ if (m)
256
+ v("R1", p, m);
257
+ }
258
+ }
259
+ for (const f of ["conditions", "edits", "emits"])
260
+ if (!Array.isArray(c[f]))
261
+ v("R1", p, `${f} must be a list`);
262
+ if (c.creates !== undefined && !Array.isArray(c.creates))
263
+ v("R1", p, "creates must be a list");
264
+ if (!isObj(c.link_effects))
265
+ v("R1", p, "link_effects missing (use {})");
266
+ if (c.crossContext !== undefined) {
267
+ const cc = c.crossContext, ks = isObj(cc) ? ["via", "unspecified"].filter((k) => k in cc) : [];
268
+ if (ks.length !== 1 || !isStr(cc[ks[0]]) || !isObj(cc.cite))
269
+ v("R1", `${p}.crossContext`, "exactly one of via / unspecified (non-empty string), and a cite");
270
+ }
271
+ if (c.idempotencyKey !== undefined) {
272
+ const ik = c.idempotencyKey;
273
+ if (!isObj(ik) || Object.keys(ik).sort().join() !== "cite,required" || typeof ik.required !== "boolean" || !isObj(ik.cite))
274
+ v("R1", `${p}.idempotencyKey`, "needs {required: boolean, cite}");
275
+ }
276
+ const params = isObj(c.parameters) ? c.parameters : {};
277
+ if (c.parameters !== undefined && !isObj(c.parameters))
278
+ v("R1", p, "parameters must be a mapping name -> {cite}");
279
+ for (const [n, pv] of Object.entries(params)) {
280
+ if (!isObj(pv) || !isObj(pv.cite))
281
+ v("R1", `${p}.parameters.${n}`, "needs a cite");
282
+ const vs = isObj(pv) ? pv.values : undefined;
283
+ if (vs !== undefined && !(pv.type === "enum" && Array.isArray(vs) && vs.length > 0 && vs.every(isStr) && new Set(vs).size === vs.length))
284
+ v("R1", `${p}.parameters.${n}`, "values needs type enum and a non-empty list of distinct non-empty strings");
285
+ }
286
+ const pids = new Set();
287
+ for (const [i, pc] of arr(c.conditions).entries()) {
288
+ if (!isObj(pc) || !isStr(pc.id) || !(Array.isArray(pc.cites) && pc.cites.length > 0 && pc.cites.every((x) => isObj(x) && isObj(x.cite)))) {
289
+ v("R1", `${p}.conditions[${i}]`, "needs id, cites[{cite}]");
290
+ continue;
291
+ }
292
+ if (pc.reads !== undefined && !Array.isArray(pc.reads))
293
+ v("R1", `${p}.conditions.${pc.id}`, "reads must be a list");
294
+ if (pc.reads_unspecified !== undefined && !isStr(pc.reads_unspecified))
295
+ v("R1", `${p}.conditions.${pc.id}`, "reads_unspecified must say what the doc leaves open");
296
+ if (pids.has(pc.id))
297
+ v("R1", `${p}.conditions.${pc.id}`, "duplicate condition id");
298
+ pids.add(pc.id);
299
+ for (const r of arr(pc.reads))
300
+ if (isObj(r) && "param" in r) {
301
+ if (!isStr(r.param) || !Object.hasOwn(params, r.param))
302
+ v("R1", `${p}.conditions.${pc.id}`, `parameter read ${JSON.stringify(r)} does not resolve to a declared parameter of ${cid}`);
303
+ if (r.values !== undefined && !(Array.isArray(r.values) && r.values.length > 0 && r.values.every(isStr)))
304
+ v("R1", `${p}.conditions.${pc.id}`, `parameter read ${JSON.stringify(r)}: values must be a non-empty list of non-empty strings`);
305
+ }
306
+ else if (isObj(r) && "actor" in r) {
307
+ if (r.actor !== "id" && r.actor !== "keys")
308
+ v("R1", `${p}.conditions.${pc.id}`, `actor read ${JSON.stringify(r)} must be {actor: id} or {actor: keys}`);
309
+ }
310
+ else if (isObj(r)) {
311
+ if (!isStr(r.prop) || propClass(r.prop) === null || !Array.isArray(r.values) || r.values.length === 0)
312
+ v("R1", `${p}.conditions.${pc.id}`, `value read ${JSON.stringify(r)} needs a declared prop and values[]`);
313
+ }
314
+ else if (!own(derived, r) && propClass(r) === null)
315
+ v("R1", `${p}.conditions.${pc.id}`, `read ${r} does not resolve`);
316
+ }
317
+ for (const e of arr(c.edits)) {
318
+ const [o] = String(e).split(".");
319
+ if (!(outOfScope.has(o) || propClass(e) !== null))
320
+ v("R1", p, `edit ${e} does not resolve to a declared property`);
321
+ }
322
+ for (const o of arr(c.creates))
323
+ if (!objType(o) && !outOfScope.has(o))
324
+ v("R1", p, `creates ${o}: unknown object`);
325
+ for (const e of Object.keys(isObj(c.edit_cites) ? c.edit_cites : {}))
326
+ if (!arr(c.edits).includes(e) || !isObj(c.edit_cites[e]?.cite))
327
+ v("R1", `${p}.edit_cites.${e}`, "must name an edit and carry a cite");
328
+ for (const [lid, le] of Object.entries(isObj(c.link_effects) ? c.link_effects : {})) {
329
+ const kinds = isObj(le) ? ["effect", "none", "unspecified"].filter((k) => k in le) : [];
330
+ if (kinds.length !== 1 || !isStr(le[kinds[0]]))
331
+ v("R1", `${p}.link_effects.${lid}`, "exactly one of effect / none / unspecified");
332
+ else if (kinds[0] !== "none" && !isObj(le.cite))
333
+ v("R1", `${p}.link_effects.${lid}`, `${kinds[0]} needs a cite`);
334
+ if (!linkIds.has(lid))
335
+ v("R1", `${p}.link_effects.${lid}`, "unknown link id");
336
+ }
337
+ if (c.evidence !== undefined) {
338
+ if (facts.evidence === undefined)
339
+ v("R1", p, `evidence ${c.evidence} cannot be resolved: the source has no evidence adapter`);
340
+ else if (!facts.evidence.has(c.evidence))
341
+ v("R1", p, `evidence ${c.evidence} is not provided by the evidence adapter`);
342
+ else if (evidenceSeen.has(c.evidence))
343
+ v("R1", p, `evidence ${c.evidence} already claimed by ${evidenceSeen.get(c.evidence)}`);
344
+ evidenceSeen.set(c.evidence, cid);
345
+ }
346
+ }
347
+ const nonCmdRows = arr(ont.permission_rows_not_commands);
348
+ if (!Array.isArray(ont.permission_rows_not_commands))
349
+ v("R1", "permission_rows_not_commands", "missing (use [])");
350
+ for (const [i, r] of nonCmdRows.entries())
351
+ if (!isObj(r) || !isStr(r.name) || !isStr(r.reason) || !isObj(r.cite))
352
+ v("R1", `permission_rows_not_commands[${i}]`, "needs name, reason, cite");
353
+ else if (own(commands, r.name))
354
+ v("R1", `permission_rows_not_commands[${i}]`, `${r.name} is an ontology command`);
355
+ for (const [i, g] of gaps.entries())
356
+ if (!isObj(g) || !isStr(g.id) || !/^R\d+$/.test(String(g.rule)) || !isStr(g.kind) || !Array.isArray(g.keys) || !isStr(g.doc_line) || !isStr(g.conflict) || !isStr(g.proposed))
357
+ v("R1", `knownSourceGaps[${i}]`, "needs id, rule, kind, keys[], doc_line, conflict, proposed");
358
+ if (config.idempotencyDeclarationRequired === true)
359
+ for (const [cid, c] of Object.entries(commands))
360
+ if (isObj(c) && c.idempotencyKey === undefined)
361
+ v("R1", cid, "the profile requires an idempotencyKey declaration, but the action declares none", "idempotency_undeclared");
362
+ // ------------------------------------------------------------------------------------------ R2
363
+ const walkCites = (x, path) => {
364
+ if (Array.isArray(x))
365
+ x.forEach((y, i) => walkCites(y, `${path}[${i}]`));
366
+ else if (isObj(x))
367
+ for (const [k, y] of Object.entries(x)) {
368
+ if (k === "cite") {
369
+ const s = section(isObj(y) ? y.doc : undefined, "R2", `${path}.cite`);
370
+ if (s && !(isStr(y.quote) && s.text.includes(y.quote)))
371
+ v("R2", `${path}.cite`, `quote not found verbatim in ${y.doc}: ${y.quote}`);
372
+ }
373
+ else
374
+ walkCites(y, `${path}.${k}`);
375
+ }
376
+ };
377
+ walkCites({ objectTypes: objects, stateMachines, linkTypes: links, actionTypes: commands, permission_rows_not_commands: nonCmdRows, derivedProperties: derived, scope: ont.scope, dispositions: ont.dispositions }, "");
378
+ for (const [name, o] of Object.entries(objects)) {
379
+ if (!isObj(o))
380
+ continue;
381
+ const s = section(o.doc, "R2", `objectTypes.${name}`);
382
+ if (!s)
383
+ continue;
384
+ if (!IDENT(name).test(s.text))
385
+ v("R2", `objectTypes.${name}`, `object name not in ${o.doc}`);
386
+ const fl = facts.fieldList(o.doc, name)?.value ?? null;
387
+ if (!fl)
388
+ v("R2", `objectTypes.${name}:fields`, `${o.doc} has no single field list headed ${name}`, "field_list_missing");
389
+ else
390
+ for (const pn of Object.keys(isObj(o.properties) ? o.properties : {}))
391
+ if (!fl.has(pn))
392
+ v("R2", `${name}.${pn}`, `property ${pn} is not a field line (- ${pn}) of the ${name} field list in ${o.doc}`, "property_not_field");
393
+ }
394
+ const tableRefs = [
395
+ ...Object.entries(objects).map(([n, o]) => [`objectTypes.${n}.datasource`, o?.datasource]),
396
+ ...links.filter((l) => isObj(l) && l.table !== undefined).map((l) => [`linkTypes.${l.id}.table`, l.table]),
397
+ ...[...infra].map((t) => [`infrastructureStores.${t}`, t]),
398
+ ];
399
+ if (facts.storeCatalog !== undefined)
400
+ for (const [k, t] of tableRefs)
401
+ if (!facts.storeCatalog.value.has(String(t)))
402
+ v("R2", k, `table ${t} is not in the source store catalog`, "table_not_in_catalog");
403
+ for (const l of links) {
404
+ if (!isObj(l) || !isStr(l.id))
405
+ continue;
406
+ const s = section(l.doc, "R2", `linkTypes.${l.id}`);
407
+ if (s && isStr(l.via) && !IDENT(l.via).test(s.text))
408
+ v("R2", `linkTypes.${l.id}`, `via ${l.via} not in ${l.doc}`);
409
+ }
410
+ for (const [dn, d] of Object.entries(derived)) {
411
+ const s = isObj(d) ? section(d.doc, "R2", `derivedProperties.${dn}`) : null;
412
+ if (s && !s.text.includes(d.doc_term))
413
+ v("R2", `derivedProperties.${dn}`, `doc_term ${d.doc_term} not in ${d.doc}`);
414
+ }
415
+ const newNames = [];
416
+ for (const [cid, c] of Object.entries(commands)) {
417
+ if (!isObj(c))
418
+ continue;
419
+ const s = section(c.doc, "R2", `actionTypes.${cid}`);
420
+ if (s && !(isStr(c.doc_term) && s.text.includes(c.doc_term)))
421
+ v("R2", `actionTypes.${cid}`, `doc_term ${c.doc_term} not verbatim in ${c.doc}`);
422
+ if (!facts.containsTerm(cid))
423
+ newNames.push(`${cid} (doc_term ${c.doc_term} in ${c.doc})`);
424
+ for (const [n, pv] of Object.entries(isObj(c.parameters) ? c.parameters : {})) {
425
+ if (!isObj(pv) || !isStr(pv.cite?.quote))
426
+ continue;
427
+ if (!IDENT(n).test(pv.cite.quote))
428
+ v("R2", `${cid}:${n}`, `parameter ${n} does not appear in its cite quote`, "parameter_not_in_quote");
429
+ for (const x of Array.isArray(pv.values) ? pv.values.filter(isStr) : [])
430
+ if (!IDENT(x).test(pv.cite.quote))
431
+ v("R2", `${cid}:${n}:${x}`, `parameter ${n} value ${x} does not appear in its cite quote`, "parameter_value_not_in_quote");
432
+ }
433
+ }
434
+ info.push(`new action names (not in the source; owner to confirm): ${newNames.length ? newNames.join("; ") : "none"}`);
435
+ // ------------------------------------------------------------------------------------------ R3
436
+ const smFacts = facts.stateMachines;
437
+ const triggers = facts.transitionTriggers;
438
+ if (smFacts !== undefined)
439
+ for (const [name, fact] of smFacts)
440
+ if (objType(name) && (!isObj(stateMachines[name]) || stateMachines[name].doc !== fact.source.anchor))
441
+ v("R3", `stateMachines.${name}`, `${fact.source.anchor} has a state machine for ${name}; stateMachines.${name}.doc must be ${fact.source.anchor}`);
442
+ const byCmds = new Map(); // object -> commands named in `by`
443
+ for (const [name, o] of Object.entries(objects)) {
444
+ if (!isObj(o) || !own(stateMachines, name))
445
+ continue;
446
+ const st = stateMachines[name];
447
+ const k = `stateMachines.${name}`;
448
+ if (!isObj(st) || !isStr(st.initial) || !Array.isArray(st.terminal) || !Array.isArray(st.transitions)) {
449
+ v("R1", k, "stateMachine needs doc, initial, terminal[], transitions[]");
450
+ continue;
451
+ }
452
+ const s = section(st.doc, "R3", k);
453
+ if (!s)
454
+ continue;
455
+ if (smFacts === undefined)
456
+ continue;
457
+ const fact = smFacts.get(name);
458
+ if (!fact) {
459
+ v("R3", k, `the source has no state machine for ${name}`);
460
+ continue;
461
+ }
462
+ if (fact.value === null)
463
+ continue;
464
+ const docEdges = new Set(fact.value.edges);
465
+ const docInit = fact.value.initial;
466
+ const docNodes = new Set(fact.value.nodes);
467
+ if (docInit.length !== 1)
468
+ v("R3", k, `${st.doc}: expected exactly one initial edge, found ${docInit.length}`);
469
+ const ontEdges = new Set();
470
+ const ontNodes = new Set([st.initial, ...st.terminal]);
471
+ for (const [i, t] of st.transitions.entries()) {
472
+ if (!isObj(t) || !isStr(t.from) || !isStr(t.to) || !Array.isArray(t.by)) {
473
+ v("R1", `${k}.transitions[${i}]`, "needs from, to, by[]");
474
+ continue;
475
+ }
476
+ const e = `${t.from}->${t.to}`;
477
+ if (ontEdges.has(e))
478
+ v("R3", k, `duplicate transition ${e}`);
479
+ ontEdges.add(e);
480
+ ontNodes.add(t.from).add(t.to);
481
+ if (t.by.length === 0 && !isStr(t.out_of_scope))
482
+ v("R3", `${k}.${e}`, "by is empty: name the actions or give out_of_scope");
483
+ if (t.by.length > 0 && t.out_of_scope !== undefined)
484
+ v("R3", `${k}.${e}`, "by and out_of_scope are exclusive");
485
+ const want = triggers?.get(name)?.get(e)?.value ?? new Set();
486
+ for (const c of t.by) {
487
+ if (!own(commands, c))
488
+ v("R3", `${k}.${e}`, `by ${c} is not a defined command`);
489
+ if (!byCmds.has(name))
490
+ byCmds.set(name, new Set());
491
+ byCmds.get(name).add(c);
492
+ if (triggers !== undefined && !want.has(c))
493
+ v("R3", `${name}:${e}:${c}`, `no source transition trigger binds ${c} to ${name} ${t.from} → ${t.to}`, "transition_binding_unsupported");
494
+ }
495
+ if (triggers !== undefined)
496
+ for (const c of want)
497
+ if (!t.by.includes(c))
498
+ v("R3", `${name}:${e}:${c}`, `the source binds ${c} to ${name} ${t.from} → ${t.to}; by omits it`, "transition_binding_missing");
499
+ }
500
+ const diff = (a, b) => [...a].filter((x) => !b.has(x));
501
+ for (const x of diff(docNodes, ontNodes))
502
+ v("R3", k, `state ${x} in ${st.doc} missing from ontology`);
503
+ for (const x of diff(ontNodes, docNodes))
504
+ v("R3", k, `state ${x} not in ${st.doc}`);
505
+ for (const x of diff(docEdges, ontEdges))
506
+ v("R3", k, `transition ${x} in ${st.doc} missing from ontology`);
507
+ for (const x of diff(ontEdges, docEdges))
508
+ v("R3", k, `transition ${x} not in ${st.doc}`);
509
+ if (triggers !== undefined)
510
+ for (const x of diff(new Set(triggers.get(name)?.keys() ?? []), docEdges))
511
+ v("R3", k, `source trigger transition ${x} not in ${st.doc}`);
512
+ if (docInit.length === 1 && docInit[0] !== st.initial)
513
+ v("R3", k, `initial ${st.initial} ≠ doc ${docInit[0]}`);
514
+ const sinks = new Set([...docNodes].filter((n) => ![...docEdges].some((e) => e.startsWith(`${n}->`))));
515
+ const term = new Set(st.terminal);
516
+ if (diff(sinks, term).length || diff(term, sinks).length)
517
+ v("R3", k, `terminal [${[...term]}] ≠ doc states without exits [${[...sinks]}]`);
518
+ if (docInit.length === 1) {
519
+ const seen = new Set([docInit[0]]);
520
+ for (let grew = true; grew;) {
521
+ grew = false;
522
+ for (const e of docEdges) {
523
+ const [a, b] = e.split("->");
524
+ if (seen.has(a) && !seen.has(b))
525
+ seen.add(b), (grew = true);
526
+ }
527
+ }
528
+ for (const n of docNodes)
529
+ if (!seen.has(n))
530
+ v("R3", k, `${st.doc}: state ${n} unreachable from ${docInit[0]}`);
531
+ }
532
+ }
533
+ if (smFacts !== undefined)
534
+ for (const [cid, c] of Object.entries(commands)) {
535
+ if (!isObj(c))
536
+ continue;
537
+ const stateEdits = new Set(arr(c.edits).filter((e) => e.endsWith(`.${config.stateProperty}`)).map((e) => e.split(".")[0]));
538
+ for (const o of stateEdits)
539
+ if (objType(o) && isObj(stateMachines[o]) && !byCmds.get(o)?.has(cid))
540
+ v("R3", `${cid}:${o}.${config.stateProperty}`, `${cid} edits ${o}.${config.stateProperty} but is in no ${o} transition's by`);
541
+ for (const [o, cs] of byCmds)
542
+ if (cs.has(cid) && !stateEdits.has(o))
543
+ v("R3", `${cid}:${o}.${config.stateProperty}`, `${cid} is in a ${o} transition's by but does not edit ${o}.${config.stateProperty}`);
544
+ }
545
+ // ------------------------------------------------------------------------------------------ R4
546
+ for (const [cid, c] of Object.entries(commands)) {
547
+ if (!isObj(c))
548
+ continue;
549
+ const bad = new Map();
550
+ for (const pc of arr(c.conditions)) {
551
+ if (!isObj(pc))
552
+ continue;
553
+ const quotes = quotesOf(pc.cites); // any one quote may support a value
554
+ // an expression condition reads what its type check recorded (stage1-check), in the shape of hand-written reads
555
+ const ex = isStr(pc.id) && pc.expr !== undefined ? conditionExprs.get(cid)?.get(pc.id) : undefined;
556
+ const exReads = ex !== undefined && "reads" in ex ? ex.reads : undefined;
557
+ for (const x of new Set(exReads?.literals.map(String)))
558
+ if (!quotes.some((q) => IDENT(x).test(q)))
559
+ v("R4", `${cid}:${pc.id}:${x}`, `condition ${pc.id} uses the literal ${x}, which does not appear in its cite quote`, "literal_unsupported");
560
+ if (isStr(pc.reads_unspecified))
561
+ v("R4", `${cid}:${pc.id}`, `precondition ${pc.id}: the doc does not say which property it reads — ${pc.reads_unspecified}`, "precondition_reads_unspecified");
562
+ // [read, suffix]: a hand-written read of a derived property with an expr stands for what that expression reads
563
+ const reads = [];
564
+ for (const r of pc.expr !== undefined ? (exReads ? asReads(exReads) : []) : arr(pc.reads)) {
565
+ if (pc.expr === undefined && isStr(r) && own(derived, r) && derived[r]?.expr !== undefined) {
566
+ const collected = newReads();
567
+ try {
568
+ derivedPropertyType(ont, r, collected);
569
+ }
570
+ catch {
571
+ continue; // R1 reports it (stage1-check checkDerived)
572
+ }
573
+ for (const x of asReads(collected))
574
+ reads.push([x, ` (via derived ${r})`]);
575
+ }
576
+ else
577
+ reads.push([r, ""]);
578
+ }
579
+ for (const [r, suffix] of reads) {
580
+ // parameter and actor reads are request inputs, not state: R4 does not classify them
581
+ if (isObj(r) && "actor" in r)
582
+ continue;
583
+ if (isObj(r) && "param" in r) {
584
+ for (const x of arr(r.values).filter(isStr)) // R1 reports empty and non-string values
585
+ if (!quotes.some((q) => IDENT(x).test(q)))
586
+ v("R4", `${cid}:${pc.id}:${x}`, `precondition ${pc.id} compares parameter ${r.param} with ${x}, which does not appear in its cite quote`, "parameter_value_unsupported");
587
+ continue;
588
+ }
589
+ if (isObj(r)) {
590
+ // value-level read: canonical property, or only values the doc marks canonical (canonical_values)
591
+ const cls = propClass(r.prop);
592
+ const [o, pn] = String(r.prop).split(".");
593
+ const cv = arr(objects[o]?.properties?.[pn]?.canonical_values?.values);
594
+ const off = arr(r.values).filter((x) => !cv.includes(x));
595
+ if (cls !== null && cls !== "canonical" && cls !== "policy" && off.length)
596
+ bad.set(r.prop, `precondition ${pc.id} reads ${r.prop} values [${off}], class ${cls}; canonical values are [${cv}]`);
597
+ continue;
598
+ }
599
+ const via = suffix ? [[r, suffix]] : own(derived, r) ? arr(derived[r]?.reads).map((x) => [x, ` (via derived ${r})`]) : [[r, ""]];
600
+ for (const [ref, how] of via) {
601
+ const cls = propClass(ref);
602
+ if (cls !== null && cls !== "canonical" && cls !== "policy")
603
+ bad.set(ref, `precondition ${pc.id} reads ${ref}${how}, class ${cls}`);
604
+ }
605
+ }
606
+ }
607
+ for (const [ref, msg] of bad)
608
+ v("R4", `${cid}:${ref}`, msg, "precondition_reads_non_canonical");
609
+ }
610
+ // canonical_values are not self-authorized: each value must appear in its own (verbatim, R2) cite quote
611
+ for (const [name, o] of Object.entries(objects))
612
+ for (const [pn, pr] of Object.entries(isObj(o?.properties) ? o.properties : {})) {
613
+ const cv = pr?.canonical_values;
614
+ if (!isObj(cv))
615
+ continue;
616
+ for (const x of arr(cv.values))
617
+ if (!(isStr(cv.cite?.quote) && IDENT(String(x)).test(cv.cite.quote)))
618
+ v("R4", `${name}.${pn}:${x}`, `canonical value ${x} does not appear in its cite quote`, "canonical_value_unsupported");
619
+ }
620
+ // ------------------------------------------------------------------------------------------ R5
621
+ const members = new Map();
622
+ for (const [ctx, f] of facts.contextMembers ?? [])
623
+ members.set(ctx, f.value);
624
+ if (facts.contextMembers !== undefined) {
625
+ for (const [name, o] of Object.entries(objects))
626
+ if (isObj(o) && members.has(o.context) && !members.get(o.context).has(name))
627
+ v("R5", `object:${name}`, `the source does not list ${name} under ${o.context}`);
628
+ const entities = new Set([...members.values()].flatMap((m) => [...m]).filter((x) => /^[A-Z][A-Za-z]+$/.test(x)));
629
+ for (const x of entities)
630
+ if (!objType(x) && !outOfScope.has(x))
631
+ v("R5", `entity:${x}`, `the source lists entity ${x}; it must be in objectTypes or outOfScopeObjectTypes`, "context_entity_uncovered");
632
+ for (const x of outOfScope)
633
+ if (!entities.has(x))
634
+ v("R5", `entity:${x}`, `outOfScopeObjectTypes.${x} is not a source entity`, "out_of_scope_unknown");
635
+ }
636
+ const touched = (c) => new Set([...arr(c.edits).map((e) => e.split(".")[0]), ...arr(c.creates)].filter(objType));
637
+ for (const [cid, c] of Object.entries(commands)) {
638
+ if (!isObj(c))
639
+ continue;
640
+ const cross = [...touched(c)].filter((o) => objects[o].context !== c.context);
641
+ const cc = c.crossContext, unspec = isObj(cc) && isStr(cc.unspecified);
642
+ if (cross.length && unspec)
643
+ v("R5", cid, `${c.context} command touches ${cross.join(", ")} of another context; the source does not say the mechanism: ${cc.unspecified}`, "mechanism_unspecified");
644
+ else if (cross.length && !(isObj(cc) && config.crossContextMechanisms.includes(cc.via) && isObj(cc.cite)))
645
+ v("R5", cid, `${c.context} command touches ${cross.join(", ")} of another context without a declared crossContext mechanism (${config.crossContextMechanisms.join(", ")})`);
646
+ if (!cross.length && c.crossContext !== undefined)
647
+ v("R5", cid, "crossContext declared but no object of another context is touched");
648
+ for (const l of links)
649
+ if (isObj(l) && isObj(l.create_rule) && arr(c.creates).includes(l.from)) {
650
+ const r = l.create_rule;
651
+ if (arr(r.exceptions?.commands).includes(cid))
652
+ continue;
653
+ if (c.context !== r.context || !arr(r.with_creates).every((o) => arr(c.creates).includes(o)))
654
+ v("R5", `${cid}:${l.id}`, `${cid} (${c.context}) creates ${l.from} with ${l.id}, but ${r.cite?.doc} allows only ${r.context} commands that also create ${arr(r.with_creates).join(", ")}`);
655
+ }
656
+ }
657
+ // ------------------------------------------------------------------------------------------ R6
658
+ // evidence aggregates are derived here from the invocation records, never stored by the adapter. Records are runtime data
659
+ // (for example parsed JSON) and may break the type: one shape predicate decides what is committed or rejected; every other
660
+ // shape is neither (reported as an R7 info diagnostic) and adds nothing
661
+ const rowsOk = (x, image) => Array.isArray(x) && x.every((r) => isObj(r) && (typeof r.row === "string" || typeof r.row === "number") && isObj(r[image]));
662
+ const storeDeltaOk = (d) => isObj(d) && rowsOk(d.inserted, "after") && rowsOk(d.updated, "changes") && rowsOk(d.deleted, "before");
663
+ const shapeDefects = (i) => {
664
+ if (!isObj(i))
665
+ return ["is not a record"];
666
+ const bad = [];
667
+ if (!isStr(i.id))
668
+ bad.push("has no string id");
669
+ if (!(Number.isInteger(i.rolledBackAttempts) && i.rolledBackAttempts >= 0))
670
+ bad.push("rolledBackAttempts is not a non-negative integer");
671
+ if (i.outcome === "committed") {
672
+ if (!isObj(i.delta))
673
+ bad.push("is committed but has no delta");
674
+ else {
675
+ if (!isObj(i.delta.stores))
676
+ bad.push("is committed but delta.stores is not a mapping");
677
+ else
678
+ for (const [tb, d] of Object.entries(i.delta.stores))
679
+ if (!storeDeltaOk(d))
680
+ bad.push(`is committed but delta.stores.${tb} is not a store delta (inserted, updated, deleted rows)`);
681
+ if (!Array.isArray(i.delta.events) || !i.delta.events.every(isStr))
682
+ bad.push("is committed but delta.events is not a list of event types");
683
+ }
684
+ }
685
+ else if (i.outcome === "rejected") {
686
+ if (i.delta !== null)
687
+ bad.push(i.delta === undefined ? "is rejected but has no delta key (must be null)" : "is rejected but has a delta (must be null)");
688
+ }
689
+ else
690
+ bad.push(`has outcome ${String(i.outcome)}, neither committed nor rejected`);
691
+ return bad;
692
+ };
693
+ const parts = new Map();
694
+ for (const [b, list] of facts.evidence ?? []) {
695
+ const p = { committed: [], rejected: [], malformed: [] };
696
+ if (!Array.isArray(list))
697
+ p.malformed.push({ id: "list", bad: ["is not a list"] });
698
+ else
699
+ for (const [n, i] of list.entries()) {
700
+ const bad = shapeDefects(i);
701
+ if (bad.length)
702
+ p.malformed.push({ id: isObj(i) && isStr(i.id) ? i.id : `[${n}]`, bad }); // a record without an id is keyed by its list position
703
+ else if (i.outcome === "committed")
704
+ p.committed.push(i);
705
+ else
706
+ p.rejected.push(i);
707
+ }
708
+ parts.set(b, p);
709
+ }
710
+ const part = (id) => (typeof id === "string" ? parts.get(id) : undefined) ?? { committed: [], rejected: [], malformed: [] };
711
+ const hasRows = (d) => d.inserted.length + d.updated.length + d.deleted.length > 0;
712
+ // over the committed invocations of one evidence id: stores with a changed row, stores with an inserted row,
713
+ // columns changed on existing rows (per store; checked per object), and the events written
714
+ const observe = (id) => {
715
+ const committed = part(id).committed;
716
+ const rowStores = new Set(), insertedStores = new Set(), insDelStores = new Set(), events = new Set();
717
+ const changedCols = new Map();
718
+ for (const i of committed) {
719
+ for (const e of i.delta.events)
720
+ events.add(e);
721
+ for (const [tb, d] of Object.entries(i.delta.stores)) {
722
+ if (hasRows(d))
723
+ rowStores.add(tb);
724
+ if (d.inserted.length)
725
+ insertedStores.add(tb);
726
+ if (d.inserted.length || d.deleted.length)
727
+ insDelStores.add(tb);
728
+ for (const u of d.updated)
729
+ for (const col of Object.keys(u.changes))
730
+ changedCols.set(tb, (changedCols.get(tb) ?? new Set()).add(col));
731
+ }
732
+ }
733
+ return { committed, rowStores, insertedStores, insDelStores, changedCols, events };
734
+ };
735
+ for (const [cid, c] of Object.entries(commands)) {
736
+ if (!isObj(c))
737
+ continue;
738
+ const t = touched(c);
739
+ const need = new Set(links.filter((l) => isObj(l) && (t.has(l.from) || t.has(l.to))).map((l) => l.id));
740
+ const have = isObj(c.link_effects) ? c.link_effects : {};
741
+ for (const lid of need) {
742
+ if (!own(have, lid))
743
+ v("R6", `${cid}:${lid}`, `${cid} touches ${[...t].join(", ")} but link ${lid} has no link_effects entry`, "link_effect_missing");
744
+ else if (isObj(have[lid]) && "unspecified" in have[lid])
745
+ v("R6", `${cid}:${lid}`, `effect on ${lid} is not specified by the doc: ${have[lid].unspecified}`, "effect_unspecified");
746
+ }
747
+ for (const lid of Object.keys(have))
748
+ if (!need.has(lid))
749
+ v("R6", `${cid}:${lid}:stray`, `link_effects.${lid}: ${lid} is not incident to any touched object`);
750
+ // a `none` effect on a link with its own table contradicts an observed write to that table (R7 ground truth)
751
+ const written = observe(c.evidence).rowStores;
752
+ for (const l of links)
753
+ if (isObj(l) && isStr(l.table) && isObj(have[l.id]) && "none" in have[l.id] && written.has(l.table))
754
+ v("R6", `${cid}:${l.id}`, `link_effects.${l.id} is none, but the evidence (${c.evidence}) wrote its table ${l.table}`, "none_but_written");
755
+ }
756
+ // link catalog completeness: every *_id field of each field list is a declared link or a declared non-link field;
757
+ // with a declared scope, the tenant field of a tenant type counts as declared
758
+ const tenantField = (name, f) => scope !== null && scope.isTenant(name) && f === scope.by;
759
+ for (const [name, o] of Object.entries(objects)) {
760
+ if (!isObj(o))
761
+ continue;
762
+ const fl = facts.fieldList(o.doc, name)?.value ?? null;
763
+ if (!fl)
764
+ continue; // reported by R2 (field_list_missing / anchor)
765
+ const nl = isObj(o.non_link_fields) ? o.non_link_fields : {};
766
+ for (const f of fl.keys())
767
+ if (config.linkFieldPattern.test(f) && !links.some((l) => isObj(l) && l.from === name && l.via === f) && !own(nl, f) && !tenantField(name, f))
768
+ v("R6", `${name}.${f}`, `field ${f} of ${name} (${o.doc}) is neither a declared link nor in non_link_fields`, "link_catalog_incomplete");
769
+ for (const f of Object.keys(nl)) {
770
+ if (!fl.has(f))
771
+ v("R6", `${name}.${f}`, `non_link_fields.${f} is not a field of ${name} in ${o.doc}`, "non_link_field_stray");
772
+ if (links.some((l) => isObj(l) && l.from === name && l.via === f))
773
+ v("R6", `${name}.${f}`, `${f} is a declared link; remove it from non_link_fields`, "non_link_field_stray");
774
+ else if (fl.has(f) && tenantField(name, f))
775
+ v("R6", `${name}.${f}`, `${f} is the scope field of the tenant type ${name}; remove it from non_link_fields`, "non_link_field_stray");
776
+ }
777
+ }
778
+ // ------------------------------------------------------------------------------------------ R7
779
+ const claimed = new Set();
780
+ if (config.evidenceRequired === true)
781
+ for (const [cid, c] of Object.entries(commands))
782
+ if (isObj(c) && c.evidence === undefined)
783
+ v("R7", cid, "the profile requires evidence, but the action declares none", "evidence_missing");
784
+ if (facts.evidence !== undefined) {
785
+ // a pure link table (not any object's table) may be written when the action declares an `effect` on that link;
786
+ // an object's table still needs the object in edits / creates
787
+ const objTables = new Set(Object.values(objects).map((o) => o?.datasource));
788
+ for (const [cid, c] of Object.entries(commands)) {
789
+ if (!isObj(c) || c.evidence === undefined)
790
+ continue;
791
+ claimed.add(c.evidence);
792
+ const { rejected } = part(c.evidence);
793
+ const { committed, rowStores, insertedStores, insDelStores, changedCols, events } = observe(c.evidence);
794
+ const attempts = [...committed, ...rejected].reduce((n, i) => n + i.rolledBackAttempts, 0); // malformed records add nothing
795
+ info.push(`R7 ${cid} (${c.evidence}, ${committed.length} committed, ${rejected.length} rejected invocations, ${attempts} rolled-back attempts): changed {${[...rowStores].sort()}} events {${[...events].sort()}}`);
796
+ if (!committed.length) {
797
+ v("R7", `${cid}:no-batch`, `no committed invocation of ${c.evidence} was observed`);
798
+ continue;
799
+ }
800
+ const touchedObjs = [...touched(c)];
801
+ const declared = new Set(touchedObjs.map((o) => objects[o].datasource));
802
+ const pureLink = (l) => isObj(l) && isStr(l.table) && !objTables.has(l.table) && isObj(c.link_effects?.[l.id]) && "effect" in c.link_effects[l.id];
803
+ const linkTables = links.filter(pureLink).map((l) => l.table);
804
+ const allowed = new Set([...declared, ...linkTables, ...infra]);
805
+ for (const tb of rowStores)
806
+ if (!allowed.has(tb))
807
+ v("R7", `${cid}:${tb}`, `evidence (${c.evidence}) changed store ${tb}, not declared by edits/creates`, "table_undeclared");
808
+ for (const e of events)
809
+ if (!arr(c.emits).includes(e))
810
+ v("R7", `${cid}:event:${e}`, `evidence (${c.evidence}) wrote event ${e}, not in emits`, "event_undeclared");
811
+ // column level, per touched object (objects may share a store): a column changed on an existing row of its store must
812
+ // be a property of a resolvable edit of that same object; reported once per touched object and column
813
+ const editCols = new Map(); // per object
814
+ const resolvedEdits = arr(c.edits).filter((e) => isStr(e) && propClass(e) !== null); // the R1 resolver
815
+ for (const e of resolvedEdits) {
816
+ const [o, p] = e.split(".");
817
+ editCols.set(o, (editCols.get(o) ?? new Set()).add(p));
818
+ }
819
+ for (const o of touchedObjs)
820
+ for (const col of changedCols.get(objects[o].datasource) ?? [])
821
+ if (!editCols.get(o)?.has(col))
822
+ v("R7", `${cid}:${o}.${col}`, `evidence (${c.evidence}) changed column ${col} on an existing row of ${objects[o].datasource} (store of ${o}), not declared in edits`, "column_undeclared");
823
+ // effect witness: every resolvable declared effect is made by at least one committed invocation; unresolvable edits are R1
824
+ // only, and out-of-scope objects have no store
825
+ const unwitnessed = (key, what) => v("R7", `${cid}:${key}`, `no committed invocation of ${c.evidence} ${what}`, "effect_unwitnessed");
826
+ for (const e of resolvedEdits) {
827
+ const [o, p] = e.split(".");
828
+ if (!changedCols.get(objects[o].datasource)?.has(p))
829
+ unwitnessed(e, `updated ${e} on an existing row of ${objects[o].datasource}`);
830
+ }
831
+ for (const o of arr(c.creates))
832
+ if (objType(o) && !insertedStores.has(objects[o].datasource))
833
+ unwitnessed(`creates:${o}`, `inserted a row into ${objects[o].datasource}`);
834
+ for (const l of links.filter(pureLink))
835
+ if (!insDelStores.has(l.table))
836
+ unwitnessed(`link:${l.id}`, `inserted or deleted a row of the link store ${l.table}`);
837
+ }
838
+ for (const [b, p] of parts) {
839
+ for (const { id, bad } of p.malformed)
840
+ info.push(`R7 evidence ${b}: malformed invocation ${id}: ${bad.join("; ")}`);
841
+ if (!claimed.has(b) && p.committed.length)
842
+ v("R7", `unclaimed:${b}`, `evidence ${b} has committed invocations but no action declares evidence: ${b}`);
843
+ }
844
+ }
845
+ // ------------------------------------------------------------------------------------------ R8
846
+ for (const [cid, c] of Object.entries(commands))
847
+ for (const e of arr(isObj(c) ? c.emits : []))
848
+ if (facts.eventCatalog !== undefined && !facts.eventCatalog.value.has(e))
849
+ v("R8", `${cid}:${e}`, `event ${e} is not in the source event catalog`);
850
+ // ------------------------------------------------------------------------------------------ R9
851
+ const scenarioIds = facts.scenarios?.ids ?? new Set();
852
+ const cites = facts.scenarios?.cites ?? new Map();
853
+ for (const [scen, ids] of cites)
854
+ for (const id of ids)
855
+ if (!own(commands, id))
856
+ v("R9", `${scen}:${id}`, `${scen} cites unknown action ${id}`);
857
+ if (facts.scenarios !== undefined) {
858
+ let pre = 0, preCov = 0, eff = 0, effCov = 0;
859
+ const enforced = config.scenarioCoverage === "enforced";
860
+ const p0 = (w) => w.slice(w.indexOf(" ") + 1);
861
+ for (const [cid, c] of Object.entries(commands)) {
862
+ if (!isObj(c))
863
+ continue;
864
+ const by = [...cites].filter(([, s]) => s.has(cid)).map(([k]) => k);
865
+ if (!by.length)
866
+ v("R9", `uncited:${cid}`, `no story scenario cites ${cid}`);
867
+ info.push(`R9 ${cid}: cited by ${by.length} scenario(s) ${by.join(" ")}`);
868
+ const items = [
869
+ ...arr(c.conditions).map((p) => [`precondition ${p?.id}`, p]),
870
+ ...Object.entries(isObj(c.link_effects) ? c.link_effects : {}).map(([l, e]) => [`link_effect ${l}`, e]),
871
+ ];
872
+ for (const [what, it] of items) {
873
+ if (!isObj(it))
874
+ continue;
875
+ const isPre = what.startsWith("precondition");
876
+ if (!isPre && "none" in it)
877
+ continue;
878
+ isPre ? pre++ : eff++;
879
+ const covered = Array.isArray(it.scenarios) && it.scenarios.length > 0;
880
+ if (covered)
881
+ isPre ? preCov++ : effCov++;
882
+ else if (enforced && isPre)
883
+ v("R9", `${cid}:${p0(what)}`, `${what} of ${cid} lists no scenario`, "criterion_uncovered");
884
+ else if (enforced && !("unspecified" in it))
885
+ v("R9", `${cid}:${p0(what)}`, `${what} of ${cid} lists no scenario`, "effect_uncovered");
886
+ for (const s of arr(it.scenarios))
887
+ if (!scenarioIds.has(s))
888
+ v("R9", `${cid}:${what}:${s}`, `${what} lists scenario ${s}, which does not exist`);
889
+ else if (!cites.get(s)?.has(cid))
890
+ v("R9", `${cid}:${what}:${s}`, `${what} lists scenario ${s}, which does not cite ${cid}`);
891
+ }
892
+ }
893
+ info.push(`coverage (${enforced ? "enforced" : "printed, not enforced"}): preconditions with scenarios ${preCov}/${pre}; non-none link effects with scenarios ${effCov}/${eff}`);
894
+ }
895
+ // ----------------------------------------------------------------------------------------- R10
896
+ const perm = facts.permissions;
897
+ if (perm !== undefined) {
898
+ if (perm.rows.source.anchor !== config.requiredSource.R10)
899
+ v("R10", "permissions", `permission rows come from ${perm.rows.source.anchor}, but permission cites must name ${config.requiredSource.R10}`, "source_mismatch");
900
+ for (const cid of perm.rows.value.keys())
901
+ if (!own(commands, cid)) {
902
+ if (nonCmdRows.some((r) => isObj(r) && r.name === cid))
903
+ info.push(`source permission row names ${cid}: declared in permission_rows_not_commands, binds nothing`);
904
+ else
905
+ v("R10", `permissions:${cid}`, `source permission row names ${cid}, which is neither a declared action nor declared in permission_rows_not_commands`, "permission_row_undeclared");
906
+ }
907
+ if (perm.rows.value.size)
908
+ for (const r of nonCmdRows)
909
+ if (isObj(r) && isStr(r.name) && !perm.rows.value.has(r.name))
910
+ v("R10", `permission_rows_not_commands:${r.name}`, `declared non-command ${r.name} is not in the source permission table`, "permission_row_stale");
911
+ for (const [cid, c] of Object.entries(commands)) {
912
+ if (!isObj(c))
913
+ continue;
914
+ const p = c.permission;
915
+ const row = perm.rows.value.get(cid);
916
+ if (p === "unknown")
917
+ v("R10", cid, "permission unknown: the doc names no key for this command", "permission_unknown");
918
+ else if (!isObj(p))
919
+ continue; // R1
920
+ else if (!row)
921
+ v("R10", cid, `${cid} has no row in the source permission table`, "permission_unmapped");
922
+ else {
923
+ // Each keys-form (or any_of alternative): disjoint lists, catalog, cites. Returns the keys it names.
924
+ const same = (a, b) => JSON.stringify([...new Set(a)].sort()) === JSON.stringify([...new Set(b)].sort());
925
+ const formChecks = (f, idx) => {
926
+ const base = Array.isArray(f.keys) ? f.keys.filter(isStr) : [];
927
+ const cond = Array.isArray(f.conditional_keys) ? f.conditional_keys.filter(isStr) : [];
928
+ const pk = [...base, ...cond];
929
+ const outside = pk.filter((k) => !perm.catalog.value.has(k));
930
+ const overlap = cond.filter((k) => base.includes(k));
931
+ if (overlap.length)
932
+ v("R10", cid, `permission ${overlap.join(", ")} is in both keys and conditional_keys`, "permission_conditional_overlap");
933
+ if (outside.length)
934
+ v("R10", cid, `permission ${outside.join(", ")} is not in the source permission catalog`, "permission_not_in_catalog");
935
+ if (!(isStr(f.cite?.quote) && f.cite.doc === config.requiredSource.R10 && row.text.includes(f.cite.quote)))
936
+ v("R10", cid, `permission cite must quote the ${cid} row of the source permission table`, "permission_cite_mismatch");
937
+ const cc = f.conditional_cite;
938
+ if (cond.length && !(isStr(cc?.quote) && cc.doc === config.requiredSource.R10 && row.text.includes(cc.quote) && cond.every((k) => perm.quoteNamesKey(cc.quote, k))))
939
+ v("R10", cid, `conditional_cite must quote the condition in the ${cid} row of the source permission table, naming each conditional key`, "permission_cite_mismatch");
940
+ // any_of only: each alternative's cite must itself name the keys it lists.
941
+ if (idx !== undefined) {
942
+ const cq = isStr(f.cite?.quote) ? f.cite.quote : "", ccq = isStr(f.conditional_cite?.quote) ? f.conditional_cite.quote : "";
943
+ const unnamed = [...base.filter((k) => !perm.quoteNamesKey(cq, k)), ...cond.filter((k) => !perm.quoteNamesKey(cq, k) && !perm.quoteNamesKey(ccq, k))];
944
+ if (unnamed.length)
945
+ v("R10", cid, `any_of[${idx}] cite does not name ${unnamed.join(", ")}`, "permission_cite_mismatch");
946
+ }
947
+ return pk;
948
+ };
949
+ // Keys ∪ conditional_keys (for any_of: over all alternatives) must equal the row's key set.
950
+ const pk = Array.isArray(p.any_of)
951
+ ? p.any_of.flatMap((f, i) => (isObj(f) ? formChecks(f, i) : [])) // keep the original index; R1 reports non-objects
952
+ : formChecks(p);
953
+ const outside = pk.filter((k) => !perm.catalog.value.has(k));
954
+ if (!outside.length && !same(pk, [...row.keys]))
955
+ v("R10", cid, `permission [${pk.length ? [...new Set(pk)] : "none"}] ≠ source row [${[...row.keys]}]`, "permission_mismatch");
956
+ }
957
+ }
958
+ }
959
+ // ----------------------------------------------------------------------------------------- R11
960
+ // A materialized property is written in the same operation as the facts it is computed from: every action that writes an input lists it.
961
+ for (const [name, o] of Object.entries(objects))
962
+ for (const [pn, pr] of Object.entries(isObj(o?.properties) ? o.properties : {})) {
963
+ const reads = arr(pr?.materializedFrom?.reads);
964
+ if (!reads.length)
965
+ continue;
966
+ const target = `${name}.${pn}`;
967
+ for (const [cid, c] of Object.entries(commands)) {
968
+ if (!isObj(c))
969
+ continue;
970
+ const hit = reads.filter((r) => arr(c.edits).includes(r) || arr(c.creates).includes(r.split(".")[0]));
971
+ if (hit.length && !arr(c.edits).includes(target))
972
+ v("R11", `${cid}:${target}`, `${cid} writes ${hit.join(", ")} (inputs of ${target}) but does not list ${target} in edits`, "materialization_missing");
973
+ }
974
+ }
975
+ // ------------------------------------------------------------------------------------- unknowns
976
+ const unknown = Object.entries(objects).flatMap(([n, o]) => Object.entries(isObj(o?.properties) ? o.properties : {}).filter(([, p]) => p?.class === "unknown").map(([pn]) => `${n}.${pn}`));
977
+ info.push(`class: unknown (the source does not classify): ${unknown.join(", ") || "none"}`);
978
+ // ----------------------------------------------------------------------- stage 1: R1, R2, R4, R12
979
+ // expressions, scope, dispositions and decision tables (./stage1-check)
980
+ out.push(...stage1Violations({ ont, facts, scope, conditions: conditionExprs }));
981
+ // -------------------------------------------------------------------------------------- waivers
982
+ const reported = [...out, ...facts.diagnostics].filter((x) => !disabled.has(x.rule));
983
+ info.unshift(...facts.info);
984
+ const violations = [];
985
+ const waived = [];
986
+ const used = new Set();
987
+ for (const x of reported) {
988
+ const g = gaps.find((g) => isObj(g) && g.rule === x.rule && g.kind === x.kind && arr(g.keys).includes(x.key));
989
+ if (g)
990
+ waived.push({ ...x, msg: `[${g.id}] ${x.msg}` }), used.add(`${g.id}|${x.key}`);
991
+ else
992
+ violations.push(x);
993
+ }
994
+ // a waiver that suppresses nothing is itself a failure, reported under the waiver's rule
995
+ for (const g of gaps)
996
+ if (isObj(g))
997
+ for (const k of arr(g.keys))
998
+ if (!used.has(`${g.id}|${k}`))
999
+ violations.push({ rule: g.rule, key: `${g.id}:${k}`, msg: `knownSourceGaps ${g.id}: ${g.rule}/${g.kind} ${k} no longer fails — remove the waiver`, kind: "stale_waiver" });
1000
+ return { violations, waived, info };
1001
+ }