@oscharko-dev/keiko-evidence 0.2.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 (92) hide show
  1. package/dist/.tsbuildinfo +1 -0
  2. package/dist/aggregate.d.ts +4 -0
  3. package/dist/aggregate.d.ts.map +1 -0
  4. package/dist/aggregate.js +21 -0
  5. package/dist/build.d.ts +3 -0
  6. package/dist/build.d.ts.map +1 -0
  7. package/dist/build.js +227 -0
  8. package/dist/connected-context-evidence.d.ts +47 -0
  9. package/dist/connected-context-evidence.d.ts.map +1 -0
  10. package/dist/connected-context-evidence.js +197 -0
  11. package/dist/errors.d.ts +3 -0
  12. package/dist/errors.d.ts.map +1 -0
  13. package/dist/errors.js +4 -0
  14. package/dist/index-api.d.ts +15 -0
  15. package/dist/index-api.d.ts.map +1 -0
  16. package/dist/index-api.js +136 -0
  17. package/dist/index.d.ts +20 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +34 -0
  20. package/dist/persist.d.ts +9 -0
  21. package/dist/persist.d.ts.map +1 -0
  22. package/dist/persist.js +40 -0
  23. package/dist/promptEnhancement/index.d.ts +7 -0
  24. package/dist/promptEnhancement/index.d.ts.map +1 -0
  25. package/dist/promptEnhancement/index.js +10 -0
  26. package/dist/promptEnhancement/manifestSchema.d.ts +71 -0
  27. package/dist/promptEnhancement/manifestSchema.d.ts.map +1 -0
  28. package/dist/promptEnhancement/manifestSchema.js +307 -0
  29. package/dist/promptEnhancement/redaction.d.ts +17 -0
  30. package/dist/promptEnhancement/redaction.d.ts.map +1 -0
  31. package/dist/promptEnhancement/redaction.js +66 -0
  32. package/dist/promptEnhancement/store.d.ts +64 -0
  33. package/dist/promptEnhancement/store.d.ts.map +1 -0
  34. package/dist/promptEnhancement/store.js +409 -0
  35. package/dist/qualityIntelligence/candidatesArtifact.d.ts +74 -0
  36. package/dist/qualityIntelligence/candidatesArtifact.d.ts.map +1 -0
  37. package/dist/qualityIntelligence/candidatesArtifact.js +258 -0
  38. package/dist/qualityIntelligence/companionStore.d.ts +37 -0
  39. package/dist/qualityIntelligence/companionStore.d.ts.map +1 -0
  40. package/dist/qualityIntelligence/companionStore.js +158 -0
  41. package/dist/qualityIntelligence/figmaSnapshot/schema.d.ts +123 -0
  42. package/dist/qualityIntelligence/figmaSnapshot/schema.d.ts.map +1 -0
  43. package/dist/qualityIntelligence/figmaSnapshot/schema.js +163 -0
  44. package/dist/qualityIntelligence/figmaSnapshot/store.d.ts +144 -0
  45. package/dist/qualityIntelligence/figmaSnapshot/store.d.ts.map +1 -0
  46. package/dist/qualityIntelligence/figmaSnapshot/store.js +898 -0
  47. package/dist/qualityIntelligence/index.d.ts +18 -0
  48. package/dist/qualityIntelligence/index.d.ts.map +1 -0
  49. package/dist/qualityIntelligence/index.js +21 -0
  50. package/dist/qualityIntelligence/manifestSchema.d.ts +154 -0
  51. package/dist/qualityIntelligence/manifestSchema.d.ts.map +1 -0
  52. package/dist/qualityIntelligence/manifestSchema.js +70 -0
  53. package/dist/qualityIntelligence/redaction.d.ts +10 -0
  54. package/dist/qualityIntelligence/redaction.d.ts.map +1 -0
  55. package/dist/qualityIntelligence/redaction.js +103 -0
  56. package/dist/qualityIntelligence/retention.d.ts +71 -0
  57. package/dist/qualityIntelligence/retention.d.ts.map +1 -0
  58. package/dist/qualityIntelligence/retention.js +287 -0
  59. package/dist/qualityIntelligence/retentionPolicy.d.ts +10 -0
  60. package/dist/qualityIntelligence/retentionPolicy.d.ts.map +1 -0
  61. package/dist/qualityIntelligence/retentionPolicy.js +38 -0
  62. package/dist/qualityIntelligence/store.d.ts +95 -0
  63. package/dist/qualityIntelligence/store.d.ts.map +1 -0
  64. package/dist/qualityIntelligence/store.js +483 -0
  65. package/dist/redaction.d.ts +2 -0
  66. package/dist/redaction.d.ts.map +1 -0
  67. package/dist/redaction.js +4 -0
  68. package/dist/report.d.ts +17 -0
  69. package/dist/report.d.ts.map +1 -0
  70. package/dist/report.js +50 -0
  71. package/dist/retention.d.ts +4 -0
  72. package/dist/retention.d.ts.map +1 -0
  73. package/dist/retention.js +95 -0
  74. package/dist/runid.d.ts +2 -0
  75. package/dist/runid.d.ts.map +1 -0
  76. package/dist/runid.js +4 -0
  77. package/dist/side-file.d.ts +9 -0
  78. package/dist/side-file.d.ts.map +1 -0
  79. package/dist/side-file.js +102 -0
  80. package/dist/store.d.ts +8 -0
  81. package/dist/store.d.ts.map +1 -0
  82. package/dist/store.js +332 -0
  83. package/dist/types.d.ts +3 -0
  84. package/dist/types.d.ts.map +1 -0
  85. package/dist/types.js +5 -0
  86. package/dist/version.d.ts +2 -0
  87. package/dist/version.d.ts.map +1 -0
  88. package/dist/version.js +1 -0
  89. package/dist/workflow-evidence.d.ts +36 -0
  90. package/dist/workflow-evidence.d.ts.map +1 -0
  91. package/dist/workflow-evidence.js +158 -0
  92. package/package.json +32 -0
@@ -0,0 +1,483 @@
1
+ // Local-state store for Quality Intelligence runs (Issue #274, Epic #270, ADR-0023 D7+D8).
2
+ //
3
+ // Extends the existing `keiko-evidence` JSON-on-disk discipline (NOT a separate database, NOT a
4
+ // new runtime dependency) per ADR-0023 D7 "extend, don't fork". Each QI run is persisted as one
5
+ // schema-validated JSON file `<runId>.qi.json` under a `qi/` subdirectory of the evidence base
6
+ // dir; the four conceptual "tables" of the brief (runs / findings / exports / evidence-refs)
7
+ // surface as the readonly arrays on the manifest itself.
8
+ //
9
+ // Why JSON-on-disk and not a new SQLite table set: the local-state contract (issue #175) freezes
10
+ // the on-disk surface to "evidence is JSON". Introducing a SQLite DB inside keiko-evidence would
11
+ // fork the contract. The brief explicitly allows the "analogous structure if the store is not
12
+ // SQLite" alternative.
13
+ //
14
+ // Safety:
15
+ // - Base dir is realpath-contained once at construction; every child path is derived from a
16
+ // validated runId and the lexical directory entry is inspected before overwrite/delete.
17
+ // - File names are derived from the VALIDATED runId via assertValidRunId — no separator/`..`/NUL
18
+ // can reach the resolved path.
19
+ // - Writes are atomic O_EXCL temp + rename. A partial write leaves a `.tmp` that is invisible to
20
+ // list (which only counts `.qi.json` suffixes), so an unclean shutdown never surfaces a
21
+ // half-written run.
22
+ // - The QI base dir is created with mode 0o700, files with the default umask + 0o600 intent (the
23
+ // atomic temp inherits the umask; the rename preserves it).
24
+ import { createHash, randomUUID } from "node:crypto";
25
+ import { chmodSync, mkdirSync, readdirSync, readFileSync, lstatSync, renameSync, rmSync, writeFileSync, } from "node:fs";
26
+ import { join, resolve } from "node:path";
27
+ import { resolveWithinWorkspace } from "@oscharko-dev/keiko-workspace";
28
+ import { nodeWorkspaceFs } from "@oscharko-dev/keiko-workspace/internal/fs";
29
+ import { assertValidRunId } from "@oscharko-dev/keiko-security";
30
+ import { EvidenceReadError, EvidenceWriteError } from "../errors.js";
31
+ import { QUALITY_INTELLIGENCE_EVIDENCE_SCHEMA_VERSION, validateQualityIntelligenceEvidenceManifest, } from "./manifestSchema.js";
32
+ import { redactQualityIntelligenceEvidence, } from "./redaction.js";
33
+ // `qi/` subdir of the evidence base; chosen so `listEvidence()` (the existing API for run-level
34
+ // JSON manifests) does NOT see QI manifests by accident — different layer, different shape.
35
+ export const QI_SUBDIR = "qi";
36
+ const QI_MANIFEST_SUFFIX = ".qi.json";
37
+ const QI_DIR_MODE = 0o700;
38
+ // ─── In-memory store (tests + future port-injected callers) ─────────────────────────
39
+ export function createInMemoryQualityIntelligenceLocalStore() {
40
+ const data = new Map();
41
+ return {
42
+ record: (manifest) => {
43
+ assertValidRunId(manifest.runId);
44
+ data.set(manifest.runId, manifest);
45
+ return `${manifest.runId}${QI_MANIFEST_SUFFIX}`;
46
+ },
47
+ load: (runId) => {
48
+ assertValidRunId(runId);
49
+ return data.get(runId);
50
+ },
51
+ list: () => [...data.keys()].sort(),
52
+ location: (runId) => {
53
+ assertValidRunId(runId);
54
+ return `${runId}${QI_MANIFEST_SUFFIX}`;
55
+ },
56
+ delete: (runId) => {
57
+ assertValidRunId(runId);
58
+ return data.delete(runId);
59
+ },
60
+ };
61
+ }
62
+ // ─── Node adapter ──────────────────────────────────────────────────────────────────
63
+ function prepareQiBaseDir(baseDir, fs) {
64
+ try {
65
+ mkdirSync(baseDir, { recursive: true, mode: QI_DIR_MODE });
66
+ return fs.realPath(baseDir);
67
+ }
68
+ catch (error) {
69
+ throw new EvidenceWriteError(`cannot create QI evidence directory: ${error instanceof Error ? error.message : "unknown"}`);
70
+ }
71
+ }
72
+ function existingQiBaseDir(baseDir, fs) {
73
+ if (!fs.exists(baseDir)) {
74
+ return undefined;
75
+ }
76
+ try {
77
+ return fs.realPath(baseDir);
78
+ }
79
+ catch (error) {
80
+ throw new EvidenceReadError(`cannot read QI evidence directory: ${error instanceof Error ? error.message : "unknown"}`);
81
+ }
82
+ }
83
+ function lexicalQiManifestPath(runId, realBase) {
84
+ assertValidRunId(runId);
85
+ return resolveWithinWorkspace(realBase, `${runId}${QI_MANIFEST_SUFFIX}`);
86
+ }
87
+ function isQiManifestName(name) {
88
+ if (!name.endsWith(QI_MANIFEST_SUFFIX)) {
89
+ return false;
90
+ }
91
+ const runId = name.slice(0, name.length - QI_MANIFEST_SUFFIX.length);
92
+ try {
93
+ assertValidRunId(runId);
94
+ return true;
95
+ }
96
+ catch {
97
+ return false;
98
+ }
99
+ }
100
+ function isSingleLinkRegularFile(path, fs) {
101
+ try {
102
+ const stat = fs.stat(path);
103
+ return stat.isFile && (stat.hardLinkCount ?? 1) <= 1;
104
+ }
105
+ catch (error) {
106
+ throw new EvidenceReadError(`cannot inspect QI manifest: ${error instanceof Error ? error.message : "unknown"}`);
107
+ }
108
+ }
109
+ function assertWritableQiManifestEntry(target, fs) {
110
+ const entry = lstatSync(target, { throwIfNoEntry: false });
111
+ if (entry === undefined)
112
+ return;
113
+ if (!entry.isFile() || !isSingleLinkRegularFile(target, fs)) {
114
+ throw new EvidenceWriteError("cannot overwrite a non-ledger QI manifest");
115
+ }
116
+ }
117
+ function listQiRunIds(realBase, fs) {
118
+ const runIds = [];
119
+ try {
120
+ for (const entry of readdirSync(realBase, { withFileTypes: true })) {
121
+ if (entry.isSymbolicLink() ||
122
+ !entry.isFile() ||
123
+ !isQiManifestName(entry.name) ||
124
+ !isSingleLinkRegularFile(join(realBase, entry.name), fs)) {
125
+ continue;
126
+ }
127
+ runIds.push(entry.name.slice(0, entry.name.length - QI_MANIFEST_SUFFIX.length));
128
+ }
129
+ }
130
+ catch (error) {
131
+ throw new EvidenceReadError(`cannot list QI manifests: ${error instanceof Error ? error.message : "unknown"}`);
132
+ }
133
+ return runIds.sort();
134
+ }
135
+ function atomicWriteQiManifest(target, json, randomSuffix) {
136
+ const temp = `${target}.${randomSuffix()}.tmp`;
137
+ try {
138
+ writeFileSync(temp, json, { encoding: "utf8", flag: "wx" });
139
+ // Best-effort 0o600 on the temp file (the rename preserves the mode). Failure is non-fatal:
140
+ // POSIX-default umask handles the common case; the assertion is realpath containment, not
141
+ // permission bits.
142
+ try {
143
+ chmodSync(temp, 0o600);
144
+ }
145
+ catch {
146
+ // ignore; not all filesystems support chmod (e.g. Windows)
147
+ }
148
+ renameSync(temp, target);
149
+ }
150
+ catch (error) {
151
+ rmSync(temp, { force: true });
152
+ throw new EvidenceWriteError(`QI manifest write failed: ${error instanceof Error ? error.message : "unknown"}`);
153
+ }
154
+ }
155
+ function reportQiLocation(baseDir, fs, runId) {
156
+ assertValidRunId(runId);
157
+ const realBase = existingQiBaseDir(baseDir, fs);
158
+ return realBase === undefined
159
+ ? join(resolve(baseDir), `${runId}${QI_MANIFEST_SUFFIX}`)
160
+ : lexicalQiManifestPath(runId, realBase);
161
+ }
162
+ function parseAndValidateManifest(json) {
163
+ let parsed;
164
+ try {
165
+ parsed = JSON.parse(json);
166
+ }
167
+ catch (error) {
168
+ throw new EvidenceReadError(`QI manifest is not valid JSON: ${error instanceof Error ? error.message : "unknown"}`);
169
+ }
170
+ const validation = validateQualityIntelligenceEvidenceManifest(parsed);
171
+ if (!validation.ok) {
172
+ throw new EvidenceReadError(`QI manifest schema invalid: ${validation.reason ?? "unknown"}`);
173
+ }
174
+ const manifest = parsed;
175
+ // Issue #637 — verify recorded SHA-256 integrity hashes AND totals against the live
176
+ // collections on read. The strict-schema gate above only validates the schema-version literal,
177
+ // the closed top-level key set, and the status enum; it does NOT detect a tampered finding /
178
+ // export / evidenceRef payload or a totals/collections drift. Failing closed here keeps the
179
+ // BFF list endpoint from surfacing corrupted runs and forces the detail endpoint into its
180
+ // controlled error path.
181
+ assertManifestIntegrity(manifest);
182
+ return manifest;
183
+ }
184
+ function assertHashMatches(label, expected, stored) {
185
+ if (expected !== stored) {
186
+ throw new EvidenceReadError(`QI manifest ${label} integrity hash mismatch`);
187
+ }
188
+ }
189
+ function assertIntegrityHashesMatch(manifest) {
190
+ const expected = buildIntegrityHashes(manifest.findings, manifest.exports, manifest.evidenceRefs);
191
+ assertHashMatches("findings", expected.findings, manifest.integrityHashes.findings);
192
+ assertHashMatches("exports", expected.exports, manifest.integrityHashes.exports);
193
+ assertHashMatches("evidenceRefs", expected.evidenceRefs, manifest.integrityHashes.evidenceRefs);
194
+ // atomFingerprints are hashed unconditionally whenever present (#821), so compare expected-vs-stored
195
+ // directly — a removed or added set (stored present, expected absent or vice versa) is caught too.
196
+ const expectedAtomFingerprints = manifest.atomFingerprints === undefined ? undefined : sha256OfJson(manifest.atomFingerprints);
197
+ assertHashMatches("atomFingerprints", expectedAtomFingerprints, manifest.integrityHashes.atomFingerprints);
198
+ // coverageMatrix and sourceFingerprints: derive expected = (collection === undefined ? undefined :
199
+ // sha256OfJson(collection)) and compare with assertHashMatches so a present collection with a
200
+ // deleted stored sub-hash FAILS CLOSED (mirrors atomFingerprints above — removal-proof).
201
+ const expectedCoverageMatrix = manifest.coverageMatrix === undefined ? undefined : sha256OfJson(manifest.coverageMatrix);
202
+ assertHashMatches("coverageMatrix", expectedCoverageMatrix, manifest.integrityHashes.coverageMatrix);
203
+ const expectedSourceFingerprints = manifest.sourceFingerprints === undefined
204
+ ? undefined
205
+ : sha256OfJson(manifest.sourceFingerprints);
206
+ assertHashMatches("sourceFingerprints", expectedSourceFingerprints, manifest.integrityHashes.sourceFingerprints);
207
+ }
208
+ // Integrity scope (be precise — this is the on-read tamper-detection contract): we verify the
209
+ // (totals ↔ collection-length) invariant for findings/exports AND the per-group SHA-256 hashes for
210
+ // findings / exports / evidenceRefs / atomFingerprints (+ coverageMatrix / sourceFingerprints when
211
+ // their stored hash is present). We do NOT hash the run-level scalars (`status`, `totals.candidates`,
212
+ // `provenanceRefs`, `qualityScore`, `modelId`, `modelParameters`, `seedUsed`, `planAt`/`completedAt`,
213
+ // `retentionPolicyId`, `policyProfileIds`, `modelGatewayCallCount`, `redactionSummary`): a local
214
+ // on-disk edit of those passes load. This is an accepted limitation of the local-state threat model
215
+ // (the operator owns the disk); extending coverage to a scalar `meta` group is tracked as a #274
216
+ // follow-up (see ADR-0023 D8). The schema gate already rejects unknown/missing top-level keys and a
217
+ // bad status enum, so a scalar edit cannot change the manifest SHAPE — only a value.
218
+ function assertManifestIntegrity(manifest) {
219
+ if (manifest.totals.findings !== manifest.findings.length) {
220
+ throw new EvidenceReadError(`QI manifest totals.findings (${String(manifest.totals.findings)}) does not match findings.length (${String(manifest.findings.length)})`);
221
+ }
222
+ if (manifest.totals.exports !== manifest.exports.length) {
223
+ throw new EvidenceReadError(`QI manifest totals.exports (${String(manifest.totals.exports)}) does not match exports.length (${String(manifest.exports.length)})`);
224
+ }
225
+ assertIntegrityHashesMatch(manifest);
226
+ }
227
+ function loadQiManifest(baseDir, fs, runId) {
228
+ assertValidRunId(runId);
229
+ const realBase = existingQiBaseDir(baseDir, fs);
230
+ if (realBase === undefined) {
231
+ return undefined;
232
+ }
233
+ const target = join(realBase, `${runId}${QI_MANIFEST_SUFFIX}`);
234
+ try {
235
+ if (lstatSync(target, { throwIfNoEntry: false })?.isFile() !== true) {
236
+ return undefined;
237
+ }
238
+ if (!isSingleLinkRegularFile(target, fs)) {
239
+ return undefined;
240
+ }
241
+ const json = readFileSync(target, "utf8");
242
+ return parseAndValidateManifest(json);
243
+ }
244
+ catch (error) {
245
+ if (error instanceof EvidenceReadError) {
246
+ throw error;
247
+ }
248
+ throw new EvidenceReadError(`cannot read QI manifest: ${error instanceof Error ? error.message : "unknown"}`);
249
+ }
250
+ }
251
+ function recordQiManifest(baseDir, fs, randomSuffix, manifest) {
252
+ assertValidRunId(manifest.runId);
253
+ const realBase = prepareQiBaseDir(baseDir, fs);
254
+ const target = lexicalQiManifestPath(manifest.runId, realBase);
255
+ assertWritableQiManifestEntry(target, fs);
256
+ atomicWriteQiManifest(target, JSON.stringify(manifest), randomSuffix);
257
+ return target;
258
+ }
259
+ function deleteQiManifest(baseDir, fs, runId) {
260
+ assertValidRunId(runId);
261
+ const realBase = existingQiBaseDir(baseDir, fs);
262
+ if (realBase === undefined) {
263
+ return false;
264
+ }
265
+ const target = lexicalQiManifestPath(runId, realBase);
266
+ if (lstatSync(target, { throwIfNoEntry: false })?.isFile() !== true) {
267
+ return false;
268
+ }
269
+ if (!isSingleLinkRegularFile(target, fs)) {
270
+ return false;
271
+ }
272
+ rmSync(target, { force: true });
273
+ return true;
274
+ }
275
+ // Build a QI store that writes under `<evidenceDir>/qi/`. The caller passes the SAME evidence dir
276
+ // it would pass to `createNodeEvidenceStore` (i.e. the output of `resolveEvidenceDir`), and the
277
+ // store layers the `qi/` subdir itself so the local-state contract resolves identically for both
278
+ // the run-level evidence manifest and the QI sub-manifest.
279
+ export function createNodeQualityIntelligenceLocalStore(evidenceDir, options = {}) {
280
+ const baseDir = join(evidenceDir, QI_SUBDIR);
281
+ const fs = options.fs ?? nodeWorkspaceFs;
282
+ const randomSuffix = options.randomSuffix ?? randomUUID;
283
+ return {
284
+ record: (manifest) => recordQiManifest(baseDir, fs, randomSuffix, manifest),
285
+ load: (runId) => loadQiManifest(baseDir, fs, runId),
286
+ list: () => {
287
+ const realBase = existingQiBaseDir(baseDir, fs);
288
+ return realBase === undefined ? [] : listQiRunIds(realBase, fs);
289
+ },
290
+ location: (runId) => reportQiLocation(baseDir, fs, runId),
291
+ delete: (runId) => deleteQiManifest(baseDir, fs, runId),
292
+ };
293
+ }
294
+ function sha256OfJson(value) {
295
+ return createHash("sha256").update(JSON.stringify(value)).digest("hex");
296
+ }
297
+ function buildIntegrityHashes(findings, exports_, evidenceRefs, atomFingerprints, coverageMatrix, sourceFingerprints) {
298
+ return {
299
+ findings: sha256OfJson(findings),
300
+ exports: sha256OfJson(exports_),
301
+ evidenceRefs: sha256OfJson(evidenceRefs),
302
+ ...(atomFingerprints !== undefined ? { atomFingerprints: sha256OfJson(atomFingerprints) } : {}),
303
+ ...(coverageMatrix !== undefined ? { coverageMatrix: sha256OfJson(coverageMatrix) } : {}),
304
+ ...(sourceFingerprints !== undefined
305
+ ? { sourceFingerprints: sha256OfJson(sourceFingerprints) }
306
+ : {}),
307
+ };
308
+ }
309
+ function assertTotalsMatchCollections(input) {
310
+ // The `candidates` total is reported by the workflow (it isn't carried as a separate collection
311
+ // on the manifest), so we only validate findings and exports here.
312
+ if (input.totals.findings !== input.findings.length) {
313
+ throw new EvidenceWriteError(`QI totals.findings (${String(input.totals.findings)}) does not match findings.length (${String(input.findings.length)})`);
314
+ }
315
+ if (input.totals.exports !== input.exports.length) {
316
+ throw new EvidenceWriteError(`QI totals.exports (${String(input.totals.exports)}) does not match exports.length (${String(input.exports.length)})`);
317
+ }
318
+ }
319
+ function resolveStore(options) {
320
+ if (options.store !== undefined) {
321
+ return options.store;
322
+ }
323
+ if (options.evidenceDir !== undefined) {
324
+ return createNodeQualityIntelligenceLocalStore(options.evidenceDir);
325
+ }
326
+ return undefined;
327
+ }
328
+ /** Optional manifest fields that are only present when supplied (exactOptionalPropertyTypes). */
329
+ function optionalManifestFields(input, redacted) {
330
+ return {
331
+ // coverageMatrix + modelParameters are taken from the redacted set; the remaining optionals are
332
+ // ids / sha-256 hashes / numbers that carry no free text and need no persist-time scrub.
333
+ ...(redacted.coverageMatrix !== undefined ? { coverageMatrix: redacted.coverageMatrix } : {}),
334
+ ...(input.qualityScore !== undefined ? { qualityScore: input.qualityScore } : {}),
335
+ ...(input.sourceFingerprints !== undefined
336
+ ? { sourceFingerprints: input.sourceFingerprints }
337
+ : {}),
338
+ ...(input.atomFingerprints !== undefined ? { atomFingerprints: input.atomFingerprints } : {}),
339
+ ...(input.modelId !== undefined ? { modelId: input.modelId } : {}),
340
+ ...(redacted.modelParameters !== undefined
341
+ ? { modelParameters: redacted.modelParameters }
342
+ : {}),
343
+ ...(input.seedUsed !== undefined ? { seedUsed: input.seedUsed } : {}),
344
+ };
345
+ }
346
+ function buildRunManifest(input, redacted, summary, integrityHashes, redactedOptionals) {
347
+ return {
348
+ qiEvidenceSchemaVersion: QUALITY_INTELLIGENCE_EVIDENCE_SCHEMA_VERSION,
349
+ runId: input.runId,
350
+ planAt: redacted.planAt,
351
+ completedAt: redacted.completedAt,
352
+ status: input.status,
353
+ policyProfileIds: redacted.policyProfileIds,
354
+ retentionPolicyId: redacted.retentionPolicyId,
355
+ modelGatewayCallCount: input.modelGatewayCallCount,
356
+ totals: input.totals,
357
+ findings: redacted.findings,
358
+ exports: redacted.exports,
359
+ evidenceRefs: redacted.evidenceRefs,
360
+ provenanceRefs: redacted.provenanceRefs,
361
+ redactionSummary: summary,
362
+ integrityHashes,
363
+ ...optionalManifestFields(input, redactedOptionals),
364
+ };
365
+ }
366
+ export function recordQualityIntelligenceRun(input, options = {}) {
367
+ assertValidRunId(input.runId);
368
+ assertTotalsMatchCollections(input);
369
+ const store = resolveStore(options);
370
+ if (store === undefined) {
371
+ throw new EvidenceWriteError("recordQualityIntelligenceRun requires options.store or options.evidenceDir");
372
+ }
373
+ // Redact every string leaf of the user-supplied collections + scalars BEFORE the manifest is
374
+ // assembled or persisted. The summary is the counts-only artefact the audit will cross-check.
375
+ const { redacted, summary } = redactQualityIntelligenceEvidence({
376
+ planAt: input.planAt,
377
+ completedAt: input.completedAt,
378
+ policyProfileIds: input.policyProfileIds,
379
+ retentionPolicyId: input.retentionPolicyId,
380
+ findings: input.findings,
381
+ exports: input.exports,
382
+ evidenceRefs: input.evidenceRefs,
383
+ provenanceRefs: input.provenanceRefs,
384
+ // coverageMatrix carries requirementExcerptRedacted (derived from raw source text) and
385
+ // modelParameters is a free-shaped Record; both are string-bearing leaves that must pass the
386
+ // persist redactor — not just their build-time scrub — so audit storage keeps the same
387
+ // fail-closed backstop as findings/evidenceRefs (#273 audit — AC#3 audit-storage safety).
388
+ coverageMatrix: input.coverageMatrix,
389
+ modelParameters: input.modelParameters,
390
+ }, options.redaction ?? {});
391
+ const integrityHashes = buildIntegrityHashes(redacted.findings, redacted.exports, redacted.evidenceRefs, input.atomFingerprints, redacted.coverageMatrix, input.sourceFingerprints);
392
+ const manifest = buildRunManifest(input, redacted, summary, integrityHashes, {
393
+ coverageMatrix: redacted.coverageMatrix,
394
+ modelParameters: redacted.modelParameters,
395
+ });
396
+ return { manifest, location: store.record(manifest) };
397
+ }
398
+ // Fold a second redaction summary into a base one so the manifest's counts-only redaction summary
399
+ // stays internally consistent after an export row is appended (the run summary + the row's scan).
400
+ function foldRedactionSummary(base, add) {
401
+ const patternsMatched = { ...base.patternsMatched };
402
+ for (const [key, count] of Object.entries(add.patternsMatched)) {
403
+ patternsMatched[key] = (patternsMatched[key] ?? 0) + count;
404
+ }
405
+ return {
406
+ totalStringsScanned: base.totalStringsScanned + add.totalStringsScanned,
407
+ stringsRedacted: base.stringsRedacted + add.stringsRedacted,
408
+ patternsMatched,
409
+ };
410
+ }
411
+ /**
412
+ * Append one export-evidence row to an already-recorded run manifest (Issue #283, AC4 — "export
413
+ * evidence records target type, artifact IDs, mapping profile, and result without leaking secrets";
414
+ * Audit Addendum — "audit evidence for every export action").
415
+ *
416
+ * Every QI export action — a local serialisation download, a binary PDF/ZIP bundle, or a dry-run
417
+ * preview — emits one audit row recording WHAT was exported (`targetAdapter`), the artifact id, its
418
+ * integrity hash, the redaction attestation, and whether it was a dry-run. The disabled external-TMS
419
+ * write path produces no artifact and therefore records nothing.
420
+ *
421
+ * Rows are deduplicated by `(id, dryRun)` so re-exporting the same adapter in the same mode is
422
+ * idempotent — the audit captures each distinct (artifact, mode) once rather than once per click,
423
+ * which also bounds manifest growth.
424
+ *
425
+ * Invariants preserved (mirrors {@link recordQualityIntelligenceRun}):
426
+ * - the new row's string leaves pass the persist redactor before assembly (the row carries only
427
+ * ids / an enum / a sha-256 hash / booleans, so this is a no-op in practice, but the persist
428
+ * redactor is applied unconditionally to keep the fail-closed contract uniform);
429
+ * - `integrityHashes.exports` is recomputed over the full new collection; the other hash groups are
430
+ * carried over unchanged because their collections did not change;
431
+ * - `totals.exports` stays equal to `exports.length` (asserted on read);
432
+ * - the counts-only `redactionSummary` folds in the new row's scan;
433
+ * - the manifest is rewritten through the same atomic O_EXCL temp + rename path.
434
+ *
435
+ * Throws `EvidenceReadError` when the run manifest does not exist (an export cannot precede its run)
436
+ * and `EvidenceWriteError` when neither `store` nor `evidenceDir` is supplied or the write fails.
437
+ */
438
+ export function appendQualityIntelligenceExportRow(input, options = {}) {
439
+ assertValidRunId(input.runId);
440
+ const store = resolveStore(options);
441
+ if (store === undefined) {
442
+ throw new EvidenceWriteError("appendQualityIntelligenceExportRow requires options.store or options.evidenceDir");
443
+ }
444
+ const existing = store.load(input.runId);
445
+ if (existing === undefined) {
446
+ throw new EvidenceReadError(`cannot append export evidence: QI run "${input.runId}" was not found`);
447
+ }
448
+ const { redacted: redactedRow, summary: rowSummary } = redactQualityIntelligenceEvidence(input.export, options.redaction ?? {});
449
+ const isSameRow = (row) => row.id === redactedRow.id && (row.dryRun ?? false) === (redactedRow.dryRun ?? false);
450
+ if (existing.exports.some(isSameRow)) {
451
+ return { manifest: existing, location: store.location(input.runId) };
452
+ }
453
+ const exports = [...existing.exports, redactedRow];
454
+ const integrityHashes = {
455
+ ...existing.integrityHashes,
456
+ exports: sha256OfJson(exports),
457
+ };
458
+ const manifest = {
459
+ ...existing,
460
+ exports,
461
+ totals: { ...existing.totals, exports: exports.length },
462
+ integrityHashes,
463
+ redactionSummary: foldRedactionSummary(existing.redactionSummary, rowSummary),
464
+ };
465
+ return { manifest, location: store.record(manifest) };
466
+ }
467
+ export function loadQualityIntelligenceRun(runId, options = {}) {
468
+ const store = resolveLoadStore(options);
469
+ return store.load(runId);
470
+ }
471
+ export function listQualityIntelligenceRuns(options = {}) {
472
+ const store = resolveLoadStore(options);
473
+ return store.list();
474
+ }
475
+ function resolveLoadStore(options) {
476
+ if (options.store !== undefined) {
477
+ return options.store;
478
+ }
479
+ if (options.evidenceDir !== undefined) {
480
+ return createNodeQualityIntelligenceLocalStore(options.evidenceDir);
481
+ }
482
+ throw new EvidenceReadError("QI load/list requires options.store or options.evidenceDir");
483
+ }
@@ -0,0 +1,2 @@
1
+ export { createAuditRedactor, deepRedactStrings } from "@oscharko-dev/keiko-security";
2
+ //# sourceMappingURL=redaction.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"redaction.d.ts","sourceRoot":"","sources":["../src/redaction.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,mBAAmB,EAAE,iBAAiB,EAAE,MAAM,8BAA8B,CAAC"}
@@ -0,0 +1,4 @@
1
+ // Re-export shim: the audit-redaction layer (createAuditRedactor + deepRedactStrings) now lives in
2
+ // @oscharko-dev/keiko-security (issue #159, ADR-0019). All existing import sites
3
+ // (`from "./redaction.js"`) keep resolving unchanged via this barrel.
4
+ export { createAuditRedactor, deepRedactStrings } from "@oscharko-dev/keiko-security";
@@ -0,0 +1,17 @@
1
+ import type { CostClass, RunOutcome, VerificationStatus } from "@oscharko-dev/keiko-contracts";
2
+ import type { EvidenceManifest, EvidenceTaskType, EvidenceUsageTotals } from "./types.js";
3
+ export interface EvidenceReport {
4
+ readonly evidenceLocation: string;
5
+ readonly runId: string;
6
+ readonly fingerprint: string;
7
+ readonly taskType: EvidenceTaskType;
8
+ readonly outcome: RunOutcome;
9
+ readonly changedFiles: number;
10
+ readonly usageTotals: EvidenceUsageTotals;
11
+ readonly costClass: CostClass | "unknown";
12
+ readonly verificationStatus: VerificationStatus | "not-run";
13
+ readonly knownLimitations: readonly string[];
14
+ }
15
+ export declare function buildEvidenceReport(manifest: EvidenceManifest, location: string): EvidenceReport;
16
+ export declare function renderEvidenceReport(report: EvidenceReport): string;
17
+ //# sourceMappingURL=report.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"report.d.ts","sourceRoot":"","sources":["../src/report.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,SAAS,EAAE,UAAU,EAAE,kBAAkB,EAAE,MAAM,+BAA+B,CAAC;AAC/F,OAAO,KAAK,EACV,gBAAgB,EAChB,gBAAgB,EAChB,mBAAmB,EAEpB,MAAM,YAAY,CAAC;AAEpB,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,QAAQ,EAAE,gBAAgB,CAAC;IACpC,QAAQ,CAAC,OAAO,EAAE,UAAU,CAAC;IAC7B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,WAAW,EAAE,mBAAmB,CAAC;IAC1C,QAAQ,CAAC,SAAS,EAAE,SAAS,GAAG,SAAS,CAAC;IAC1C,QAAQ,CAAC,kBAAkB,EAAE,kBAAkB,GAAG,SAAS,CAAC;IAC5D,QAAQ,CAAC,gBAAgB,EAAE,SAAS,MAAM,EAAE,CAAC;CAC9C;AAuBD,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,gBAAgB,EAAE,QAAQ,EAAE,MAAM,GAAG,cAAc,CAahG;AAED,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,cAAc,GAAG,MAAM,CAgBnE"}
package/dist/report.js ADDED
@@ -0,0 +1,50 @@
1
+ // Final-report payload + renderer (ADR-0010 D9). buildEvidenceReport produces a structured,
2
+ // JSON-serializable summary of a persisted manifest; renderEvidenceReport renders it for the CLI.
3
+ // Both are PURE (no IO). knownLimitations is static text stating the Wave-1 evidence bounds
4
+ // (no tamper-evidence, no encryption at rest, per-run cost attribution) so a reviewer reads the
5
+ // honest trust boundary alongside the evidence.
6
+ const KNOWN_LIMITATIONS = [
7
+ "Evidence files are developer-writable: no tamper-evidence or immutability (out of scope).",
8
+ "Evidence is stored as plaintext JSON: no encryption at rest; redaction removes known shapes only.",
9
+ "Cost attribution is per-run (the declared model's class), not per model call.",
10
+ ];
11
+ function statusFromHarnessResults(results) {
12
+ if (results === undefined || results.length === 0) {
13
+ return "not-run";
14
+ }
15
+ return results.every((result) => result.passed) ? "passed" : "failed";
16
+ }
17
+ function verificationStatus(manifest) {
18
+ return (manifest.verification?.overallStatus ?? statusFromHarnessResults(manifest.verificationResults));
19
+ }
20
+ export function buildEvidenceReport(manifest, location) {
21
+ return {
22
+ evidenceLocation: location,
23
+ runId: manifest.run.runId,
24
+ fingerprint: manifest.run.fingerprint,
25
+ taskType: manifest.run.taskType,
26
+ outcome: manifest.run.outcome,
27
+ changedFiles: manifest.patch?.changedFiles ?? 0,
28
+ usageTotals: manifest.usageTotals,
29
+ costClass: manifest.model.costClass,
30
+ verificationStatus: verificationStatus(manifest),
31
+ knownLimitations: KNOWN_LIMITATIONS,
32
+ };
33
+ }
34
+ export function renderEvidenceReport(report) {
35
+ const { usageTotals: u } = report;
36
+ const lines = [
37
+ `Evidence: ${report.evidenceLocation}`,
38
+ ` run ${report.runId} (fingerprint ${report.fingerprint})`,
39
+ ` task ${report.taskType}`,
40
+ ` outcome ${report.outcome}`,
41
+ ` changed files ${String(report.changedFiles)}`,
42
+ ` usage ${String(u.promptTokens)} prompt / ${String(u.completionTokens)} completion tokens, ` +
43
+ `${String(u.requestCount)} request(s), ${String(u.totalLatencyMs)}ms`,
44
+ ` cost class ${report.costClass}`,
45
+ ` verification ${report.verificationStatus}`,
46
+ " known limitations:",
47
+ ...report.knownLimitations.map((limitation) => ` - ${limitation}`),
48
+ ];
49
+ return `${lines.join("\n")}\n`;
50
+ }
@@ -0,0 +1,4 @@
1
+ import type { EvidenceStore } from "./store.js";
2
+ import type { RetentionPolicy } from "./types.js";
3
+ export declare function applyRetention(store: EvidenceStore, policy: RetentionPolicy): void;
4
+ //# sourceMappingURL=retention.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"retention.d.ts","sourceRoot":"","sources":["../src/retention.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAChD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AA+FlD,wBAAgB,cAAc,CAAC,KAAK,EAAE,aAAa,EAAE,MAAM,EAAE,eAAe,GAAG,IAAI,CAQlF"}
@@ -0,0 +1,95 @@
1
+ // Retention and rotation (ADR-0010 D6). The single most dangerous operation in the layer, so it is
2
+ // the most tightly bounded: it deletes ONLY ledger-created `<runId>.json` files (every runId the
3
+ // store enumerates already passed assertValidRunId), inside the contained base dir, via
4
+ // EvidenceStore.delete. It computes the delete set then deletes that set (no recursion). "Oldest" is
5
+ // read from each manifest's finishedAt header — never filesystem mtime, which a developer touch
6
+ // could perturb. When disabled, deletion is a no-op. An unparseable manifest is left untouched
7
+ // rather than risking deletion of a file we cannot read a header from.
8
+ function readHeader(json) {
9
+ try {
10
+ const parsed = JSON.parse(json);
11
+ const finishedAt = parsed.run?.finishedAt;
12
+ if (typeof finishedAt !== "number") {
13
+ return undefined;
14
+ }
15
+ return { finishedAt, bytes: Buffer.byteLength(json, "utf8") };
16
+ }
17
+ catch {
18
+ return undefined;
19
+ }
20
+ }
21
+ // Newest-first ordering by finishedAt; ties broken by runId so the order is deterministic.
22
+ function collectCandidates(store) {
23
+ const candidates = [];
24
+ for (const runId of store.list()) {
25
+ const json = store.get(runId);
26
+ if (json === undefined) {
27
+ continue;
28
+ }
29
+ const header = readHeader(json);
30
+ if (header === undefined) {
31
+ continue;
32
+ }
33
+ candidates.push({ runId, finishedAt: header.finishedAt, bytes: header.bytes });
34
+ }
35
+ candidates.sort((a, b) => b.finishedAt - a.finishedAt || a.runId.localeCompare(b.runId));
36
+ return candidates;
37
+ }
38
+ function beyondMaxRuns(sorted, maxRuns) {
39
+ return sorted.slice(Math.max(maxRuns, 0)).map((c) => c.runId);
40
+ }
41
+ function olderThanAge(sorted, maxAgeMs) {
42
+ const newest = sorted[0]?.finishedAt;
43
+ if (newest === undefined) {
44
+ return [];
45
+ }
46
+ const cutoff = newest - maxAgeMs;
47
+ return sorted.filter((c) => c.finishedAt < cutoff).map((c) => c.runId);
48
+ }
49
+ function overByteCap(sorted, maxTotalBytes) {
50
+ const doomed = [];
51
+ let running = 0;
52
+ // Walk newest-first, keeping manifests until the cap is reached; the rest (oldest) are deleted.
53
+ // The newest manifest (index 0) is ALWAYS kept even if it alone exceeds the cap — retention must
54
+ // never delete the just-written run, and "delete oldest until under the cap" cannot apply to a
55
+ // single most-recent file.
56
+ for (let i = 0; i < sorted.length; i += 1) {
57
+ const candidate = sorted[i];
58
+ if (candidate === undefined) {
59
+ continue;
60
+ }
61
+ running += candidate.bytes;
62
+ if (i > 0 && running > maxTotalBytes) {
63
+ doomed.push(candidate.runId);
64
+ }
65
+ }
66
+ return doomed;
67
+ }
68
+ function computeDeleteSet(sorted, policy) {
69
+ const doomed = new Set();
70
+ if (policy.maxRuns !== undefined) {
71
+ for (const id of beyondMaxRuns(sorted, policy.maxRuns)) {
72
+ doomed.add(id);
73
+ }
74
+ }
75
+ if (policy.maxAgeMs !== undefined) {
76
+ for (const id of olderThanAge(sorted, policy.maxAgeMs)) {
77
+ doomed.add(id);
78
+ }
79
+ }
80
+ if (policy.maxTotalBytes !== undefined) {
81
+ for (const id of overByteCap(sorted, policy.maxTotalBytes)) {
82
+ doomed.add(id);
83
+ }
84
+ }
85
+ return doomed;
86
+ }
87
+ export function applyRetention(store, policy) {
88
+ if (policy.disabled === true) {
89
+ return;
90
+ }
91
+ const sorted = collectCandidates(store);
92
+ for (const runId of computeDeleteSet(sorted, policy)) {
93
+ store.delete(runId);
94
+ }
95
+ }