agent-doc-system 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/INSTALL_FOR_AGENTS.md +60 -0
  2. package/LICENSE +21 -0
  3. package/README.md +151 -2
  4. package/adapters/cache.mjs +27 -0
  5. package/adapters/contract-consumers.mjs +56 -0
  6. package/adapters/database.mjs +116 -0
  7. package/adapters/deploy-config.mjs +34 -0
  8. package/adapters/dockerfile.mjs +26 -0
  9. package/adapters/events.mjs +145 -0
  10. package/adapters/generic.mjs +86 -0
  11. package/adapters/github-actions.mjs +106 -0
  12. package/adapters/go.mjs +91 -0
  13. package/adapters/graphql.mjs +28 -0
  14. package/adapters/index.mjs +55 -0
  15. package/adapters/node.mjs +118 -0
  16. package/adapters/object-store.mjs +64 -0
  17. package/adapters/oidc.mjs +56 -0
  18. package/adapters/openapi.mjs +50 -0
  19. package/adapters/pnpm.mjs +42 -0
  20. package/adapters/protobuf.mjs +35 -0
  21. package/adapters/registry.mjs +27 -0
  22. package/adapters/resource-client.mjs +61 -0
  23. package/adapters/turborepo.mjs +135 -0
  24. package/adapters/typescript.mjs +85 -0
  25. package/adapters/wrangler.mjs +206 -0
  26. package/bin/agentdoc.mjs +478 -0
  27. package/bin/usage.txt +26 -0
  28. package/core/acceptances.mjs +69 -0
  29. package/core/audit.mjs +407 -0
  30. package/core/authority/classes.mjs +39 -0
  31. package/core/authority/engine.mjs +243 -0
  32. package/core/authority/facts.mjs +41 -0
  33. package/core/codes.mjs +119 -0
  34. package/core/compile.mjs +881 -0
  35. package/core/contracts.mjs +121 -0
  36. package/core/derive.mjs +102 -0
  37. package/core/descriptors.mjs +486 -0
  38. package/core/determinism.mjs +95 -0
  39. package/core/discover.mjs +147 -0
  40. package/core/facts.mjs +53 -0
  41. package/core/fsx.mjs +342 -0
  42. package/core/graph.mjs +260 -0
  43. package/core/impact.mjs +175 -0
  44. package/core/indexes.mjs +165 -0
  45. package/core/jsonschema.mjs +298 -0
  46. package/core/observations.mjs +129 -0
  47. package/core/provenance.mjs +83 -0
  48. package/core/query.mjs +384 -0
  49. package/core/relations.mjs +286 -0
  50. package/core/scaffold.mjs +322 -0
  51. package/core/secrets.mjs +92 -0
  52. package/core/sourcescan.mjs +88 -0
  53. package/core/toml.mjs +163 -0
  54. package/core/yaml.mjs +475 -0
  55. package/docs/ADAPTERS.md +172 -0
  56. package/docs/AUTHORITY.md +260 -0
  57. package/docs/CI.md +136 -0
  58. package/docs/COMPARISON-KODA.md +164 -0
  59. package/docs/EVALS.md +166 -0
  60. package/docs/MIGRATION.md +193 -0
  61. package/docs/PROVENANCE.md +133 -0
  62. package/docs/QUERY.md +180 -0
  63. package/docs/SPEC.md +339 -0
  64. package/docs/VERIFICATION.md +28 -0
  65. package/docs/ci/generic-ci.sh +28 -0
  66. package/docs/ci/github-actions.yml +35 -0
  67. package/evals/harness.mjs +426 -0
  68. package/evals/scenarios/fixture-a-01-change-api.json +9 -0
  69. package/evals/scenarios/fixture-a-02-change-db.json +9 -0
  70. package/evals/scenarios/fixture-a-03-enforce-constraint.json +9 -0
  71. package/evals/scenarios/fixture-b-01-change-proto.json +9 -0
  72. package/evals/scenarios/fixture-b-02-change-write-path.json +9 -0
  73. package/evals/scenarios/fixture-b-03-change-shared-lib.json +9 -0
  74. package/evals/scenarios/fixture-b-04-journey.json +9 -0
  75. package/evals/scenarios/fixture-c-01-mixed-runtime-edge.json +19 -0
  76. package/evals/scenarios/fixture-c-02-stale-doc-contradiction.json +9 -0
  77. package/evals/scenarios/fixture-c-03-shared-vocabulary.json +9 -0
  78. package/evals/scenarios/fixture-c-04-contract-change.json +9 -0
  79. package/evals/scenarios/koda-01-modify-api-consumer.json +26 -0
  80. package/evals/scenarios/koda-02-change-db-schema.json +20 -0
  81. package/evals/scenarios/koda-03-change-auth.json +21 -0
  82. package/evals/scenarios/koda-04-add-event-producer.json +19 -0
  83. package/evals/scenarios/koda-05-modify-worker-binding.json +25 -0
  84. package/evals/scenarios/koda-06-change-cross-component-endpoint.json +22 -0
  85. package/evals/scenarios/koda-07-change-shared-package.json +16 -0
  86. package/evals/scenarios/koda-08-change-runtime-cron.json +23 -0
  87. package/evals/scenarios/koda-09-debug-production-mismatch.json +26 -0
  88. package/evals/thresholds.json +11 -0
  89. package/package.json +46 -4
  90. package/schemas/api.schema.json +84 -0
  91. package/schemas/common.schema.json +107 -0
  92. package/schemas/component.schema.json +40 -0
  93. package/schemas/config.schema.json +685 -0
  94. package/schemas/domain.schema.json +14 -0
  95. package/schemas/graph.schema.json +1084 -0
  96. package/schemas/journeys.schema.json +47 -0
  97. package/schemas/observation.schema.json +143 -0
  98. package/schemas/resource.schema.json +28 -0
  99. package/schemas/system.schema.json +26 -0
  100. package/skills/agent-doc-system/SKILL.md +113 -0
  101. package/skills/agent-doc-system/references/authority-and-conflicts.md +70 -0
  102. package/skills/agent-doc-system/references/cli.md +89 -0
  103. package/skills/agent-doc-system/references/create-migrate.md +101 -0
  104. package/skills/agent-doc-system/references/maintenance.md +52 -0
  105. package/templates/ADR.md +26 -0
  106. package/templates/ARCHITECTURE.md +19 -0
  107. package/templates/CONSTRAINTS.md +17 -0
  108. package/templates/PRODUCT.md +17 -0
  109. package/templates/api.yaml +10 -0
  110. package/templates/ci.yml +31 -0
  111. package/templates/component.yaml +14 -0
  112. package/templates/domain.yaml +6 -0
  113. package/templates/journey.yaml +7 -0
  114. package/templates/observation.yaml +19 -0
  115. package/templates/resource.yaml +8 -0
  116. package/templates/system.yaml +10 -0
  117. package/templates/warning-acceptance.yaml +14 -0
@@ -0,0 +1,298 @@
1
+ // Zero-dependency JSON Schema validator (2020-12 subset).
2
+ //
3
+ // Supported keywords — exactly what the agentdoc schemas use:
4
+ // $ref (local bundle ids and #/$defs/... pointers), $defs, type, const, enum,
5
+ // required, properties, additionalProperties, patternProperties, items,
6
+ // prefixItems, minItems, maxItems, uniqueItems, minLength, maxLength,
7
+ // pattern, minimum, maximum, allOf, anyOf, oneOf, not, if/then/else,
8
+ // propertyNames, minProperties, format (annotation only).
9
+ //
10
+ // Anything else in a schema is a hard authoring error, so a schema can never
11
+ // silently validate less than it appears to. See SPEC.md "Schema dialect".
12
+ import { AgentDocError, CODES } from "./codes.mjs";
13
+
14
+ export const SUPPORTED_KEYWORDS = Object.freeze(new Set([
15
+ "$schema", "$id", "$defs", "$ref", "title", "description", "examples", "default", "deprecated",
16
+ "type", "const", "enum", "required", "properties", "additionalProperties", "patternProperties",
17
+ "items", "prefixItems", "minItems", "maxItems", "uniqueItems", "minLength", "maxLength",
18
+ "pattern", "minimum", "maximum", "allOf", "anyOf", "oneOf", "not", "if", "then", "else",
19
+ "propertyNames", "minProperties", "$comment",
20
+ ]));
21
+
22
+ function typeOf(v) {
23
+ if (v === null) return "null";
24
+ if (Array.isArray(v)) return "array";
25
+ if (Number.isInteger(v)) return "integer";
26
+ return typeof v;
27
+ }
28
+
29
+ function typeMatches(v, t) {
30
+ const a = typeOf(v);
31
+ if (t === "number") return a === "number" || a === "integer";
32
+ if (t === "integer") return a === "integer";
33
+ return a === t;
34
+ }
35
+
36
+ function deepEqual(a, b) {
37
+ if (a === b) return true;
38
+ if (typeOf(a) !== typeOf(b)) {
39
+ if (!(typeof a === "number" && typeof b === "number")) return false;
40
+ }
41
+ if (Array.isArray(a)) {
42
+ if (!Array.isArray(b) || a.length !== b.length) return false;
43
+ return a.every((x, i) => deepEqual(x, b[i]));
44
+ }
45
+ if (a && typeof a === "object") {
46
+ if (!b || typeof b !== "object" || Array.isArray(b)) return false;
47
+ const ka = Object.keys(a).sort();
48
+ const kb = Object.keys(b).sort();
49
+ if (ka.length !== kb.length || ka.some((k, i) => k !== kb[i])) return false;
50
+ return ka.every((k) => deepEqual(a[k], b[k]));
51
+ }
52
+ return false;
53
+ }
54
+
55
+ export class SchemaBundle {
56
+ constructor(schemas) {
57
+ this.byId = new Map();
58
+ for (const s of schemas) this.byId.set(s.$id, s);
59
+ }
60
+ static fromMap(map) {
61
+ return new SchemaBundle([...map.values()]);
62
+ }
63
+ get(id) {
64
+ const s = this.byId.get(id);
65
+ if (!s) throw new AgentDocError(CODES.SCHEMA, "schema not found in bundle: " + id);
66
+ return s;
67
+ }
68
+ // Resolve a cross-schema $ref. Schemas may refer to a sibling by bare file
69
+ // name ("common.json#/$defs/x"); both that alias and the fully qualified id
70
+ // are accepted, so a bundle can be registered under either.
71
+ //
72
+ // Returns the target *and* the document it was found in. The document matters:
73
+ // a node reached through `common.json#/$defs/metadata` contains its own local
74
+ // `#/$defs/name` pointers, which must resolve against common rather than
75
+ // against whichever schema happened to lead here.
76
+ resolve(baseId, ref) {
77
+ const [file, pointer] = splitRef(ref);
78
+ const candidates = [];
79
+ if (file) {
80
+ if (this.byId.has(file)) candidates.push(file);
81
+ if (baseId) candidates.push(relativeTo(baseId, file));
82
+ for (const id of this.byId.keys()) {
83
+ if (id === file || id.endsWith("/" + file)) candidates.push(id);
84
+ }
85
+ } else if (baseId) {
86
+ candidates.push(baseId);
87
+ }
88
+ for (const c of candidates) {
89
+ if (!this.byId.has(c)) continue;
90
+ let target = this.byId.get(c);
91
+ if (pointer) {
92
+ for (const part of pointer.split("/").filter(Boolean)) {
93
+ target = target[unescapePointer(part)];
94
+ }
95
+ }
96
+ if (target !== undefined) return { node: target, document: this.byId.get(c) };
97
+ }
98
+ throw new AgentDocError(CODES.SCHEMA, "cannot resolve $ref " + ref + " from " + baseId);
99
+ }
100
+ validator(schemaOrId) {
101
+ const root = typeof schemaOrId === "string" ? this.get(schemaOrId) : schemaOrId;
102
+ return new Validator(this, root);
103
+ }
104
+ }
105
+
106
+ export class Validator {
107
+ constructor(bundle, root) {
108
+ this.bundle = bundle;
109
+ this.root = root;
110
+ }
111
+ // Returns null when valid, else an array of {instancePath, keyword, message}.
112
+ validate(value) {
113
+ const errs = [];
114
+ this.#check(this.root, value, "", errs, this.root);
115
+ return errs.length ? errs : null;
116
+ }
117
+
118
+ // A `#/...` pointer resolves against the *document* that contains it, and that
119
+ // document changes as a cross-file `$ref` is followed. Two bugs live here and
120
+ // both failed silently, because dereferencing to `undefined` means "no
121
+ // constraint" rather than an error:
122
+ //
123
+ // 1. Resolving against the referring subschema rather than its document looks
124
+ // up `#/$defs/E` inside `{"$ref": "#/$defs/E"}` and yields undefined, so any
125
+ // schema using `$ref` for an enum inside `items` validated anything at all.
126
+ // 2. Resolving against `this.root` always is wrong in the other direction:
127
+ // after following `common.json#/$defs/metadata`, the local `#/$defs/name`
128
+ // inside *common* must resolve against common, not against the original
129
+ // root. That made every component descriptor fail to validate.
130
+ //
131
+ // `doc` therefore tracks the current base document, and `#` alone means the
132
+ // current document's root.
133
+ // Resolve `schema` to a node, and report which document that node's own local
134
+ // `#/...` pointers must be resolved against. Threading the document explicitly
135
+ // is what makes nested cross-file references work: without it, a
136
+ // `#/$defs/repoPath` written inside `common.json` was looked up in whichever
137
+ // schema happened to lead there, and dereferencing to `undefined` means "no
138
+ // constraint" rather than an error — a silent soundness hole.
139
+ #resolve(schema, doc = this.root, seen = new Set()) {
140
+ let s = schema;
141
+ let base = doc;
142
+ while (s && typeof s.$ref === "string") {
143
+ const id = s.$ref;
144
+ const tag = (base && base.$id) + "|" + id;
145
+ if (seen.has(tag)) throw new AgentDocError(CODES.SCHEMA, "cyclic $ref: " + id);
146
+ seen.add(tag);
147
+ if (id.startsWith("#")) {
148
+ if (id === "#") { s = base; continue; }
149
+ if (!id.startsWith("#/")) throw new AgentDocError(CODES.SCHEMA, "unsupported $ref " + id);
150
+ s = pointerGet(base, id.slice(2).split("/").map(unescapePointer));
151
+ if (s === undefined) {
152
+ throw new AgentDocError(CODES.SCHEMA, "cannot resolve local $ref " + id + " against " + base.$id);
153
+ }
154
+ } else {
155
+ const found = this.bundle.resolve(base.$id, id);
156
+ s = found.node;
157
+ base = found.document;
158
+ }
159
+ }
160
+ return { node: s, document: base };
161
+ }
162
+
163
+ #check(rawSchema, value, ip, errs, doc = this.root) {
164
+ const resolved = this.#resolve(rawSchema, doc);
165
+ const schema = resolved.node;
166
+ const base = resolved.document;
167
+ if (schema === true || schema === undefined) return;
168
+ if (schema === false) {
169
+ errs.push({ instancePath: ip || "/", keyword: "false", message: "schema forbids any value" });
170
+ return;
171
+ }
172
+ const add = (keyword, message) => errs.push({ instancePath: ip || "/", keyword, message });
173
+
174
+ if (schema.type !== undefined) {
175
+ const types = Array.isArray(schema.type) ? schema.type : [schema.type];
176
+ if (!types.some((t) => typeMatches(value, t))) {
177
+ add("type", "must be " + types.join("|") + " (got " + typeOf(value) + ")");
178
+ return;
179
+ }
180
+ }
181
+ if (schema.const !== undefined && !deepEqual(value, schema.const)) {
182
+ add("const", "must equal " + JSON.stringify(schema.const));
183
+ }
184
+ if (schema.enum !== undefined && !schema.enum.some((e) => deepEqual(value, e))) {
185
+ add("enum", "must be one of " + JSON.stringify(schema.enum));
186
+ }
187
+
188
+ const t = typeOf(value);
189
+ if (t === "string") {
190
+ if (schema.minLength !== undefined && [...value].length < schema.minLength) add("minLength", "shorter than " + schema.minLength);
191
+ if (schema.maxLength !== undefined && [...value].length > schema.maxLength) add("maxLength", "longer than " + schema.maxLength);
192
+ if (schema.pattern !== undefined && !new RegExp(schema.pattern, "u").test(value)) {
193
+ add("pattern", "does not match " + schema.pattern);
194
+ }
195
+ }
196
+ if (t === "number" || t === "integer") {
197
+ if (schema.minimum !== undefined && value < schema.minimum) add("minimum", "below minimum " + schema.minimum);
198
+ if (schema.maximum !== undefined && value > schema.maximum) add("maximum", "above maximum " + schema.maximum);
199
+ }
200
+ if (t === "array") {
201
+ if (schema.minItems !== undefined && value.length < schema.minItems) add("minItems", "fewer than " + schema.minItems + " items");
202
+ if (schema.maxItems !== undefined && value.length > schema.maxItems) add("maxItems", "more than " + schema.maxItems + " items");
203
+ if (schema.uniqueItems === true) {
204
+ for (let i = 0; i < value.length; i++) {
205
+ for (let j = i + 1; j < value.length; j++) {
206
+ if (deepEqual(value[i], value[j])) add("uniqueItems", "duplicate item at index " + j);
207
+ }
208
+ }
209
+ }
210
+ const prefix = schema.prefixItems || [];
211
+ for (let i = 0; i < value.length; i++) {
212
+ if (i < prefix.length) this.#check(prefix[i], value[i], ip + "/" + i, errs, base);
213
+ else if (schema.items !== undefined) this.#check(schema.items, value[i], ip + "/" + i, errs, base);
214
+ }
215
+ }
216
+ if (t === "object") {
217
+ const keys = Object.keys(value);
218
+ if (schema.minProperties !== undefined && keys.length < schema.minProperties) add("minProperties", "fewer than " + schema.minProperties + " properties");
219
+ for (const r of schema.required || []) {
220
+ if (!Object.prototype.hasOwnProperty.call(value, r)) add("required", "missing required property " + JSON.stringify(r));
221
+ }
222
+ const props = schema.properties || {};
223
+ const patterns = Object.entries(schema.patternProperties || {});
224
+ for (const k of keys) {
225
+ const escaped = k.replace(/~/g, "~0").replace(/\//g, "~1");
226
+ const childIp = ip + "/" + escaped;
227
+ let covered = false;
228
+ if (Object.prototype.hasOwnProperty.call(props, k)) {
229
+ covered = true;
230
+ this.#check(props[k], value[k], childIp, errs, base);
231
+ }
232
+ for (const [re, sub] of patterns) {
233
+ if (new RegExp(re, "u").test(k)) {
234
+ covered = true;
235
+ this.#check(sub, value[k], childIp, errs, base);
236
+ }
237
+ }
238
+ if (!covered) {
239
+ if (schema.additionalProperties === false) {
240
+ errs.push({ instancePath: childIp, keyword: "additionalProperties", message: "unknown property " + JSON.stringify(k) });
241
+ } else if (schema.additionalProperties !== undefined) {
242
+ this.#check(schema.additionalProperties, value[k], childIp, errs, base);
243
+ }
244
+ }
245
+ if (schema.propertyNames !== undefined) this.#check(schema.propertyNames, k, childIp, errs, base);
246
+ }
247
+ }
248
+
249
+ for (const sub of schema.allOf || []) this.#check(sub, value, ip, errs, base);
250
+ if (schema.anyOf) {
251
+ const ok = schema.anyOf.some((s) => this.#sub(value, s, base));
252
+ if (!ok) add("anyOf", "does not match any allowed variant");
253
+ }
254
+ if (schema.oneOf) {
255
+ const n = schema.oneOf.filter((s) => this.#sub(value, s, base)).length;
256
+ if (n !== 1) add("oneOf", "must match exactly one variant (matched " + n + ")");
257
+ }
258
+ if (schema.not !== undefined && this.#sub(value, schema.not, base)) add("not", "matches a forbidden shape");
259
+ if (schema.if !== undefined) {
260
+ if (this.#sub(value, schema.if, base)) this.#check(schema.then, value, ip, errs, base);
261
+ else if (schema.else !== undefined) this.#check(schema.else, value, ip, errs, base);
262
+ }
263
+ }
264
+
265
+ #sub(value, schema, doc = this.root) {
266
+ const errs = [];
267
+ this.#check(schema, value, "", errs, doc);
268
+ return errs.length === 0;
269
+ }
270
+ }
271
+
272
+ function unescapePointer(s) {
273
+ return s.replace(/~1/g, "/").replace(/~0/g, "~");
274
+ }
275
+
276
+ function pointerGet(root, parts) {
277
+ let cur = root;
278
+ for (const p of parts) {
279
+ if (cur === undefined || cur === null) return undefined;
280
+ cur = cur[p];
281
+ }
282
+ return cur;
283
+ }
284
+
285
+ function relativeTo(baseId, ref) {
286
+ const base = baseId || "";
287
+ const cut = base.lastIndexOf("/");
288
+ return cut >= 0 ? base.slice(0, cut + 1) + ref : ref;
289
+ }
290
+
291
+ function splitRef(ref) {
292
+ const at = ref.indexOf("#");
293
+ return at < 0 ? [ref, null] : [ref.slice(0, at), ref.slice(at + 1)];
294
+ }
295
+
296
+ export function formatErrors(errs, limit = 8) {
297
+ return errs.slice(0, limit).map((e) => (e.instancePath || "/") + " " + e.message).join("; ");
298
+ }
@@ -0,0 +1,129 @@
1
+ // Observation ingestion.
2
+ //
3
+ // An ObservationSet is a committed, secret-free record of what an external
4
+ // system actually said at a known time. Ingesting it produces
5
+ // OBSERVED_RUNTIME assertions. It can never overwrite an authored fact: it is
6
+ // added to the same (subject, key) group and the authority engine decides what
7
+ // happens. Freshness is evaluated at gate time (check/query), never at compile
8
+ // time, so a compile stays a pure function of the checkout.
9
+ import { CODES, AgentDocError } from "./codes.mjs";
10
+
11
+ export function ingestObservations(ctx) {
12
+ const out = [];
13
+ const { sets } = ctx.observations;
14
+ const byName = new Map();
15
+ for (const s of sets) {
16
+ if (byName.has(s.meta.name)) {
17
+ throw new AgentDocError(
18
+ CODES.DUPLICATE_IDENTITY,
19
+ "duplicate observation set name '" + s.meta.name + "' (also in " + byName.get(s.meta.name).file + ")",
20
+ { path: s.file }
21
+ );
22
+ }
23
+ byName.set(s.meta.name, s);
24
+ }
25
+ for (const s of sets) {
26
+ const m = s.meta;
27
+ if (m.supersedes && !byName.has(m.supersedes)) {
28
+ throw new AgentDocError(
29
+ CODES.REF_UNRESOLVED,
30
+ "observation set '" + m.name + "' supersedes unknown set '" + m.supersedes + "'",
31
+ { path: s.file }
32
+ );
33
+ }
34
+ const bundleProv = ctx.prov.add(
35
+ "runtime",
36
+ m.evidenceBundle,
37
+ "observation:" + m.collector.replace(/\s+/g, "-").toLowerCase(),
38
+ ["obs:" + m.name],
39
+ { jsonPointer: "/facts" }
40
+ );
41
+ const observed = {
42
+ at: m.capturedAt,
43
+ environment: m.environment,
44
+ sourceSystem: m.sourceSystem,
45
+ collector: m.collector,
46
+ evidenceBundle: m.evidenceBundle,
47
+ retrievalMethod: m.collector,
48
+ supersedes: m.supersedes || null,
49
+ };
50
+ const facts = [];
51
+ for (const f of s.facts) {
52
+ const fact = ctx.facts.add({
53
+ subject: f.subject,
54
+ key: f.key,
55
+ value: f.value,
56
+ evidenceClass: "OBSERVED_RUNTIME",
57
+ confidence: "direct",
58
+ provRecs: [bundleProv],
59
+ semantics: f.semantics,
60
+ observed,
61
+ source: s.file,
62
+ });
63
+ facts.push({
64
+ id: f.id,
65
+ subject: f.subject,
66
+ key: f.key,
67
+ value: f.value,
68
+ semantics: f.semantics,
69
+ assertionId: fact.id,
70
+ observedRefs: [...(f.observedRefs || [])].sort(),
71
+ });
72
+ }
73
+ out.push({
74
+ id: m.name,
75
+ name: m.name,
76
+ description: m.description,
77
+ environment: m.environment,
78
+ sourceSystem: m.sourceSystem,
79
+ collector: m.collector,
80
+ capturedAt: m.capturedAt,
81
+ maxAgeDays: m.maxAgeDays,
82
+ evidenceBundle: m.evidenceBundle,
83
+ supersedes: m.supersedes || null,
84
+ facts,
85
+ provenanceIds: null,
86
+ provs: new Set([bundleProv]),
87
+ });
88
+ }
89
+ out.sort((a, b) => (a.id < b.id ? -1 : 1));
90
+ return out;
91
+ }
92
+
93
+ // Freshness of an observation is a time-dependent judgement and therefore lives
94
+ // outside the compiled graph. `now` is injected so tests and CI are hermetic.
95
+ export function observationFreshness(observations, now = new Date()) {
96
+ const out = [];
97
+ // A superseded set is history, not a promise of freshness — its maxAgeDays
98
+ // no longer gates, or every superseding observation would immediately make
99
+ // the one it replaced a permanent staleness failure.
100
+ const supersededIds = new Set((observations || []).map((o) => o.supersedes).filter(Boolean));
101
+ for (const o of observations || []) {
102
+ if (supersededIds.has(o.id)) continue;
103
+ const captured = Date.parse(o.capturedAt);
104
+ const ageDays = Math.floor((now.getTime() - captured) / 86400000);
105
+ out.push({
106
+ id: o.id,
107
+ environment: o.environment,
108
+ capturedAt: o.capturedAt,
109
+ ageDays,
110
+ maxAgeDays: o.maxAgeDays,
111
+ stale: !Number.isFinite(ageDays) || ageDays > o.maxAgeDays,
112
+ });
113
+ }
114
+ return out.sort((a, b) => (a.id < b.id ? -1 : 1));
115
+ }
116
+
117
+ export function stalenessDiagnostics(freshness) {
118
+ return freshness
119
+ .filter((f) => f.stale)
120
+ .map((f) => ({
121
+ severity: "error",
122
+ code: CODES.OBSERVATION_STALE,
123
+ subject: f.id,
124
+ message:
125
+ "observation set '" + f.id + "' (" + f.environment + ") was captured " + f.capturedAt +
126
+ " and exceeds its declared maxAgeDays of " + f.maxAgeDays + " — re-observe the source system or restate the fact",
127
+ paths: [],
128
+ }));
129
+ }
@@ -0,0 +1,83 @@
1
+ // Provenance registry.
2
+ //
3
+ // Every fact that reaches the compiled graph is backed by a record carrying the
4
+ // evidence path, extractor identity, content hash, and RFC 6901 subject
5
+ // pointers into the graph. Provenance classes:
6
+ // declared — the file states the fact outright (authored descriptor, config)
7
+ // observed — the file shows the fact (manifest, source file, config)
8
+ // validated — two independent files agree (a generated client + the contract)
9
+ // runtime — read from an external system via a recorded collector
10
+ // standard — the canonical authority is a published standard
11
+ import { sha256Hex } from "./fsx.mjs";
12
+
13
+ // Extractor identities appear in the graph and are matched by tooling, so they
14
+ // are normalised to a slug. "discovery:pnpm" and "pnpm" must not be two
15
+ // different extractors.
16
+ export function slugExtractor(name) {
17
+ const s = String(name).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
18
+ return s || "unknown";
19
+ }
20
+
21
+ // `reviewed` is the provenance class for a human interpretation recorded in
22
+ // configuration. It was previously filed as `declared`, which mapped to
23
+ // AUTHORED — the strongest class in the model — so a reviewed override merged
24
+ // with a derived edge and was relabelled AUTHORED, silently promoting a human
25
+ // judgement to the top of the hierarchy. The authority model elects classes per
26
+ // fact; a provenance class must not decide that on its own.
27
+ export const CLASSES = Object.freeze(["declared", "observed", "validated", "runtime", "standard", "reviewed"]);
28
+
29
+ export class ProvRegistry {
30
+ constructor(repo) {
31
+ this.repo = repo;
32
+ this.records = [];
33
+ }
34
+ // subjects are symbolic keys ("rel:pending", "ent:<ref>", "evt:<pattern>")
35
+ // resolved to graph pointers after assembly. Callers add "pending" first and
36
+ // rebind later; see bindPending in graph.mjs.
37
+ add(cls, path, extractorName, subjects, location, extractorVersion = "1.0.0") {
38
+ if (!CLASSES.includes(cls)) throw new Error("unknown provenance class " + cls);
39
+ const name = slugExtractor(extractorName);
40
+ const buf = this.repo.readBytes(path);
41
+ const rec = {
42
+ cls,
43
+ path,
44
+ extractor: { name, version: extractorVersion },
45
+ subjects: new Set(subjects.filter(Boolean)),
46
+ hash: "sha256:" + sha256Hex(buf),
47
+ location,
48
+ };
49
+ this.records.push(rec);
50
+ return rec;
51
+ }
52
+ // Canonical order: path, extractor, first subject. IDs are positional, so the
53
+ // numbering is a pure function of the input set.
54
+ finalize(resolve) {
55
+ const sorted = [...this.records].sort((a, b) => {
56
+ const ka = a.path + "|" + a.extractor.name + "|" + [...a.subjects].sort()[0];
57
+ const kb = b.path + "|" + b.extractor.name + "|" + [...b.subjects].sort()[0];
58
+ return ka < kb ? -1 : ka > kb ? 1 : 0;
59
+ });
60
+ const list = sorted.map((r, i) => {
61
+ const subjects = [...r.subjects].map(resolve).sort();
62
+ const out = {
63
+ id: "p-" + String(i + 1).padStart(4, "0"),
64
+ class: r.cls,
65
+ path: r.path,
66
+ extractor: { name: r.extractor.name, version: r.extractor.version },
67
+ contentHash: r.hash,
68
+ subjects,
69
+ };
70
+ if (r.location) out.location = r.location;
71
+ return out;
72
+ });
73
+ const idOf = new Map(sorted.map((r, i) => [r, "p-" + String(i + 1).padStart(4, "0")]));
74
+ return { list, idOf };
75
+ }
76
+ }
77
+
78
+ // Collect the distinct extractor identities for the compiler stamp.
79
+ export function extractorSet(prov) {
80
+ const m = new Map();
81
+ for (const r of prov.records) m.set(r.extractor.name, r.extractor.version);
82
+ return [...m.entries()].sort().map(([name, version]) => ({ name, version }));
83
+ }